@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/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
|
+
});
|
package/src/scaffold.ts
ADDED
|
@@ -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
|
+
};
|