quest'n'click 4.5.0 · r1

Construite pour quest'n'click 4.5.0, révision r1. Si elle est publiée, et laquelle est la courante, est dit par publication.json.

État et sauvegardes : persistant ou temporaire, renommer un id, migrations

English · Type : explication + procédure · Public : auteur · Contexte : dépôt du moteur, projet créé · Statut : prête · S'applique depuis : 4.2.2

Ce que garde la sauvegarde d’un joueur, ce qu’elle oublie, et comment faire évoluer votre jeu sans casser les sauvegardes de ceux qui y jouent déjà : renommer un id, faire passer une sauvegarde d’une version de votre contenu à la suivante, et ce qui se passe quand un jeu installé se met à jour.

Ce que contient une sauvegarde, et ce qu’elle ne contient pas

Une sauvegarde est l’état de la partie en cours (GameState, src/engine/core/types/state.ts), dans une enveloppe qui nomme le jeu, son saveVersion, l’heure et le monde (SaveEnvelopeV4, src/engine/core/save.ts).

Persistant : dans la sauvegarde.

QuoiChamp
Le lieu courant, la position du héros dans chaque lieu, les lieux visités et combien de foisroom, hero, visited
L’inventaire et les objets déjà utilisésinventory, used
Chaque drapeau, avec sa valeur (booléen, nombre ou texte)flags
L’état de chaque accessoire, et les acteurs déplacés, cachés ou montrés par un scriptprops, actors (clés <lieu>.<id>)
Les places débloquées sur la carteunlocked
Les compteurs des blocs once, nth, cycle et randomcounters (par id de bloc)
Ce qui a déjà été vu : sujets, écouteurs once, choix once, signaux Reality appliquésseen (topic.<id>, event.<id>, choice.<id>, reality.<signal>)
Où est chaque personnage mobilewhere
La position de chaque script : sa prochaine étape, fini ou arrêtéscripts (l’étape par son id de stepIds)
La caméra du lieu courantcamera
Plusieurs personnages jouables : qui est actif, le lieu, les positions et l’inventaire des autresactive, players
Si la partie est finiedone
La musique et où elle en étaitmusic (écrit et lu par l’application du joueur seulement)
Le lien avec un Reality Bridge : curseur et signaux appliquésreality

Temporaire : jamais dans la sauvegarde.

D’où la règle simple : ce qui doit survivre à un rechargement est un drapeau, un objet, l’état d’un accessoire, un compteur ou la position d’un script. Une variable gardée dans le JavaScript d’une commande personnalisée (voir Code personnalisé) est perdue au rechargement, sauf si vous l’écrivez dans l’état. Quand choisir un drapeau, une variable ou un événement : Drapeaux, variables et événements.

Ce qui est garanti

Où vivent les sauvegardes. L’autosauvegarde et les emplacements manuels vivent dans IndexedDB (src/engine/dom/save-store.ts). Les écritures passent une par une, et chaque écriture validée est relue et comparée avant que la sauvegarde compte comme faite (ADR 0005). Un navigateur sans IndexedDB (fenêtre privée, profil verrouillé) reçoit le stockage localStorage (src/engine/dom/storage.ts), annoncé une fois.

Garantie · save-read-back · référencée · exécutée sur aff6caab7514 · portée vérifiée par son auteur (IA), pas relue L’autosauvegarde est écrite dans IndexedDB sans erreur, et retrouvée, identique, après la réouverture de la base.

Chaque écriture est relue et vérifiée avant de compter (whenIdle() rejette quand la copie ne correspond pas).

Chaque opération dit ce qu’elle a fait. save() signale une écriture refusée par le rappel d’échec du stockage et whenIdle() rejette ; clear() et clearSlot() rendent false quand le navigateur a refusé la suppression (la sauvegarde est toujours là, et la page continue de la montrer). L’application du joueur en tient compte :

Chaque cas affiche ui.saveFailed (valeur anglaise par défaut « Save failed »).

Garantie · save-refused-write-reported · référencée · exécutée sur aff6caab7514 · portée vérifiée par son auteur (IA), pas relue Une écriture refusée par le navigateur (quota plein) fait rejeter whenIdle() et elle est signalée ; l’état reste en mémoire.

