clone

a takuzu puzzle field for agents to explore tile by tile, with a referee and a replayable spectator view

site: serialise referee writes; log summary route fix; concurrent-write test

dami committed 548926d
Concurrent agents raced through checkRevision while one awaited the
judge and hit the log's out-of-order guard as a generic error; writes
now run one at a time behind a promise chain and a stale expected
revision is a clean revision conflict the client retries.
commit
548926d92659c7490675ac20f7d3f22e337fbfb6
parent
883a08f0291da3fdb8f3fdbfe1893d5a86cc04f2
author
dami <gazey55@gmail.com>
3 files changed, +100 -14
Msite/lib/referee.ts58++++++++++++++++++++++++++++--
Msite/routes/log/[id].ts24+++++++-----
Msite/tests/referee_test.ts32+++++++++++++++++
M site/lib/referee.ts +54 -4
@@ -190,6 +190,19 @@ export class Referee {
190190 }191191 192192 private seed?: string;193+ private chain: Promise<unknown> = Promise.resolve();194+195+ /**196+ * Run one mutating call at a time. Agents write concurrently; a call that197+ * awaited the judge while another landed must not append a stale revision,198+ * so every write takes the lock first and checks its expected revision199+ * inside it.200+ */201+ private locked<T>(fn: () => Promise<T>): Promise<T> {202+ const run = this.chain.then(fn, fn);203+ this.chain = run.catch(() => {});204+ return run;205+ }193206 194207 private async apply(ev: GameEvent): Promise<void> {195208 const next = applyEvent(this.state, ev);
@@ -255,7 +268,11 @@ export class Referee {
255268 * tiles that touch nothing revealed, at random from the seed commit. Appends256269 * one join event.257270 */258- async join(opts: JoinOptions = {}): Promise<JoinResult> {271+ join(opts: JoinOptions = {}): Promise<JoinResult> {272+ return this.locked(() => this.joinLocked(opts));273+ }274+275+ private async joinLocked(opts: JoinOptions): Promise<JoinResult> {259276 if (this.state.status === "solved") {260277 throw new RefereeError("The mosaic is already solved.", "ended");261278 }
@@ -337,11 +354,22 @@ export class Referee {
337354 );338355 }339356 340- async reveal(357+ reveal(341358 expected: number,342359 tile: number,343360 intent = "",344361 agentId?: string,362+ ): Promise<RevealResult> {363+ return this.locked(() =>364+ this.revealLocked(expected, tile, intent, agentId)365+ );366+ }367+368+ private async revealLocked(369+ expected: number,370+ tile: number,371+ intent: string,372+ agentId?: string,345373 ): Promise<RevealResult> {346374 this.checkRevision(expected);347375 const agent = await this.resolveAgent(agentId);
@@ -400,11 +428,22 @@ export class Referee {
400428 return ownersOf(geo, id).some((t) => this.state.tiles[t] !== "hidden");401429 }402430 403- async commit(431+ commit(404432 expected: number,405433 cells: [number, number][],406434 intent = "",407435 agentId?: string,436+ ): Promise<CommitResult> {437+ return this.locked(() =>438+ this.commitLocked(expected, cells, intent, agentId)439+ );440+ }441+442+ private async commitLocked(443+ expected: number,444+ cells: [number, number][],445+ intent: string,446+ agentId?: string,408447 ): Promise<CommitResult> {409448 this.checkRevision(expected);410449 const agent = await this.resolveAgent(agentId);
@@ -541,11 +580,22 @@ export class Referee {
541580 };542581 }543582 544- async retract(583+ retract(545584 expected: number,546585 ids: number[],547586 intent = "",548587 agentId?: string,588+ ): Promise<{ revision: number; retracted: number[]; ignored: number[] }> {589+ return this.locked(() =>590+ this.retractLocked(expected, ids, intent, agentId)591+ );592+ }593+594+ private async retractLocked(595+ expected: number,596+ ids: number[],597+ intent: string,598+ agentId?: string,549599 ): Promise<{ revision: number; retracted: number[]; ignored: number[] }> {550600 this.checkRevision(expected);551601 const agent = await this.resolveAgent(agentId);
M site/routes/log/[id].ts +14 -10
M site/tests/referee_test.ts +32 -0
@@ -526,3 +526,35 @@ Deno.test("scoped observe on a daily-sized field stays under 64 KB", async () =>
526526 `observe{agent}: ${bytes} bytes, ${obs.tiles.length}/${ref.manifest.tiles.length} tiles`,527527 );528528 });529+530+Deno.test("concurrent writes: one lands, the other gets a revision conflict, the log stays contiguous", async () => {531+ const out = structuredClone(field);532+ const ref = await Referee.create(533+ crypto.randomUUID(),534+ out,535+ { guess: "accept", depth: 0 },536+ stubJudge,537+ { persist: false },538+ );539+ const a = await ref.join({ name: "a", harness: "t" });540+ const b = await ref.join({ name: "b", harness: "t" });541+ const rev = ref.revision;542+ const results = await Promise.allSettled([543+ ref.reveal(rev, a.seed_tile, "a", "a0"),544+ ref.reveal(rev, b.seed_tile, "b", "a1"),545+ ]);546+ const ok = results.filter((r) => r.status === "fulfilled").length;547+ const conflicts =548+ results.filter((r) =>549+ r.status === "rejected" && r.reason instanceof RefereeError &&550+ r.reason.code === "revision"551+ ).length;552+ assertEquals([ok, conflicts], [1, 1]);553+ assertEquals(ref.revision, rev + 1);554+ ref.log.events.forEach((e, i) => assertEquals(e.rev, i));555+ // the loser retries with the new revision and succeeds556+ const loser = results[0].status === "rejected" ? "a0" : "a1";557+ const tile = loser === "a0" ? a.seed_tile : b.seed_tile;558+ const r = await ref.reveal(ref.revision, tile, "retry", loser);559+ assertEquals(r.revision, rev + 2);560+});