create-siltrun 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.mjs +98 -0
- package/package.json +18 -0
- package/templates/mass-grid/AGENTS.md +106 -0
- package/templates/mass-grid/README.md +59 -0
- package/templates/mass-grid/_gitignore +4 -0
- package/templates/mass-grid/index.html +22 -0
- package/templates/mass-grid/package.json +31 -0
- package/templates/mass-grid/room.test.ts +214 -0
- package/templates/mass-grid/room.ts +530 -0
- package/templates/mass-grid/skills/authoritative-tick.md +116 -0
- package/templates/mass-grid/skills/compact-snapshots.md +110 -0
- package/templates/mass-grid/skills/demo-room-lifecycle.md +145 -0
- package/templates/mass-grid/skills/genre-lane-mapping.md +88 -0
- package/templates/mass-grid/skills/territory-capture.md +115 -0
- package/templates/mass-grid/src/Game.tsx +174 -0
- package/templates/mass-grid/src/main.tsx +9 -0
- package/templates/mass-grid/tsconfig.json +15 -0
- package/templates/mass-grid/vite.config.ts +9 -0
- package/templates/minimal/README.md +39 -0
- package/templates/minimal/_gitignore +4 -0
- package/templates/minimal/index.html +15 -0
- package/templates/minimal/package.json +30 -0
- package/templates/minimal/room.ts +30 -0
- package/templates/minimal/src/Game.tsx +71 -0
- package/templates/minimal/src/main.tsx +9 -0
- package/templates/minimal/tsconfig.json +15 -0
- package/templates/minimal/vite.config.ts +7 -0
- package/templates/tower-defense/AGENTS.md +132 -0
- package/templates/tower-defense/README.md +53 -0
- package/templates/tower-defense/_gitignore +4 -0
- package/templates/tower-defense/index.html +15 -0
- package/templates/tower-defense/package.json +31 -0
- package/templates/tower-defense/room.test.ts +159 -0
- package/templates/tower-defense/room.ts +321 -0
- package/templates/tower-defense/skills/authoritative-tick.md +96 -0
- package/templates/tower-defense/skills/demo-room-lifecycle.md +145 -0
- package/templates/tower-defense/skills/state-budget.md +79 -0
- package/templates/tower-defense/skills/two-lanes.md +66 -0
- package/templates/tower-defense/skills/waves-and-timing.md +79 -0
- package/templates/tower-defense/src/Game.tsx +227 -0
- package/templates/tower-defense/src/main.tsx +9 -0
- package/templates/tower-defense/tsconfig.json +15 -0
- package/templates/tower-defense/vite.config.ts +7 -0
- package/templates/turn-based-grid/AGENTS.md +183 -0
- package/templates/turn-based-grid/README.md +72 -0
- package/templates/turn-based-grid/_gitignore +4 -0
- package/templates/turn-based-grid/index.html +15 -0
- package/templates/turn-based-grid/package.json +30 -0
- package/templates/turn-based-grid/room.ts +309 -0
- package/templates/turn-based-grid/skills/play.md +97 -0
- package/templates/turn-based-grid/src/App.tsx +141 -0
- package/templates/turn-based-grid/src/GraphicsView.tsx +148 -0
- package/templates/turn-based-grid/src/brains.ts +112 -0
- package/templates/turn-based-grid/src/main.tsx +8 -0
- package/templates/turn-based-grid/src/stubAgent.ts +82 -0
- package/templates/turn-based-grid/src/substrate/ascii.ts +87 -0
- package/templates/turn-based-grid/src/substrate/map.ts +104 -0
- package/templates/turn-based-grid/src/substrate/types.ts +59 -0
- package/templates/turn-based-grid/src/view.ts +94 -0
- package/templates/turn-based-grid/tsconfig.json +15 -0
- package/templates/turn-based-grid/vite.config.ts +7 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// GraphicsView.tsx — the HUMAN projection of the same deterministic State the agents
|
|
2
|
+
// read as ASCII. This is the wedge dramatized: identical truth, two renders.
|
|
3
|
+
//
|
|
4
|
+
// It makes the two "agents being agents" primitives READ to a spectator:
|
|
5
|
+
// • EMOTE CHANNEL — a player's emote glyph pops as a bubble above its piece and fades.
|
|
6
|
+
// • MISSED-BEAT STUTTER — a piece that failed to act by the boundary dims, shows a "z"
|
|
7
|
+
// thought bubble, and its scoreboard `missed` climbs. The slow agent visibly stutters
|
|
8
|
+
// while the greedy ones glide. Too-slow-is-the-fun, made watchable.
|
|
9
|
+
//
|
|
10
|
+
// Rendered on @siltrun/stage. This is a pure SPECTATOR surface — no interaction exists, so
|
|
11
|
+
// the ceremony is createStage + one fitRect, full stop (no input primitive). Movement is
|
|
12
|
+
// interpolated client-side (pieces glide toward their new cell each beat) — presentation
|
|
13
|
+
// over the one authoritative State; the truth is still the grid cell.
|
|
14
|
+
|
|
15
|
+
import { useEffect, useRef } from "react";
|
|
16
|
+
import { createStage, createCamera, type StageHandle } from "@siltrun/stage";
|
|
17
|
+
import { Graphics, Text } from "pixi.js";
|
|
18
|
+
import type { State } from "./substrate/types.ts";
|
|
19
|
+
import { MAP, GRID_W, GRID_H, BEAT_TICKS } from "./substrate/map.ts";
|
|
20
|
+
|
|
21
|
+
const CELL = 40; // world units per cell (arbitrary — the camera fits the board)
|
|
22
|
+
const INK = "#0b0b0d", WALL = "#050506", FLOOR = "#17171b", COPPER = "#b07050", RUST = "#a65a2e";
|
|
23
|
+
|
|
24
|
+
// Seat colors — the register's warm family (hue-restrained), keyed by seat glyph.
|
|
25
|
+
const COLORS: Record<string, string> = {
|
|
26
|
+
A: "#eae7de", B: "#cfc9bc", C: "#b07050", D: "#c98a3d", E: "#7e837a", F: "#a65a2e",
|
|
27
|
+
};
|
|
28
|
+
const colorFor = (glyph: string) => COLORS[glyph] ?? "#cfc9bc";
|
|
29
|
+
|
|
30
|
+
type Piece = { x: number; y: number; glyph: Text; bubble: Text };
|
|
31
|
+
|
|
32
|
+
export function GraphicsView({ state }: { state: State }) {
|
|
33
|
+
// The stage draws from the latest state via this ref (React re-renders only the DOM bar).
|
|
34
|
+
const stateRef = useRef(state);
|
|
35
|
+
stateRef.current = state;
|
|
36
|
+
const hostRef = useRef<HTMLDivElement>(null);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
let stage: StageHandle | undefined;
|
|
40
|
+
let cancelled = false;
|
|
41
|
+
// createStage is awaited INSIDE the effect — never top-level (see @siltrun/stage README).
|
|
42
|
+
createStage(hostRef.current!, { background: INK }).then((s) => {
|
|
43
|
+
if (cancelled) return s.dispose();
|
|
44
|
+
stage = s;
|
|
45
|
+
|
|
46
|
+
// One camera call: frame the whole board, refit on resize.
|
|
47
|
+
const cam = createCamera(s);
|
|
48
|
+
const fit = () => cam.fitRect({ x: 0, y: 0, w: GRID_W * CELL, h: GRID_H * CELL }, { pad: 6 });
|
|
49
|
+
fit();
|
|
50
|
+
s.onResize(fit);
|
|
51
|
+
|
|
52
|
+
const gfx = s.world.addChild(new Graphics());
|
|
53
|
+
// World-space text lives in Pixi (glyphs, bubbles). Each piece keeps its Text
|
|
54
|
+
// objects across frames — `.text` is only ASSIGNED on change, because every
|
|
55
|
+
// assignment re-rasterizes the glyph texture (the stage README's 10 Hz warning).
|
|
56
|
+
const pieces = new Map<string, Piece>();
|
|
57
|
+
const makeText = (size: number): Text => {
|
|
58
|
+
const t = new Text({ text: "", style: { fontFamily: "ui-monospace, monospace", fontSize: size, fontWeight: "700", fill: "#0b0b0c" } });
|
|
59
|
+
t.anchor.set(0.5);
|
|
60
|
+
return s.world.addChild(t);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
s.app.ticker.add(() => {
|
|
64
|
+
cam.update(1 / 60);
|
|
65
|
+
const st = stateRef.current;
|
|
66
|
+
gfx.clear();
|
|
67
|
+
|
|
68
|
+
// cells — walls tight, floors rounded (same shape language as the ASCII: # vs .)
|
|
69
|
+
for (let y = 0; y < GRID_H; y++)
|
|
70
|
+
for (let x = 0; x < GRID_W; x++) {
|
|
71
|
+
const wall = MAP[y]!.charAt(x) === "#";
|
|
72
|
+
gfx.roundRect(x * CELL + 2, y * CELL + 2, CELL - 4, CELL - 4, wall ? 3 : 6)
|
|
73
|
+
.fill(wall ? WALL : FLOOR);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// pellets — the scoring resource; warmth carries the meaning (copper, pulsing)
|
|
77
|
+
const pulse = 0.55 + 0.45 * Math.sin(performance.now() / 300);
|
|
78
|
+
for (const p of st.pellets) {
|
|
79
|
+
gfx.circle(p.x * CELL + CELL / 2, p.y * CELL + CELL / 2, 5).fill({ color: COPPER, alpha: pulse });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// pieces — eased toward the authoritative cell (the glide); truth stays discrete
|
|
83
|
+
for (const [id, pc] of pieces) {
|
|
84
|
+
if (!st.players[id]) {
|
|
85
|
+
pc.glyph.destroy();
|
|
86
|
+
pc.bubble.destroy();
|
|
87
|
+
pieces.delete(id);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const p of Object.values(st.players)) {
|
|
91
|
+
let pc = pieces.get(p.id);
|
|
92
|
+
if (!pc) {
|
|
93
|
+
pc = { x: p.pos.x * CELL, y: p.pos.y * CELL, glyph: makeText(16), bubble: makeText(15) };
|
|
94
|
+
pc.glyph.text = p.glyph;
|
|
95
|
+
pieces.set(p.id, pc);
|
|
96
|
+
}
|
|
97
|
+
pc.x += (p.pos.x * CELL - pc.x) * 0.22; // ease per frame (60fps)
|
|
98
|
+
pc.y += (p.pos.y * CELL - pc.y) * 0.22;
|
|
99
|
+
const cx = pc.x + CELL / 2, cy = pc.y + CELL / 2;
|
|
100
|
+
const missed = !p.actedLastBeat && st.phase === "playing";
|
|
101
|
+
const color = colorFor(p.glyph);
|
|
102
|
+
const alpha = missed ? 0.5 : 1;
|
|
103
|
+
|
|
104
|
+
if (missed) gfx.circle(cx, cy, CELL / 2 - 3).stroke({ color: RUST, width: 1.5, alpha }); // stutter halo
|
|
105
|
+
gfx.roundRect(pc.x + 5, pc.y + 5, CELL - 10, CELL - 10, 7)
|
|
106
|
+
.fill({ color, alpha })
|
|
107
|
+
.stroke({ color: missed ? RUST : "#eae7de", width: 1.5, alpha: missed ? alpha : 0.25 });
|
|
108
|
+
pc.glyph.position.set(cx, cy);
|
|
109
|
+
pc.glyph.alpha = alpha;
|
|
110
|
+
|
|
111
|
+
// the coarse comms channel (emote bubble) — or the "z" thinking-past-the-beat mark
|
|
112
|
+
const bubbleText = p.emote ?? (missed ? "z" : "");
|
|
113
|
+
if (pc.bubble.text !== bubbleText) pc.bubble.text = bubbleText;
|
|
114
|
+
if (bubbleText) {
|
|
115
|
+
if (p.emote) {
|
|
116
|
+
gfx.roundRect(cx + 8, pc.y - 12, 22, 22, 6).fill("#121214").stroke({ color, width: 1.5 });
|
|
117
|
+
pc.bubble.style.fill = color;
|
|
118
|
+
} else {
|
|
119
|
+
pc.bubble.style.fill = RUST;
|
|
120
|
+
}
|
|
121
|
+
pc.bubble.position.set(cx + 19, pc.y - 1);
|
|
122
|
+
pc.bubble.visible = true;
|
|
123
|
+
} else {
|
|
124
|
+
pc.bubble.visible = false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
return () => { cancelled = true; stage?.dispose(); };
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
132
|
+
const ticksLeft = Math.max(0, state.nextBeatTick - state.clock);
|
|
133
|
+
const beatProgress = state.phase === "playing" ? 1 - ticksLeft / BEAT_TICKS : 0;
|
|
134
|
+
const W = "min(92vw, 480px)";
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div>
|
|
138
|
+
{/* beat rhythm bar — fills over the beat, snaps back when the beat resolves */}
|
|
139
|
+
<div style={{ width: W, height: 6, background: "#1c1c1f", borderRadius: 3, overflow: "hidden", marginBottom: 8 }}>
|
|
140
|
+
<div style={{
|
|
141
|
+
width: `${Math.round(beatProgress * 100)}%`, height: "100%",
|
|
142
|
+
background: state.phase === "playing" ? "#cfc9bc" : "#3a3a3d", transition: "width 90ms linear",
|
|
143
|
+
}} />
|
|
144
|
+
</div>
|
|
145
|
+
<div ref={hostRef} style={{ width: W, aspectRatio: `${GRID_W} / ${GRID_H}`, borderRadius: 10, overflow: "hidden" }} />
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// brains.ts — scripted stub agents. Each brain is a pure decision function over the
|
|
2
|
+
// AGENT PERCEPTION (the rendered ASCII string) — it never sees State. That constraint
|
|
3
|
+
// is the whole point: if these can play from text alone, the substrate is sufficient,
|
|
4
|
+
// and the same brain lifts behind a real MCP agent later (DIG-718) unchanged.
|
|
5
|
+
//
|
|
6
|
+
// The four brains double as the substrate's live demonstrators:
|
|
7
|
+
// greedy — competent play (walks to the nearest pellet)
|
|
8
|
+
// random — a control (moves arbitrarily)
|
|
9
|
+
// chatty — exercises the EMOTE CHANNEL (a 728 primitive)
|
|
10
|
+
// slow — thinks longer than a beat, so it visibly MISSES beats (a 728 primitive)
|
|
11
|
+
|
|
12
|
+
import type { Cmd, Dir } from "./substrate/types.ts";
|
|
13
|
+
import { EMOTE_VOCAB } from "./substrate/map.ts";
|
|
14
|
+
import { readView, stepTowardNearestPellet } from "./view.ts";
|
|
15
|
+
|
|
16
|
+
export type Brain = {
|
|
17
|
+
name: string;
|
|
18
|
+
/** simulated think latency in ms — the read→think→act loop's variable cost. */
|
|
19
|
+
thinkMs: () => number;
|
|
20
|
+
/** decide an action from the ASCII perception alone. null = do nothing this cycle. */
|
|
21
|
+
decide: (ascii: string, rng: () => number) => Cmd | null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const DIRS: Dir[] = ["up", "down", "left", "right", "stay"];
|
|
25
|
+
|
|
26
|
+
/** competent: pathfind to the nearest pellet, else drift to a random legal cell. */
|
|
27
|
+
export const greedy: Brain = {
|
|
28
|
+
name: "greedy",
|
|
29
|
+
thinkMs: () => 250 + Math.random() * 400, // ~0.25–0.65s: comfortably inside a 4s beat
|
|
30
|
+
decide(ascii) {
|
|
31
|
+
const view = readView(ascii);
|
|
32
|
+
const toward = stepTowardNearestPellet(view);
|
|
33
|
+
if (toward) return { t: "act", move: toward };
|
|
34
|
+
// no reachable pellet: pick any non-wall step so the piece still reads as alive.
|
|
35
|
+
if (view.self) {
|
|
36
|
+
for (const dir of ["up", "down", "left", "right"] as Dir[]) {
|
|
37
|
+
const d = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[dir as "up"];
|
|
38
|
+
if (!view.isWall(view.self.x + d[0], view.self.y + d[1])) return { t: "act", move: dir };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { t: "act", move: "stay" };
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** control: a legal random move each cycle. */
|
|
46
|
+
export const random: Brain = {
|
|
47
|
+
name: "random",
|
|
48
|
+
thinkMs: () => 200 + Math.random() * 500,
|
|
49
|
+
decide(ascii, rng) {
|
|
50
|
+
const view = readView(ascii);
|
|
51
|
+
if (!view.self) return { t: "act", move: "stay" };
|
|
52
|
+
const legal = (["up", "down", "left", "right"] as Dir[]).filter((dir) => {
|
|
53
|
+
const d = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[dir as "up"];
|
|
54
|
+
return !view.isWall(view.self!.x + d[0], view.self!.y + d[1]);
|
|
55
|
+
});
|
|
56
|
+
legal.push("stay");
|
|
57
|
+
return { t: "act", move: legal[Math.floor(rng() * legal.length)] };
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** talkative: plays greedily but every few cycles flashes an emote — exercises the channel. */
|
|
62
|
+
export function makeChatty(): Brain {
|
|
63
|
+
let cycle = 0;
|
|
64
|
+
return {
|
|
65
|
+
name: "chatty",
|
|
66
|
+
thinkMs: () => 300 + Math.random() * 400,
|
|
67
|
+
decide(ascii, rng) {
|
|
68
|
+
cycle++;
|
|
69
|
+
if (cycle % 3 === 0) {
|
|
70
|
+
const glyph = EMOTE_VOCAB[Math.floor(rng() * EMOTE_VOCAB.length)];
|
|
71
|
+
return { t: "emote", glyph };
|
|
72
|
+
}
|
|
73
|
+
return greedy.decide(ascii, rng);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* SLOW: thinks longer than a beat (beat ≈ 4000ms), straddling the boundary so it lands
|
|
80
|
+
* some beats and misses many — the visible stutter. Its missedBeats climbs while the
|
|
81
|
+
* greedy agents' stays near zero. This is the too-slow-is-the-fun demonstrator.
|
|
82
|
+
*/
|
|
83
|
+
export const slow: Brain = {
|
|
84
|
+
name: "slow",
|
|
85
|
+
thinkMs: () => 3500 + Math.random() * 4000, // ~3.5–7.5s vs a 4s beat → frequent misses
|
|
86
|
+
decide(ascii, rng) {
|
|
87
|
+
return greedy.decide(ascii, rng);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const BRAINS: Record<string, () => Brain> = {
|
|
92
|
+
greedy: () => greedy,
|
|
93
|
+
random: () => random,
|
|
94
|
+
chatty: () => makeChatty(),
|
|
95
|
+
slow: () => slow,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** small deterministic-ish rng for a stub (seeded off its id so runs are reproducible-ish). */
|
|
99
|
+
export function seededRng(seedStr: string): () => number {
|
|
100
|
+
let h = 2166136261;
|
|
101
|
+
for (let i = 0; i < seedStr.length; i++) {
|
|
102
|
+
h ^= seedStr.charCodeAt(i);
|
|
103
|
+
h = Math.imul(h, 16777619);
|
|
104
|
+
}
|
|
105
|
+
return () => {
|
|
106
|
+
h += 0x6d2b79f5;
|
|
107
|
+
let t = h;
|
|
108
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
109
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
110
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { createRoot } from "react-dom/client";
|
|
2
|
+
import { App } from "./App.tsx";
|
|
3
|
+
|
|
4
|
+
// NB: no React.StrictMode here. StrictMode's dev double-invoke of effects would launch
|
|
5
|
+
// the long-lived stub-agent connections, tear them down, and not relaunch them (the
|
|
6
|
+
// once-guard suppresses the second run). The stubs are real sockets, not idempotent
|
|
7
|
+
// effects — so this app opts out of StrictMode rather than fight the double-mount.
|
|
8
|
+
createRoot(document.getElementById("root")!).render(<App />);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// stubAgent.ts — a scripted agent as a REAL Silt client. It opens its own joinRoom
|
|
2
|
+
// connection (exactly like the drift diag two-connection pattern), so from the room's
|
|
3
|
+
// point of view it is indistinguishable from any other peer — and from a real MCP-driven
|
|
4
|
+
// agent later (DIG-718). The only difference is the brain: scripted here, an LLM there.
|
|
5
|
+
//
|
|
6
|
+
// The read→think→act loop is deliberately faithful to real agent latency:
|
|
7
|
+
// 1. snapshot the latest received State,
|
|
8
|
+
// 2. render it to the ASCII perception the brain is allowed to see,
|
|
9
|
+
// 3. WAIT brain.thinkMs (the variable LLM-latency stand-in),
|
|
10
|
+
// 4. send the decision on the reliable lane.
|
|
11
|
+
// Because the send happens AFTER the think delay, the agent acts on stale perception and
|
|
12
|
+
// may land after the beat boundary — which is the entire point of the model.
|
|
13
|
+
|
|
14
|
+
import { joinRoom, type Room } from "@siltrun/client";
|
|
15
|
+
import type { State } from "./substrate/types.ts";
|
|
16
|
+
import { renderAscii } from "./substrate/ascii.ts";
|
|
17
|
+
import type { Brain } from "./brains.ts";
|
|
18
|
+
import { seededRng } from "./brains.ts";
|
|
19
|
+
|
|
20
|
+
export type StubHandle = { id: string; brain: string; room: Room; stop: () => void };
|
|
21
|
+
|
|
22
|
+
export async function runStubAgent(opts: {
|
|
23
|
+
url: string;
|
|
24
|
+
id: string;
|
|
25
|
+
brain: Brain;
|
|
26
|
+
certHash?: string;
|
|
27
|
+
}): Promise<StubHandle> {
|
|
28
|
+
const { url, id, brain } = opts;
|
|
29
|
+
const room = await joinRoom(url, { id, certHash: opts.certHash });
|
|
30
|
+
const rng = seededRng(id);
|
|
31
|
+
let latest: State | null = null;
|
|
32
|
+
let running = true;
|
|
33
|
+
let thinking = false;
|
|
34
|
+
|
|
35
|
+
room.on("state", (s) => {
|
|
36
|
+
latest = s.state as State;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// take a seat immediately; if the connection blips, we re-sit on reconnect via the loop.
|
|
40
|
+
room.events.send({ t: "sit" });
|
|
41
|
+
|
|
42
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
43
|
+
|
|
44
|
+
(async function loop() {
|
|
45
|
+
while (running) {
|
|
46
|
+
// `as`: TS can't see the room.on("state") callback assigning `latest`, so it
|
|
47
|
+
// narrows it to its `null` initializer here. The cast restores the real type.
|
|
48
|
+
const cur = latest as State | null;
|
|
49
|
+
if (cur) {
|
|
50
|
+
// ensure we're seated: resend sit until we appear as a player.
|
|
51
|
+
if (!cur.players[id]) {
|
|
52
|
+
room.events.send({ t: "sit" });
|
|
53
|
+
await sleep(200);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (!thinking) {
|
|
57
|
+
thinking = true;
|
|
58
|
+
const ascii = renderAscii(cur, id); // the ONLY thing the brain sees
|
|
59
|
+
await sleep(brain.thinkMs()); // variable think latency
|
|
60
|
+
if (!running) break;
|
|
61
|
+
const cmd = brain.decide(ascii, rng);
|
|
62
|
+
if (cmd) room.events.send(cmd);
|
|
63
|
+
thinking = false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
await sleep(60);
|
|
67
|
+
}
|
|
68
|
+
})();
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
id,
|
|
72
|
+
brain: brain.name,
|
|
73
|
+
room,
|
|
74
|
+
stop() {
|
|
75
|
+
running = false;
|
|
76
|
+
try {
|
|
77
|
+
room.events.send({ t: "stand" });
|
|
78
|
+
} catch {}
|
|
79
|
+
room.close?.();
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// ascii.ts — the AGENT-FACING projection of the canonical state.
|
|
2
|
+
//
|
|
3
|
+
// One deterministic State; two renders. The human sees the React graphics view; the
|
|
4
|
+
// agent sees THIS — a plain-text grid plus a status block. This string is the agent's
|
|
5
|
+
// entire sensorium. The stub-agent honesty rule (test/perception.test.ts) is that a
|
|
6
|
+
// brain may read ONLY this string and the SYMBOLS table — never the State object — so
|
|
7
|
+
// if a brain can play, the projection is provably sufficient.
|
|
8
|
+
|
|
9
|
+
import type { State, Pos } from "./types.ts";
|
|
10
|
+
import { MAP, GRID_W, GRID_H, cellKey } from "./map.ts";
|
|
11
|
+
|
|
12
|
+
/** The symbol table. Publish this to agents verbatim (AGENTS.md embeds it). */
|
|
13
|
+
export const SYMBOLS: ReadonlyArray<{ glyph: string; meaning: string }> = [
|
|
14
|
+
{ glyph: "#", meaning: "wall — impassable" },
|
|
15
|
+
{ glyph: ".", meaning: "floor — walkable" },
|
|
16
|
+
{ glyph: "*", meaning: "pellet — move onto it to score" },
|
|
17
|
+
{ glyph: "A-Z", meaning: "a player piece — your own letter is named in the status line" },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function playerAt(state: State, key: string): string | null {
|
|
21
|
+
for (const id in state.players) {
|
|
22
|
+
const p = state.players[id];
|
|
23
|
+
if (cellKey(p.pos) === key) return p.glyph;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function pelletKeys(state: State): Set<string> {
|
|
29
|
+
const s = new Set<string>();
|
|
30
|
+
for (const p of state.pellets) s.add(cellKey(p));
|
|
31
|
+
return s;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Render just the grid (walls, pellets, players) — no status. */
|
|
35
|
+
export function renderGrid(state: State): string {
|
|
36
|
+
const pellets = pelletKeys(state);
|
|
37
|
+
const rows: string[] = [];
|
|
38
|
+
for (let y = 0; y < GRID_H; y++) {
|
|
39
|
+
let row = "";
|
|
40
|
+
for (let x = 0; x < GRID_W; x++) {
|
|
41
|
+
const key = `${x},${y}`;
|
|
42
|
+
if (MAP[y][x] === "#") { row += "#"; continue; }
|
|
43
|
+
const who = playerAt(state, key);
|
|
44
|
+
if (who) { row += who; continue; }
|
|
45
|
+
if (pellets.has(key)) { row += "*"; continue; }
|
|
46
|
+
row += ".";
|
|
47
|
+
}
|
|
48
|
+
rows.push(row);
|
|
49
|
+
}
|
|
50
|
+
return rows.join("\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The full agent perception: grid + status. `selfId` names which piece is "you" and
|
|
55
|
+
* surfaces your score/emote and how many ticks remain before the beat resolves — the
|
|
56
|
+
* agent's read on "how long do I have to think." Others' emotes are listed so coarse
|
|
57
|
+
* comms is legible from the ASCII alone.
|
|
58
|
+
*/
|
|
59
|
+
export function renderAscii(state: State, selfId?: string): string {
|
|
60
|
+
const grid = renderGrid(state);
|
|
61
|
+
const lines: string[] = [grid, ""];
|
|
62
|
+
|
|
63
|
+
const self = selfId ? state.players[selfId] : undefined;
|
|
64
|
+
const ticksLeft = Math.max(0, state.nextBeatTick - state.clock);
|
|
65
|
+
lines.push(`phase: ${state.phase} round: ${state.round} beat: ${state.beat}`);
|
|
66
|
+
lines.push(`beat resolves in: ${ticksLeft} ticks (~${(ticksLeft / 60).toFixed(1)}s) pellets left: ${state.pellets.length}`);
|
|
67
|
+
|
|
68
|
+
if (self) {
|
|
69
|
+
const buffered = state.pending[selfId!];
|
|
70
|
+
lines.push(
|
|
71
|
+
`you are: ${self.glyph} pos: (${self.pos.x},${self.pos.y}) score: ${self.score}` +
|
|
72
|
+
` missed: ${self.missedBeats} buffered this beat: ${buffered ?? "—"}`,
|
|
73
|
+
);
|
|
74
|
+
} else if (selfId) {
|
|
75
|
+
lines.push(`you are: (spectating — send { t: "sit" } to take a seat)`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const others = Object.values(state.players).filter((p) => p.id !== selfId);
|
|
79
|
+
if (others.length) {
|
|
80
|
+
lines.push(
|
|
81
|
+
`others: ${others
|
|
82
|
+
.map((p) => `${p.glyph}@(${p.pos.x},${p.pos.y})${p.emote ? " " + p.emote : ""}`)
|
|
83
|
+
.join(" ")}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return lines.join("\n");
|
|
87
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// map.ts — the static arena + the tick/beat constants + pure geometry.
|
|
2
|
+
//
|
|
3
|
+
// Shared by the server contract and the client. The MAP literal is itself an ASCII
|
|
4
|
+
// teaching artifact: `#` wall, `.` floor. It is the SAME symbol vocabulary the agent
|
|
5
|
+
// perceives, so the source you read and the grid the agent reads are one thing.
|
|
6
|
+
|
|
7
|
+
import type { Dir, Pos } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
// ── The tick / beat model constants (the load-bearing design, DIG-725) ──
|
|
10
|
+
//
|
|
11
|
+
// The relay's world clock runs at a fixed 60 ticks/sec and never pauses while ≥1 peer
|
|
12
|
+
// is present. On top of that constant clock we quantize turns into BEATS.
|
|
13
|
+
//
|
|
14
|
+
// A beat is BEAT_TICKS ticks (~4s). Agents buffer at most one action per beat on the
|
|
15
|
+
// reliable lane; at the beat boundary every buffered action resolves SIMULTANEOUSLY.
|
|
16
|
+
// An agent still thinking when the boundary lands simply has nothing buffered — it
|
|
17
|
+
// idles that beat. The too-slow failure is visible (missedBeats) and is the fun.
|
|
18
|
+
//
|
|
19
|
+
// Tune BEAT_TICKS to taste — it's the one knob for tempo. Everything else derives.
|
|
20
|
+
export const TICKS_PER_SEC = 60;
|
|
21
|
+
export const BEAT_TICKS = 240; // ~4.0s per beat — a watchable turn cadence
|
|
22
|
+
export const ROUND_BEATS = 20; // a round runs 20 beats (~80s), then intermission
|
|
23
|
+
export const INTERMISSION_BEATS = 3; // scoreboard breather between rounds (~12s)
|
|
24
|
+
export const EMOTE_DECAY_BEATS = 2; // an emote glyph shows for 2 beats, then clears
|
|
25
|
+
export const PELLET_COUNT = 10; // collectibles spawned per round
|
|
26
|
+
|
|
27
|
+
// The coarse comms vocabulary — deliberately tiny. Agents don't chat in prose; they
|
|
28
|
+
// flash one primitive. Meanings are convention (documented in AGENTS.md); the substrate
|
|
29
|
+
// only guarantees the glyph is delivered, rendered, and decays deterministically.
|
|
30
|
+
export const EMOTE_VOCAB = ["!", "?", "+", "-", "^", "x"] as const;
|
|
31
|
+
export function isEmoteGlyph(g: unknown): g is string {
|
|
32
|
+
return typeof g === "string" && (EMOTE_VOCAB as readonly string[]).includes(g);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── The arena. 12×12. Border walls + a few interior blocks to make routing matter. ──
|
|
36
|
+
export const MAP: readonly string[] = [
|
|
37
|
+
"############",
|
|
38
|
+
"#..........#",
|
|
39
|
+
"#..#....#..#",
|
|
40
|
+
"#..........#",
|
|
41
|
+
"#....##....#",
|
|
42
|
+
"#....##....#",
|
|
43
|
+
"#..........#",
|
|
44
|
+
"#..#....#..#",
|
|
45
|
+
"#..........#",
|
|
46
|
+
"#..........#",
|
|
47
|
+
"#..........#",
|
|
48
|
+
"############",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
export const GRID_W = MAP[0].length; // 12
|
|
52
|
+
export const GRID_H = MAP.length; // 12
|
|
53
|
+
|
|
54
|
+
export function isWall(x: number, y: number): boolean {
|
|
55
|
+
if (y < 0 || y >= GRID_H || x < 0 || x >= GRID_W) return true; // out of bounds = wall
|
|
56
|
+
return MAP[y][x] === "#";
|
|
57
|
+
}
|
|
58
|
+
export function isFloor(x: number, y: number): boolean {
|
|
59
|
+
return !isWall(x, y);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** All floor cells, top-to-bottom / left-to-right — a stable ordering for deterministic spawns. */
|
|
63
|
+
export const FLOOR_CELLS: readonly Pos[] = (() => {
|
|
64
|
+
const cells: Pos[] = [];
|
|
65
|
+
for (let y = 0; y < GRID_H; y++) {
|
|
66
|
+
for (let x = 0; x < GRID_W; x++) {
|
|
67
|
+
if (isFloor(x, y)) cells.push({ x, y });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return cells;
|
|
71
|
+
})();
|
|
72
|
+
|
|
73
|
+
/** Fixed seat spawn points (floor cells near the corners), assigned in seat order. */
|
|
74
|
+
export const SPAWNS: readonly Pos[] = [
|
|
75
|
+
{ x: 1, y: 1 },
|
|
76
|
+
{ x: 10, y: 10 },
|
|
77
|
+
{ x: 10, y: 1 },
|
|
78
|
+
{ x: 1, y: 10 },
|
|
79
|
+
{ x: 1, y: 5 },
|
|
80
|
+
{ x: 10, y: 6 },
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
const DELTA: Record<Dir, Pos> = {
|
|
84
|
+
up: { x: 0, y: -1 },
|
|
85
|
+
down: { x: 0, y: 1 },
|
|
86
|
+
left: { x: -1, y: 0 },
|
|
87
|
+
right: { x: 1, y: 0 },
|
|
88
|
+
stay: { x: 0, y: 0 },
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Where `dir` from `pos` lands — no wall check (the resolver decides legality). */
|
|
92
|
+
export function step(pos: Pos, dir: Dir): Pos {
|
|
93
|
+
const d = DELTA[dir];
|
|
94
|
+
return { x: pos.x + d.x, y: pos.y + d.y };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const cellKey = (p: Pos): string => `${p.x},${p.y}`;
|
|
98
|
+
export const samePos = (a: Pos, b: Pos): boolean => a.x === b.x && a.y === b.y;
|
|
99
|
+
|
|
100
|
+
/** Seat index → board glyph. A, B, C … then wraps into lowercase if ever exhausted. */
|
|
101
|
+
export function glyphFor(seatIndex: number): string {
|
|
102
|
+
const upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
103
|
+
return upper[seatIndex % upper.length];
|
|
104
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// types.ts — the shape of the world, shared verbatim across the seam.
|
|
2
|
+
//
|
|
3
|
+
// The SERVER contract (../../room.ts) produces `State`; the CLIENT (spectator app +
|
|
4
|
+
// stub agents) reads it. Both import these types. Nothing here is server-only —
|
|
5
|
+
// it's plain data (CONTRACT §1: "State is just data"), so the same file is safe in
|
|
6
|
+
// the browser bundle and in the Bun host.
|
|
7
|
+
|
|
8
|
+
/** The five things an agent can do with a turn. `stay` is a real, deliberate move. */
|
|
9
|
+
export type Dir = "up" | "down" | "left" | "right" | "stay";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The AGENT ACTION API — every message an agent sends on the RELIABLE, ordered lane
|
|
13
|
+
* (`send(msg, { reliable: true })`). Turns MUST NOT ride the droppable datagram lane;
|
|
14
|
+
* a dropped turn is a silently missed beat you didn't choose. This genre→lane mapping
|
|
15
|
+
* is a teaching point (see AGENTS.md).
|
|
16
|
+
*
|
|
17
|
+
* NB: none of these carry a `clientTick` field — that field would divert the event
|
|
18
|
+
* into the relay's lag-compensation shot path (main.go). Turn games want the plain
|
|
19
|
+
* ordered queue, so the shapes stay clientTick-free by construction.
|
|
20
|
+
*/
|
|
21
|
+
export type Cmd =
|
|
22
|
+
| { t: "sit" } // take a seat: spectator → player (spawns your piece)
|
|
23
|
+
| { t: "stand" } // leave your seat: player → spectator (despawns)
|
|
24
|
+
| { t: "act"; move: Dir } // buffer your move for the CURRENT beat (latest-wins)
|
|
25
|
+
| { t: "emote"; glyph: string }; // broadcast a coarse comms glyph (applies on arrival)
|
|
26
|
+
|
|
27
|
+
export type Pos = { x: number; y: number };
|
|
28
|
+
|
|
29
|
+
export type Player = {
|
|
30
|
+
id: string;
|
|
31
|
+
glyph: string; // stable single-char board symbol, assigned in seat order (A, B, C…)
|
|
32
|
+
pos: Pos;
|
|
33
|
+
score: number;
|
|
34
|
+
emote: string | null; // currently-showing emote glyph, or null
|
|
35
|
+
emoteUntilBeat: number; // beat index at which the emote decays back to null
|
|
36
|
+
missedBeats: number; // cumulative beats this round where no action was buffered in time
|
|
37
|
+
actedLastBeat: boolean; // did they land an action on the most recent beat resolution (view hint)
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type Phase = "waiting" | "playing" | "intermission";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* THE canonical state — the single deterministic source both renders project from.
|
|
44
|
+
* Kept small on purpose (BOUNDARIES.md state-size ceiling): the static map lives as a
|
|
45
|
+
* module constant, NOT in state; only what changes is here.
|
|
46
|
+
*/
|
|
47
|
+
export type State = {
|
|
48
|
+
phase: Phase;
|
|
49
|
+
round: number; // 0 while waiting for the first sitter; 1+ once play starts
|
|
50
|
+
clock: number; // mirror of ctx.tick — server truth, for the countdown view
|
|
51
|
+
beat: number; // beat index within the current phase span
|
|
52
|
+
phaseStartTick: number; // anchor: beat = floor((clock - phaseStartTick) / BEAT_TICKS)
|
|
53
|
+
nextBeatTick: number; // clock at which the current beat resolves (derived, stored for view)
|
|
54
|
+
players: Record<string, Player>;
|
|
55
|
+
spectators: string[]; // connected peers who haven't sat down (the humans watching)
|
|
56
|
+
pending: Record<string, Dir>; // buffered action per seated player, latest-wins, cleared each beat
|
|
57
|
+
pellets: Pos[]; // remaining collectibles this round
|
|
58
|
+
seatCounter: number; // monotonic seat index → next glyph, so leaving/rejoining doesn't reuse a letter mid-round
|
|
59
|
+
};
|