Ce qui est refusé, et ce qui est ajusté. Au chargement d’une sauvegarde (l’autosauvegarde au démarrage, un emplacement, un fichier importé), parseSave la confronte au jeu :

Garantie · save-refused-untouched · référencée · exécutée sur aff6caab7514 · portée vérifiée par son auteur (IA), pas relue La sauvegarde d’un autre jeu, une forme corrompue ou un lieu courant absent est refusée sans rien changer à la partie en cours.

Garantie · save-stale-pruned · référencée · exécutée sur aff6caab7514 · portée vérifiée par son auteur (IA), pas relue Les références périmées dont la reprise n’a pas besoin sont élaguées, et le joueur est prévenu une fois.

D’une version du moteur à l’autre. Une sauvegarde écrite par n’importe quelle release des lignées 3.x ou 4.x se charge dans toute 4.x suivante : l’enveloppe est versionnée (schéma 4 depuis 4.1.15, le schéma 3 toujours lu), et les migrations de votre jeu portent ses ids. tests/save-v3.test.ts charge une sauvegarde figée du jeu d’exemple par release (tests/fixtures/saves/demo-<version>.json, de 3.0.0 à 4.4.0 en 4.4.0), la migre et la joue jusqu’à la fin. Dans le dépôt du moteur, dans un vrai navigateur, npm run e2e -- --save vérifie qu’une sauvegarde manuelle survit à un rechargement, et --save --no-indexeddb la même chose pour le repli. La partie stockage de npm run e2e:a11y (une porte de la CI depuis la 3.3, Chromium et WebKit) met à niveau une sauvegarde ancienne avec la vraie version construite : l’autosauvegarde et l’emplacement v2 en localStorage sont copiés dans IndexedDB et supprimés seulement une fois la copie vérifiée, une enveloppe 3.1.0 déjà dans IndexedDB est migrée, le bouton Continuer du titre la reprend, et une sauvegarde manuelle survit ensuite à un rechargement.

Le fichier .wsrun d’un speedrun est de schéma 2 depuis 4.1.16 (le monde du run scellé dans sa preuve) ; un fichier de schéma 1 de 4.1.14 ou 4.1.15 est toujours vérifié, comme un run Story. Son format est dans Formats de fichiers.

Limite · save-own-content-only Le moteur garantit sa propre enveloppe d’une release à l’autre. Que vos sauvegardes survivent à un changement de votre contenu dépend de vos migrations : le moteur ne peut pas deviner que vous avez renommé un drapeau.

Renommer un id sans casser les sauvegardes

Depuis le schéma 3 (schemaVersion: 3 dans game.ts), tout ce dont la position survit dans une sauvegarde porte un id explicite et unique : chaque Rule, TalkTopic, Choice, EventRule, et chaque bloc once, nth, cycle ou random ; un ScriptDef ajoute une entrée stepIds par commande de premier niveau. Le validateur refuse un jeu v3 avec des ids absents ou dupliqués. Ainsi, réordonner le contenu ou traduire un texte ne change plus le sens d’une sauvegarde ; renommer un id, si. Le contrat lui-même est dans Les principes du contenu.

schemaVersion: 3,
// …
{ id: 'ouvrir.garde_manger', verb: 'open', a: 'garde_manger', do: […] }
{ id: 'demander.cle', topic: 'Où est la clé ?', do: […] }
{ once: […], id: 'arrivee.premiere' }
{ id: 'horloge', stepIds: ['attendre', 'sonner'], do: [{ wait: 1000 }, { sfx: 'carillon' }] }

L’outil ids avec --write --map écrit ces ids dans un jeu existant, ainsi que l’étape de migration que ses sauvegardes demandent (ids.migration.json) et la table des chemins de traduction (ids.paths.json), à côté des sources du jeu. Le passage complet au schéma 3 est dans Notes de mise à niveau, de la v2 à la v3.

