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.

Speedrun pour les auteurs

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

Un jeu fait avec quest'n'click peut déclarer des catégories de speedrun dans son contenu. Le joueur lance une tentative depuis le menu pause, le moteur la chronomètre, la découpe en splits sur ce qui se passe dans le jeu (jamais sur des pixels) et scelle le run dans un fichier .wsrun que n'importe qui peut rejouer et vérifier. Cette page est pour l'auteur : déclarer les catégories et les splits, vérifier un run, proposer des classements. Le côté joueur (le chrono, LiveSplit, OBS) est dans Speedrun pour les joueurs.

Ce que vous obtenez

Votre jeu propose une ou plusieurs catégories de speedrun (Any%, sans indices, un monde Remix…) avec des splits automatiques ; un run qu'un joueur exporte en .wsrun se vérifie sur votre machine en une commande, qui donne un verdict, un code et une raison ; et, si vous faites tourner un Reality Bridge, ses classements rangent les runs que ses workers isolés ont trouvés valides.

Contexte et versions

Prérequis

Où lancer les commandes

Comment un run est chronométré et prouvé

L'horloge du run observe le jeu et n'écrit jamais son état ; les tirages aléatoires du run viennent d'un générateur à graine ; les entrées sont enregistrées en chunks chaînés par SHA-256 et scellées dans le .wsrun. Un vérificateur les rejoue sur le vrai jeu et recalcule tout. La conception est dans l'ADR 0016 (l'horloge, le générateur à graine, la preuve chaînée) et l'ADR 0017 (verdicts et confiance) ; l'horloge et le générateur sont expliqués dans Modèle d'exécution.

Limite · not-anti-cheat Rien ici ne promet de détecter toute triche. Un rejeu prouve qu'un run est possible et que son temps de jeu est exact ; seule l'observation d'un serveur ou d'un humain élève la confiance au-delà (Niveaux de confiance).

Étapes

  1. Déclarer les catégories et les splits dans game.ts (du contenu seulement : une nouvelle catégorie ne demande aucun changement du moteur) :

    // games/<id>/game.ts (dépôt du moteur) ou game.ts (projet créé)
    speedrun: {
      rulesVersion: 1,
      categories: [
        { id: 'any%', name: 'Any%', timing: 'igt',
          start: { event: 'sessionStarted', session: 'new' }, finish: { event: 'endingReached' },
          allowSaves: true, allowPauses: true, allowHints: true, reload: 'allowed', realityPolicy: 'forbidden',
          fingerprint: ['logic', 'trustedExtensions'],
          inputs: { mouse: true, touch: true, keyboard: true, gamepad: true, macros: 'forbidden' } },
      ],
      splits: [
        { id: 'ladder', name: 'Échelle', at: { event: 'objectiveCompleted', objective: 'ladder' } },
        { id: 'end', name: 'Festival', at: { event: 'endingReached' } },
      ],
    },
    

    Chaque champ est décrit dans Définir des catégories.

  1. Valider. Le validateur vérifie le manifeste :

    # dans le dépôt du moteur
    npm run validate
    # ou dans le projet créé
    npx quest-n-click validate
    
  1. Enregistrer un run. Jouez (npm run dev), ouvrez le menu pause, lancez une tentative dans une catégorie, allez jusqu'à l'arrivée, puis « Exporter le run » dans le menu pause : le navigateur télécharge <jeu>-<catégorie>.wsrun. L'onglet Play du Studio en exporte un aussi.
  1. Le vérifier.

    # dans le dépôt du moteur, le jeu sélectionné
    npm run speedrun:verify -- run.wsrun [--keys=bridge-keys.json] [--json] [--timeout=<ms>]
    # ou dans le projet créé
    npx quest-n-click speedrun verify run.wsrun [--keys=<file>] [--json]
    

    L'outil MCP speedrun_verify fait de même pour un assistant. Sortie 0 pour valid et valid-unranked, 1 pour tout autre verdict, inconclusive compris ; 2 sans fichier.

  1. Proposer des classements (facultatif). Ajoutez une section runs (et une section daily pour les mondes Daily et Mystery) à la configuration du Bridge, sur un store SQL : voir Classements sur le Bridge.

Résultat attendu, et comment le vérifier

speedrun:verify affiche ✔ <verdict> (<code>): <raison>, puis la confiance, le jeu et le moteur, le monde et sa clé de classement, et l'IGT, l'IGT actif et les pas recalculés ; avec --json, le résultat entier en JSON canonique. Un run à vous, joué honnêtement jusqu'à l'arrivée, donne valid (ok) dans une catégorie IGT et valid-unranked (rta-unverifiable) dans une catégorie RTA. L'exemple du dépôt est tests/fixtures/speedrun/reference-any.wsrun : un run Any% complet du chapitre de référence (schéma 2, fait par tools/speedrun/reference-run.ts), vérifié par un test et par le workflow de release, qui l'attache à chaque release. Le fichier de schéma 1 de 4.1.15 est gardé comme fixture de référence (reference-any.v1.wsrun).

Garantie · altered-run-refused · référencée · exécutée sur aff6caab7514 · portée vérifiée par son auteur (IA), pas relue Une copie du run de référence modifiée après son scellement est refusée, avec sa raison, et speedrun:verify sort en 1.

Erreurs courantes

SymptômeCauseCorrection
validate refuse le manifesteun événement inconnu, un id qui ne nomme rien, un départ égal à l'arrivée, une catégorie sans entrée, une politique Reality sans reality, un split qui est son propre ancêtre, reload: 'segment'corriger le champ que nomme le message
validate avertitun drapeau jamais posé ; une catégorie qui n'exige pas logicajouter la règle manquante, ou logic à fingerprint
modified-game (fingerprint)le jeu a changé depuis l'enregistrement du run, dans un composant qu'exige la catégorievérifier contre le jeu sur lequel le run a été joué
unsupported-version (engine-version)le run a été enregistré sur une autre version du moteurvérifier avec l'archive de cette version
unsupported-version (rules-version)vous avez changé rulesVersionnormal : un ancien run n'est jamais requalifié
invalid-category-rule (legacy-world-missing)un fichier de schéma 1 (4.1.14, 4.1.15) présenté à une catégorie Remixil ne se lit que comme un run Story
inconclusive (no-keyring)une catégorie recorded ou live vérifiée sans les clés du Bridgepasser --keys= (depuis GET /v1/keys)

La liste complète des verdicts et des codes est dans Vérifier un run.

Fichiers ou données modifiés

game.ts (la section speedrun). Le lecteur garde runs et records dans l'IndexedDB du navigateur. Un Bridge avec une section runs écrit les runs et leurs verdicts dans son store SQL.

Retour arrière

Retirer une catégorie ou changer ses règles ne touche pas les runs déjà exportés ; augmentez rulesVersion quand les règles changent, pour qu'un ancien run ne soit jamais classé sous de nouvelles règles. Un run supprimé d'un classement est perdu : DELETE ne se défait pas.

Définir des catégories

GameDef.speedrun (types dans API publique, vérifiés par npm run validate) contient rulesVersion, categories et splits. Une catégorie (SpeedrunCategory) :

ChampValeurs
id, namel'id et ce que lisent les joueurs (le classement, l'overlay)
timingrta, igt ou active-igt (Le temps)
start, finishun déclencheur sémantique
allowSaves, allowPauses, allowHintsdes booléens (la sauvegarde automatique tourne toujours ; un indice est enregistré par la session)
reloadinvalidates (un chargement disqualifie) ou allowed (un état que le run a lui-même atteint) ; segment est réservé, refusé par le validateur
realityPolicyforbidden, recorded ou live (Politiques Reality)
fingerprintles composants qu'un run doit égaler : logic, trustedExtensions, presentation, engine (Politiques)
inputs{ mouse, touch, keyboard, gamepad, macros: 'forbidden' | 'allowed' }
seedle générateur du run : fixed (chaque run tire de fixed:<id>) ou random (par défaut, une graine neuve par run)
worldle monde où se joue le run (4.1.16, plus bas)

