clone

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

referee: territory-gated commits and retractions; idempotent join with reseat; no start_mosaic on the daily host

dami committed cf97a70
A real agent on the hosted daily hit both: another agent solved its
revealed seed tile from outside (commits were not territory-gated) and
it had to join again as a new agent; and the prompt invited agents to
start their own field on the box. Now a tile revealed by one group is
that group's (commits/retracts elsewhere are ignored as 'territory'),
join with the same machine id or name returns the same agent and
reseats it on a fresh seed tile only when it has nothing playable left,
and with MOSAIC_DAILY=1 the MCP tool list has no start_mosaic and the
prompt says to wait for the day's field, never to create one.
commit
cf97a70a7af112fde2c99b7b761c7c8fbb58d14e
parent
6f3e1d8788f6395336d0406d0f2b256a96312bdc
author
dami <gazey55@gmail.com>
5 files changed, +288 -5
Msite/lib/fold.ts29++++++
Msite/lib/mcp.ts16+++-
Msite/lib/prompt.ts11++-
Msite/lib/referee.ts91++++++++++++++++++-
Msite/tests/referee_test.ts146++++++++++++++++++++++++++++++
M site/lib/fold.ts +29 -0
M site/lib/mcp.ts +14 -2
M site/lib/prompt.ts +9 -2
M site/lib/referee.ts +90 -1
@@ -396,6 +396,34 @@ export class Referee {
396396 throw new RefereeError("The mosaic is already solved.", "ended");397397 }398398 const index = this.state.agents.length;399+ // the same agent joining again (same machine id, or same name from a400+ // client without one) is the same agent: nothing appended, unless it401+ // has nowhere left to play, in which case it is reseated402+ const returning = this.state.agents.find((a) =>403+ (opts.machine_id && a.id === opts.machine_id) ||404+ (opts.name && a.name === opts.name)405+ );406+ if (returning) {407+ // playable: a hidden frontier tile, or a revealed unsolved tile of408+ // the agent's own group (a frontier tile another group revealed is409+ // theirs to finish)410+ const s = this.state;411+ const group = groupOf(s, returning.id);412+ const playable = frontierOf(s, returning.id).some((t) =>413+ s.tiles[t] === "hidden" ||414+ (s.tileRevealer[t] !== null && groupOf(s, s.tileRevealer[t]!) === group)415+ );416+ const stranded = !playable;417+ if (!stranded) {418+ return {419+ revision: this.state.revision,420+ agent: returning.id,421+ seed_tile: returning.seed_tile,422+ frontier: frontierOf(this.state, returning.id),423+ };424+ }425+ return await this.reseat(returning.id);426+ }399427 const agent =400428 opts.machine_id && /^[a-zA-Z0-9_:.-]{1,80}$/.test(opts.machine_id)401429 ? opts.machine_id
@@ -448,6 +476,60 @@ export class Referee {
448476 };449477 }450478 479+ /** Seat an existing agent on a fresh seed tile (a second join event480+ * with the same agent id; the fold treats it as a move). */481+ private async reseat(agent: string): Promise<JoinResult> {482+ const a = agentOf(this.state, agent)!;483+ let candidates = joinCandidates(this.state);484+ if (candidates.length === 0) {485+ const claimed = new Set(this.state.agents.map((x) => x.seed_tile));486+ candidates = this.manifest.tiles.filter((t) =>487+ this.state.tiles[t.id] === "hidden" && !claimed.has(t.id)488+ ).map((t) => t.id);489+ }490+ if (candidates.length === 0) {491+ throw new RefereeError("No hidden tile is left to start on.", "full");492+ }493+ const r = this.joinRng(this.state.revision + 1);494+ const seedTile = candidates[Math.floor(r() * candidates.length)];495+ const ev: JoinEvent = {496+ rev: this.state.revision + 1,497+ t: new Date().toISOString(),498+ kind: "join",499+ agent,500+ name: a.name,501+ harness: a.harness,502+ harness_source: "none",503+ ...(a.model ? { model: a.model } : {}),504+ seed_tile: seedTile,505+ };506+ await this.apply(ev);507+ return {508+ revision: this.state.revision,509+ agent,510+ seed_tile: seedTile,511+ frontier: frontierOf(this.state, agent),512+ };513+ }514+515+ /**516+ * Whether a cell lies in the agent's group's territory: some tile517+ * covering it was revealed by a member of the group (a tile revealed by518+ * group A is A's; contested tiles will be an explicit mechanic later).519+ */520+ private inTerritory(id: number, agent: string): boolean {521+ const s = this.state;522+ const group = groupOf(s, agent);523+ for (let i = s.geo!.ownerStart[id]; i < s.geo!.ownerStart[id + 1]; i++) {524+ const t = s.geo!.ownerList[i];525+ const r = s.tileRevealer[t];526+ if (r !== null && s.tiles[t] !== "hidden" && groupOf(s, r) === group) {527+ return true;528+ }529+ }530+ return false;531+ }532+451533 /**452534 * The agent a write belongs to. Writes without an agent are accepted only453535 * from the single implicit player (old clients): it is joined on first use
@@ -694,6 +776,10 @@ export class Referee {
694776 ignored.push({ id, reason: "not_revealed" });695777 continue;696778 }779+ if (this.state.agents.length > 1 && !this.inTerritory(id, agent)) {780+ ignored.push({ id, reason: "territory" });781+ continue;782+ }697783 candidates.push([id, v]);698784 }699785 if (candidates.length === 0) {
@@ -817,7 +903,10 @@ export class Referee {
817903 if (!Number.isInteger(id)) {818904 throw new RefereeError("Cell ids are integers.", "shape");819905 }820- if (cellAt(this.state, id)?.kind === "commit") retracted.push(id);906+ if (907+ cellAt(this.state, id)?.kind === "commit" &&908+ (this.state.agents.length <= 1 || this.inTerritory(id, agent))909+ ) retracted.push(id);821910 else ignored.push(id);822911 }823912 if (retracted.length === 0) {
M site/tests/referee_test.ts +146 -0
@@ -563,3 +563,149 @@ Deno.test("concurrent writes: one lands, the other gets a revision conflict, the
563563 const r = await ref.reveal(ref.revision, tile, "retry", loser);564564 assertEquals(r.revision, rev + 2);565565 });566+567+// A tile revealed by one group is that group's: other agents' commits into568+// it are ignored ("territory"), as are their retractions of its cells.569+Deno.test("commits and retractions are territory-gated", async () => {570+ const out = synthesise(gridLayout(3, 6), "territory");571+ const sol = solutionOf(out);572+ const ref = await Referee.create(573+ crypto.randomUUID(),574+ structuredClone(out),575+ { guess: "accept", depth: 0 },576+ stubJudge,577+ {578+ persist: false,579+ honours: [580+ {581+ agent: "alice",582+ name: "alice",583+ model: null,584+ user: "alice",585+ hue: 0,586+ seed_tile: 0,587+ day: "d",588+ reward: 1,589+ },590+ {591+ agent: "bob",592+ name: "bob",593+ model: null,594+ user: "bob",595+ hue: 1,596+ seed_tile: 4,597+ day: "d",598+ reward: 1,599+ },600+ ],601+ },602+ );603+ const alice = await ref.join({ name: "alice" });604+ const bob = await ref.join({ name: "bob" });605+ assertEquals([alice.seed_tile, bob.seed_tile], [0, 4]);606+ await ref.reveal(ref.revision, 0, "seed", alice.agent);607+ const cells = ref.state.geo!.cellsOf[0].filter((c) => !ref.state.cells[c]);608+ // bob cannot commit into alice's tile609+ await assertRejects(610+ () =>611+ ref.commit(612+ ref.revision,613+ cells.slice(0, 4).map((c) => [c, sol(c)] as [number, number]),614+ "steal",615+ bob.agent,616+ ),617+ RefereeError,618+ "territory",619+ );620+ // alice can, and bob cannot retract what she committed621+ const r = await ref.commit(622+ ref.revision,623+ cells.slice(0, 4).map((c) => [c, sol(c)] as [number, number]),624+ "mine",625+ alice.agent,626+ );627+ assertEquals(r.accepted.length, 4);628+ await assertRejects(629+ () => ref.retract(ref.revision, cells.slice(0, 2), "undo", bob.agent),630+ RefereeError,631+ );632+ const rr = await ref.retract(633+ ref.revision,634+ cells.slice(0, 2),635+ "undo",636+ alice.agent,637+ );638+ assertEquals(rr.retracted.length, 2);639+});640+641+// The same agent joining again is the same agent (no event); when it has642+// nothing playable left it is reseated on a fresh seed tile instead.643+Deno.test("join is idempotent and reseats a stranded agent", async () => {644+ const out = synthesise(gridLayout(3, 6), "reseat");645+ const sol = solutionOf(out);646+ const seat = (name: string, tile: number, hue: number) => ({647+ agent: name,648+ name,649+ model: null,650+ user: name,651+ hue,652+ seed_tile: tile,653+ day: "d",654+ reward: 1,655+ });656+ const ref = await Referee.create(657+ crypto.randomUUID(),658+ structuredClone(out),659+ { guess: "accept", depth: 0 },660+ stubJudge,661+ {662+ persist: false,663+ // a 3x3 grid: alice in a corner, bob and carol on non-touching tiles664+ // whose frontiers cover all of alice's neighbours (1, 3, 4)665+ honours: [seat("alice", 0, 0), seat("bob", 2, 1), seat("carol", 6, 2)],666+ },667+ );668+ const alice = await ref.join({ name: "alice" });669+ const bob = await ref.join({ name: "bob" });670+ const carol = await ref.join({ name: "carol" });671+ const rev = ref.revision;672+ const again = await ref.join({ name: "alice" });673+ assertEquals(again.agent, alice.agent);674+ assertEquals(ref.revision, rev, "no event for a repeated join");675+ assertEquals(ref.state.agents.length, 3);676+ const finish = async (tile: number, agent: string) => {677+ if (ref.state.tiles[tile] === "hidden") {678+ await ref.reveal(ref.revision, tile, "", agent);679+ }680+ const cells = ref.state.geo!.cellsOf[tile].filter((c) =>681+ !ref.state.cells[c]682+ );683+ await ref.commit(684+ ref.revision,685+ cells.map((c) => [c, sol(c)] as [number, number]),686+ "finish",687+ agent,688+ );689+ };690+ await finish(2, bob.agent);691+ await ref.reveal(ref.revision, 1, "", bob.agent);692+ await ref.reveal(ref.revision, 4, "", bob.agent);693+ await finish(6, carol.agent);694+ await ref.reveal(ref.revision, 3, "", carol.agent);695+ await finish(0, alice.agent);696+ assertEquals(ref.state.meets.length, 0, "nobody met");697+ // alice's frontier is other groups' revealed tiles only: stranded, so698+ // joining again reseats her on a fresh hidden tile that is nobody's seed699+ const before = ref.revision;700+ const moved = await ref.join({ name: "alice" });701+ assertEquals(moved.agent, alice.agent);702+ assertEquals(ref.revision, before + 1);703+ assert(![0, 2, 6].includes(moved.seed_tile));704+ assertEquals(ref.state.tiles[moved.seed_tile], "hidden");705+ assertEquals(706+ ref.state.agents.find((a) => a.id === alice.agent)!.seed_tile,707+ moved.seed_tile,708+ );709+ assertEquals(ref.state.agents.length, 3);710+ assert(frontierOf(ref.state, alice.agent).includes(moved.seed_tile));711+});