Ce qu’un renommage touche dans une sauvegarde. Un drapeau, un objet, un lieu, un accessoire, un acteur, une place de la carte, un script, une étape de script, un personnage jouable, un personnage mobile, l’id du bloc d’un compteur, ou l’id de ce dont seen se souvient (un sujet, un écouteur once, un choix once). En cas de doute, cherchez l’id parmi les clés de l’état d’une sauvegarde (le tableau en haut de cette page). L’id d’un hotspot n’y apparaît jamais ; le renommer peut quand même changer des chemins de traduction (plus bas).

La procédure. Les sources du jeu sont dans game/ dans un projet créé, dans games/<id>/ dans le dépôt du moteur ; les outils se lancent avec npx quest-n-click <outil> dans un projet créé, avec GAME=<id> npm run <outil> -- dans le dépôt du moteur.

  1. Renommez l’id partout dans le contenu : sa définition et chaque référence (conditions, commandes, goals, objectives). Le validateur liste comme id inconnu chaque référence oubliée.
  1. Incrémentez saveVersion dans game.ts, de un.
  1. Ajoutez une étape de migration depuis l’ancienne version, avec le renommage :

    saveVersion: 4,
    migrations: [
      // …les étapes précédentes, inchangées
      { from: 3, renameFlag: { found: 'key_found' } },
    ],
    

    La cible renommée doit exister dans le contenu : le validateur refuse renamed item does not exist: "<id>" (de même pour les lieux, places de la carte, scripts, joueurs, personnages, accessoires et acteurs).

  1. Suivez les textes. Les chemins de traduction contiennent des ids (room:house/props.pantry.name). Après un renommage, lancez extract --lang <xx> de l’outil i18n pour chaque langue : ce qui ne correspond plus à rien est mis de côté sous _stale:<chemin> ; déplacez chaque traduction de son ancien chemin vers le nouveau, à la main (voir Traductions). Les clips de voix rangés par id de réplique (audio.voices, voicesByLang) se renomment de la même façon (Voix).
  1. Vérifiez que le contenu passe la validation et que chaque traduction est toujours en place :

    # dans le projet créé
    npx quest-n-click validate
    npx quest-n-click i18n status
    
    # dans le dépôt du moteur
    GAME=<id> npm run validate
    GAME=<id> npm run i18n -- status
    

Emplacements de sauvegarde et migrations

Les emplacements manuels se demandent :

saves: { slots: 3 },   // le menu pause gagne Sauver / Charger avec trois emplacements, plus export et import en fichier JSON
ui: { …, save: 'Sauver', load: 'Charger', slot: 'Emplacement {n}', emptySlot: 'vide', exportSave: 'Exporter en fichier', importSave: 'Importer un fichier', confirmOverwrite: 'Écraser cet emplacement ?' },

Absent ou 0 : l’autosauvegarde seule. Les emplacements manuels sont stockés comme l’autosauvegarde : dans IndexedDB, en enveloppes, relus après chaque écriture. Un fichier importé depuis le menu remplit aussi le premier emplacement libre. L’autosauvegarde est une enveloppe validée, contrôlée avant de toucher à la partie courante ; le premier lancement d’une version v3 importe une fois l’ancienne autosauvegarde <jeu>.save du localStorage (et les emplacements <jeu>.slot.<n>), et ne les supprime qu’après la copie vérifiée. Ce que le joueur voit des emplacements, de l’export et de l’import est dans Les sauvegardes, côté joueur.

Les migrations. Quand le contenu change de façon incompatible, incrémentez saveVersion et convertissez l’état, en données, une étape par version :

saveVersion: 3,
migrations: [
  { from: 1, renameFlag: { trouve: 'cle_trouvee' }, renameItem: { cle: 'key' } },
  { from: 2, renameRoom: { entree: 'hall' }, renameProp: { 'hall.lampe': 'hall.lanterne' }, dropFlag: ['tmp'] },
],

Une étape va de from à from + 1 ; les clés sont les anciens ids, les valeurs les nouveaux. Le type Migration complet (src/engine/core/types/game.ts) :

