kind=field with grow and rings: N|all runs field --rings once at start (cached), keeps the ring deltas privately, and each growth applies the next stored ring as the grow event without running the engine; when no ring is stored the on-demand field --grow path remains (dev). list_games reports rings_left. The fold's end line is honest when a log ends with tiles unsolved. Tests: stored rings need no engine call; the engine is asked only once they are used up; a field is not solved right after a grow.
- commit
- 478ef02d9f728439becb92ed789da528234820ee
- parent
- ed6e9517e124a8d4161675763925c177de262ea3
- author
- dami <gazey55@gmail.com>
| M | site/ | 25 | +++++++++-- |
| M | site/ | 9 | +++- |
| M | site/ | 29 | ++++++++++-- |
| M | site/ | 74 | ++++++++++++++++++++++++++++++ |
@@ -103,8 +103,14 @@ export type GenParams = {
103103 /** field only: allocate the field at this side (>= the chunk grid) with104104 * the initial cluster centred, so rings can grow around it */105105 field_g?: number;106+ /** field only: pre-generate this many growth rings ("all" = until the107+ * field is full); the engine emits {base, rings: [delta, ...]} */108+ rings?: number | "all";106109 };107110 111+/** A field with its pre-generated growth rings (deltas in order). */112+export type FieldOutput = GenOutput & { rings?: GenOutput[] };113+108114 /** The single place the generator's command line is spelled out. */109115 export function genArgs(p: GenParams): string[] {110116 if (p.kind === "field") {@@ -117,6 +123,7 @@ export function genArgs(p: GenParams): string[] {
117123 "--chunk-G",118124 String(p.chunk_g ?? 60),119125 ...(p.field_g ? ["--G", String(p.field_g)] : []),126+ ...(p.rings !== undefined ? ["--rings", String(p.rings)] : []),120127 ];121128 }122129 const args = ["gen", "--seed", p.seed, "--G", String(p.G), "--kind", p.kind];@@ -135,23 +142,33 @@ export function version(): Promise<string> {
135142 /** Generation is a batch job: results are cached by parameter hash, which136143 * includes the generator version so a changed generator never serves a137144 * stale board for the same seed. */138-export async function gen(p: GenParams): Promise<GenOutput> {145+export async function gen(p: GenParams): Promise<FieldOutput> {139146 const key = await sha256(140147 `${await version()}|${p.seed}|${p.G}|${p.kind}|${p.n ?? ""}|${141148 p.chunks ?? ""142- }|${p.chunk_g ?? ""}|${p.field_g ?? ""}`,149+ }|${p.chunk_g ?? ""}|${p.field_g ?? ""}|${p.rings ?? ""}`,143150 );144151 const dir = cacheDir();145152 const file = `${dir}${key}.json`;146153 try {147- return JSON.parse(await Deno.readTextFile(file)) as GenOutput;154+ return unwrapField(JSON.parse(await Deno.readTextFile(file)));148155 } catch {149156 // miss150157 }151158 const text = await run(genArgs(p));152159 await Deno.mkdir(dir, { recursive: true });153160 await Deno.writeTextFile(file, text);154- return JSON.parse(text) as GenOutput;161+ return unwrapField(JSON.parse(text));162+}163+164+/**165+ * `field --rings N` emits {base, rings}; everything else emits an Output.166+ * Both become a FieldOutput: the base with its rings attached.167+ */168+export function unwrapField(raw: unknown): FieldOutput {169+ const o = raw as { base?: GenOutput; rings?: GenOutput[] } & GenOutput;170+ if (o.base) return { ...o.base, rings: o.rings ?? [] };171+ return o;155172 }156173 157174 export async function judge(input: JudgeInput): Promise<JudgeOutput> {@@ -1542,7 +1542,14 @@ export function applyEvent(prev: State, ev: GameEvent): State {
15421542 }15431543 case "end": {15441544 s.status = "solved";1545- s.console.push({ ...base, op: "end", intent: "all tiles solved" });1545+ const done = s.tiles.filter((t) => t === "solved").length;1546+ s.console.push({1547+ ...base,1548+ op: "end",1549+ intent: done === s.tiles.length1550+ ? "all tiles solved"1551+ : `ended with ${done} of ${s.tiles.length} tiles solved`,1552+ });15461553 break;15471554 }15481555 }@@ -6,6 +6,7 @@
66 // continue a game; the demo is one process). Games nobody joined expire.77 import { type GameRules, type GrowRules } from "./fold.ts";88 import {9+ type FieldOutput,910 gen,1011 type GenOutput,1112 type GenParams,@@ -32,6 +33,8 @@ type Ready = {
3233 priv: GenOutput;3334 seed: string;3435 grow: GrowRules | null;36+ /** pre-generated growth rings, applied in order before any engine call */37+ rings: GenOutput[];3538 growing: boolean;3639 exhausted: boolean;3740 timer: number | null;@@ -97,6 +100,9 @@ export type StartParams = {
97100 /** kind=field: allocate the field at this side with the initial chunks98101 * centred, so rings can grow around them (1000 for the daily) */99102 field_g?: number;103+ /** kind=field: pre-generate this many rings at start ("all" = until104+ * full); growth then reveals a stored ring, the engine is not run by day */105+ rings?: number | "all";100106 };101107 102108 export type GameSummary = {@@ -108,6 +114,7 @@ export type GameSummary = {
108114 agents: string[];109115 grown?: number;110116 growing?: boolean;117+ rings_left?: number;111118 fog?: boolean;112119 error?: string;113120 };@@ -125,6 +132,7 @@ export function listGames(): GameSummary[] {
125132 agents: e.ref.state.agents.map((a) => a.id),126133 grown: e.ref.state.grown,127134 growing: e.growing,135+ rings_left: e.rings.length,128136 fog: e.ref.fog,129137 }130138 : {@@ -139,7 +147,7 @@ export function listGames(): GameSummary[] {
139147 );140148 }141149 142-export type Generator = (p: GenParams) => Promise<GenOutput>;150+export type Generator = (p: GenParams) => Promise<FieldOutput>;143151 144152 /**145153 * Start a game: returns at once with a generating entry; the field is@@ -165,6 +173,9 @@ export function startGame(
165173 chunks: p.chunks,166174 chunk_g: p.chunk_g,167175 ...(p.kind === "field" && p.field_g ? { field_g: p.field_g } : {}),176+ ...(p.kind === "field" && growing && p.rings !== undefined177+ ? { rings: p.rings }178+ : {}),168179 };169180 const pending: Pending = {170181 status: "generating",@@ -183,15 +194,17 @@ export function startGame(
183194 (async () => {184195 try {185196 const out = await generate(params);186- const ref = await Referee.create(gameId, out, rules, judgeFn, { seed });197+ const { rings = [], ...base } = out;198+ const ref = await Referee.create(gameId, base, rules, judgeFn, { seed });187199 if (!games.has(gameId)) return; // expired meanwhile188200 const entry: Ready = {189201 kind: "ready",190202 ref,191203 started: pending.started,192- priv: out,204+ priv: base,193205 seed,194206 grow: growing ? p.grow! : null,207+ rings: [...rings],195208 growing: false,196209 exhausted: false,197210 timer: null,@@ -253,7 +266,11 @@ function wireGrowth(id: string, e: Ready, now: () => number): void {
253266 }254267 }255268 256-/** Grow a field by one ring now (idempotent while a growth is running). */269+/**270+ * Grow a field by one ring now (idempotent while a growth is running): the271+ * next pre-generated ring if any is stored (the daily: rings are made the272+ * night before and revealed by day), else the engine on demand.273+ */257274 export async function growGame(id: string): Promise<boolean> {258275 const e = games.get(id);259276 if (!e || e.kind !== "ready" || !e.grow || e.growing || e.exhausted) {@@ -263,7 +280,9 @@ export async function growGame(id: string): Promise<boolean> {
263280 e.growing = true;264281 try {265282 const ring = e.ref.state.grown + 1;266- const delta = await grower(e.priv, e.seed, ring);283+ const delta = e.rings.length284+ ? e.rings.shift()!285+ : await grower(e.priv, e.seed, ring);267286 const ev = await e.ref.grow(delta, ring);268287 e.priv = mergeGrowth(e.priv, delta);269288 e.lastGrow = Date.now();@@ -5,6 +5,7 @@
55 // on the newest ring; the registry grows on coverage and instead of ending.66 import { assert, assertEquals } from "@std/assert";77 import {8+ allSolved,89 applyEvent,910 buildGeometry,1011 emptyState,@@ -157,6 +158,8 @@ Deno.test("a field grown twice folds identically live and from the log; geometry
157158 assertEquals(geo.holes, union.holes);158159 assertEquals(ref.state.grown, ring);159160 assertEquals(ref.state.seedRings.length, ring + 1);161+ // new tiles are unsolved: the field is not solved after a grow162+ assert(!allSolved(ref.state));160163 assertEquals(ref.state.tiles.length, out.manifest.tiles.length);161164 for (let i = 0; i < 3; i++) await solveOne(ref, sol, agent, live);162165 }@@ -360,3 +363,74 @@ Deno.test("registry: growth on coverage, and instead of ending when every tile i
360363 assertEquals(await growGame(pending.game_id), false);361364 resetGames();362365 });366+367+Deno.test("registry: stored rings are revealed without the engine; the engine is the fallback", async () => {368+ resetGames();369+ const out = base();370+ const mk = (b: typeof out, ring: number) =>371+ synthesiseDelta(372+ b,373+ growLayout(374+ {375+ G: b.manifest.G,376+ kind: "scatter",377+ seed_tile: b.manifest.seed_tile,378+ tiles: b.manifest.tiles,379+ },380+ "grow-base",381+ ring,382+ ),383+ ring,384+ );385+ const d1 = mk(out, 1);386+ const union1 = {387+ ...out,388+ manifest: {389+ ...out.manifest,390+ tiles: [...out.manifest.tiles, ...d1.manifest.tiles],391+ },392+ solution: d1.solution,393+ tiles: [...out.tiles, ...d1.tiles],394+ };395+ const d2 = mk(union1, 2);396+ let engineCalls = 0;397+ setGrower((state, _seed, ring) => {398+ engineCalls++;399+ // the on-demand fallback: an empty ring (field full) over the state400+ const empty = mk(out, ring);401+ empty.manifest.tiles = [];402+ empty.manifest.seed_tiles = [];403+ empty.solution = state.solution;404+ empty.tiles = [];405+ empty.stats = { exhausted: 1 };406+ return Promise.resolve(empty);407+ });408+ const pending = startGame(409+ {410+ G: 140,411+ kind: "field",412+ chunks: 1,413+ chunk_g: 60,414+ grow: { every_minutes: 60 },415+ field_g: 140,416+ rings: 2,417+ },418+ () => Promise.resolve({ ...structuredClone(out), rings: [d1, d2] }),419+ Date.now,420+ stubJudge,421+ );422+ const ref = await whenReady(pending.game_id);423+ const n0 = ref.manifest.tiles.length;424+ assertEquals(await growGame(pending.game_id), true);425+ assertEquals(ref.state.grown, 1);426+ assertEquals(ref.manifest.tiles.length, n0 + d1.manifest.tiles.length);427+ assertEquals(await growGame(pending.game_id), true);428+ assertEquals(ref.state.grown, 2);429+ assertEquals(engineCalls, 0, "stored rings need no engine");430+ // rings used up: the engine is asked, finds no room, the field is exhausted431+ assertEquals(await growGame(pending.game_id), true);432+ assertEquals(engineCalls, 1);433+ assert(ref.state.exhausted);434+ assertEquals(await growGame(pending.game_id), false);435+ resetGames();436+});