@flayerlabs/gamemode-cli 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/LICENSE +9 -0
- package/README.md +50 -0
- package/dist/AGENTS.md +213 -0
- package/dist/build-game-mode/SKILL.md +57 -0
- package/dist/build-game-mode/agents/openai.yaml +4 -0
- package/dist/lint.d.ts +20 -0
- package/dist/lint.d.ts.map +1 -0
- package/dist/lint.js +70 -0
- package/dist/lint.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +65 -0
- package/dist/main.js.map +1 -0
- package/dist/scaffold.d.ts +13 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/scaffold.js +553 -0
- package/dist/scaffold.js.map +1 -0
- package/package.json +37 -0
- package/src/lint.ts +85 -0
- package/src/main.ts +70 -0
- package/src/scaffold.ts +587 -0
package/dist/scaffold.js
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* A new game, playable before it does anything.
|
|
5
|
+
*
|
|
6
|
+
* The first command has to produce something that runs. A scaffold that needs a database, a chain
|
|
7
|
+
* and a wallet before it shows anything is a scaffold most people abandon, and an agent given one
|
|
8
|
+
* has nothing to check its work against.
|
|
9
|
+
*
|
|
10
|
+
* So what comes out is a complete, working guessing game: rules, a page, and tests that pass. It is
|
|
11
|
+
* meant to be edited into something else, not read and replaced.
|
|
12
|
+
*/
|
|
13
|
+
export async function scaffold(name) {
|
|
14
|
+
if (!/^[a-z][a-z0-9-]{0,48}$/.test(name)) {
|
|
15
|
+
throw new Error('A name should be lower case letters, numbers and dashes, e.g. my-game');
|
|
16
|
+
}
|
|
17
|
+
const root = join(process.cwd(), name);
|
|
18
|
+
await mkdir(join(root, 'src', 'game'), { recursive: true });
|
|
19
|
+
await mkdir(join(root, 'test'), { recursive: true });
|
|
20
|
+
for (const [path, contents] of Object.entries(await files(name))) {
|
|
21
|
+
await mkdir(dirname(join(root, path)), { recursive: true });
|
|
22
|
+
await writeFile(join(root, path), contents);
|
|
23
|
+
}
|
|
24
|
+
return root;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Source runs read the repository's canonical contract. Builds copy that same file beside the
|
|
28
|
+
* compiled module, which gives the published CLI the identical contract without maintaining a
|
|
29
|
+
* second authored string.
|
|
30
|
+
*/
|
|
31
|
+
async function readAgentContract() {
|
|
32
|
+
return readAsset('./AGENTS.md', '../templates/game/AGENTS.template.md');
|
|
33
|
+
}
|
|
34
|
+
async function readCreatorSkill() {
|
|
35
|
+
return readAsset('./build-game-mode/SKILL.md', '../../../.agents/skills/build-game-mode/SKILL.md');
|
|
36
|
+
}
|
|
37
|
+
async function readCreatorSkillMetadata() {
|
|
38
|
+
return readAsset('./build-game-mode/agents/openai.yaml', '../../../.agents/skills/build-game-mode/agents/openai.yaml');
|
|
39
|
+
}
|
|
40
|
+
async function readAsset(packaged, source) {
|
|
41
|
+
try {
|
|
42
|
+
return await readFile(new URL(packaged, import.meta.url), 'utf8');
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (error.code !== 'ENOENT')
|
|
46
|
+
throw error;
|
|
47
|
+
return readFile(new URL(source, import.meta.url), 'utf8');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function dependencyVersionFor(version) {
|
|
51
|
+
if (typeof version !== 'string' ||
|
|
52
|
+
!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(version)) {
|
|
53
|
+
throw new Error('the CLI package has no valid version');
|
|
54
|
+
}
|
|
55
|
+
return /^\d+\.\d+\.\d+$/.test(version) ? `^${version}` : version;
|
|
56
|
+
}
|
|
57
|
+
async function sdkVersionRange() {
|
|
58
|
+
const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
59
|
+
return dependencyVersionFor(manifest.version);
|
|
60
|
+
}
|
|
61
|
+
const files = async (name) => {
|
|
62
|
+
const sdk = await sdkVersionRange();
|
|
63
|
+
return {
|
|
64
|
+
'package.json': `${JSON.stringify({
|
|
65
|
+
name,
|
|
66
|
+
private: true,
|
|
67
|
+
type: 'module',
|
|
68
|
+
scripts: {
|
|
69
|
+
dev: 'vite',
|
|
70
|
+
test: 'gamemode check src/game/rules.ts && vitest run',
|
|
71
|
+
check: 'gamemode check src/game/rules.ts',
|
|
72
|
+
typecheck: 'tsc --noEmit',
|
|
73
|
+
},
|
|
74
|
+
dependencies: {
|
|
75
|
+
'@flayerlabs/gamemode-client': sdk,
|
|
76
|
+
'@flayerlabs/gamemode-spec': sdk,
|
|
77
|
+
},
|
|
78
|
+
devDependencies: {
|
|
79
|
+
'@flayerlabs/gamemode-cli': sdk,
|
|
80
|
+
typescript: '^5.7.2',
|
|
81
|
+
vite: '^5.4.21',
|
|
82
|
+
vitest: '^2.1.8',
|
|
83
|
+
},
|
|
84
|
+
}, null, 2)}\n`,
|
|
85
|
+
'AGENTS.md': await readAgentContract(),
|
|
86
|
+
'README.md': `# ${name}
|
|
87
|
+
|
|
88
|
+
This is a Flaunch Game Mode. The browser owns the game experience. Pure server rules decide which
|
|
89
|
+
actions earn points.
|
|
90
|
+
|
|
91
|
+
## Start the game
|
|
92
|
+
|
|
93
|
+
Install dependencies and run the local game:
|
|
94
|
+
|
|
95
|
+
\`\`\`bash
|
|
96
|
+
pnpm install
|
|
97
|
+
pnpm dev
|
|
98
|
+
\`\`\`
|
|
99
|
+
|
|
100
|
+
The local room uses the real rules with a mock economy. It needs no server, chain or wallet.
|
|
101
|
+
|
|
102
|
+
Run the evidence before you finish a change:
|
|
103
|
+
|
|
104
|
+
\`\`\`bash
|
|
105
|
+
pnpm test
|
|
106
|
+
pnpm typecheck
|
|
107
|
+
\`\`\`
|
|
108
|
+
|
|
109
|
+
## Work with an agent
|
|
110
|
+
|
|
111
|
+
Ask the agent to read \`AGENTS.md\` before it edits the game. Use \`$build-game-mode\` when the
|
|
112
|
+
agent supports repository skills.
|
|
113
|
+
|
|
114
|
+
The project includes:
|
|
115
|
+
|
|
116
|
+
\`\`\`text
|
|
117
|
+
src/game/rules.ts pure server rules and scoring
|
|
118
|
+
src/play.ts browser game and room subscriptions
|
|
119
|
+
test/rules.test.ts scoring, secrecy and replay checks
|
|
120
|
+
\`\`\`
|
|
121
|
+
|
|
122
|
+
Start with the working game. Change one complete play loop at a time and keep the tests green.
|
|
123
|
+
|
|
124
|
+
## Connect a live gate
|
|
125
|
+
|
|
126
|
+
Finish the game against the mock room first. Then follow the
|
|
127
|
+
[gate guide](https://github.com/flayerlabs/gamemode-sdk/blob/main/docs/guides/run-a-gate.md).
|
|
128
|
+
|
|
129
|
+
For the rules and client workflow, read the
|
|
130
|
+
[game guide](https://github.com/flayerlabs/gamemode-sdk/blob/main/docs/guides/build-a-game.md).
|
|
131
|
+
`,
|
|
132
|
+
'.agents/skills/build-game-mode/SKILL.md': await readCreatorSkill(),
|
|
133
|
+
'.agents/skills/build-game-mode/agents/openai.yaml': await readCreatorSkillMetadata(),
|
|
134
|
+
'src/game/rules.ts': `import { defineGame, type Decision, type PlayerId, type Refusal } from '@flayerlabs/gamemode-spec';
|
|
135
|
+
import { scheduleWithin } from '@flayerlabs/gamemode-spec/schedule';
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Guess the number.
|
|
139
|
+
*
|
|
140
|
+
* A complete, working game so there is something to run before there is something to read. Change
|
|
141
|
+
* it into whatever you are actually making.
|
|
142
|
+
*/
|
|
143
|
+
|
|
144
|
+
export interface Config {
|
|
145
|
+
/** How many rounds before the game ends. */
|
|
146
|
+
rounds: number;
|
|
147
|
+
/** Highest number a player might guess. */
|
|
148
|
+
ceiling: number;
|
|
149
|
+
/** What a correct guess is worth. */
|
|
150
|
+
points: number;
|
|
151
|
+
roundMs: number;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface State {
|
|
155
|
+
config: Config;
|
|
156
|
+
/** The supplied round seed, kept in server state so every answer derives from it. */
|
|
157
|
+
roundSeed: number;
|
|
158
|
+
round: number;
|
|
159
|
+
/** Derived from the round seed. Never sent to anyone until the answer is revealed. */
|
|
160
|
+
answer: number;
|
|
161
|
+
endsAt: number;
|
|
162
|
+
over: boolean;
|
|
163
|
+
guesses: Record<PlayerId, number>;
|
|
164
|
+
scores: Record<PlayerId, number>;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export type Action = { guess: number };
|
|
168
|
+
|
|
169
|
+
export type Event =
|
|
170
|
+
| { t: 'joined'; player: PlayerId }
|
|
171
|
+
| { t: 'guessed'; player: PlayerId; guess: number }
|
|
172
|
+
| { t: 'next'; answer: number; endsAt: number }
|
|
173
|
+
| { t: 'over' };
|
|
174
|
+
|
|
175
|
+
/** Deterministic in the seed, so every player gets the same game and a replay gets the same one. */
|
|
176
|
+
function answerFor(seed: number, round: number, ceiling: number): number {
|
|
177
|
+
const mixed = Math.imul(seed ^ (round + 1), 2654435761) >>> 0;
|
|
178
|
+
return (mixed % ceiling) + 1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export const rules = defineGame<Config, State, Event, Action, PublicView, PlayerView>({
|
|
182
|
+
id: '${name}',
|
|
183
|
+
|
|
184
|
+
parseAction(input): Action | null {
|
|
185
|
+
if (typeof input !== 'object' || input === null) return null;
|
|
186
|
+
const { guess } = input as { guess?: unknown };
|
|
187
|
+
if (typeof guess !== 'number' || !Number.isInteger(guess) || guess < 1) return null;
|
|
188
|
+
return { guess };
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
initRound(config, seed, window) {
|
|
192
|
+
scheduleWithin(
|
|
193
|
+
window,
|
|
194
|
+
Array.from({ length: config.rounds }, (_, round) => ({ value: round, durationMs: config.roundMs })),
|
|
195
|
+
);
|
|
196
|
+
return {
|
|
197
|
+
config,
|
|
198
|
+
roundSeed: seed,
|
|
199
|
+
round: 0,
|
|
200
|
+
answer: answerFor(seed, 0, config.ceiling),
|
|
201
|
+
endsAt: config.rounds > 0 ? window.opensAt + config.roundMs : window.closesAt,
|
|
202
|
+
over: config.rounds === 0,
|
|
203
|
+
guesses: {},
|
|
204
|
+
scores: {},
|
|
205
|
+
};
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
decide(state, command): Decision<Event> | Refusal {
|
|
209
|
+
switch (command.kind) {
|
|
210
|
+
case 'join':
|
|
211
|
+
return state.scores[command.player] === undefined
|
|
212
|
+
? { events: [{ t: 'joined', player: command.player }] }
|
|
213
|
+
: { events: [] };
|
|
214
|
+
|
|
215
|
+
case 'leave':
|
|
216
|
+
return { events: [] };
|
|
217
|
+
|
|
218
|
+
case 'wake': {
|
|
219
|
+
if (state.over) return { events: [] };
|
|
220
|
+
const next = state.round + 1;
|
|
221
|
+
if (next >= state.config.rounds) return { events: [{ t: 'over' }] };
|
|
222
|
+
return {
|
|
223
|
+
events: [
|
|
224
|
+
{
|
|
225
|
+
t: 'next',
|
|
226
|
+
answer: answerFor(state.roundSeed, next, state.config.ceiling),
|
|
227
|
+
endsAt: state.endsAt + state.config.roundMs,
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
case 'action': {
|
|
234
|
+
if (state.over) return { refuse: 'guess.game_over' };
|
|
235
|
+
if (state.scores[command.player] === undefined) return { refuse: 'guess.not_playing' };
|
|
236
|
+
if (state.guesses[command.player] !== undefined) return { refuse: 'guess.already_guessed' };
|
|
237
|
+
if (command.action.guess > state.config.ceiling) return { refuse: 'guess.out_of_range' };
|
|
238
|
+
|
|
239
|
+
const right = command.action.guess === state.answer;
|
|
240
|
+
return {
|
|
241
|
+
events: [{ t: 'guessed', player: command.player, guess: command.action.guess }],
|
|
242
|
+
// Paid immediately, because a guess is right or wrong the moment it lands and there is
|
|
243
|
+
// nothing to reveal later. A game with a reveal should award at the reveal instead.
|
|
244
|
+
awards: right ? [{ player: command.player, points: state.config.points }] : [],
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
evolve(state, event) {
|
|
251
|
+
switch (event.t) {
|
|
252
|
+
case 'joined':
|
|
253
|
+
return { ...state, scores: { ...state.scores, [event.player]: 0 } };
|
|
254
|
+
case 'guessed': {
|
|
255
|
+
const right = event.guess === state.answer;
|
|
256
|
+
return {
|
|
257
|
+
...state,
|
|
258
|
+
guesses: { ...state.guesses, [event.player]: event.guess },
|
|
259
|
+
scores: right
|
|
260
|
+
? { ...state.scores, [event.player]: (state.scores[event.player] ?? 0) + state.config.points }
|
|
261
|
+
: state.scores,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
case 'next':
|
|
265
|
+
return { ...state, round: state.round + 1, answer: event.answer, endsAt: event.endsAt, guesses: {} };
|
|
266
|
+
case 'over':
|
|
267
|
+
return { ...state, over: true };
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
publicView(state): PublicView {
|
|
272
|
+
return {
|
|
273
|
+
round: state.round + 1,
|
|
274
|
+
rounds: state.config.rounds,
|
|
275
|
+
ceiling: state.config.ceiling,
|
|
276
|
+
endsAt: state.endsAt,
|
|
277
|
+
over: state.over,
|
|
278
|
+
// WHO has guessed, never WHAT. The answer is not in this type at all, which is what makes
|
|
279
|
+
// leaking it impossible rather than unlikely.
|
|
280
|
+
guessed: Object.keys(state.guesses),
|
|
281
|
+
standings: Object.entries(state.scores)
|
|
282
|
+
.map(([player, score]) => ({ player, score }))
|
|
283
|
+
.sort((a, b) => b.score - a.score || a.player.localeCompare(b.player)),
|
|
284
|
+
};
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
playerView(state, player): PlayerView {
|
|
288
|
+
const guess = state.guesses[player];
|
|
289
|
+
return {
|
|
290
|
+
playing: state.scores[player] !== undefined,
|
|
291
|
+
score: state.scores[player] ?? 0,
|
|
292
|
+
yourGuess: guess ?? null,
|
|
293
|
+
// Only ever told about your own guess, and only once you have made it.
|
|
294
|
+
wasRight: guess === undefined ? null : guess === state.answer,
|
|
295
|
+
};
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
nextWakeAt(state) {
|
|
299
|
+
return state.over ? null : state.endsAt;
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
rewardBounds(config) {
|
|
303
|
+
return { maxPointsPerPlayer: config.rounds * config.points };
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
export interface PublicView {
|
|
308
|
+
round: number;
|
|
309
|
+
rounds: number;
|
|
310
|
+
ceiling: number;
|
|
311
|
+
endsAt: number;
|
|
312
|
+
over: boolean;
|
|
313
|
+
guessed: PlayerId[];
|
|
314
|
+
standings: { player: PlayerId; score: number }[];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export interface PlayerView {
|
|
318
|
+
playing: boolean;
|
|
319
|
+
score: number;
|
|
320
|
+
yourGuess: number | null;
|
|
321
|
+
wasRight: boolean | null;
|
|
322
|
+
}
|
|
323
|
+
`,
|
|
324
|
+
'src/play.ts': `import { createMockRoom, type Snapshot } from '@flayerlabs/gamemode-client';
|
|
325
|
+
import { rules, type Action, type Config, type PlayerView, type PublicView } from './game/rules.js';
|
|
326
|
+
|
|
327
|
+
const config: Config = { rounds: 3, ceiling: 10, points: 500, roundMs: 10_000 };
|
|
328
|
+
const room = createMockRoom(rules, { config, lobbyMs: 1_000, roundMs: 60_000 });
|
|
329
|
+
const app = document.querySelector<HTMLElement>('#app');
|
|
330
|
+
if (!app) throw new Error('the page needs an #app element');
|
|
331
|
+
const gameRoot = app;
|
|
332
|
+
|
|
333
|
+
let latest: Snapshot<PublicView, PlayerView> | null = null;
|
|
334
|
+
|
|
335
|
+
function render(snapshot = latest): void {
|
|
336
|
+
if (!snapshot) return;
|
|
337
|
+
latest = snapshot;
|
|
338
|
+
const { publicView, playerView } = snapshot;
|
|
339
|
+
const seconds = Math.max(0, Math.ceil((publicView.endsAt - room.now()) / 1_000));
|
|
340
|
+
const balance = room.economy.current();
|
|
341
|
+
|
|
342
|
+
gameRoot.innerHTML = \`
|
|
343
|
+
<main>
|
|
344
|
+
<p class="eyebrow">${name}</p>
|
|
345
|
+
<h1>Guess the number</h1>
|
|
346
|
+
<p>Round \${publicView.round} of \${publicView.rounds} · <span data-clock>\${seconds}s</span> left</p>
|
|
347
|
+
<div class="numbers" aria-label="Choose a number">
|
|
348
|
+
\${Array.from({ length: publicView.ceiling }, (_, index) => {
|
|
349
|
+
const guess = index + 1;
|
|
350
|
+
const selected = playerView?.yourGuess === guess;
|
|
351
|
+
return \`<button data-guess="\${guess}" \${playerView?.yourGuess !== null || publicView.over ? 'disabled' : ''} \${selected ? 'aria-pressed="true"' : ''}>\${guess}</button>\`;
|
|
352
|
+
}).join('')}
|
|
353
|
+
</div>
|
|
354
|
+
<p class="result">\${publicView.over
|
|
355
|
+
? 'Game over'
|
|
356
|
+
: playerView?.wasRight === true
|
|
357
|
+
? 'Correct!'
|
|
358
|
+
: playerView?.wasRight === false
|
|
359
|
+
? 'Not this time'
|
|
360
|
+
: 'Pick once before the round ends.'}</p>
|
|
361
|
+
<dl>
|
|
362
|
+
<div><dt>Score</dt><dd>\${playerView?.score ?? 0}</dd></div>
|
|
363
|
+
<div><dt>Available</dt><dd>\${balance.availableWei} wei</dd></div>
|
|
364
|
+
</dl>
|
|
365
|
+
</main>
|
|
366
|
+
\`;
|
|
367
|
+
|
|
368
|
+
for (const button of gameRoot.querySelectorAll<HTMLButtonElement>('[data-guess]')) {
|
|
369
|
+
button.addEventListener('click', () => {
|
|
370
|
+
const guess = Number(button.dataset.guess);
|
|
371
|
+
void room.send({ guess } satisfies Action);
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
room.subscribe((snapshot) => render(snapshot));
|
|
377
|
+
room.economy.subscribe(() => render());
|
|
378
|
+
const clock = setInterval(() => {
|
|
379
|
+
const countdown = gameRoot.querySelector<HTMLElement>('[data-clock]');
|
|
380
|
+
if (!latest || !countdown) return;
|
|
381
|
+
countdown.textContent = \`\${Math.max(0, Math.ceil((latest.publicView.endsAt - room.now()) / 1_000))}s\`;
|
|
382
|
+
}, 250);
|
|
383
|
+
window.addEventListener('pagehide', () => {
|
|
384
|
+
clearInterval(clock);
|
|
385
|
+
room.dispose();
|
|
386
|
+
});
|
|
387
|
+
`,
|
|
388
|
+
'index.html': `<!doctype html>
|
|
389
|
+
<html lang="en">
|
|
390
|
+
<head>
|
|
391
|
+
<meta charset="UTF-8" />
|
|
392
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
393
|
+
<title>${name}</title>
|
|
394
|
+
<style>
|
|
395
|
+
:root { color: #f8fafc; background: #09090b; font-family: ui-sans-serif, system-ui, sans-serif; }
|
|
396
|
+
body { min-height: 100vh; margin: 0; display: grid; place-items: center; }
|
|
397
|
+
main { width: min(36rem, calc(100vw - 3rem)); }
|
|
398
|
+
.eyebrow { color: #a78bfa; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
|
399
|
+
h1 { margin: .25rem 0; font-size: clamp(2.25rem, 8vw, 4.5rem); line-height: .95; }
|
|
400
|
+
.numbers { display: grid; grid-template-columns: repeat(5, 1fr); gap: .65rem; margin: 2rem 0; }
|
|
401
|
+
button { min-height: 3.5rem; border: 1px solid #3f3f46; border-radius: .75rem; color: inherit; background: #18181b; font: inherit; cursor: pointer; }
|
|
402
|
+
button:hover:not(:disabled), button[aria-pressed="true"] { border-color: #a78bfa; background: #4c1d95; }
|
|
403
|
+
button:disabled { cursor: default; opacity: .65; }
|
|
404
|
+
.result { min-height: 1.5rem; color: #d4d4d8; }
|
|
405
|
+
dl { display: flex; gap: 1rem; }
|
|
406
|
+
dl div { flex: 1; padding: 1rem; border: 1px solid #27272a; border-radius: .75rem; }
|
|
407
|
+
dt { color: #a1a1aa; font-size: .8rem; text-transform: uppercase; }
|
|
408
|
+
dd { margin: .25rem 0 0; font-size: 1.25rem; }
|
|
409
|
+
</style>
|
|
410
|
+
</head>
|
|
411
|
+
<body>
|
|
412
|
+
<div id="app"></div>
|
|
413
|
+
<script type="module" src="/src/play.ts"></script>
|
|
414
|
+
</body>
|
|
415
|
+
</html>
|
|
416
|
+
`,
|
|
417
|
+
'test/rules.test.ts': `import { describe, expect, it } from 'vitest';
|
|
418
|
+
import { Round } from '@flayerlabs/gamemode-spec/round';
|
|
419
|
+
import { isRefusal } from '@flayerlabs/gamemode-spec';
|
|
420
|
+
import { rules, type Config } from '../src/game/rules.js';
|
|
421
|
+
|
|
422
|
+
const OPENS = 1_000_000;
|
|
423
|
+
const config: Config = { rounds: 3, ceiling: 10, points: 500, roundMs: 10_000 };
|
|
424
|
+
|
|
425
|
+
function game(roundSeed = 42) {
|
|
426
|
+
const r = Round.start(rules, config, roundSeed, { opensAt: OPENS, closesAt: OPENS + 300_000 });
|
|
427
|
+
r.send({ kind: 'join', player: 'you', seed: 1, at: OPENS - 100 });
|
|
428
|
+
return r;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function answers(roundSeed: number): number[] {
|
|
432
|
+
const r = game(roundSeed);
|
|
433
|
+
const result = [r.snapshot().answer];
|
|
434
|
+
for (let round = 1; round < config.rounds; round++) {
|
|
435
|
+
r.advanceTo(OPENS + round * config.roundMs);
|
|
436
|
+
result.push(r.snapshot().answer);
|
|
437
|
+
}
|
|
438
|
+
return result;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** The answer is deliberately not in the public view, so a test has to find it the hard way. */
|
|
442
|
+
function correctGuess(r: ReturnType<typeof game>, at: number): number {
|
|
443
|
+
for (let guess = 1; guess <= config.ceiling; guess++) {
|
|
444
|
+
const probe = game();
|
|
445
|
+
probe.send({ kind: 'action', player: 'you', action: { guess }, at });
|
|
446
|
+
if (probe.playerView('you').wasRight) return guess;
|
|
447
|
+
}
|
|
448
|
+
throw new Error('no correct guess found');
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
describe('${name}', () => {
|
|
452
|
+
it('pays for a correct guess', () => {
|
|
453
|
+
const r = game();
|
|
454
|
+
const at = OPENS + 1_000;
|
|
455
|
+
r.send({ kind: 'action', player: 'you', action: { guess: correctGuess(r, at) }, at });
|
|
456
|
+
expect(r.pointsFor('you')).toBe(config.points);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('pays nothing for a wrong one', () => {
|
|
460
|
+
const r = game();
|
|
461
|
+
const at = OPENS + 1_000;
|
|
462
|
+
const wrong = (correctGuess(r, at) % config.ceiling) + 1;
|
|
463
|
+
r.send({ kind: 'action', player: 'you', action: { guess: wrong }, at });
|
|
464
|
+
expect(r.pointsFor('you')).toBe(0);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('allows one guess a round', () => {
|
|
468
|
+
const r = game();
|
|
469
|
+
r.send({ kind: 'action', player: 'you', action: { guess: 1 }, at: OPENS + 1_000 });
|
|
470
|
+
const second = r.send({ kind: 'action', player: 'you', action: { guess: 2 }, at: OPENS + 2_000 });
|
|
471
|
+
expect(second).toEqual({ refuse: 'guess.already_guessed' });
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('refuses a guess from someone who never joined', () => {
|
|
475
|
+
const r = game();
|
|
476
|
+
expect(r.send({ kind: 'action', player: 'nobody', action: { guess: 1 }, at: OPENS + 1_000 })).toEqual({
|
|
477
|
+
refuse: 'guess.not_playing',
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it('refuses a guess outside the range, and nonsense before that', () => {
|
|
482
|
+
const r = game();
|
|
483
|
+
expect(r.send({ kind: 'action', player: 'you', action: { guess: 999 }, at: OPENS + 1_000 })).toEqual({
|
|
484
|
+
refuse: 'guess.out_of_range',
|
|
485
|
+
});
|
|
486
|
+
expect(rules.parseAction({ guess: 'four' })).toBeNull();
|
|
487
|
+
expect(rules.parseAction({ guess: 1.5 })).toBeNull();
|
|
488
|
+
expect(rules.parseAction(null)).toBeNull();
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('never puts the answer where everyone can see it', () => {
|
|
492
|
+
const r = game();
|
|
493
|
+
const everything = JSON.stringify(r.publicView());
|
|
494
|
+
for (let n = 1; n <= config.ceiling; n++) {
|
|
495
|
+
expect(everything).not.toContain(\`"answer":\${n}\`);
|
|
496
|
+
}
|
|
497
|
+
expect(everything).not.toContain('answer');
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('runs out of rounds and stops', () => {
|
|
501
|
+
const r = game();
|
|
502
|
+
r.advanceTo(OPENS + 10 * config.roundMs);
|
|
503
|
+
expect(r.publicView().over).toBe(true);
|
|
504
|
+
expect(rules.nextWakeAt(r.snapshot())).toBeNull();
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('refuses an authored schedule that cannot fit the launch window', () => {
|
|
508
|
+
expect(() =>
|
|
509
|
+
Round.start(rules, config, 42, {
|
|
510
|
+
opensAt: OPENS,
|
|
511
|
+
closesAt: OPENS + config.rounds * config.roundMs - 1,
|
|
512
|
+
}),
|
|
513
|
+
).toThrow(/schedule needs/);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('replaying the same guesses gives the same result', () => {
|
|
517
|
+
const a = game();
|
|
518
|
+
const b = game();
|
|
519
|
+
for (const r of [a, b]) r.send({ kind: 'action', player: 'you', action: { guess: 4 }, at: OPENS + 1_000 });
|
|
520
|
+
expect(a.snapshot()).toEqual(b.snapshot());
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it('derives every answer from the supplied round seed', () => {
|
|
524
|
+
const first = answers(42);
|
|
525
|
+
expect(answers(42)).toEqual(first);
|
|
526
|
+
const variations = Array.from({ length: 32 }, (_, seed) => answers(seed));
|
|
527
|
+
for (let round = 0; round < config.rounds; round++) {
|
|
528
|
+
expect(new Set(variations.map((result) => result[round])).size).toBeGreaterThan(1);
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('says what a perfect game is worth', () => {
|
|
533
|
+
expect(rules.rewardBounds(config)).toEqual({ maxPointsPerPlayer: 1_500 });
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
`,
|
|
537
|
+
'tsconfig.json': `${JSON.stringify({
|
|
538
|
+
compilerOptions: {
|
|
539
|
+
target: 'ES2022',
|
|
540
|
+
module: 'NodeNext',
|
|
541
|
+
moduleResolution: 'NodeNext',
|
|
542
|
+
strict: true,
|
|
543
|
+
noUncheckedIndexedAccess: true,
|
|
544
|
+
verbatimModuleSyntax: true,
|
|
545
|
+
skipLibCheck: true,
|
|
546
|
+
noEmit: true,
|
|
547
|
+
},
|
|
548
|
+
include: ['src', 'test'],
|
|
549
|
+
}, null, 2)}\n`,
|
|
550
|
+
'.gitignore': 'node_modules/\ndist/\n',
|
|
551
|
+
};
|
|
552
|
+
};
|
|
553
|
+
//# sourceMappingURL=scaffold.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scaffold.js","sourceRoot":"","sources":["../src/scaffold.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flayerlabs/gamemode-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Create and validate Flaunch Game Modes",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Flayer Labs",
|
|
7
|
+
"homepage": "https://github.com/flayerlabs/gamemode-sdk#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/flayerlabs/gamemode-sdk.git",
|
|
11
|
+
"directory": "packages/cli"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/flayerlabs/gamemode-sdk/issues"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"bin": {
|
|
21
|
+
"gamemode": "dist/main.js"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"prebuild": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
25
|
+
"build": "tsc -p tsconfig.build.json && node scripts/copy-agent-contract.mjs",
|
|
26
|
+
"prepack": "pnpm build",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"test": "vitest run"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"src"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/lint.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checking that a game's rules are pure.
|
|
3
|
+
*
|
|
4
|
+
* `decide` and `evolve` must give the same answer for the same input, forever. Replay, reconnect,
|
|
5
|
+
* audit and anti-cheat all rest on it, and every one of them fails quietly rather than loudly when
|
|
6
|
+
* it stops being true — a round that scores differently on replay does not announce itself.
|
|
7
|
+
*
|
|
8
|
+
* Text rather than a full parse, deliberately. An agent writing `Date.now()` in a reducer is the
|
|
9
|
+
* failure this catches, and it catches that perfectly. A syntax tree would catch cleverer evasions
|
|
10
|
+
* that nobody is attempting, at the cost of a dependency and a lot of code.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface Finding {
|
|
14
|
+
line: number;
|
|
15
|
+
found: string;
|
|
16
|
+
why: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Rule {
|
|
20
|
+
pattern: RegExp;
|
|
21
|
+
why: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const RULES: Rule[] = [
|
|
25
|
+
{ pattern: /\bDate\s*\.\s*now\s*\(/, why: 'time must come from the command, as command.at' },
|
|
26
|
+
{ pattern: /\bnew\s+Date\s*\(/, why: 'time must come from the command, as command.at' },
|
|
27
|
+
{ pattern: /\bperformance\s*\.\s*now\s*\(/, why: 'time must come from the command, as command.at' },
|
|
28
|
+
{ pattern: /\bMath\s*\.\s*random\s*\(/, why: 'randomness must be derived from the seed on the join command' },
|
|
29
|
+
{ pattern: /\bcrypto\s*\.\s*(getRandomValues|randomUUID)\s*\(/, why: 'randomness must be derived from the seed on the join command' },
|
|
30
|
+
{ pattern: /\bfetch\s*\(/, why: 'rules cannot reach the network' },
|
|
31
|
+
{ pattern: /\brequire\s*\(\s*['"]node:/, why: 'rules cannot reach the system' },
|
|
32
|
+
{ pattern: /\bfrom\s+['"]node:/, why: 'rules cannot reach the system' },
|
|
33
|
+
{ pattern: /\bprocess\s*\.\s*env\b/, why: 'rules cannot read configuration; it belongs in the round config' },
|
|
34
|
+
{ pattern: /\bglobalThis\b/, why: 'rules cannot reach outside themselves' },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/** Lines that are only a comment. A rule named in prose is documentation, not a violation. */
|
|
38
|
+
function isComment(line: string): boolean {
|
|
39
|
+
const trimmed = line.trim();
|
|
40
|
+
return trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Module-level mutable state: a `let` or `var` at column zero.
|
|
45
|
+
*
|
|
46
|
+
* Two calls with the same arguments must not differ because of something one of them left behind.
|
|
47
|
+
*/
|
|
48
|
+
function moduleState(line: string): boolean {
|
|
49
|
+
return /^(let|var)\s+\w/.test(line);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function lintRules(source: string): Finding[] {
|
|
53
|
+
const findings: Finding[] = [];
|
|
54
|
+
|
|
55
|
+
source.split('\n').forEach((line, index) => {
|
|
56
|
+
if (isComment(line)) return;
|
|
57
|
+
|
|
58
|
+
for (const rule of RULES) {
|
|
59
|
+
const match = rule.pattern.exec(line);
|
|
60
|
+
if (match) findings.push({ line: index + 1, found: match[0].replace(/\s+/g, ''), why: rule.why });
|
|
61
|
+
}
|
|
62
|
+
if (moduleState(line)) {
|
|
63
|
+
findings.push({
|
|
64
|
+
line: index + 1,
|
|
65
|
+
found: line.trim().split(/\s+/).slice(0, 2).join(' '),
|
|
66
|
+
why: 'rules cannot keep state between calls; it belongs in the game state',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return findings;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** What a person reads. No codes, no rule names — the line, what is there, and what to do. */
|
|
75
|
+
export function report(file: string, findings: Finding[]): string {
|
|
76
|
+
if (findings.length === 0) return `${file}: rules look pure.`;
|
|
77
|
+
return [
|
|
78
|
+
`${file}: ${findings.length} thing${findings.length === 1 ? '' : 's'} to fix.`,
|
|
79
|
+
'',
|
|
80
|
+
...findings.map((f) => ` line ${f.line}: ${f.found} — ${f.why}`),
|
|
81
|
+
'',
|
|
82
|
+
'These have to go. Same input, same answer, every time — replay, reconnect and anti-cheat',
|
|
83
|
+
'all depend on it, and each of them fails quietly rather than loudly when it stops being true.',
|
|
84
|
+
].join('\n');
|
|
85
|
+
}
|