ChampCe qu’il convertit
renameFlag, dropFlagLes drapeaux.
renameItem, dropItemLes objets, dans l’inventaire du joueur actif et dans used.
renameRoomLe lieu courant, les positions du héros, visited, la partie lieu des clés d’accessoires et d’acteurs, where.
renameProp, renameActorlieu.accessoire → lieu.accessoire, lieu.acteur → lieu.acteur.
renamePlaceLes places débloquées de la carte.
renameCounter, dropCounterLes compteurs des blocs once / nth / cycle / random.
renameSeen, dropSeenLes sujets, écouteurs et choix vus (topic.<id>, event.<id>, choice.<id>).
renameScript, dropScriptLes positions des scripts.
renameScriptStepId du script → ancien id d’étape → nouvel id d’étape.
renamePlayerLe joueur actif et les fiches des autres joueurs.
renameCharacterLes personnages mobiles dans where.

renameItem et dropItem n’atteignent pas les inventaires des autres personnages jouables (players) : un id d’objet renommé y devient inconnu, et le chargement l’élague avec l’avertissement ci-dessus.

La chaîne s’applique dans l’ordre, sur une copie, jusqu’à ce que la sauvegarde atteigne saveVersion (src/engine/core/migrate.ts). Le validateur refuse deux étapes depuis la même version, un from qui n’est pas inférieur à saveVersion, et une cible renommée qui n’existe pas ; il avertit quand une version n’a pas d’étape (no migration from version <n>: those saves start a new game).

Garantie · migration-newer-save-refused · référencée · exécutée sur aff6caab7514 · portée vérifiée par son auteur (IA), pas relue Une sauvegarde plus ancienne est migrée par les étapes du jeu ; une sauvegarde plus récente que le jeu est refusée, jamais lue à moitié.

Une sauvegarde sans chemin jusqu’à saveVersion n’est pas chargée : le joueur repart d’une nouvelle partie. Ne convertissez les clés positionnelles de compteurs et de sujets d’un ancien contenu que si vous connaissez ce contenu et son sens : une fois le contenu réordonné ou traduit, il n’existe pas de conversion automatique sûre. Une référence facultative périmée non convertie est élaguée avec l’avertissement visible ci-dessus.

Les sauvegardes face aux mises à jour d’un jeu installé

Un jeu publié est une PWA : une fois visité, il se joue hors ligne depuis le cache du service worker. Quand vous publiez une nouvelle version, un jeu installé la trouve à sa prochaine visite en ligne, et la sauvegarde du joueur rencontre votre nouveau contenu.

  1. La mise à jour attend une sauvegarde vérifiée. La page affiche ui.updateAvailable (« A new version is ready. ») avec un bouton ui.updateNow (« Save and update »). Au tap, une partie en cours est d’abord sauvegardée, et la nouvelle version n’est activée qu’une fois l’écriture confirmée par le stockage (offerUpdate dans src/engine/dom/update.ts). Si l’écriture est refusée, la mise à jour n’a pas lieu, ui.saveFailed s’affiche, et le bouton peut être touché de nouveau. Sur l’écran titre, il n’y a pas d’état vivant : l’autosauvegarde déjà enregistrée est la progression.
  2. La nouvelle version lit l’ancienne sauvegarde avec vos migrations, comme plus haut : migrée, ajustée avec un avertissement, ou refusée (et alors une nouvelle partie).
  3. Tant que le joueur ne touche pas le bouton, la page ouverte garde la version qu’elle a chargée, hors ligne compris. Le nouveau service worker attend (registerType: 'prompt', skipWaiting: false dans vite.config.ts) ; un navigateur ne passe de lui-même à la nouvelle version qu’une fois tous les onglets du jeu fermés, et c’est alors l’autosauvegarde écrite après chaque action qui fait foi.

Limite · update-needs-migrations La mise à jour protège l’écriture, pas le sens : si votre nouveau contenu a renommé un id sans migration, l’ancienne sauvegarde perd cette référence (élaguée avec un avertissement) ou, pour un lieu courant qui n’existe plus, est refusée.

Sur un Studio hébergé, les aperçus de toutes les révisions partagent la même origine : une sauvegarde faite dans une révision peut être reprise dans une autre. Une version d’aperçu demande d’abord quand le contenu diffère (Resume here, Restart, Stay in revision N) ; les versions destinées aux joueurs ne demandent jamais. C’est l’ADR 0026 ; voir Aperçu, application, annulation.

Pourquoi ça marche ainsi

Références


Modifier la source de cette page · Signaler une erreur