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