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,15 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
|
6
|
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
7
|
+
<meta name="mobile-web-app-capable" content="yes" />
|
|
8
|
+
<style>html, body { overscroll-behavior: none; }</style>
|
|
9
|
+
<title>turn-based grid — agent substrate</title>
|
|
10
|
+
</head>
|
|
11
|
+
<body style="margin: 0; background: #0b0b0c">
|
|
12
|
+
<div id="root"></div>
|
|
13
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "turn-based-grid-game",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "concurrently -k -n room,web -c yellow,cyan \"siltrun dev room.ts\" \"vite\"",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"preview": "vite preview",
|
|
10
|
+
"typecheck": "tsc --noEmit"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@siltrun/stage": "^0.1.0",
|
|
14
|
+
"pixi.js": "^8.19.0",
|
|
15
|
+
"@siltrun/client": "^0.3.0",
|
|
16
|
+
"@siltrun/react": "^0.1.0",
|
|
17
|
+
"react": "^18.3.0",
|
|
18
|
+
"react-dom": "^18.3.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@siltrun/room": "^0.1.0",
|
|
22
|
+
"@types/react": "^18.3.0",
|
|
23
|
+
"@types/react-dom": "^18.3.0",
|
|
24
|
+
"@vitejs/plugin-react": "^4.3.0",
|
|
25
|
+
"concurrently": "^9.0.0",
|
|
26
|
+
"siltrun": "^0.1.0",
|
|
27
|
+
"typescript": "^5.6.0",
|
|
28
|
+
"vite": "^5.4.0"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// room.ts — THE AUTHORITY. This contract runs server-side inside Silt's deterministic
|
|
2
|
+
// realm; it is the single source of truth the whole world projects from. Agents submit
|
|
3
|
+
// intent on the reliable lane; this tick() decides what actually happens; every peer
|
|
4
|
+
// (playing agents + spectating humans) receives the resulting State each tick.
|
|
5
|
+
//
|
|
6
|
+
// THE TICK / BEAT MODEL (DIG-725, the load-bearing decision) ─────────────────────────
|
|
7
|
+
//
|
|
8
|
+
// • The relay clock is a constant 60Hz and never pauses while ≥1 peer is present.
|
|
9
|
+
// We do NOT fight it. On top of it we quantize turns into BEATS of BEAT_TICKS ticks.
|
|
10
|
+
// • Agents buffer at most ONE action per beat (reliable lane, latest-wins within the
|
|
11
|
+
// window — you may revise until the beat lands). We hold the buffer in `state.pending`.
|
|
12
|
+
// • At the beat boundary, ALL buffered actions resolve SIMULTANEOUSLY from one snapshot,
|
|
13
|
+
// with symmetric conflict rules (two movers into one cell → both bounce; no initiative
|
|
14
|
+
// order to explain). Validation is at RESOLVE time, so agents act on ~1-beat-stale info.
|
|
15
|
+
// • An agent still thinking when the boundary lands has nothing buffered → it idles that
|
|
16
|
+
// beat (`missedBeats`++). The too-slow failure is visible and is the fun.
|
|
17
|
+
// • Emotes are EXEMPT from beat-buffering: they apply on their arrival tick (still
|
|
18
|
+
// deterministic — ordered inputs) and decay after EMOTE_DECAY_BEATS. This keeps a 4s
|
|
19
|
+
// beat alive for spectators without touching turn fairness.
|
|
20
|
+
//
|
|
21
|
+
// Determinism: this file touches only ctx.tick and ctx.random. No Date, no Math.random,
|
|
22
|
+
// no wall clock — the realm would throw on ambient authority inside tick() anyway.
|
|
23
|
+
|
|
24
|
+
import type { Room, Ctx } from "@siltrun/room";
|
|
25
|
+
import type { State, Cmd, Dir, Player, Pos } from "./src/substrate/types.ts";
|
|
26
|
+
import {
|
|
27
|
+
BEAT_TICKS,
|
|
28
|
+
ROUND_BEATS,
|
|
29
|
+
INTERMISSION_BEATS,
|
|
30
|
+
EMOTE_DECAY_BEATS,
|
|
31
|
+
PELLET_COUNT,
|
|
32
|
+
FLOOR_CELLS,
|
|
33
|
+
SPAWNS,
|
|
34
|
+
isFloor,
|
|
35
|
+
step,
|
|
36
|
+
cellKey,
|
|
37
|
+
samePos,
|
|
38
|
+
glyphFor,
|
|
39
|
+
isEmoteGlyph,
|
|
40
|
+
} from "./src/substrate/map.ts";
|
|
41
|
+
|
|
42
|
+
// ── initial (empty, waiting) state ──
|
|
43
|
+
function freshState(): State {
|
|
44
|
+
return {
|
|
45
|
+
phase: "waiting",
|
|
46
|
+
round: 0,
|
|
47
|
+
clock: 0,
|
|
48
|
+
beat: 0,
|
|
49
|
+
phaseStartTick: 0,
|
|
50
|
+
nextBeatTick: BEAT_TICKS,
|
|
51
|
+
players: {},
|
|
52
|
+
spectators: [],
|
|
53
|
+
pending: {},
|
|
54
|
+
pellets: [],
|
|
55
|
+
seatCounter: 0,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── deterministic pellet spawn: pick PELLET_COUNT distinct floor cells not under a player ──
|
|
60
|
+
function spawnPellets(state: State, ctx: Ctx): Pos[] {
|
|
61
|
+
const occupied = new Set<string>();
|
|
62
|
+
for (const id in state.players) occupied.add(cellKey(state.players[id].pos));
|
|
63
|
+
const candidates = FLOOR_CELLS.filter((c) => !occupied.has(cellKey(c)));
|
|
64
|
+
// Fisher–Yates with ctx.random (deterministic: hash(seed, tick, draw)); take the first N.
|
|
65
|
+
const pool = candidates.slice();
|
|
66
|
+
for (let i = pool.length - 1; i > 0; i--) {
|
|
67
|
+
const j = Math.floor(ctx.random() * (i + 1));
|
|
68
|
+
const t = pool[i];
|
|
69
|
+
pool[i] = pool[j];
|
|
70
|
+
pool[j] = t;
|
|
71
|
+
}
|
|
72
|
+
return pool.slice(0, Math.min(PELLET_COUNT, pool.length));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── seat a spectator as a player, assigning a spawn point + stable glyph ──
|
|
76
|
+
function seat(state: State, id: string): void {
|
|
77
|
+
if (state.players[id]) return; // already seated
|
|
78
|
+
const seatIndex = state.seatCounter++;
|
|
79
|
+
const spawn = SPAWNS[seatIndex % SPAWNS.length];
|
|
80
|
+
// If the natural spawn is taken, walk floor cells for the first free one (deterministic order).
|
|
81
|
+
let pos: Pos = spawn;
|
|
82
|
+
const taken = new Set<string>();
|
|
83
|
+
for (const pid in state.players) taken.add(cellKey(state.players[pid].pos));
|
|
84
|
+
if (taken.has(cellKey(pos))) {
|
|
85
|
+
pos = FLOOR_CELLS.find((c) => !taken.has(cellKey(c))) ?? spawn;
|
|
86
|
+
}
|
|
87
|
+
const player: Player = {
|
|
88
|
+
id,
|
|
89
|
+
glyph: glyphFor(seatIndex),
|
|
90
|
+
pos: { x: pos.x, y: pos.y },
|
|
91
|
+
score: 0,
|
|
92
|
+
emote: null,
|
|
93
|
+
emoteUntilBeat: 0,
|
|
94
|
+
missedBeats: 0,
|
|
95
|
+
actedLastBeat: false,
|
|
96
|
+
};
|
|
97
|
+
state.players[id] = player;
|
|
98
|
+
state.spectators = state.spectators.filter((s) => s !== id);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function unseat(state: State, id: string): void {
|
|
102
|
+
delete state.players[id];
|
|
103
|
+
delete state.pending[id];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── start a fresh round: place seated players at spawns, spawn pellets, reset the beat span ──
|
|
107
|
+
function startRound(state: State, ctx: Ctx): void {
|
|
108
|
+
state.round += 1;
|
|
109
|
+
state.phase = "playing";
|
|
110
|
+
state.phaseStartTick = ctx.tick;
|
|
111
|
+
state.beat = 0;
|
|
112
|
+
state.nextBeatTick = ctx.tick + BEAT_TICKS;
|
|
113
|
+
// Re-home every seated player to a spawn and clear round-scoped counters.
|
|
114
|
+
let i = 0;
|
|
115
|
+
for (const id in state.players) {
|
|
116
|
+
const p = state.players[id];
|
|
117
|
+
const spawn = SPAWNS[i % SPAWNS.length];
|
|
118
|
+
p.pos = { x: spawn.x, y: spawn.y };
|
|
119
|
+
p.score = 0;
|
|
120
|
+
p.missedBeats = 0;
|
|
121
|
+
p.actedLastBeat = false;
|
|
122
|
+
p.emote = null;
|
|
123
|
+
p.emoteUntilBeat = 0;
|
|
124
|
+
i++;
|
|
125
|
+
}
|
|
126
|
+
state.pending = {};
|
|
127
|
+
state.pellets = spawnPellets(state, ctx);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function enterIntermission(state: State, ctx: Ctx): void {
|
|
131
|
+
state.phase = "intermission";
|
|
132
|
+
state.phaseStartTick = ctx.tick;
|
|
133
|
+
state.beat = 0;
|
|
134
|
+
state.nextBeatTick = ctx.tick + BEAT_TICKS;
|
|
135
|
+
state.pending = {};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── the simultaneous, symmetric beat resolution ──
|
|
139
|
+
//
|
|
140
|
+
// Everyone resolves from the SAME pre-move snapshot. A player moves iff its target is
|
|
141
|
+
// floor AND not the current cell of a player who is staying AND not targeted by any
|
|
142
|
+
// other mover. Any contention → bounce (stay). No initiative, order-independent, fair.
|
|
143
|
+
function resolveBeat(state: State): void {
|
|
144
|
+
const ids = Object.keys(state.players);
|
|
145
|
+
const before: Record<string, Pos> = {};
|
|
146
|
+
const intended: Record<string, Pos> = {};
|
|
147
|
+
for (const id of ids) {
|
|
148
|
+
const p = state.players[id];
|
|
149
|
+
before[id] = { x: p.pos.x, y: p.pos.y };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 1. intended target for each seated player (buffered move, or stay).
|
|
153
|
+
for (const id of ids) {
|
|
154
|
+
const move: Dir = state.pending[id] ?? "stay";
|
|
155
|
+
const target = move === "stay" ? before[id] : step(before[id], move);
|
|
156
|
+
// wall / out-of-bounds → bounce to origin.
|
|
157
|
+
intended[id] = isFloor(target.x, target.y) ? target : before[id];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 2. cells occupied by a player who is NOT moving this beat (stayers block).
|
|
161
|
+
const stayerCells = new Set<string>();
|
|
162
|
+
for (const id of ids) {
|
|
163
|
+
if (samePos(intended[id], before[id])) stayerCells.add(cellKey(before[id]));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 3. count how many movers target each destination cell (to detect head-on contention).
|
|
167
|
+
const targetCount = new Map<string, number>();
|
|
168
|
+
for (const id of ids) {
|
|
169
|
+
if (samePos(intended[id], before[id])) continue; // stayers don't contend
|
|
170
|
+
const k = cellKey(intended[id]);
|
|
171
|
+
targetCount.set(k, (targetCount.get(k) ?? 0) + 1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 4. grant a move only if its target is uncontended by other movers AND not a stayer's cell.
|
|
175
|
+
const granted: Record<string, boolean> = {};
|
|
176
|
+
for (const id of ids) {
|
|
177
|
+
if (samePos(intended[id], before[id])) { granted[id] = false; continue; }
|
|
178
|
+
const k = cellKey(intended[id]);
|
|
179
|
+
const contested = (targetCount.get(k) ?? 0) > 1 || stayerCells.has(k);
|
|
180
|
+
granted[id] = !contested;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 5. apply, mark acted/missed, collect pellets.
|
|
184
|
+
for (const id of ids) {
|
|
185
|
+
const p = state.players[id];
|
|
186
|
+
const buffered = state.pending[id] != null;
|
|
187
|
+
if (buffered && p.missedBeats >= 0) p.actedLastBeat = true;
|
|
188
|
+
if (!buffered) { p.missedBeats += 1; p.actedLastBeat = false; }
|
|
189
|
+
|
|
190
|
+
if (granted[id]) p.pos = { x: intended[id].x, y: intended[id].y };
|
|
191
|
+
|
|
192
|
+
// pellet pickup on the resulting cell.
|
|
193
|
+
const here = cellKey(p.pos);
|
|
194
|
+
const idx = state.pellets.findIndex((pel) => cellKey(pel) === here);
|
|
195
|
+
if (idx !== -1) {
|
|
196
|
+
state.pellets.splice(idx, 1);
|
|
197
|
+
p.score += 1;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
state.pending = {};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function decayEmotes(state: State): void {
|
|
205
|
+
for (const id in state.players) {
|
|
206
|
+
const p = state.players[id];
|
|
207
|
+
if (p.emote && state.beat >= p.emoteUntilBeat) p.emote = null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── the contract ──
|
|
212
|
+
export default {
|
|
213
|
+
tick(state = freshState(), inputs, ctx): State {
|
|
214
|
+
state.clock = ctx.tick;
|
|
215
|
+
|
|
216
|
+
// 1) fold in everything that happened since last tick (deterministic order:
|
|
217
|
+
// joins → leaves → reliable events → datagram inputs; we use joins/leaves/events).
|
|
218
|
+
for (const ev of inputs) {
|
|
219
|
+
if (ev.kind === "join") {
|
|
220
|
+
if (!state.players[ev.id] && !state.spectators.includes(ev.id)) {
|
|
221
|
+
state.spectators.push(ev.id);
|
|
222
|
+
}
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (ev.kind === "leave") {
|
|
226
|
+
unseat(state, ev.id);
|
|
227
|
+
state.spectators = state.spectators.filter((s) => s !== ev.id);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (ev.kind === "event") {
|
|
231
|
+
const msg = ev.data as Cmd;
|
|
232
|
+
if (!msg || typeof msg !== "object") continue;
|
|
233
|
+
switch (msg.t) {
|
|
234
|
+
case "sit":
|
|
235
|
+
seat(state, ev.from);
|
|
236
|
+
break;
|
|
237
|
+
case "stand":
|
|
238
|
+
unseat(state, ev.from);
|
|
239
|
+
if (!state.spectators.includes(ev.from)) state.spectators.push(ev.from);
|
|
240
|
+
break;
|
|
241
|
+
case "act":
|
|
242
|
+
// buffer latest-wins; only seated players can act, only during play.
|
|
243
|
+
if (state.players[ev.from] && state.phase === "playing") {
|
|
244
|
+
const m = msg.move;
|
|
245
|
+
if (m === "up" || m === "down" || m === "left" || m === "right" || m === "stay") {
|
|
246
|
+
state.pending[ev.from] = m;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
break;
|
|
250
|
+
case "emote":
|
|
251
|
+
// applies immediately (exempt from beat-buffering), decays after N beats.
|
|
252
|
+
if (state.players[ev.from] && isEmoteGlyph(msg.glyph)) {
|
|
253
|
+
const p = state.players[ev.from];
|
|
254
|
+
p.emote = msg.glyph;
|
|
255
|
+
p.emoteUntilBeat = state.beat + EMOTE_DECAY_BEATS;
|
|
256
|
+
}
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
// ev.kind === "input" (datagram lane) is unused by this genre — turns ride reliable.
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const seatedCount = Object.keys(state.players).length;
|
|
265
|
+
|
|
266
|
+
// 2) lifecycle: waiting → playing → intermission → (playing | waiting), agent-population-based.
|
|
267
|
+
if (state.phase === "waiting") {
|
|
268
|
+
if (seatedCount > 0) startRound(state, ctx);
|
|
269
|
+
state.nextBeatTick = state.phaseStartTick + BEAT_TICKS; // keep view honest while waiting
|
|
270
|
+
return state;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 3) beat boundary crossing. `beat` advances whenever the clock passes nextBeatTick.
|
|
274
|
+
// A pause/resume across an empty room can't desync: everything is anchored to
|
|
275
|
+
// phaseStartTick, which we reset on every phase entry.
|
|
276
|
+
if (ctx.tick >= state.nextBeatTick) {
|
|
277
|
+
const elapsedBeats = Math.floor((ctx.tick - state.phaseStartTick) / BEAT_TICKS);
|
|
278
|
+
|
|
279
|
+
if (state.phase === "playing") {
|
|
280
|
+
resolveBeat(state);
|
|
281
|
+
state.beat = elapsedBeats;
|
|
282
|
+
decayEmotes(state);
|
|
283
|
+
state.nextBeatTick = state.phaseStartTick + (elapsedBeats + 1) * BEAT_TICKS;
|
|
284
|
+
// round over? (all pellets gone also ends it early)
|
|
285
|
+
if (state.beat >= ROUND_BEATS || state.pellets.length === 0) {
|
|
286
|
+
enterIntermission(state, ctx);
|
|
287
|
+
}
|
|
288
|
+
} else {
|
|
289
|
+
// intermission: just advance the beat counter; roll to next round or back to waiting.
|
|
290
|
+
state.beat = elapsedBeats;
|
|
291
|
+
state.nextBeatTick = state.phaseStartTick + (elapsedBeats + 1) * BEAT_TICKS;
|
|
292
|
+
if (state.beat >= INTERMISSION_BEATS) {
|
|
293
|
+
if (Object.keys(state.players).length > 0) startRound(state, ctx);
|
|
294
|
+
else {
|
|
295
|
+
const s = freshState();
|
|
296
|
+
s.clock = ctx.tick;
|
|
297
|
+
s.round = state.round;
|
|
298
|
+
s.spectators = state.spectators;
|
|
299
|
+
s.phaseStartTick = ctx.tick;
|
|
300
|
+
s.nextBeatTick = ctx.tick + BEAT_TICKS;
|
|
301
|
+
return s;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return state;
|
|
308
|
+
},
|
|
309
|
+
} satisfies Room<State, Cmd>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Skill: play the turn-based grid
|
|
2
|
+
|
|
3
|
+
A hands-on recipe for turning the briefing in [`../AGENTS.md`](../AGENTS.md) into a
|
|
4
|
+
**working brain** — an agent that perceives the ASCII grid, decides a move, and acts before
|
|
5
|
+
the beat deadline. Agent-agnostic: any agent runtime that can (a) receive the rendered ASCII
|
|
6
|
+
and (b) send a reliable-lane JSON message can use this.
|
|
7
|
+
|
|
8
|
+
Read `AGENTS.md` first for the world model, the tick/beat model, the action API, and the
|
|
9
|
+
symbol/emote tables. This file is the *how*.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## The loop
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
connect
|
|
17
|
+
send { "t": "sit" } # once — become a player
|
|
18
|
+
loop forever:
|
|
19
|
+
ascii = <latest rendered perception> # your only input
|
|
20
|
+
deadline = ticks-until-beat (from the status line)
|
|
21
|
+
move = decide(ascii) # keep this FAST — you have a deadline
|
|
22
|
+
send { "t": "act", "move": move } # reliable lane; latest-wins within the beat
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Two rules that matter more than cleverness:
|
|
26
|
+
|
|
27
|
+
1. **Beat the deadline.** The `beat resolves in: N ticks` line is a countdown. If `decide`
|
|
28
|
+
takes longer than that, you buffer nothing and miss the beat (your piece idles, your
|
|
29
|
+
`missed` count climbs). A rough move on time beats a perfect move that's late.
|
|
30
|
+
2. **You may revise.** Sent a move, then realized a better one before the beat landed? Send
|
|
31
|
+
again — latest-wins. Cheap insurance: send an early rough move, refine if time allows.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Step 1 — parse the ASCII into something you can reason over
|
|
36
|
+
|
|
37
|
+
The perception is text. Turn it into structure:
|
|
38
|
+
|
|
39
|
+
- Split into the grid rows (the block of `#./*`-and-letters lines) and the status lines.
|
|
40
|
+
- Find **your own** glyph from the `you are: X` status line.
|
|
41
|
+
- Scan the grid: record wall cells (`#`), pellet cells (`*`), your position (your glyph),
|
|
42
|
+
and other players (other letters).
|
|
43
|
+
|
|
44
|
+
(A reference parser is in [`../src/view.ts`](../src/view.ts) — `readView(ascii)` returns
|
|
45
|
+
`{ self, pellets, others, isWall }`, all recovered from the string alone.)
|
|
46
|
+
|
|
47
|
+
## Step 2 — decide a move
|
|
48
|
+
|
|
49
|
+
The simplest competent brain: **walk toward the nearest pellet.** Breadth-first search over
|
|
50
|
+
floor cells from your position; the first step of the shortest path to the closest pellet is
|
|
51
|
+
your move. If no pellet is reachable, take any legal step (or `stay`).
|
|
52
|
+
|
|
53
|
+
**Directions are grid-relative** (see AGENTS.md §1): `up` = row − 1 (toward the top), `down`
|
|
54
|
+
= row + 1, `left`/`right` = column ∓ 1. **Treat other players' cells as blocked when you
|
|
55
|
+
path** — but only for *this* beat: pieces move simultaneously, so a cell occupied now is
|
|
56
|
+
often free next beat. Re-plan each beat from fresh perception rather than committing to a
|
|
57
|
+
route around a piece that will have moved.
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
decide(ascii):
|
|
61
|
+
v = parse(ascii)
|
|
62
|
+
if v.self is null: return "stay" # not seated / spectating
|
|
63
|
+
target = nearest reachable pellet (BFS over non-wall, non-occupied cells)
|
|
64
|
+
if target: return first-step-direction toward it
|
|
65
|
+
else: return any non-wall direction, or "stay"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
(Reference: `stepTowardNearestPellet(view)` in `../src/view.ts`, used by the `greedy` brain
|
|
69
|
+
in [`../src/brains.ts`](../src/brains.ts).)
|
|
70
|
+
|
|
71
|
+
## Step 3 — (optional) use the emote channel
|
|
72
|
+
|
|
73
|
+
You have no chat, but you can flash one glyph (`! ? + - ^ x`) that others see for a couple
|
|
74
|
+
of beats. Send `{ "t": "emote", "glyph": "!" }` to signal. Emotes don't consume your turn —
|
|
75
|
+
they apply immediately. Convention gives the glyphs meaning (see `AGENTS.md` §4); the
|
|
76
|
+
substrate just delivers, shows, and decays them.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## What "a good brain" looks like
|
|
81
|
+
|
|
82
|
+
- **Never misses on a clear board.** If a pellet is reachable and you have time, you should
|
|
83
|
+
be moving toward it every beat. A climbing `missed` count means your `decide` is too slow
|
|
84
|
+
or you're not sending in time.
|
|
85
|
+
- **Handles contention.** When another piece contests your target cell, both bounce. Notice
|
|
86
|
+
repeated bounces and try a different route rather than shoving into the same wall.
|
|
87
|
+
- **Degrades gracefully.** No reachable pellet, or ambiguous perception? Return `stay` — a
|
|
88
|
+
deliberate, legal non-move — never nothing.
|
|
89
|
+
|
|
90
|
+
## The bar this template sets
|
|
91
|
+
|
|
92
|
+
Four scripted brains ship as proof the substrate is playable from the ASCII alone —
|
|
93
|
+
`greedy` (this recipe), `random`, `chatty` (adds emotes), and `slow` (deliberately misses
|
|
94
|
+
its deadline, to show the stutter). Each perceives **only** the rendered ASCII, never the
|
|
95
|
+
server's state object. That's the whole claim: if a brain can play from the text, the
|
|
96
|
+
agent-facing projection is a sufficient interface — which is what a real agent connection
|
|
97
|
+
(over MCP) will target.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// App.tsx — the SPECTATOR. Humans watch; they never take a seat (the demo variant is
|
|
2
|
+
// agent-play / human-spectate). This tab joins as a spectator, renders the live state,
|
|
3
|
+
// and — for local demoing — launches in-page stub agents named in the URL:
|
|
4
|
+
//
|
|
5
|
+
// http://localhost:5173/?agents=greedy,slow,chatty
|
|
6
|
+
//
|
|
7
|
+
// Each stub is its own real client connection (see stubAgent.ts). Ugly-e2e stage: the
|
|
8
|
+
// ASCII pane + scoreboard, driven by real agents. The graphics view lands in polish.
|
|
9
|
+
|
|
10
|
+
import { useEffect, useRef, useState } from "react";
|
|
11
|
+
import { useRoom } from "@siltrun/react";
|
|
12
|
+
import type { State } from "./substrate/types.ts";
|
|
13
|
+
import { renderAscii, SYMBOLS } from "./substrate/ascii.ts";
|
|
14
|
+
import { GraphicsView } from "./GraphicsView.tsx";
|
|
15
|
+
import { BRAINS } from "./brains.ts";
|
|
16
|
+
import { runStubAgent, type StubHandle } from "./stubAgent.ts";
|
|
17
|
+
|
|
18
|
+
// Defaults to the standard siltrun dev room-info port. Override for local multi-room
|
|
19
|
+
// setups with ?room=<port> or ?room=<full-url> (used by the monorepo verify loop).
|
|
20
|
+
const ROOM_URL = (() => {
|
|
21
|
+
const p = new URLSearchParams(location.search).get("room");
|
|
22
|
+
if (!p) return "http://localhost:4000";
|
|
23
|
+
return /^\d+$/.test(p) ? `http://localhost:${p}` : p;
|
|
24
|
+
})();
|
|
25
|
+
const SPECTATOR_ID = "spectator-" + Math.random().toString(36).slice(2, 8);
|
|
26
|
+
|
|
27
|
+
function useStubAgents() {
|
|
28
|
+
const started = useRef(false);
|
|
29
|
+
const [names, setNames] = useState<string[]>([]);
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (started.current) return;
|
|
32
|
+
started.current = true;
|
|
33
|
+
const param = new URLSearchParams(location.search).get("agents");
|
|
34
|
+
if (!param) return;
|
|
35
|
+
const requested = param.split(",").map((s) => s.trim()).filter(Boolean);
|
|
36
|
+
const handles: Promise<StubHandle>[] = [];
|
|
37
|
+
const launched: string[] = [];
|
|
38
|
+
// A per-page-load nonce keeps stub ids unique across reloads — otherwise a fresh
|
|
39
|
+
// "greedy-0" collides with the previous load's still-closing "greedy-0" and the two
|
|
40
|
+
// same-id sessions thrash (the documented concurrent-same-id pathology). Real agents
|
|
41
|
+
// carry unique identities; the stubs must too.
|
|
42
|
+
const nonce = Math.random().toString(36).slice(2, 6);
|
|
43
|
+
requested.forEach((name, i) => {
|
|
44
|
+
const make = BRAINS[name];
|
|
45
|
+
if (!make) return;
|
|
46
|
+
const id = `${name}-${nonce}${i}`;
|
|
47
|
+
launched.push(name);
|
|
48
|
+
handles.push(runStubAgent({ url: ROOM_URL, id, brain: make() }));
|
|
49
|
+
});
|
|
50
|
+
setNames(launched);
|
|
51
|
+
return () => {
|
|
52
|
+
Promise.all(handles).then((hs) => hs.forEach((h) => h.stop()));
|
|
53
|
+
};
|
|
54
|
+
}, []);
|
|
55
|
+
return names;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function App() {
|
|
59
|
+
const { state, status, error } = useRoom<State>(ROOM_URL, { id: SPECTATOR_ID });
|
|
60
|
+
const agents = useStubAgents();
|
|
61
|
+
const [showAscii, setShowAscii] = useState(true);
|
|
62
|
+
|
|
63
|
+
if (status === "failed") {
|
|
64
|
+
return <pre style={box}>connection failed: {String(error)} — is `npm run dev` running?</pre>;
|
|
65
|
+
}
|
|
66
|
+
if (!state) return <pre style={box}>{status === "reconnecting" ? "reconnecting…" : "joining…"}</pre>;
|
|
67
|
+
|
|
68
|
+
const players = Object.values(state.players).sort((a, b) => b.score - a.score);
|
|
69
|
+
const brainOf = (id: string) => id.split("-")[0];
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div style={{ display: "flex", gap: 28, padding: 24, fontFamily: "ui-monospace, monospace", color: "#eae7de", background: "#0b0b0d", minHeight: "100vh", alignItems: "flex-start" }}>
|
|
73
|
+
{/* HUMAN VIEW — watch the agents play */}
|
|
74
|
+
<div>
|
|
75
|
+
<div style={{ color: "#7e837a", marginBottom: 8 }}>
|
|
76
|
+
human view — watch the agents play · phase <b style={{ color: "#cfc9bc" }}>{state.phase}</b> · round {state.round} · beat {state.beat}
|
|
77
|
+
</div>
|
|
78
|
+
<GraphicsView state={state} />
|
|
79
|
+
{agents.length > 0 && (
|
|
80
|
+
<div style={{ color: "#7e837a", marginTop: 10, fontSize: 13 }}>stub agents: {agents.join(", ")}</div>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{/* AGENT VIEW — the exact ASCII an agent perceives (toggle) */}
|
|
85
|
+
{showAscii && (
|
|
86
|
+
<div>
|
|
87
|
+
<div style={{ color: "#7e837a", marginBottom: 8, display: "flex", gap: 10, alignItems: "center" }}>
|
|
88
|
+
agent view — the exact ASCII an agent reads
|
|
89
|
+
<button onClick={() => setShowAscii(false)} style={btn}>hide</button>
|
|
90
|
+
</div>
|
|
91
|
+
<pre style={{ ...box, fontSize: 18, lineHeight: 1.18 }}>{renderAscii(state)}</pre>
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
|
|
95
|
+
<div style={{ minWidth: 280 }}>
|
|
96
|
+
{!showAscii && (
|
|
97
|
+
<button onClick={() => setShowAscii(true)} style={{ ...btn, marginBottom: 12 }}>show agent (ASCII) view</button>
|
|
98
|
+
)}
|
|
99
|
+
<div style={{ color: "#7e837a", marginBottom: 8 }}>scoreboard</div>
|
|
100
|
+
<table style={{ borderCollapse: "collapse", fontSize: 14, width: "100%" }}>
|
|
101
|
+
<thead>
|
|
102
|
+
<tr style={{ color: "#7e837a", textAlign: "left" }}>
|
|
103
|
+
<th style={cell}>#</th><th style={cell}>who</th><th style={cell}>score</th><th style={cell}>missed</th><th style={cell}>emote</th>
|
|
104
|
+
</tr>
|
|
105
|
+
</thead>
|
|
106
|
+
<tbody>
|
|
107
|
+
{players.map((p) => (
|
|
108
|
+
<tr key={p.id}>
|
|
109
|
+
<td style={cell}><b style={{ color: "#cfc9bc" }}>{p.glyph}</b></td>
|
|
110
|
+
<td style={cell}>{brainOf(p.id)}</td>
|
|
111
|
+
<td style={cell}>{p.score}</td>
|
|
112
|
+
<td style={{ ...cell, color: p.missedBeats > 0 ? "#a65a2e" : "#3a3a3d", fontWeight: p.missedBeats > 0 ? 700 : 400 }}>{p.missedBeats}</td>
|
|
113
|
+
<td style={{ ...cell, color: p.emote ? "#cfc9bc" : "#3a3a3d", fontSize: 16 }}>{p.emote ?? "·"}</td>
|
|
114
|
+
</tr>
|
|
115
|
+
))}
|
|
116
|
+
{players.length === 0 && (
|
|
117
|
+
<tr><td style={cell} colSpan={5}>no agents seated — waiting…</td></tr>
|
|
118
|
+
)}
|
|
119
|
+
</tbody>
|
|
120
|
+
</table>
|
|
121
|
+
|
|
122
|
+
<div style={{ color: "#7e837a", margin: "20px 0 8px" }}>symbol table</div>
|
|
123
|
+
<table style={{ borderCollapse: "collapse", fontSize: 13 }}>
|
|
124
|
+
<tbody>
|
|
125
|
+
{SYMBOLS.map((s) => (
|
|
126
|
+
<tr key={s.glyph}><td style={{ ...cell, color: "#cfc9bc" }}>{s.glyph}</td><td style={cell}>{s.meaning}</td></tr>
|
|
127
|
+
))}
|
|
128
|
+
</tbody>
|
|
129
|
+
</table>
|
|
130
|
+
<div style={{ color: "#7e837a", marginTop: 8, fontSize: 12 }}>watching: {state.spectators.length}</div>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const box: React.CSSProperties = { background: "#121214", padding: 16, borderRadius: 8, margin: 0 };
|
|
137
|
+
const cell: React.CSSProperties = { padding: "3px 10px 3px 0", borderBottom: "1px solid #26262a" };
|
|
138
|
+
const btn: React.CSSProperties = {
|
|
139
|
+
background: "#1c1c1f", color: "#cfc9bc", border: "1px solid #3a3a3d",
|
|
140
|
+
borderRadius: 6, padding: "3px 10px", fontSize: 12, cursor: "pointer", fontFamily: "ui-monospace, monospace",
|
|
141
|
+
};
|