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>
| M | site/ | 29 | ++++++ |
| M | site/ | 16 | +++- |
| M | site/ | 11 | ++- |
| M | site/ | 91 | ++++++++++++++++++- |
| M | site/ | 146 | ++++++++++++++++++++++++++++++ |
@@ -1115,6 +1115,11 @@ function newAgentStats(geo: Geometry, seed: number): AgentStats {
11151115 };11161116 }11171117 1118+/** The seed-dependent part of an agent's stats (moves on a reseat). */1119+function seedStats(geo: Geometry, seed: number): Pick<AgentStats, "seed_dist"> {1120+ return { seed_dist: distancesFrom(geo, seed) };1121+}1122+11181123 /** Register an agent in the state (mutates s). */11191124 function addAgent(11201125 s: State,@@ -1416,6 +1421,30 @@ export function applyEvent(prev: State, ev: GameEvent): State {
14161421 break;14171422 }14181423 case "join": {1424+ const existing = agentOf(s, ev.agent);1425+ if (existing) {1426+ // the same agent joining again: a reseat on a fresh seed tile1427+ // (it had nowhere left to play); identity, group and counters1428+ // stay, only the seed moves1429+ s.agents = s.agents.map((a) =>1430+ a.id === ev.agent ? { ...a, seed_tile: ev.seed_tile } : a1431+ );1432+ s.agentStats = {1433+ ...s.agentStats,1434+ [ev.agent]: {1435+ ...s.agentStats[ev.agent],1436+ ...seedStats(s.geo!, ev.seed_tile),1437+ },1438+ };1439+ s.console.push({1440+ ...base,1441+ op: "join",1442+ agent: ev.agent,1443+ tile: ev.seed_tile,1444+ intent: `${existing.name} reseated at seed tile #${ev.seed_tile}`,1445+ });1446+ break;1447+ }14191448 addAgent(s, {14201449 id: ev.agent,14211450 name: ev.name ?? ev.agent,@@ -103,9 +103,21 @@ function requireGame(id: string) {
103103 );104104 }105105 106+/** On the daily host nothing may create games: the day's field is the107+ * only one, and agents join it. start_mosaic exists for a checkout. */108+export function hostedDaily(): boolean {109+ return Deno.env.get("MOSAIC_DAILY") === "1";110+}111+106112 export function createServer(): McpServer {107113 const server = new McpServer({ name: "mosaic", version: "0.3.0" });108114 115+ if (!hostedDaily()) registerStart(server);116+ registerPlay(server);117+ return server;118+}119+120+function registerStart(server: McpServer): void {109121 server.registerTool(110122 "start_mosaic",111123 {@@ -191,7 +203,9 @@ export function createServer(): McpServer {
191203 }192204 },193205 );206+}194207 208+function registerPlay(server: McpServer): void {195209 server.registerTool(196210 "list_games",197211 {@@ -427,6 +441,4 @@ export function createServer(): McpServer {
427441 }428442 },429443 );430-431- return server;432444 }@@ -29,14 +29,21 @@ export function promptFor(origin: string, day?: string | null): string {
2929 /** The prompt itself (what "copy prompt" copies). */3030 export function bootstrap(origin: string, day?: string | null): string[] {3131 const o = origin.replace(/\/$/, "");32+ const hosted = Deno.env.get("MOSAIC_DAILY") === "1";3233 const target = day3334 ? `join today's field (${day}): call list_games and join the game whose day is ${day}`3435 : "call list_games; if a field is playing, join it";36+ const noField = hosted37+ ? "If no field is playing yet, wait a few minutes and call list_games again"38+ : "If no field is playing, start one (start_mosaic with kind=field, chunks=3,";39+ const noField2 = hosted40+ ? "(today's field may still be generating); never try to create one."41+ : "grow={every_minutes: 30}) and join it.";3542 return [3643 `Using the mosaic MCP server at ${o}/mcp: ${target} (join with game_id, a`,3744 "short name, machine_id if you have a durable one, and the model you are, e.g.",38- "model=claude-opus-5). If no field is playing, start one (start_mosaic with",39- "kind=field, chunks=3, grow={every_minutes: 30}) and join it. join gives you an",45+ `model=claude-opus-5). ${noField}`,46+ `${noField2} join gives you an`,4047 "agent id and a seed tile at a random spot; every reveal, commit and retract",4148 "must carry that agent id. Play autonomously with observe (agent=<your id>:",4249 "your territory and its surroundings), reveal and commit. Read the tool",@@ -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) {@@ -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+});