@energy8platform/create-slot 0.2.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/README.md +48 -0
- package/dist/cli.js +784 -0
- package/dist/cli.js.map +1 -0
- package/package.json +20 -0
- package/template/README.md +15 -0
- package/template/_gitignore +3 -0
- package/template/dev.config.ts +13 -0
- package/template/index.html +17 -0
- package/template/public/assets/audio/NAMING.md +3 -0
- package/template/public/assets/bg/NAMING.md +3 -0
- package/template/public/assets/symbols/NAMING.md +3 -0
- package/template/public/assets/vfx/NAMING.md +3 -0
- package/template/src/slot/symbols.ts +6 -0
- package/template/src/theme.ts +3 -0
- package/template/tsconfig.json +13 -0
- package/template/vite.config.ts +19 -0
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# @energy8platform/create-slot
|
|
2
|
+
|
|
3
|
+
Scaffolder for a new PixiJS slot game on the Energy8 `@energy8platform/game-engine` framework,
|
|
4
|
+
ready to run locally, test in the Stake dev harness, generate its math, and build for Stake Engine.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm create @energy8platform/slot@latest my-game
|
|
8
|
+
# or
|
|
9
|
+
npx @energy8platform/create-slot my-game
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
It asks a few questions (id, title, mechanic, grid, …) and writes a complete, type-checking project.
|
|
13
|
+
|
|
14
|
+
## What it generates
|
|
15
|
+
|
|
16
|
+
| File | Role |
|
|
17
|
+
|---|---|
|
|
18
|
+
| `src/game.spec.ts` | **Single source of truth.** `defineGame(spec)` derives the Lua game definition, the `modeMap`, the math modes, the paytable, the shell's buy-bonus cards AND the Game Info per-mode table. Edit modes / symbols / bet levels / `rtp` / `maxWin` here. |
|
|
19
|
+
| `src/scenes/GameScene.ts` | The render-only scene: `present(result, ctx)` + optional `onBonusEnter`/`onBonusExit`. The host owns the play loop; the scene never calls play/ack. |
|
|
20
|
+
| `src/scenes/IntroScene.ts` | Tap-to-start splash (skipped on replay). |
|
|
21
|
+
| `src/game/script.logic.lua` | The game's math logic (returns a bet-multiplier `total_win`). |
|
|
22
|
+
| `src/game/normalize.ts` + `schema.ts` | Map the raw play result → the scene's typed `SpinData`. |
|
|
23
|
+
| `src/stake/adapter.ts` | The Stake book adapter (`createGameAdapter`) — slices a round-book into segments. |
|
|
24
|
+
| `math.config.ts` | Per-mode sim + curate tuning for the math pipeline (independent of the spec's declared rtp/maxWin). |
|
|
25
|
+
| `CLAUDE.md` | Guidance for Claude Code on how the project is wired and what the framework already handles. |
|
|
26
|
+
| `vite.config.ts` | Dev (DevBridge), the Stake harness, and the `build:stake` frontend target. |
|
|
27
|
+
|
|
28
|
+
## Workflow
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install
|
|
32
|
+
npm run dev # local dev via Energy8 DevBridge (config + Lua)
|
|
33
|
+
npm run stake # Stake dev harness: iframe wrapper + dev-RGS backed by curated books
|
|
34
|
+
npm run math:pool # Stage A — honest large simulation (the pool)
|
|
35
|
+
npm run math:curate # Stage B — compress into the publishable stake-math/ bundle
|
|
36
|
+
npm run build:stake # the Stake frontend build → dist-stake/
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The framework handles the Stake integration end-to-end (RGS bridge, shell, play loop, bonus
|
|
40
|
+
segment-drain, resume, autoplay, social mode, jurisdiction, money formatting, the math pipeline).
|
|
41
|
+
You write the spec, the Lua math, and the rendering — see the generated `CLAUDE.md` for the full map.
|
|
42
|
+
|
|
43
|
+
## Related packages
|
|
44
|
+
|
|
45
|
+
- `@energy8platform/game-engine` — the PixiJS engine + host the game runs on.
|
|
46
|
+
- `@energy8platform/platform-core` — renderer-agnostic platform (Lua engine, shell, SDK session).
|
|
47
|
+
- `@energy8platform/stake-kit` — the Stake book adapter + dev harness used by the scaffold.
|
|
48
|
+
- `@energy8platform/stake-math-tools` — the `e8-math` simulation + curation pipeline.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { stdout, stdin, exit, argv } from 'node:process';
|
|
3
|
+
import { resolve, join } from 'node:path';
|
|
4
|
+
import { createInterface } from 'node:readline/promises';
|
|
5
|
+
import { mkdirSync, cpSync, existsSync, renameSync, writeFileSync, rmSync, readdirSync, statSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_GRID = {
|
|
9
|
+
lines: { cols: 5, rows: 3 },
|
|
10
|
+
ways: { cols: 5, rows: 3 },
|
|
11
|
+
cluster: { cols: 7, rows: 7 },
|
|
12
|
+
anywhere: { cols: 5, rows: 4 },
|
|
13
|
+
custom: { cols: 6, rows: 6 },
|
|
14
|
+
};
|
|
15
|
+
function titleCase(id) {
|
|
16
|
+
return id.split(/[-_]/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
|
|
17
|
+
}
|
|
18
|
+
function parseFlags(argv) {
|
|
19
|
+
// normalize --flag=value → --flag value
|
|
20
|
+
const args = [];
|
|
21
|
+
for (const tok of argv) {
|
|
22
|
+
if (tok.startsWith('--') && tok.includes('=')) {
|
|
23
|
+
const i = tok.indexOf('=');
|
|
24
|
+
args.push(tok.slice(0, i), tok.slice(i + 1));
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
args.push(tok);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const out = {};
|
|
31
|
+
for (let i = 0; i < args.length; i++) {
|
|
32
|
+
const a = args[i];
|
|
33
|
+
if (a === '--id')
|
|
34
|
+
out.id = args[++i];
|
|
35
|
+
else if (a === '--title')
|
|
36
|
+
out.title = args[++i];
|
|
37
|
+
else if (a === '--mechanic')
|
|
38
|
+
out.mechanic = args[++i];
|
|
39
|
+
else if (a === '--grid') {
|
|
40
|
+
const [c, r] = args[++i].split('x').map(Number);
|
|
41
|
+
out.grid = { cols: c, rows: r };
|
|
42
|
+
}
|
|
43
|
+
else if (a === '--cascades')
|
|
44
|
+
out.cascades = true;
|
|
45
|
+
else if (a === '--no-cascades')
|
|
46
|
+
out.cascades = false;
|
|
47
|
+
else if (a === '--stake')
|
|
48
|
+
out.stake = true;
|
|
49
|
+
else if (a === '--no-stake')
|
|
50
|
+
out.stake = false;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
function applyDefaults(partial) {
|
|
55
|
+
const mechanic = partial.mechanic ?? 'cluster';
|
|
56
|
+
return {
|
|
57
|
+
id: partial.id ?? '',
|
|
58
|
+
title: partial.title ?? titleCase(partial.id ?? ''),
|
|
59
|
+
mechanic,
|
|
60
|
+
grid: partial.grid ?? DEFAULT_GRID[mechanic],
|
|
61
|
+
stake: partial.stake ?? true,
|
|
62
|
+
cascades: partial.cascades ?? (mechanic !== 'lines'),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function validate(a) {
|
|
66
|
+
if (!/^[a-z][a-z0-9-]*$/.test(a.id))
|
|
67
|
+
throw new Error(`invalid id (must be kebab-case): "${a.id}"`);
|
|
68
|
+
if (!['lines', 'ways', 'cluster', 'anywhere', 'custom'].includes(a.mechanic))
|
|
69
|
+
throw new Error(`invalid mechanic: "${a.mechanic}"`);
|
|
70
|
+
if (a.grid.cols <= 0 || a.grid.rows <= 0)
|
|
71
|
+
throw new Error('grid dimensions must be > 0');
|
|
72
|
+
}
|
|
73
|
+
/** Flags that consume the next token as their value — used to skip those tokens during positional scan. */
|
|
74
|
+
const VALUE_FLAGS = new Set(['--id', '--title', '--mechanic', '--grid']);
|
|
75
|
+
/**
|
|
76
|
+
* Build an answers seed from argv: flags, plus a lone positional used as
|
|
77
|
+
* the id when --id is absent. Correctly skips tokens that are values of
|
|
78
|
+
* known value-taking flags (e.g. `--mechanic cluster` — `cluster` is NOT
|
|
79
|
+
* treated as a positional even when no --id is supplied).
|
|
80
|
+
*/
|
|
81
|
+
function seedFromArgv(argv) {
|
|
82
|
+
// Normalize --flag=value to ['--flag', 'value'] for uniform processing
|
|
83
|
+
const args = [];
|
|
84
|
+
for (const tok of argv) {
|
|
85
|
+
if (tok.startsWith('--') && tok.includes('=')) {
|
|
86
|
+
const i = tok.indexOf('=');
|
|
87
|
+
args.push(tok.slice(0, i), tok.slice(i + 1));
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
args.push(tok);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Collect indices that are consumed as flag values so we don't
|
|
94
|
+
// accidentally treat them as positionals.
|
|
95
|
+
const valueIndices = new Set();
|
|
96
|
+
for (let i = 0; i < args.length; i++) {
|
|
97
|
+
if (VALUE_FLAGS.has(args[i]) && i + 1 < args.length) {
|
|
98
|
+
valueIndices.add(i + 1);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const flags = parseFlags(argv);
|
|
102
|
+
if (!flags.id) {
|
|
103
|
+
const positional = args.find((a, idx) => !a.startsWith('--') && !valueIndices.has(idx));
|
|
104
|
+
if (positional)
|
|
105
|
+
flags.id = positional;
|
|
106
|
+
}
|
|
107
|
+
return flags;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Ask the 5 questions interactively, applying defaults for blank answers. */
|
|
111
|
+
async function prompt(seed) {
|
|
112
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
113
|
+
try {
|
|
114
|
+
const id = seed.id ?? (await rl.question('Game id (kebab-case): ')).trim();
|
|
115
|
+
const titleInput = (await rl.question(`Title [${applyDefaults({ id }).title}]: `)).trim();
|
|
116
|
+
const title = seed.title ?? (titleInput || undefined);
|
|
117
|
+
const mechanic = (seed.mechanic ?? ((await rl.question('Mechanic (lines|ways|cluster|anywhere|custom) [cluster]: ')).trim() || 'cluster'));
|
|
118
|
+
const gridStr = (await rl.question('Grid colsxrows [default for mechanic]: ')).trim();
|
|
119
|
+
const grid = seed.grid ?? (gridStr ? { cols: Number(gridStr.split('x')[0]), rows: Number(gridStr.split('x')[1]) } : undefined);
|
|
120
|
+
const stakeAns = seed.stake ?? ((await rl.question('Stake integration? (Y/n): ')).trim().toLowerCase() !== 'n');
|
|
121
|
+
return applyDefaults({ id, title, mechanic, grid, stake: stakeAns });
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
rl.close();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Emit a game.spec.ts with a sensible default symbol set + actions; author edits it. */
|
|
129
|
+
function genGameSpec(a) {
|
|
130
|
+
return `import { defineGame, type GameSpec } from '@energy8platform/platform-core/game-spec';
|
|
131
|
+
|
|
132
|
+
// Single source of truth. Edit symbols / paytable / bet levels / actions to design your game.
|
|
133
|
+
export const spec: GameSpec = {
|
|
134
|
+
id: '${a.id}',
|
|
135
|
+
type: 'slot',
|
|
136
|
+
mechanic: '${a.mechanic}',
|
|
137
|
+
grid: { cols: ${a.grid.cols}, rows: ${a.grid.rows} },
|
|
138
|
+
betLevels: [0.01, 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 10000, 100000, 1000000],
|
|
139
|
+
defaultBet: 1,
|
|
140
|
+
maxWin: 5000,
|
|
141
|
+
currency: 'EUR',
|
|
142
|
+
symbols: [
|
|
143
|
+
{ id: 'H1', name: 'High 1', kind: 'high', pay: { 3: 10, 4: 25, 5: 100 } },
|
|
144
|
+
{ id: 'H2', name: 'High 2', kind: 'high', pay: { 3: 8, 4: 20, 5: 80 } },
|
|
145
|
+
{ id: 'H3', name: 'High 3', kind: 'high', pay: { 3: 6, 4: 15, 5: 60 } },
|
|
146
|
+
{ id: 'H4', name: 'High 4', kind: 'high', pay: { 3: 5, 4: 12, 5: 50 } },
|
|
147
|
+
{ id: 'L1', name: 'Low 1', kind: 'low', pay: { 3: 1, 4: 2, 5: 5 } },
|
|
148
|
+
{ id: 'L2', name: 'Low 2', kind: 'low', pay: { 3: 0.8, 4: 1.5, 5: 4 } },
|
|
149
|
+
{ id: 'L3', name: 'Low 3', kind: 'low', pay: { 3: 0.6, 4: 1.2, 5: 3 } },
|
|
150
|
+
{ id: 'L4', name: 'Low 4', kind: 'low', pay: { 3: 0.5, 4: 1, 5: 2.5 } },
|
|
151
|
+
{ id: 'WILD', name: 'Wild', kind: 'wild' },
|
|
152
|
+
{ id: 'SCATTER', name: 'Scatter', kind: 'scatter' },
|
|
153
|
+
],
|
|
154
|
+
// All player-facing spec copy is socialized automatically in social mode — symbol names shown
|
|
155
|
+
// in the paytable AND the action title/description below (e.g. 'BUY BONUS' -> 'GET BONUS',
|
|
156
|
+
// 'Pay more...' -> 'Win more...'). Write normal casino wording here; it stays compliant in social.
|
|
157
|
+
//
|
|
158
|
+
// Modes are declared ONCE here. \`rtp\` (target RTP, 0..1) and \`maxWin\` (per-mode cap; defaults to
|
|
159
|
+
// the game-level maxWin) feed the Game Info per-mode table automatically. NOTE: these are the
|
|
160
|
+
// DECLARED/displayed values — the math pipeline's targets live in math.config.ts and may differ
|
|
161
|
+
// (e.g. while tuning). Keep the declared values honest against the published math.
|
|
162
|
+
actions: {
|
|
163
|
+
spin: { role: 'base', rtp: 0.96 },
|
|
164
|
+
ante: { role: 'feature', cost: 1.5, rtp: 0.96, title: 'ANTE BET', description: 'Pay more for a boosted chance' },
|
|
165
|
+
free_spin: { role: 'free' },
|
|
166
|
+
buy_bonus: { role: 'buy', cost: 100, rtp: 0.96, maxWin: 5000, title: 'BUY BONUS', description: 'Buy the feature', feature: { spins: 10 } },
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export const model = defineGame(spec);
|
|
171
|
+
`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Generate CLAUDE.md for a scaffolded game — guidance for Claude Code (and humans) on how the
|
|
175
|
+
* project is wired, what to edit, and what the framework already handles. */
|
|
176
|
+
function genClaudeMd(a) {
|
|
177
|
+
const cascade = a.cascades === true;
|
|
178
|
+
return `# CLAUDE.md
|
|
179
|
+
|
|
180
|
+
Guidance for Claude Code (claude.ai/code) working in this repository — a slot game built on the
|
|
181
|
+
Energy8 \`@energy8platform/game-engine\` framework, targeting Stake Engine.
|
|
182
|
+
|
|
183
|
+
## What this project is
|
|
184
|
+
|
|
185
|
+
\`${a.id}\` — a PixiJS slot game. The framework (\`@energy8platform/*\`) owns the boot sequence, the
|
|
186
|
+
Stake RGS bridge, the shell (control bar / modals / Game Info), the play loop, and the math
|
|
187
|
+
pipeline. This repo contains only the GAME: its spec, math logic, rendering, and assets.
|
|
188
|
+
|
|
189
|
+
## Single source of truth: \`src/game.spec.ts\`
|
|
190
|
+
|
|
191
|
+
\`defineGame(spec)\` derives everything from one file: the Lua \`GameDefinition\`, the \`modeMap\`
|
|
192
|
+
(action → Stake mode), the math modes, the paytable, AND the shell's buy-bonus cards + Game Info
|
|
193
|
+
per-mode table. **Edit modes/symbols/bet levels/RTP/maxWin there — never duplicate them elsewhere.**
|
|
194
|
+
Each \`actions[key]\` carries \`role\` (base/feature/buy/free), \`cost\`, \`rtp\`, \`maxWin\`, \`title\`,
|
|
195
|
+
\`description\`, \`feature\`. \`rtp\`/\`maxWin\` are the DECLARED (displayed) values; the math pipeline's
|
|
196
|
+
TARGETS live in \`math.config.ts\` and may legitimately differ while tuning.
|
|
197
|
+
|
|
198
|
+
## The scene contract (\`src/scenes/GameScene.ts\`)
|
|
199
|
+
|
|
200
|
+
The host owns the entire play loop (play → present → ack → drain). The scene only RENDERS:
|
|
201
|
+
- \`present(result, ctx)\` — draw ONE segment (a spin, or one free spin). All pacing lives here.
|
|
202
|
+
- \`onBonusEnter(trigger, ctx)\` / \`onBonusExit(last, ctx)\` — optional bonus intro / summary.
|
|
203
|
+
|
|
204
|
+
\`ctx\` = \`{ bet, action, mode, formatAmount(v), turbo }\`. The scene NEVER calls play/ack/roundId,
|
|
205
|
+
never touches the balance/win readouts (the host does, post-present), and never runs the FS loop —
|
|
206
|
+
a bonus is one round the host drains segment-by-segment.${cascade ? '\n\nThis game uses a CASCADE mechanic: `present` runs `result.steps` through the CascadeController and reflects `result.multiplier`.' : ''}
|
|
207
|
+
|
|
208
|
+
## Commands
|
|
209
|
+
|
|
210
|
+
\`\`\`bash
|
|
211
|
+
npm run dev # local dev via Energy8 DevBridge (config + Lua)
|
|
212
|
+
npm run stake # the Stake dev harness — iframe wrapper + dev-RGS backed by curated books
|
|
213
|
+
npm run build:stake # the Stake frontend build → dist-stake/ (base './', no DevBridge)
|
|
214
|
+
npm run math:pool # Stage A — honest large simulation (the pool); see math.config.ts
|
|
215
|
+
npm run math:curate # Stage B — compress the pool into the publishable stake-math/ bundle
|
|
216
|
+
\`\`\`
|
|
217
|
+
|
|
218
|
+
## Math pipeline
|
|
219
|
+
|
|
220
|
+
\`math.config.ts\` declares per-mode sim + curate tuning (iterations, CV, hit-rate, nRowsOut,
|
|
221
|
+
tolerances). The pipeline (\`e8-math\`) runs the native Go simulator, builds a pool, then curates a
|
|
222
|
+
~Stake-canonical book bundle (\`stake-math/\`: \`books_<MODE>.jsonl.zst\` + \`lookUpTable_<MODE>_0.csv\`
|
|
223
|
+
+ \`index.json\`). Book events are canonical \`{ type, spin }\`. The harness replays from these books.
|
|
224
|
+
|
|
225
|
+
## What the framework already handles — do NOT reimplement
|
|
226
|
+
|
|
227
|
+
Segment-drain of bonuses, \`roundId\` forwarding, free-spins counter (with retriggers), resume of an
|
|
228
|
+
unfinished round, win/balance HUD timing, social-mode vocabulary, the legal disclaimer, currency
|
|
229
|
+
formatting, jurisdiction → feature restrictions, the insufficient-funds guard, the play-error modal
|
|
230
|
+
+ reconnect overlay, autoplay, the bet ladder + default bet from \`/wallet/authenticate\`, the
|
|
231
|
+
open-redirect \`rgs_url\` guard, and spacebar handling. Write player-facing copy normally — it is
|
|
232
|
+
socialized automatically in social mode.
|
|
233
|
+
|
|
234
|
+
## Conventions
|
|
235
|
+
|
|
236
|
+
- Keep \`game.spec.ts\` the source of truth; regenerate / reason from it.
|
|
237
|
+
- The math TARGETS (math.config.ts) and the DECLARED rtp/maxWin (game.spec) are separate — keep the
|
|
238
|
+
declared values honest against the published math before submitting.
|
|
239
|
+
- Tests: \`npm test\` (vitest). Typecheck: \`npx tsc --noEmit\`.
|
|
240
|
+
`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function genPackageJson(a, v) {
|
|
244
|
+
const scripts = {
|
|
245
|
+
dev: 'vite',
|
|
246
|
+
build: 'tsc --noEmit && vite build',
|
|
247
|
+
postbuild: `rm -f ${a.id}.zip && cd dist && zip -r ../${a.id}.zip .`,
|
|
248
|
+
typecheck: 'tsc --noEmit',
|
|
249
|
+
smoke: 'tsx smoke.ts',
|
|
250
|
+
sim: 'e8-math sim --config ./math.config.ts',
|
|
251
|
+
pool: 'e8-math pool --config ./math.config.ts',
|
|
252
|
+
curate: 'e8-math curate --config ./math.config.ts',
|
|
253
|
+
math: 'e8-math all --config ./math.config.ts',
|
|
254
|
+
};
|
|
255
|
+
if (a.stake) {
|
|
256
|
+
scripts['dev:stake'] = 'BUILD_TARGET=stake vite';
|
|
257
|
+
scripts['build:stake'] = 'BUILD_TARGET=stake vite build';
|
|
258
|
+
scripts['stake'] = 'BUILD_TARGET=stake-harness vite';
|
|
259
|
+
scripts['stake:bundle'] =
|
|
260
|
+
`rm -rf dist-stake stake-math ${a.id}-stake.zip stake-math.zip && npm run build:stake && npm run math && cd dist-stake && zip -r ../${a.id}-stake.zip . && cd ../stake-math && zip -r ../stake-math.zip . && cd .. && echo 'Stake artifacts: ${a.id}-stake.zip + stake-math.zip'`;
|
|
261
|
+
}
|
|
262
|
+
const pkg = {
|
|
263
|
+
name: a.id,
|
|
264
|
+
private: true,
|
|
265
|
+
type: 'module',
|
|
266
|
+
scripts,
|
|
267
|
+
dependencies: {
|
|
268
|
+
'@energy8platform/platform-core': v['platform-core'],
|
|
269
|
+
'@energy8platform/game-engine': v['game-engine'],
|
|
270
|
+
...(a.stake ? { '@energy8platform/stake-kit': v['stake-kit'], '@energy8platform/stake-bridge': v['stake-bridge'] } : { '@energy8platform/stake-kit': v['stake-kit'] }),
|
|
271
|
+
'pixi.js': '^8.16.0',
|
|
272
|
+
zod: '^3.23.0',
|
|
273
|
+
},
|
|
274
|
+
devDependencies: {
|
|
275
|
+
'@energy8platform/stake-math-tools': v['stake-math-tools'],
|
|
276
|
+
'@types/node': '^20.0.0',
|
|
277
|
+
tsx: '^4.21.0',
|
|
278
|
+
typescript: '^5.6.0',
|
|
279
|
+
vite: '^6.0.0',
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
return JSON.stringify(pkg, null, 2) + '\n';
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function genGameScene(a) {
|
|
286
|
+
const cascade = a.cascades === true;
|
|
287
|
+
const ctrl = cascade ? 'CascadeController' : 'ReelSpinController';
|
|
288
|
+
const present = cascade
|
|
289
|
+
? ` /** Render one normalized result. Tune MultiplierAccumulator policy/reset() to your mechanic. */
|
|
290
|
+
async present(result: SpinData, ctx: RenderContext): Promise<void> {
|
|
291
|
+
const turbo = ctx.turbo > 0;
|
|
292
|
+
if (typeof result.multiplier === 'number') this.multiplier.set(result.multiplier);
|
|
293
|
+
for (const step of result.steps) await this.controller.run(step, { turbo });
|
|
294
|
+
if (result.totalWin > 0) await this.overlay.show(result.totalWin, ctx.bet, ctx.formatAmount);
|
|
295
|
+
}`
|
|
296
|
+
: ` /** Render one normalized result (one spin, or one free spin of a bonus). */
|
|
297
|
+
async present(result: SpinData, ctx: RenderContext): Promise<void> {
|
|
298
|
+
const turbo = ctx.turbo > 0;
|
|
299
|
+
await this.controller.run({ targetGrid: result.targetGrid }, { turbo });
|
|
300
|
+
if (result.totalWin > 0) await this.overlay.show(result.totalWin, ctx.bet, ctx.formatAmount);
|
|
301
|
+
}`;
|
|
302
|
+
const multiplierImport = cascade ? ', MultiplierAccumulator' : '';
|
|
303
|
+
const multiplierField = cascade
|
|
304
|
+
? ` private readonly multiplier = new MultiplierAccumulator({ policy: 'session' });\n` : '';
|
|
305
|
+
return `import { Scene } from '@energy8platform/game-engine/core';
|
|
306
|
+
import { ReelGrid, ${ctrl}, BigWinOverlay${multiplierImport} } from '@energy8platform/game-engine/slot';
|
|
307
|
+
import type { SlotSceneController, RenderContext } from '@energy8platform/game-engine/host';
|
|
308
|
+
import { model } from '../game.spec';
|
|
309
|
+
import { resolveSymbol } from '../slot/symbols';
|
|
310
|
+
import type { SpinData } from '../game/normalize';
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The host owns the play loop (play -> present -> ack -> drain). This scene only RENDERS:
|
|
314
|
+
* - present(result, ctx): draw ONE segment (a spin, or one free spin). Put all pacing here.
|
|
315
|
+
* - onBonusEnter(trigger, ctx): fires right before the first free spin (bonus intro).
|
|
316
|
+
* - onBonusExit(last, ctx): fires after the last free spin (bonus summary).
|
|
317
|
+
* ctx gives you { bet, action, mode, formatAmount(value), turbo } — turbo is live (0..3).
|
|
318
|
+
*/
|
|
319
|
+
export class GameScene extends Scene implements SlotSceneController<SpinData> {
|
|
320
|
+
private grid!: ReelGrid;
|
|
321
|
+
private controller!: ${ctrl};
|
|
322
|
+
private overlay!: BigWinOverlay;
|
|
323
|
+
${multiplierField}
|
|
324
|
+
private _vw = 1920;
|
|
325
|
+
private _vh = 1080;
|
|
326
|
+
|
|
327
|
+
async onEnter(): Promise<void> {
|
|
328
|
+
const { cols, rows } = model.spec.grid;
|
|
329
|
+
this.grid = new ReelGrid({ cols, rows, cellSize: 110, gap: 6, resolve: resolveSymbol });
|
|
330
|
+
this.container.addChild(this.grid);
|
|
331
|
+
this.controller = new ${ctrl}(this.grid);
|
|
332
|
+
this.overlay = new BigWinOverlay({
|
|
333
|
+
tiers: [
|
|
334
|
+
{ id: 'big', minMultiplier: 10, title: 'BIG WIN', accentColor: 0xffd24a },
|
|
335
|
+
{ id: 'mega', minMultiplier: 50, title: 'MEGA WIN', accentColor: 0x7ad7ff },
|
|
336
|
+
],
|
|
337
|
+
formatMoney: (v) => v.toFixed(2),
|
|
338
|
+
width: 1920, height: 1080,
|
|
339
|
+
});
|
|
340
|
+
this.container.addChild(this.overlay);
|
|
341
|
+
this.layout(this._vw, this._vh);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
${present}
|
|
345
|
+
|
|
346
|
+
/** Bonus starting — show an intro. trigger.freeSpins?.total = how many free spins were awarded. */
|
|
347
|
+
async onBonusEnter(trigger: SpinData, _ctx: RenderContext): Promise<void> {
|
|
348
|
+
// TODO: show a bonus intro (e.g. "10 FREE SPINS"). Defaults to nothing.
|
|
349
|
+
void trigger;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Bonus finished — show a summary. ctx.formatAmount(last.totalWin) = the bonus total win. */
|
|
353
|
+
async onBonusExit(last: SpinData, ctx: RenderContext): Promise<void> {
|
|
354
|
+
// TODO: show a bonus summary. Defaults to nothing.
|
|
355
|
+
void last; void ctx;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
onResize(width: number, height: number): void {
|
|
359
|
+
this._vw = width;
|
|
360
|
+
this._vh = height;
|
|
361
|
+
this.layout(width, height);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private layout(w: number, h: number): void {
|
|
365
|
+
this._vw = w; this._vh = h;
|
|
366
|
+
if (!this.grid) return;
|
|
367
|
+
const cols = model.spec.grid.cols, rows = model.spec.grid.rows;
|
|
368
|
+
const cellSize = 110, gap = 6; // must match the ReelGrid constructor above
|
|
369
|
+
const gridW = cols * cellSize + (cols - 1) * gap;
|
|
370
|
+
const gridH = rows * cellSize + (rows - 1) * gap;
|
|
371
|
+
const fit = Math.min((w * 0.92) / gridW, (h * 0.78) / gridH);
|
|
372
|
+
this.grid.scale.set(fit);
|
|
373
|
+
this.grid.x = Math.round((w - gridW * fit) / 2);
|
|
374
|
+
this.grid.y = Math.round((h - gridH * fit) / 2);
|
|
375
|
+
this.overlay?.resize?.(w, h);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function genLuaLogic(a) {
|
|
382
|
+
const cascade = a.cascades === true;
|
|
383
|
+
const ret = cascade
|
|
384
|
+
? ` return {
|
|
385
|
+
total_win = win, -- bet-multiplier; the platform multiplies by the actual bet
|
|
386
|
+
cascades = {}, -- TODO: emit cascade steps { winning, removed, new, grid }
|
|
387
|
+
free_spins = free_spins_result,
|
|
388
|
+
-- The engine reads these VARIABLES (not the nested free_spins table) to open / retrigger the
|
|
389
|
+
-- free-spins SESSION. Without them, free_spin would fail "requires an active session".
|
|
390
|
+
variables = { free_spins_awarded = fs_awarded, retrigger_spins = retrigger_awarded },
|
|
391
|
+
}`
|
|
392
|
+
: ` return {
|
|
393
|
+
total_win = win, -- bet-multiplier
|
|
394
|
+
matrix = grid, -- 2D array of SYM.* ids
|
|
395
|
+
wins = {}, -- TODO: emit line/way wins
|
|
396
|
+
free_spins = free_spins_result,
|
|
397
|
+
-- The engine reads these VARIABLES (not the nested free_spins table) to open / retrigger the
|
|
398
|
+
-- free-spins SESSION. Without them, free_spin would fail "requires an active session".
|
|
399
|
+
variables = { free_spins_awarded = fs_awarded, retrigger_spins = retrigger_awarded },
|
|
400
|
+
}`;
|
|
401
|
+
return `-- Game logic. The spec-derived prelude (SPEC/SYMBOLS/SYM/PAYTABLE) is injected above this file.
|
|
402
|
+
-- Reel weights / RTP tuning live here. Implement your mechanic; return a bet-multiplier in total_win.
|
|
403
|
+
function execute(state)
|
|
404
|
+
-- state.action: 'spin' | 'ante' | 'buy_bonus' | 'free_spin'
|
|
405
|
+
-- state.bet, state.action_config.feature_data
|
|
406
|
+
local action = state.action or 'spin'
|
|
407
|
+
local grid = {}
|
|
408
|
+
for c = 1, SPEC.cols do
|
|
409
|
+
grid[c] = {}
|
|
410
|
+
for r = 1, SPEC.rows do
|
|
411
|
+
grid[c][r] = engine.random(1, #SYMBOLS)
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
-- Placeholder random payout (replace with your real mechanic).
|
|
416
|
+
local win = 0
|
|
417
|
+
local free_spins_result = nil
|
|
418
|
+
local fs_awarded = 0 -- engine VARIABLE: spins this round awards (opens the session)
|
|
419
|
+
local retrigger_awarded = 0 -- engine VARIABLE: extra spins a free_spin retrigger awards
|
|
420
|
+
|
|
421
|
+
if action == 'buy_bonus' then
|
|
422
|
+
-- Player bought free spins: always award them, no base win.
|
|
423
|
+
fs_awarded = 10
|
|
424
|
+
free_spins_result = { awarded = fs_awarded, total = fs_awarded }
|
|
425
|
+
|
|
426
|
+
elseif action == 'free_spin' then
|
|
427
|
+
-- Free-spin round: random payout + small retrigger chance (~5%).
|
|
428
|
+
local roll = engine.random(1, 1000)
|
|
429
|
+
if roll <= 400 then -- ~40% small win (higher hit-rate in bonus)
|
|
430
|
+
win = engine.random(1, 8) * 0.3
|
|
431
|
+
elseif roll <= 440 then -- ~4% medium win
|
|
432
|
+
win = engine.random(10, 50)
|
|
433
|
+
elseif roll <= 442 then -- ~0.2% large win
|
|
434
|
+
win = engine.random(100, 800)
|
|
435
|
+
end
|
|
436
|
+
-- Retrigger: ~5% chance to award extra spins (read via retrigger_spins).
|
|
437
|
+
local retrigger_roll = engine.random(1, 100)
|
|
438
|
+
if retrigger_roll <= 5 then
|
|
439
|
+
retrigger_awarded = 5
|
|
440
|
+
free_spins_result = { awarded = retrigger_awarded, total = retrigger_awarded }
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
elseif action == 'ante' then
|
|
444
|
+
-- Ante (paid-boost) spin: same payouts as base but higher free-spins trigger (~5%).
|
|
445
|
+
local roll = engine.random(1, 1000)
|
|
446
|
+
if roll <= 250 then -- ~25% small win
|
|
447
|
+
win = engine.random(1, 5) * 0.2
|
|
448
|
+
elseif roll <= 270 then -- ~2% medium win
|
|
449
|
+
win = engine.random(5, 30)
|
|
450
|
+
elseif roll <= 272 then -- ~0.2% large win
|
|
451
|
+
win = engine.random(50, 500)
|
|
452
|
+
end
|
|
453
|
+
local fs_roll = engine.random(1, 100)
|
|
454
|
+
if fs_roll <= 5 then -- ~5% free-spins trigger
|
|
455
|
+
fs_awarded = 8
|
|
456
|
+
free_spins_result = { awarded = fs_awarded, total = fs_awarded }
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
else
|
|
460
|
+
-- Base spin ('spin'): random payout + small free-spins trigger (~2%).
|
|
461
|
+
local roll = engine.random(1, 1000)
|
|
462
|
+
if roll <= 250 then -- ~25% small win
|
|
463
|
+
win = engine.random(1, 5) * 0.2
|
|
464
|
+
elseif roll <= 270 then -- ~2% medium win
|
|
465
|
+
win = engine.random(5, 30)
|
|
466
|
+
elseif roll <= 272 then -- ~0.2% large win
|
|
467
|
+
win = engine.random(50, 500)
|
|
468
|
+
end
|
|
469
|
+
local fs_roll = engine.random(1, 100)
|
|
470
|
+
if fs_roll <= 2 then -- ~2% free-spins trigger
|
|
471
|
+
fs_awarded = 8
|
|
472
|
+
free_spins_result = { awarded = fs_awarded, total = fs_awarded }
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
${ret}
|
|
477
|
+
end
|
|
478
|
+
`;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Generate src/stake/adapter.ts. The spin schema is the shared src/game/schema.ts (one schema for all games). */
|
|
482
|
+
function genStakeAdapter(_a) {
|
|
483
|
+
const adapter = `import { createGameAdapter, type SegmentCore } from '@energy8platform/stake-kit';
|
|
484
|
+
import { model } from '../game.spec';
|
|
485
|
+
import { spinSchema, type SpinDataRaw } from '../game/schema';
|
|
486
|
+
|
|
487
|
+
export const adapter = createGameAdapter<SpinDataRaw>({
|
|
488
|
+
model,
|
|
489
|
+
schema: spinSchema,
|
|
490
|
+
segmentOf: ({ event, payload, round }) => {
|
|
491
|
+
// Canonical Stake book events are { type, spin }; a free spin is type 'free_spin'.
|
|
492
|
+
const isFs = (event as { type?: string }).type === 'free_spin';
|
|
493
|
+
const core: SegmentCore<SpinDataRaw> = {
|
|
494
|
+
action: isFs ? 'free_spin' : round.triggerAction,
|
|
495
|
+
winX: payload.total_win ?? 0,
|
|
496
|
+
session: { roundId: round.roundId },
|
|
497
|
+
};
|
|
498
|
+
const awarded = payload.free_spins?.awarded ?? 0;
|
|
499
|
+
if (!isFs && awarded > 0) {
|
|
500
|
+
core.bonusFreeSpin = { grantId: 1, remainingSpins: awarded };
|
|
501
|
+
}
|
|
502
|
+
return core;
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
export default adapter;
|
|
507
|
+
`;
|
|
508
|
+
return { adapter };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function genMainTs(a) {
|
|
512
|
+
const stakeImport = a.stake ? `import adapter from './stake/adapter';\n` : '';
|
|
513
|
+
const stakeOpt = a.stake ? ` stake: { adapter },\n` : '';
|
|
514
|
+
return `import { createSlotGame } from '@energy8platform/game-engine/host';
|
|
515
|
+
import { ScaleMode } from '@energy8platform/game-engine';
|
|
516
|
+
import { model } from './game.spec';
|
|
517
|
+
import { GameScene } from './scenes/GameScene';
|
|
518
|
+
import { IntroScene } from './scenes/IntroScene';
|
|
519
|
+
import { normalize } from './game/normalize';
|
|
520
|
+
${stakeImport}
|
|
521
|
+
createSlotGame({
|
|
522
|
+
model,
|
|
523
|
+
normalize,
|
|
524
|
+
// Scenes in order — the first eligible one starts. 'intro' is skipped on a replay launch, so a
|
|
525
|
+
// replay opens directly on the game scene.
|
|
526
|
+
scenes: [
|
|
527
|
+
{ key: 'intro', scene: IntroScene, skipOnReplay: true },
|
|
528
|
+
{ key: 'game', scene: GameScene },
|
|
529
|
+
],
|
|
530
|
+
manifest: { bundles: [] },
|
|
531
|
+
design: { width: 1920, height: 1080 },
|
|
532
|
+
scaleMode: ScaleMode.FILL,
|
|
533
|
+
fonts: ['400 24px "Inter"'],
|
|
534
|
+
textureDefaults: true,
|
|
535
|
+
dev: (import.meta as any).env?.DEV ?? false,
|
|
536
|
+
${stakeOpt} shell: {
|
|
537
|
+
// buy/ante cards + currency derive from the spec + initData.
|
|
538
|
+
// Base game-info sections. Wrap player-facing copy in t(...) so restricted gambling words are
|
|
539
|
+
// rewritten to social-casino vocabulary in social mode (t is the identity otherwise). The
|
|
540
|
+
// built-in sections (max win, paytable, controls, disclaimer) and the spec's buy/ante card copy
|
|
541
|
+
// are socialized automatically; t() is how YOUR custom copy joins in.
|
|
542
|
+
gameInfo: (t) => ({
|
|
543
|
+
sections: [
|
|
544
|
+
{
|
|
545
|
+
type: 'custom',
|
|
546
|
+
title: t('How to Play'),
|
|
547
|
+
html: \`<p>\${t('Spin the reels and match symbols to win. Buy the bonus to trigger free spins instantly.')}</p>\`,
|
|
548
|
+
},
|
|
549
|
+
],
|
|
550
|
+
}),
|
|
551
|
+
},
|
|
552
|
+
}).catch((err) => { console.error('[${a.id}] failed to start', err); });
|
|
553
|
+
`;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Generate src/scenes/IntroScene.ts: a real game-owned Scene subclass (edit freely).
|
|
557
|
+
* The host injects goto() via scene start data; call goto('game') to advance. */
|
|
558
|
+
function genIntroScene(a) {
|
|
559
|
+
return `import { Container, Graphics, Text } from 'pixi.js';
|
|
560
|
+
import { Scene } from '@energy8platform/game-engine/core';
|
|
561
|
+
import { Tween } from '@energy8platform/game-engine/animation';
|
|
562
|
+
|
|
563
|
+
/** Start data the host injects into every scene (goto navigates between scenes). */
|
|
564
|
+
interface IntroData { goto?: (key: string, data?: unknown) => void; }
|
|
565
|
+
|
|
566
|
+
/** Full intro scene for ${a.title}. Edit this freely — background, logo, buttons, animation. */
|
|
567
|
+
export class IntroScene extends Scene {
|
|
568
|
+
private layer?: Container;
|
|
569
|
+
|
|
570
|
+
async onEnter(data?: unknown): Promise<void> {
|
|
571
|
+
const { goto } = (data ?? {}) as IntroData;
|
|
572
|
+
const layer = new Container();
|
|
573
|
+
this.layer = layer;
|
|
574
|
+
this.container.addChild(layer);
|
|
575
|
+
|
|
576
|
+
// Background — replace with a texture sprite once you have art.
|
|
577
|
+
const bg = new Graphics().rect(0, 0, 1920, 1080).fill({ color: 0x0b0f1a });
|
|
578
|
+
layer.addChild(bg);
|
|
579
|
+
|
|
580
|
+
const title = new Text({ text: '${a.title}', style: { fill: 0xffffff, fontSize: 110, fontFamily: 'Inter', fontWeight: '700', align: 'center' } });
|
|
581
|
+
title.anchor.set(0.5); title.position.set(960, 420);
|
|
582
|
+
layer.addChild(title);
|
|
583
|
+
|
|
584
|
+
const subtitle = new Text({ text: 'Press PLAY to begin', style: { fill: 0x9fb3c8, fontSize: 34, fontFamily: 'Inter' } });
|
|
585
|
+
subtitle.anchor.set(0.5); subtitle.position.set(960, 520);
|
|
586
|
+
layer.addChild(subtitle);
|
|
587
|
+
|
|
588
|
+
// PLAY button (replace with the engine UI Button or your own art).
|
|
589
|
+
const btn = new Container(); btn.position.set(960, 660);
|
|
590
|
+
const bgBtn = new Graphics().roundRect(-150, -45, 300, 90, 16).fill({ color: 0xffd24a });
|
|
591
|
+
const label = new Text({ text: 'PLAY', style: { fill: 0x0b0f1a, fontSize: 40, fontFamily: 'Inter', fontWeight: '700' } });
|
|
592
|
+
label.anchor.set(0.5);
|
|
593
|
+
btn.addChild(bgBtn, label);
|
|
594
|
+
btn.eventMode = 'static'; btn.cursor = 'pointer';
|
|
595
|
+
btn.once('pointerdown', () => goto?.('game'));
|
|
596
|
+
layer.addChild(btn);
|
|
597
|
+
|
|
598
|
+
layer.alpha = 0;
|
|
599
|
+
await Tween.to(layer, { alpha: 1 }, 400);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
onExit(): void {
|
|
603
|
+
this.layer?.destroy({ children: true });
|
|
604
|
+
this.layer = undefined;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
`;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Generate src/game/normalize.ts: game-declared SpinData + the host normalizer.
|
|
611
|
+
* Coercion (Lua {} → []) is schema-driven via stake-kit's deriveArrayFields + coerceLuaArrays. */
|
|
612
|
+
function genNormalize(a) {
|
|
613
|
+
const cascade = a.cascades === true;
|
|
614
|
+
const dataShape = cascade
|
|
615
|
+
? ` /** Cascade steps the scene animates via CascadeController. */
|
|
616
|
+
steps: CascadeStepData[];
|
|
617
|
+
/** Optional running multiplier the scene reflects. */
|
|
618
|
+
multiplier?: number;`
|
|
619
|
+
: ` /** Result grid by column for the reel spin. */
|
|
620
|
+
targetGrid: CellData[][];`;
|
|
621
|
+
const mapBody = cascade
|
|
622
|
+
? ` steps: Array.isArray(d.cascades) ? d.cascades.map((step: any) => ({
|
|
623
|
+
winningCells: step.winning ?? [],
|
|
624
|
+
removedCells: step.removed ?? [],
|
|
625
|
+
newCells: step.new ?? [],
|
|
626
|
+
settledGrid: step.grid ?? [],
|
|
627
|
+
})) : [],
|
|
628
|
+
multiplier: d.multiplier,`
|
|
629
|
+
: ` targetGrid: d.matrix ?? [],`;
|
|
630
|
+
return `import type { SlotSpinResultBase, SlotResultNormalizer } from '@energy8platform/platform-core/slot-result';
|
|
631
|
+
import type { ${cascade ? 'CascadeStepData' : 'CellData'} } from '@energy8platform/game-engine/slot';
|
|
632
|
+
import { deriveArrayFields, coerceLuaArrays } from '@energy8platform/stake-kit';
|
|
633
|
+
import { spinSchema, type SpinDataRaw } from './schema';
|
|
634
|
+
|
|
635
|
+
/** The game's typed play result. Extend with any fields your script.logic.lua returns. */
|
|
636
|
+
export interface SpinData extends SlotSpinResultBase {
|
|
637
|
+
${dataShape}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Array fields are derived from the schema once (Lua empty tables {} → []), so the
|
|
641
|
+
// scene-facing mapping below can rely on real arrays — no crashes from Lua empty tables.
|
|
642
|
+
const arrayFields = deriveArrayFields(spinSchema);
|
|
643
|
+
|
|
644
|
+
/** REQUIRED: map the raw play result into SpinData. The host calls this on every play. */
|
|
645
|
+
export const normalize: SlotResultNormalizer<SpinData> = (raw) => {
|
|
646
|
+
const r = (raw ?? {}) as { totalWin?: number; data?: unknown };
|
|
647
|
+
const coerced = coerceLuaArrays((r.data ?? {}) as Record<string, unknown>, arrayFields);
|
|
648
|
+
const parsed = spinSchema.safeParse(coerced);
|
|
649
|
+
const d = (parsed.success ? parsed.data : coerced) as SpinDataRaw;
|
|
650
|
+
return {
|
|
651
|
+
totalWin: r.totalWin ?? 0,
|
|
652
|
+
freeSpins: d.free_spins ? { awarded: d.free_spins.awarded, total: d.free_spins.total } : undefined,
|
|
653
|
+
${mapBody}
|
|
654
|
+
};
|
|
655
|
+
};
|
|
656
|
+
`;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Generate src/game/schema.ts: the zod schema for the inner Lua data table.
|
|
660
|
+
* Array fields are plain z.array(...) so stake-kit's deriveArrayFields() finds them
|
|
661
|
+
* and coerceLuaArrays() can turn Lua {} into [] (used by normalize AND the stake adapter). */
|
|
662
|
+
function genSchema(a) {
|
|
663
|
+
const cascade = a.cascades === true;
|
|
664
|
+
const arrayFields = cascade
|
|
665
|
+
? ` cascades: z.array(z.object({}).passthrough()).optional(),`
|
|
666
|
+
: ` matrix: z.array(z.array(z.number())).optional(),
|
|
667
|
+
wins: z.array(z.object({}).passthrough()).optional(),`;
|
|
668
|
+
return `import { z } from 'zod';
|
|
669
|
+
|
|
670
|
+
/** The inner Lua "data" table your script.logic.lua returns (the engine nests it under result.data).
|
|
671
|
+
* Edit these fields to match your math. Array fields MUST stay z.array(...) so Lua {} coerces to []. */
|
|
672
|
+
export const spinSchema = z.object({
|
|
673
|
+
total_win: z.number().optional(),
|
|
674
|
+
${arrayFields}
|
|
675
|
+
multiplier: z.number().optional(),
|
|
676
|
+
free_spins: z.object({ awarded: z.number(), total: z.number() }).optional(),
|
|
677
|
+
});
|
|
678
|
+
export type SpinDataRaw = z.infer<typeof spinSchema>;
|
|
679
|
+
`;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function genMathConfig(_a) {
|
|
683
|
+
return `import { readFileSync } from 'node:fs';
|
|
684
|
+
import { buildLuaScript } from '@energy8platform/platform-core/game-spec';
|
|
685
|
+
import { model } from './src/game.spec';
|
|
686
|
+
import type { MathConfig } from '@energy8platform/stake-math-tools';
|
|
687
|
+
|
|
688
|
+
// node-only (the e8-math CLI runs in node) — reads the Lua via node:fs, not Vite raw imports.
|
|
689
|
+
const logic = readFileSync(new URL('./src/game/script.logic.lua', import.meta.url), 'utf8');
|
|
690
|
+
|
|
691
|
+
export default {
|
|
692
|
+
model,
|
|
693
|
+
luaScript: buildLuaScript(model, logic),
|
|
694
|
+
// One block per Stake mode key. Tune sim iterations + curate targets per mode.
|
|
695
|
+
// Missing modes use seeded defaults (see resolveModes in stake-math-tools).
|
|
696
|
+
modes: {
|
|
697
|
+
BASE: {
|
|
698
|
+
sim: { iterations: 100_000, bet: 1, rng: 'provably-fair' },
|
|
699
|
+
curate: {
|
|
700
|
+
capMaxWin: model.spec.maxWin * 100, // cents (bet-multiplier × 100)
|
|
701
|
+
algorithm: 'tiered',
|
|
702
|
+
targetRTP: 0.96,
|
|
703
|
+
toleranceRTP: 0.01,
|
|
704
|
+
targetCV: 5,
|
|
705
|
+
toleranceCV: 2,
|
|
706
|
+
targetHitRate: 0.25,
|
|
707
|
+
toleranceHitRate: 0.05,
|
|
708
|
+
nRowsOut: 50_000, // keep nRowsOut < iterations
|
|
709
|
+
},
|
|
710
|
+
},
|
|
711
|
+
// Feature modes. Only sim.iterations is set by default — the remaining sim params and all
|
|
712
|
+
// curate targets fall back to seeded defaults (capMaxWin from spec.maxWin); tune as needed.
|
|
713
|
+
BUY_BONUS: { sim: { iterations: 100_000 } },
|
|
714
|
+
ANTE: { sim: { iterations: 100_000 } },
|
|
715
|
+
},
|
|
716
|
+
} satisfies MathConfig;
|
|
717
|
+
`;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const TEMPLATE_DIR = resolve(fileURLToPath(new URL('.', import.meta.url)), '../template');
|
|
721
|
+
function substituteTree(dir, vars) {
|
|
722
|
+
// NOTE: template is text-only. If binary placeholders are ever added under template/,
|
|
723
|
+
// add a file-extension allowlist here — readFileSync(p,'utf8') would corrupt them.
|
|
724
|
+
for (const name of readdirSync(dir)) {
|
|
725
|
+
const p = join(dir, name);
|
|
726
|
+
if (statSync(p).isDirectory()) {
|
|
727
|
+
substituteTree(p, vars);
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
let text = readFileSync(p, 'utf8');
|
|
731
|
+
for (const [k, val] of Object.entries(vars))
|
|
732
|
+
text = text.split('${' + k + '}').join(val);
|
|
733
|
+
writeFileSync(p, text);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
async function generate(a, targetDir, versions) {
|
|
737
|
+
validate(a);
|
|
738
|
+
mkdirSync(targetDir, { recursive: true });
|
|
739
|
+
// 1) copy fixed template
|
|
740
|
+
cpSync(TEMPLATE_DIR, targetDir, { recursive: true });
|
|
741
|
+
// _gitignore → .gitignore
|
|
742
|
+
if (existsSync(join(targetDir, '_gitignore')))
|
|
743
|
+
renameSync(join(targetDir, '_gitignore'), join(targetDir, '.gitignore'));
|
|
744
|
+
// 2) substitute ${id}/${title}
|
|
745
|
+
substituteTree(targetDir, { id: a.id, title: a.title });
|
|
746
|
+
// 3) codegen files
|
|
747
|
+
mkdirSync(join(targetDir, 'src/game'), { recursive: true });
|
|
748
|
+
mkdirSync(join(targetDir, 'src/scenes'), { recursive: true });
|
|
749
|
+
writeFileSync(join(targetDir, 'src/game.spec.ts'), genGameSpec(a));
|
|
750
|
+
writeFileSync(join(targetDir, 'CLAUDE.md'), genClaudeMd(a));
|
|
751
|
+
writeFileSync(join(targetDir, 'package.json'), genPackageJson(a, versions));
|
|
752
|
+
writeFileSync(join(targetDir, 'math.config.ts'), genMathConfig());
|
|
753
|
+
writeFileSync(join(targetDir, 'src/scenes/GameScene.ts'), genGameScene(a));
|
|
754
|
+
writeFileSync(join(targetDir, 'src/main.ts'), genMainTs(a));
|
|
755
|
+
writeFileSync(join(targetDir, 'src/scenes/IntroScene.ts'), genIntroScene(a));
|
|
756
|
+
writeFileSync(join(targetDir, 'src/game/script.logic.lua'), genLuaLogic(a));
|
|
757
|
+
writeFileSync(join(targetDir, 'src/game/normalize.ts'), genNormalize(a));
|
|
758
|
+
writeFileSync(join(targetDir, 'src/game/schema.ts'), genSchema(a));
|
|
759
|
+
if (a.stake) {
|
|
760
|
+
mkdirSync(join(targetDir, 'src/stake'), { recursive: true });
|
|
761
|
+
const { adapter } = genStakeAdapter();
|
|
762
|
+
writeFileSync(join(targetDir, 'src/stake/adapter.ts'), adapter);
|
|
763
|
+
}
|
|
764
|
+
else if (existsSync(join(targetDir, 'src/stake'))) {
|
|
765
|
+
rmSync(join(targetDir, 'src/stake'), { recursive: true, force: true });
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Dependency versions written into a scaffolded game's package.json. Keep in lock-step with the
|
|
770
|
+
// published @energy8platform/* versions (a create-slot test asserts these match the workspace).
|
|
771
|
+
const PUBLISHED = {
|
|
772
|
+
'platform-core': '^0.25.0', 'game-engine': '^0.18.0', 'stake-kit': '^0.2.0', 'stake-bridge': '^0.3.0',
|
|
773
|
+
'stake-math-tools': '^0.8.0',
|
|
774
|
+
};
|
|
775
|
+
async function main() {
|
|
776
|
+
const seed = seedFromArgv(argv.slice(2));
|
|
777
|
+
const yes = argv.includes('--yes');
|
|
778
|
+
const answers = yes ? applyDefaults(seed) : await prompt(seed);
|
|
779
|
+
const dir = resolve(process.cwd(), answers.id);
|
|
780
|
+
await generate(answers, dir, PUBLISHED);
|
|
781
|
+
console.log(`\n✓ Created ${answers.id} at ${dir}\n cd ${answers.id} && npm install && npm run dev\n`);
|
|
782
|
+
}
|
|
783
|
+
main().catch((err) => { console.error(err.message); exit(1); });
|
|
784
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.js","sources":["../src/answers.ts","../src/prompts.ts","../src/codegen/gameSpec.ts","../src/codegen/claudeMd.ts","../src/codegen/packageJson.ts","../src/codegen/gameScene.ts","../src/codegen/luaLogic.ts","../src/codegen/stakeAdapter.ts","../src/codegen/mainTs.ts","../src/codegen/introScene.ts","../src/codegen/normalize.ts","../src/codegen/schema.ts","../src/codegen/mathConfig.ts","../src/generate.ts","../src/cli.ts"],"sourcesContent":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@energy8platform/create-slot",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": { "create-slot": "dist/cli.js" },
|
|
6
|
+
"files": ["dist", "template"],
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "rollup -c rollup.config.ts --configPlugin @rollup/plugin-typescript",
|
|
9
|
+
"test": "vitest run",
|
|
10
|
+
"typecheck": "tsc --noEmit"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@rollup/plugin-typescript": "^12.1.0",
|
|
14
|
+
"@types/node": "^20.0.0",
|
|
15
|
+
"rollup": "^4.24.0",
|
|
16
|
+
"tslib": "^2.8.0",
|
|
17
|
+
"typescript": "^5.6.0",
|
|
18
|
+
"vitest": "^2.0.0"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# ${title}
|
|
2
|
+
|
|
3
|
+
Generated with `npm create @energy8platform/slot`. Built on @energy8platform game-spec / host / stake-kit / slot.
|
|
4
|
+
|
|
5
|
+
## Develop
|
|
6
|
+
- `npm install`
|
|
7
|
+
- `npm run dev` — runs the game in a browser (Vite + in-process DevBridge running your Lua)
|
|
8
|
+
- Edit `src/game.spec.ts` (symbols/paytable/bet levels/actions) and `src/game/script.logic.lua` (math).
|
|
9
|
+
- Swap placeholder art in `public/assets/` (see NAMING.md) and wire it in `src/slot/symbols.ts`.
|
|
10
|
+
|
|
11
|
+
## Verify
|
|
12
|
+
- `npm run typecheck`
|
|
13
|
+
- `npm run smoke` — proves spec → export artifacts
|
|
14
|
+
|
|
15
|
+
See the `slot-game-creator` skill for the full mechanics → math → art → UI workflow.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { buildLuaScript } from '@energy8platform/platform-core/game-spec';
|
|
2
|
+
import { model } from './src/game.spec';
|
|
3
|
+
import logic from './src/game/script.logic.lua?raw';
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
balance: 100000,
|
|
7
|
+
currency: model.spec.currency ?? 'EUR',
|
|
8
|
+
networkDelay: 80,
|
|
9
|
+
debug: true,
|
|
10
|
+
gameDefinition: model.gameDefinition,
|
|
11
|
+
luaScript: buildLuaScript(model, logic),
|
|
12
|
+
luaSeed: 12345,
|
|
13
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
<title>${title}</title>
|
|
7
|
+
<style>
|
|
8
|
+
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #0a0a12; }
|
|
9
|
+
#game { position: fixed; inset: 0; }
|
|
10
|
+
canvas { display: block; }
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div id="game"></div>
|
|
15
|
+
<script type="module" src="/src/main.ts"></script>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Texture } from 'pixi.js';
|
|
2
|
+
import { AnimatedSymbol, type SymbolResolver } from '@energy8platform/game-engine/slot';
|
|
3
|
+
|
|
4
|
+
// Placeholder resolver: every symbol is a blank tile. Swap in real textures from public/assets/symbols.
|
|
5
|
+
export const resolveSymbol: SymbolResolver = (id: string) =>
|
|
6
|
+
new AnimatedSymbol({ textures: { base: id ? Texture.WHITE : Texture.EMPTY }, size: 110 });
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineGameConfig } from '@energy8platform/game-engine/vite';
|
|
2
|
+
import { stakeHarnessPlugin } from '@energy8platform/stake-kit/harness';
|
|
3
|
+
|
|
4
|
+
const target = process.env.BUILD_TARGET;
|
|
5
|
+
const isStake = target === 'stake';
|
|
6
|
+
const isHarness = target === 'stake-harness';
|
|
7
|
+
|
|
8
|
+
export default defineGameConfig({
|
|
9
|
+
base: './',
|
|
10
|
+
// Stake builds and harness run inside the Stake RGS shell — no local DevBridge.
|
|
11
|
+
devBridge: !isStake && !isHarness,
|
|
12
|
+
devBridgeConfig: './dev.config',
|
|
13
|
+
vite: {
|
|
14
|
+
server: { port: 5173 },
|
|
15
|
+
optimizeDeps: { include: ['pixi.js'], exclude: ['fengari'] },
|
|
16
|
+
...(isStake ? { build: { outDir: 'dist-stake' } } : {}),
|
|
17
|
+
...(isHarness ? { plugins: [stakeHarnessPlugin({ config: './math.config.ts', booksDir: 'stake-math' })] } : {}),
|
|
18
|
+
},
|
|
19
|
+
});
|