Un déclencheur nomme un événement sémantique (roomEntered, itemAcquired, itemLost, flagChanged, objectiveCompleted, endingReached, sessionStarted, playerSwitched…) et les champs qu'il doit égaler (room, item, flag avec value, objective, ending, player, session). Aucun pixel n'est jamais lu.

Les règles portent leur version : un changement ne requalifie jamais un ancien run (rulesVersion différente : unsupported-version). Le validateur refuse un événement inconnu, un id qui ne nomme rien, un départ égal à l'arrivée, une catégorie sans entrée, une politique Reality sans reality, un split qui est son propre ancêtre ; il avertit pour un drapeau jamais posé et une catégorie qui n'exige pas logic. Une catégorie définie seulement dans le contenu ne touche aucun code du moteur : tests/speedrun-manifest.test.ts le prouve avec un jeu de test.

Le chapitre de référence (games/reference/game.ts) déclare Any%, Any% No Hints, Real Time, Remix Fixed (WS-0000-02DZ), Remix Random, Daily, Mystery et Any% (wheel proved).

Mondes : Story, Fixed, Random, Daily, Mystery

Depuis 4.1.16 (D29, ADR 0019), le monde où un run se joue est world :

Chacun nomme le mode Remix d'où viennent ses mondes, et peut dire ce qu'il permet de la roue de code dans codeWheel (Niveaux de confiance). Fixed et Daily classent chaque graine à part (leaderboardKey), et worldVerdict vérifie le monde d'un run contre ce que sa catégorie permet. Le seed: 'daily' | 'mystery' de 4.1.15 sans world est lu comme ce monde (un run aléatoire dedans), avec un avertissement de dépréciation du validateur.

Le temps : RTA, IGT, IGT actif

L'horloge du run (Engine.runClock) observe le moteur et n'écrit jamais l'état (D24).

Splits, routes, records et fantôme

Les splits sont automatiques : chacun se déclenche une fois, la première fois que son déclencheur correspond après le départ ; un split qui ne se déclenche jamais est marqué manqué et le run continue. Un parent en fait un sous-split. Les records comparent un run au meilleur personnel (avance ou retard à chaque split), gardent le meilleur segment de chaque split et leur somme.

Une route est un fichier .wsroute (JSON canonique) : les entrées d'un run, éventuellement ses splits. L'onglet Play du Studio exporte la session de son cadre comme route, en importe une et en compare deux (première entrée différente, écarts des splits). Le témoin du solveur devient une route logique (kind: 'logical') : une référence pour le routage, jamais un record.

La preuve : .wsrun

Schéma 2 depuis 4.1.16 (SpeedrunEnvelopeV2, ADR 0019). Il contient le jeu, son empreinte (le jeu tel qu'écrit, avant tout monde), les versions du moteur, du générateur et des durées, la catégorie et la version de ses règles, la graine du générateur du run (runSeed), le monde exact joué (variant, affectations comprises, jamais régénéré depuis une graine) et, pour un monde Daily ou Mystery, les jetons signés du Bridge (worldEvidence) ; le temps (RTA, pas, IGT, IGT actif, les pauses, menus, arrière-plans et chargements déclarés), les splits, les entrées en chunks de 500 chaînés par SHA-256 de H0 (les règles) à Hn, les chargements, les entrées utilisées, les signaux Reality, le hash de l'état final et la preuve finale.

H0 scelle aussi le monde (son hash, la politique de monde de la catégorie, le hash des preuves, la version de l'algorithme Remix) : les mêmes entrées dans deux mondes font deux runs. Chaque hash porte sur canonicalJson. La confiance exportée est toujours local. Un fichier de schéma 1 (4.1.14, 4.1.15) se lit toujours, comme un run Story.

Garantie · schema1-not-requalified · référencée · exécutée sur aff6caab7514 · portée vérifiée par son auteur (IA), pas relue Un run de schéma 1 présenté à une catégorie Remix est refusé (legacy-world-missing), jamais requalifié d'après sa graine.

Le format, champ par champ, est dans Formats de fichiers.

Vérifier un run

Le vérificateur recharge la catégorie depuis le jeu, vérifie le monde du run contre le jeu (loadVariant) et contre sa catégorie (worldVerdict, le jeton du jour ou l'engagement et la révélation Mystery vérifiés avec la clé que nomme remix.daily), reconstruit ce monde, rejoue les entrées avec la graine du run (les tirages aléatoires sont refaits, jamais pris dans le fichier), recalcule l'horloge, les splits, l'état final et la chaîne, puis vérifie les règles, et nomme le classement où va le run (world.leaderboardKey : la catégorie, et pour Fixed et Daily la graine).

VerdictCodes
validok
valid-unrankedrta-unverifiable, mystery-unwitnessed (un run Mystery : son heure de départ est la parole du client), physical-wheel-unwitnessed
invalid-category-ruleunknown-category, hints-forbidden, saves-forbidden, pauses-forbidden, reload-forbidden, foreign-load, input-forbidden, reality-forbidden, seed-policy, start-trigger, world-policy, legacy-world-missing, daily-proof-missing, daily-proof-invalid, mystery-commitment, mystery-reveal, mystery-start-window, code-wheel-rule, code-wheel-proof
invalid-replayenvelope-shape, chunk-order, chunk-hash, chain, replay-diverged, rnd-mismatch, time-mismatch, splits-mismatch, final-state, not-finished, world-missing, world-shape, world-hash, world-value, world-constraint, world-stale, world-forged, world-algorithm, minigame-transcript
modified-gamefingerprint
missing-reality-proofsignal-missing, signal-signature, signal-mismatch
unsupported-versionschema, engine-version, prng-version, timing-version, rules-version
inconclusivetimeout, crash, no-keyring, package-not-approved (le worker du Bridge)

inconclusive n'est jamais valide.

Niveaux de confiance

local (chronométré par la machine du joueur) → replay-valid (un vérificateur autre que le client l'a rejoué) → server-witnessed (le serveur a daté les chunks du run pendant qu'il était joué : réservé, aucun code ne le pose encore) → moderator-verified (un humain muni du jeton d'administration du classement l'a regardé). Seul quelqu'un d'autre que le client élève un niveau ; une enveloppe qui en réclame davantage est lue comme local. Le niveau s'affiche dans les records, le Studio et le classement.

Ce que prouve replay-valid (4.1.17) : le run se rejoue sur le jeu, la catégorie et le monde qu'il nomme ; sa chaîne, ses tirages, son état final et son temps logique concordent ; chaque résultat de minijeu venu avec un transcript est celui que ses réponses donnent (les réponses de la roue, par leur place dans la liste de l'auteur, jugées à nouveau sur la roue que fait le monde ; ADR 0020). Il ne prouve pas qu'une personne a donné ces réponses, que inputsUsed, les pauses ou le RTA sont complets, qu'une roue imprimée a servi, ni que l'enveloppe n'a pas été fabriquée hors du lecteur : c'est server-witnessed ou moderator-verified, jamais « anti-triche ».

La roue de code dans le world.codeWheel d'une catégorie ({ enabled, skip, medium, proof? }) :

La catégorie wheel-proved du chapitre de référence veut la roue gagnée et prouvée chaque fois qu'elle est jouée (la roue reste facultative : une route qui n'ouvre jamais la carte ne la joue pas) ; ses autres catégories gardent la règle de 4.1.16.

Politiques Reality

Ce que le Bridge garantit sur les signaux est dans Garanties du Bridge.

Classements sur le Bridge

bridge/src/runs.ts est monté par npm run bridge -- serve quand la configuration a une section runs (4.1.16 ; l'option runs de bridgeServer pour un hôte à soi) ; bridge/src/daily.ts par une section daily. Les deux gardent leurs enregistrements dans le store SQL (--store=sqlite:<fichier> pour une machine, ou BRIDGE_STORE=postgres://… pour plusieurs instances ; serve les refuse sur le journal JSON-lines), migrations 0002 à 0004. Ils survivent à un redémarrage.

"runs": {
  "games": { "reference": { "dir": "/srv/games/reference", "fingerprint": { "logic": "…", "trustedExtensions": "…", "presentation": "…", "engine": "…" } } },
  "worker": ["/usr/bin/node", "/srv/quest-n-click/node_modules/tsx/dist/cli.mjs", "/srv/quest-n-click/tools/speedrun/worker.ts"],
  "workers": 2
},
"daily": { "games": { "reference": { "daily": "daily", "mystery": "mystery" } }, "kid": "daily-2026", "keyFile": "daily.jwk", "secretFile": "daily.secret" }

keyFile (la JWK privée Ed25519 dont le manifeste du jeu nomme la moitié publique dans remix.daily) et secretFile (le secret des jours) sont à côté de config.json, en mode 0600. Faire tourner la clé du jour, c'est publier le jeu avec la nouvelle clé publique : le joueur et le vérificateur n'acceptent que la clé que nomme le manifeste, et un jeton d'un autre kid est refusé.

Soumettre. POST /v1/runs avec { player, envelope } rend l'id du run et un jeton de suppression. Les workers de toutes les instances partagent une seule file : un run est créé une fois (sa clé est unique : deux instances qui reçoivent le même run en créent un), réclamé par un seul worker sous un bail, et réclamé de nouveau quand son worker est mort et que le bail a expiré ; un worker qui a perdu son bail ne peut pas enregistrer de verdict. Le processus HTTP ne rejoue jamais.

Le worker. Chaque worker est un processus isolé (tools/speedrun/worker.ts) : un tas borné, tué avec son groupe de processus à son budget de temps, un environnement qui ne contient que PATH, le dossier du paquet du jeu et la taille du tas (aucun secret du Bridge), fetch, WebSocket, TCP, UDP et DNS refusés dans le processus, le paquet vérifié contre son empreinte approuvée, sa réponse signée par une clé à usage unique.

Limite · worker-not-sandbox Les refus réseau du worker sont une défense en profondeur, pas un bac à sable : voir le worker des speedruns dans Sécurité du Bridge.

Identité d'un run. Un run est identifié par son jeu, sa catégorie, la graine du run et ses entrées (hors leurs horodatages RTA), jamais par son monde : le premier qui le soumet le garde ; une copie réhorodatée ou rescellée dans un autre monde est refusée.

Limites. Un client peut soumettre perMinute runs par minute (10 par défaut) ; un doublon ou un run invalide coûte un jeton, un refus dit Retry-After. La file tient au plus maxQueued runs en attente ou en vérification (100 par défaut). Avec bridge serve sur un store SQL, les deux valent pour toutes les instances ensemble (4.1.17) : maxQueued est compté et écrit dans une seule transaction sous le verrou du tenant, et perMinute est un compteur par client dans la table run_quota. Le client est l'adresse du socket, ou celle que nomme un proxy de confiance (--trust-proxy) ; seul un HMAC en est gardé, avec un secret que chaque instance du tenant dérive de sa clé d'événements. Faire tourner la clé d'événements (kid) fait tourner ce secret : chaque client repart d'un compteur neuf (au plus une minute de quota en plus), et les anciens compteurs sont purgés avec les inactifs. Un hôte qui embarque la bibliothèque sans limiteur SQL compte par instance. Une limite globale au proxy reste utile devant le tout. L'enveloppe est jetée une fois le verdict enregistré ; les vieux runs sont purgés chaque heure.

Lire un classement. GET /v1/runs?game=&category=[&key=][&limit=] : runs valides seulement, le meilleur de chaque pseudonyme (le Bridge ne connaît aucune identité de joueur : deux personnes qui choisissent le même pseudonyme font une seule ligne), sur la clé du vérificateur (world.leaderboardKey : la catégorie, et pour un monde Fixed ou Daily <catégorie>:<graine>). Des temps égaux partagent un rang, puis la soumission la plus ancienne passe devant. Le store classe tous les runs avant de couper la réponse (4.1.17 : jusque-là, un run plus rapide soumis après les 10 000 premiers manquait) ; la réponse a 100 lignes, &limit= jusqu'à 1 000. Un temps est gardé canonique (décimal, sans zéro de tête, 30 chiffres au plus) et ordonné comme un décimal, au-delà de Number.MAX_SAFE_INTEGER aussi. Un run Daily envoyé après son jour est un entraînement (valide, non classé) ; un run Mystery est valid-unranked sans témoin serveur. &seed=fixed|random filtre toujours les runs de 4.1.14.

Un run. GET /v1/runs/<id> le lit, DELETE /v1/runs/<id> avec x-delete-token le supprime, POST /v1/runs/<id>/moderate avec le jeton d'administration élève un run valide à moderator-verified. Soumissions, verdicts, modérations et suppressions sont des lignes d'audit du journal du Bridge.

Le défi du jour. GET /v1/daily?game=[&date=] signe la graine du jour (un vrai jour UTC, aujourd'hui ou dans retentionDays, 30 par défaut) ; POST /v1/commit et GET /v1/reveal/<id> s'engagent sur une graine Mystery et la révèlent, signés tous les deux. Jetons et engagements sont écrits une seule fois dans le même store SQL : toutes les instances répondent la même chose. Le joueur garde le jeton d'un monde Daily à côté du monde, et un speedrun dans ce monde le porte. Depuis 4.1.17, le menu Remix a une entrée Monde mystère : elle demande au Bridge du jeu un engagement puis sa révélation, vérifie les deux hors ligne avec la clé du défi du jour, construit le monde et garde les deux jetons à côté. Un speedrun lancé dans ce monde (dans la minute que la révélation permet : passé ce délai, la page le dit et ne lance pas le run) les porte avec son heure de départ. Sans témoin serveur, un tel run est valid-unranked (mystery-unwitnessed).

Un run interrompu puis repris garde son monde et sa preuve : le reprendre dans un autre monde, ou avec une autre preuve, est refusé (npm run e2e:remix-speedrun le fait dans chaque monde). Faire tourner le Bridge lui-même est dans Installer et lancer le Bridge.

Politiques

Accessibilité et équité

Une catégorie déclare ses entrées (souris, tactile, clavier, manette, macros). Une option d'accessibilité n'est jamais une triche implicite : la vitesse et la taille du texte, la réduction des animations et la police lisible ne changent rien au temps de jeu (une réplique coûte le même temps logique à toutes les vitesses), et les cibles du clavier et du lecteur d'écran sont les mêmes actions. Une catégorie qui interdit une entrée le dit ; les entrées utilisées par un run sont déclarées par le client (sa parole, comme le RTA). Les options d'accessibilité du joueur sont dans Accessibilité.

Limites

Limite · integrity-not-authenticity Intégrité n'est pas authenticité : la chaîne prouve qu'un fichier n'a pas été modifié après son scellement, pas que le client a été honnête ; un client peut la recalculer. Pauses, menus, sauvegardes, RTA et entrées utilisées sont la parole du client.

Références


Modifier la source de cette page · Signaler une erreur