@educa-corp/sdd-framework 0.6.0 → 0.7.1
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/bin/gate-trace.js +25 -2
- package/bin/index.js +32 -5
- package/bin/lint-trace.js +41 -0
- package/bin/self-check.js +430 -3
- package/bin/trace-schema.json +418 -31
- package/core/FRAMEWORK_VERSION +1 -1
- package/{commands/extend-prd.md → core/commands/amend-prd.md} +206 -173
- package/core/commands/dev-run-test.md +48 -10
- package/core/commands/extend-prd.md +39 -12
- package/core/commands/generate-bdd.md +52 -10
- package/core/commands/generate-code.md +35 -2
- package/core/commands/generate-tech-docs.md +36 -4
- package/core/commands/map-testids.md +1 -1
- package/core/commands/qc-run-test.md +29 -3
- package/core/commands/refine-prd.md +13 -2
- package/core/commands/review-context.md +43 -8
- package/core/commands/sync.md +105 -1
- package/core/commands/validate-traces.md +289 -16
- package/core/rules/workflow.md +34 -0
- package/core/steps/context-loader.md +27 -6
- package/core/templates/feature.template +1 -1
- package/core/templates/project-context.yaml +3 -3
- package/core/templates/tech-design.template.md +2 -2
- package/docs/02-concepts/architecture.md +37 -1
- package/docs/02-concepts/overview.md +1 -1
- package/docs/02-concepts/pipeline-steps/02-specification.md +13 -7
- package/docs/02-concepts/pipeline-steps/07-dev-selftest.md +2 -0
- package/docs/02-concepts/pipeline-steps/08-qc-automation.md +1 -0
- package/docs/02-concepts/pipeline-steps/09-validate-traces.md +34 -3
- package/docs/02-concepts/pipeline-steps/10-feedback-loop.md +10 -1
- package/docs/02-concepts/traceability.md +187 -183
- package/docs/03-guides/architect.md +13 -4
- package/docs/03-guides/developer.md +1 -0
- package/docs/03-guides/product-owner.md +89 -72
- package/docs/03-guides/tester-qa.md +81 -81
- package/docs/04-reference/commands.md +148 -134
- package/docs/04-reference/trace-schema.md +45 -1
- package/docs/explain/02b-extend-prd.md +1 -1
- package/docs/explain/02c-amend-prd.md +152 -0
- package/docs/explain/06-generate-bdd.md +1 -1
- package/docs/explain/13-dev-run-test.md +15 -1
- package/docs/explain/19-qc-run-test.md +91 -87
- package/docs/explain/21-validate-traces.md +79 -75
- package/docs/explain/28-sync.md +25 -0
- package/docs/explain/README.md +136 -135
- package/package.json +1 -8
- package/commands/debug.md +0 -529
- package/commands/debug.tmpl +0 -260
- package/commands/define-product.md +0 -438
- package/commands/define-product.tmpl +0 -225
- package/commands/dev-gen-test.md +0 -700
- package/commands/dev-gen-test.tmpl +0 -490
- package/commands/dev-run-test.md +0 -435
- package/commands/dev-run-test.tmpl +0 -225
- package/commands/dev-smoke-test.md +0 -374
- package/commands/dev-smoke-test.tmpl +0 -217
- package/commands/extend-prd.tmpl +0 -273
- package/commands/fix-bug.md +0 -519
- package/commands/fix-bug.tmpl +0 -197
- package/commands/generate-architecture.md +0 -354
- package/commands/generate-architecture.tmpl +0 -197
- package/commands/generate-bdd.md +0 -923
- package/commands/generate-bdd.tmpl +0 -590
- package/commands/generate-code.md +0 -859
- package/commands/generate-code.tmpl +0 -649
- package/commands/generate-design-spec.md +0 -737
- package/commands/generate-design-spec.tmpl +0 -524
- package/commands/generate-prd.md +0 -722
- package/commands/generate-prd.tmpl +0 -226
- package/commands/generate-spec-manifest.md +0 -321
- package/commands/generate-spec-manifest.tmpl +0 -164
- package/commands/generate-tech-docs.md +0 -920
- package/commands/generate-tech-docs.tmpl +0 -273
- package/commands/learn.md +0 -399
- package/commands/learn.tmpl +0 -130
- package/commands/map-testids.md +0 -238
- package/commands/map-testids.tmpl +0 -81
- package/commands/propose-scenario.md +0 -359
- package/commands/propose-scenario.tmpl +0 -202
- package/commands/qc-analyze.md +0 -269
- package/commands/qc-analyze.tmpl +0 -112
- package/commands/qc-design-test.md +0 -226
- package/commands/qc-design-test.tmpl +0 -69
- package/commands/qc-plan.md +0 -206
- package/commands/qc-plan.tmpl +0 -49
- package/commands/qc-report.md +0 -217
- package/commands/qc-report.tmpl +0 -60
- package/commands/qc-review.md +0 -210
- package/commands/qc-review.tmpl +0 -53
- package/commands/qc-run-test.md +0 -326
- package/commands/qc-run-test.tmpl +0 -116
- package/commands/refine-prd.md +0 -653
- package/commands/refine-prd.tmpl +0 -281
- package/commands/report-bug.md +0 -305
- package/commands/report-bug.tmpl +0 -148
- package/commands/review-code.md +0 -415
- package/commands/review-code.tmpl +0 -146
- package/commands/review-context.md +0 -902
- package/commands/review-context.tmpl +0 -530
- package/commands/review-tech-docs.md +0 -561
- package/commands/review-tech-docs.tmpl +0 -404
- package/commands/setup-ai-first.md +0 -602
- package/commands/setup-ai-first.tmpl +0 -450
- package/commands/sync.md +0 -430
- package/commands/sync.tmpl +0 -429
- package/commands/update-framework.md +0 -203
- package/commands/update-framework.tmpl +0 -202
- package/commands/validate-traces.md +0 -1077
- package/commands/validate-traces.tmpl +0 -920
- package/hooks/data-guard.js +0 -232
- package/hooks/settings.json +0 -19
- package/modules/android-compose/module.yaml +0 -13
- package/modules/android-compose/stack-profile.yaml +0 -57
- package/modules/angular/architecture-snippets/component-patterns.md +0 -187
- package/modules/angular/module.yaml +0 -6
- package/modules/angular/stack-profile.yaml +0 -38
- package/modules/context-engineering/architecture-snippets/context-design.md +0 -119
- package/modules/context-engineering/module.yaml +0 -9
- package/modules/context-engineering/stack-profile.yaml +0 -61
- package/modules/dotnet/architecture-snippets/clean-arch.md +0 -160
- package/modules/dotnet/module.yaml +0 -6
- package/modules/dotnet/stack-profile.yaml +0 -50
- package/modules/flutter/module.yaml +0 -14
- package/modules/flutter/stack-profile.yaml +0 -59
- package/modules/golang/architecture-snippets/domain-layout.md +0 -283
- package/modules/golang/module.yaml +0 -6
- package/modules/golang/stack-profile.yaml +0 -40
- package/modules/ios-swiftui/module.yaml +0 -13
- package/modules/ios-swiftui/stack-profile.yaml +0 -55
- package/modules/java-spring/architecture-snippets/layered-arch.md +0 -201
- package/modules/java-spring/module.yaml +0 -15
- package/modules/java-spring/stack-profile.yaml +0 -28
- package/modules/nextjs/architecture-snippets/app-router-patterns.md +0 -269
- package/modules/nextjs/module.yaml +0 -14
- package/modules/nextjs/stack-profile.yaml +0 -74
- package/modules/nuxt/module.yaml +0 -14
- package/modules/nuxt/stack-profile.yaml +0 -58
- package/modules/phaser-game/architecture-snippets/phaser-scene-patterns.md +0 -646
- package/modules/phaser-game/module.yaml +0 -15
- package/modules/phaser-game/stack-profile.yaml +0 -90
- package/modules/php-laravel/architecture-snippets/service-repository.md +0 -302
- package/modules/php-laravel/module.yaml +0 -15
- package/modules/php-laravel/stack-profile.yaml +0 -56
- package/modules/qc-playwright/stack-profile.yaml +0 -66
- package/modules/react/architecture-snippets/hooks-query-patterns.md +0 -254
- package/modules/react/module.yaml +0 -14
- package/modules/react/stack-profile.yaml +0 -63
- package/modules/react-native/module.yaml +0 -14
- package/modules/react-native/stack-profile.yaml +0 -56
- package/modules/vue/module.yaml +0 -14
- package/modules/vue/stack-profile.yaml +0 -65
- package/rules/data-protection.md +0 -80
- package/rules/workflow.md +0 -99
- package/skills/code/SKILL.md +0 -19
- package/skills/code/SKILL.tmpl +0 -19
- package/skills/debug/SKILL.md +0 -19
- package/skills/debug/SKILL.tmpl +0 -19
- package/skills/design-spec/SKILL.md +0 -11
- package/skills/design-spec/SKILL.tmpl +0 -11
- package/skills/discovery/SKILL.md +0 -14
- package/skills/discovery/SKILL.tmpl +0 -14
- package/skills/prd/SKILL.md +0 -19
- package/skills/prd/SKILL.tmpl +0 -19
- package/skills/qc/qa-analyst/DOC_GAPS.template.md +0 -63
- package/skills/qc/qa-analyst/acceptance-criteria.md +0 -60
- package/skills/qc/qa-analyst/business-rules.md +0 -59
- package/skills/qc/qa-analyst/data-flow.md +0 -64
- package/skills/qc/qa-analyst/spec-breakdown.md +0 -61
- package/skills/qc/qa-designer/e2e/journey.md +0 -41
- package/skills/qc/qa-designer/exploratory/charter.md +0 -68
- package/skills/qc/qa-designer/exploratory/explore-to-functional.md +0 -43
- package/skills/qc/qa-designer/functional/api.md +0 -45
- package/skills/qc/qa-designer/functional/gui-feature.md +0 -46
- package/skills/qc/qa-designer/functional/gui-screen.md +0 -52
- package/skills/qc/qa-designer/integration/api.md +0 -42
- package/skills/qc/qa-designer/integration/db.md +0 -39
- package/skills/qc/qa-designer/integration/gui.md +0 -40
- package/skills/qc/qa-designer/integration/kafka.md +0 -40
- package/skills/qc/qa-designer/non-functional.md +0 -40
- package/skills/qc/qa-planner/test-plan.md +0 -120
- package/skills/qc/qa-reviewer/script/e2e.md +0 -87
- package/skills/qc/qa-reviewer/script/exploratory.md +0 -45
- package/skills/qc/qa-reviewer/script/functional.md +0 -101
- package/skills/qc/qa-reviewer/script/integration.md +0 -91
- package/skills/qc/qa-reviewer/script/non-functional.md +0 -126
- package/skills/qc/qa-reviewer/test-case/e2e.md +0 -73
- package/skills/qc/qa-reviewer/test-case/exploratory.md +0 -43
- package/skills/qc/qa-reviewer/test-case/functional.md +0 -76
- package/skills/qc/qa-reviewer/test-case/integration.md +0 -69
- package/skills/qc/qa-reviewer/test-case/non-functional.md +0 -73
- package/skills/qc/qa-runner/e2e.md +0 -49
- package/skills/qc/qa-runner/exploratory/session.md +0 -36
- package/skills/qc/qa-runner/functional/api.md +0 -35
- package/skills/qc/qa-runner/functional/gui-feature.md +0 -51
- package/skills/qc/qa-runner/functional/gui-screen.md +0 -55
- package/skills/qc/qa-runner/integration.md +0 -47
- package/skills/qc/qa-runner/non-functional.md +0 -49
- package/skills/qc/qa-runner/report/report.md +0 -37
- package/skills/setup-ai-first/SKILL.md +0 -19
- package/skills/setup-ai-first/SKILL.tmpl +0 -19
- package/skills/spec/SKILL.md +0 -19
- package/skills/spec/SKILL.tmpl +0 -19
- package/skills/test/SKILL.md +0 -18
- package/skills/test/SKILL.tmpl +0 -18
- package/steps/business-language.md +0 -56
- package/steps/capture-lesson.md +0 -112
- package/steps/context-loader.md +0 -406
- package/steps/gate.md +0 -151
- package/steps/report-footer.md +0 -125
- package/steps/review-fanout.md +0 -159
- package/steps/spawn-agent.md +0 -129
- package/steps/trace-mirror.md +0 -53
- package/templates/README.md +0 -70
- package/templates/architecture.template.md +0 -394
- package/templates/ci/trace-gate.yml +0 -146
- package/templates/design-spec.template.md +0 -217
- package/templates/feature.template +0 -123
- package/templates/hooks/pre-push +0 -61
- package/templates/platform-guide.template.md +0 -145
- package/templates/prd.template.md +0 -283
- package/templates/product-definition.template.md +0 -188
- package/templates/project-context.yaml +0 -212
- package/templates/tech-design.template.md +0 -490
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
build:
|
|
2
|
-
compile: "npm run build"
|
|
3
|
-
test: "npm run test"
|
|
4
|
-
run: "npm run dev"
|
|
5
|
-
lint: "npm run lint"
|
|
6
|
-
|
|
7
|
-
architecture:
|
|
8
|
-
style: "Pages + Composables + Server routes (SSR/SSG)"
|
|
9
|
-
key_rules:
|
|
10
|
-
- "Pages in pages/ are route-level containers — no business logic"
|
|
11
|
-
- "Business logic in composables/ using useAsyncData or useFetch"
|
|
12
|
-
- "Server-side logic in server/api/ routes (nitro)"
|
|
13
|
-
- "Client-only state in Pinia stores; server state via useAsyncData"
|
|
14
|
-
- "Shared UI primitives in components/ui/, auto-imported by Nuxt"
|
|
15
|
-
- "Use useRoute/useRouter instead of $route/$router in Composition API"
|
|
16
|
-
- "'use client' equivalent: wrap client-only code in onMounted or <ClientOnly>"
|
|
17
|
-
folder_structure: |
|
|
18
|
-
├── components/
|
|
19
|
-
│ └── ui/ ← auto-imported UI primitives (BaseButton, BaseInput...)
|
|
20
|
-
├── composables/ ← auto-imported composables (useOrderList...)
|
|
21
|
-
├── pages/ ← file-based routing
|
|
22
|
-
├── server/
|
|
23
|
-
│ └── api/ ← nitro server routes (GET /api/orders.ts)
|
|
24
|
-
├── stores/ ← Pinia stores (auto-imported)
|
|
25
|
-
├── plugins/ ← Nuxt plugins
|
|
26
|
-
└── utils/ ← auto-imported utilities
|
|
27
|
-
|
|
28
|
-
coding_standards:
|
|
29
|
-
naming:
|
|
30
|
-
components: "PascalCase (auto-imported, e.g., OrderList, BaseButton)"
|
|
31
|
-
composables: "camelCase with 'use' prefix (auto-imported, e.g., useOrderList)"
|
|
32
|
-
stores: "camelCase + Store suffix via defineStore()"
|
|
33
|
-
server_routes: "method.path.ts (e.g., get.orders.ts or [id].get.ts)"
|
|
34
|
-
files:
|
|
35
|
-
component: "{Feature}.vue"
|
|
36
|
-
composable: "use{Feature}.ts"
|
|
37
|
-
store: "{feature}.store.ts"
|
|
38
|
-
server_route: "{resource}.get.ts / {resource}.post.ts"
|
|
39
|
-
patterns:
|
|
40
|
-
data_fetching: "useAsyncData() for SSR data, useFetch() for client fetches"
|
|
41
|
-
global_state: "Pinia stores"
|
|
42
|
-
forms: "VeeValidate + Zod"
|
|
43
|
-
api_client: "$fetch (ofetch) — built-in to Nuxt, SSR-compatible"
|
|
44
|
-
ssr_hydration: "useState() for shared SSR/client state to avoid hydration mismatch"
|
|
45
|
-
|
|
46
|
-
testing:
|
|
47
|
-
unit: "Vitest + @nuxt/test-utils"
|
|
48
|
-
e2e: "Playwright via @nuxt/test-utils/e2e"
|
|
49
|
-
patterns:
|
|
50
|
-
- "Use mountSuspended() for components that need Nuxt context"
|
|
51
|
-
- "Use registerEndpoint() to mock server routes in tests"
|
|
52
|
-
- "Test pages with renderSuspended() from @nuxt/test-utils"
|
|
53
|
-
|
|
54
|
-
trace_tags:
|
|
55
|
-
implements: "// @trace.implements={UC-ID}-SC{N}"
|
|
56
|
-
source: "// @trace.source=specs/{domain}/{prd-slug}/bdd/{platform}/{UC-ID}-{slug}.feature"
|
|
57
|
-
verifies: "// @trace.verifies={UC-ID}"
|
|
58
|
-
test_type: "// @trace.test_type=unit|integration"
|
|
@@ -1,646 +0,0 @@
|
|
|
1
|
-
# Phaser Game — Scenes, Prefabs, Domain Logic & WebView Bridge Patterns
|
|
2
|
-
|
|
3
|
-
Stack: **Phaser 3 + TypeScript + Vite**, packaged to run from `file://` inside an **Android/iOS WebView**.
|
|
4
|
-
Core ideas:
|
|
5
|
-
- **Scenes orchestrate**, **domain modules do the math** (pure TS, no Phaser → unit-testable in Node).
|
|
6
|
-
- **Integration = Approach A**: the game calls the **backend directly over HTTP**, using a token the
|
|
7
|
-
host app (Flutter) injects via `window.GAME_CONFIG`. The native bridge is **thin** — only
|
|
8
|
-
`onReady()` / `onClose()` (back/X → Flutter pops the WebView). No game data crosses the bridge.
|
|
9
|
-
- `vite base: './'` so the built bundle loads over `file://`; `plugin-legacy` for old WebViews.
|
|
10
|
-
- `eruda` debug console loads **only** with `?debug=1`.
|
|
11
|
-
|
|
12
|
-
Examples use a **Gold Miner** game (swinging hook grabs gold; heavier gold retracts slower).
|
|
13
|
-
|
|
14
|
-
## Vite config — WebView-ready build
|
|
15
|
-
|
|
16
|
-
```typescript
|
|
17
|
-
// vite.config.ts
|
|
18
|
-
import { defineConfig } from 'vite';
|
|
19
|
-
import legacy from '@vitejs/plugin-legacy';
|
|
20
|
-
|
|
21
|
-
export default defineConfig({
|
|
22
|
-
base: './', // REQUIRED: relative paths so dist/ works from file://
|
|
23
|
-
build: {
|
|
24
|
-
target: 'es2015',
|
|
25
|
-
assetsInlineLimit: 4096,
|
|
26
|
-
},
|
|
27
|
-
plugins: [
|
|
28
|
-
legacy({ // old Android WebView support (toggle per min-SDK)
|
|
29
|
-
targets: ['Android >= 5', 'Chrome >= 40'],
|
|
30
|
-
polyfills: true, // pulls core-js
|
|
31
|
-
renderLegacyChunks: true,
|
|
32
|
-
}),
|
|
33
|
-
],
|
|
34
|
-
});
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
## Bootstrap — `index.html` + `main.ts`
|
|
38
|
-
|
|
39
|
-
```html
|
|
40
|
-
<!-- index.html -->
|
|
41
|
-
<!doctype html>
|
|
42
|
-
<html lang="en">
|
|
43
|
-
<head>
|
|
44
|
-
<meta charset="utf-8" />
|
|
45
|
-
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover" />
|
|
46
|
-
<title>Gold Miner</title>
|
|
47
|
-
<style>html,body{margin:0;height:100%;background:#1a1a2e;overflow:hidden}#game{width:100%;height:100%}</style>
|
|
48
|
-
</head>
|
|
49
|
-
<body>
|
|
50
|
-
<div id="game"></div>
|
|
51
|
-
<script type="module" src="/src/main.ts"></script>
|
|
52
|
-
</body>
|
|
53
|
-
</html>
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
```typescript
|
|
57
|
-
// src/main.ts — mount debug console (if ?debug=1), then start Phaser
|
|
58
|
-
import Phaser from 'phaser';
|
|
59
|
-
import { gameConfig } from './config/game-config';
|
|
60
|
-
import { maybeLoadDebugConsole } from './debug/eruda';
|
|
61
|
-
|
|
62
|
-
async function bootstrap() {
|
|
63
|
-
await maybeLoadDebugConsole(); // eruda only when ?debug=1
|
|
64
|
-
new Phaser.Game(gameConfig);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
bootstrap();
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
```typescript
|
|
71
|
-
// src/config/game-config.ts
|
|
72
|
-
import Phaser from 'phaser';
|
|
73
|
-
import { BootScene } from '../scenes/BootScene';
|
|
74
|
-
import { PreloadScene } from '../scenes/PreloadScene';
|
|
75
|
-
import { MenuScene } from '../scenes/MenuScene';
|
|
76
|
-
import { GameScene } from '../scenes/GameScene';
|
|
77
|
-
import { GameOverScene } from '../scenes/GameOverScene';
|
|
78
|
-
import { UIScene } from '../scenes/UIScene';
|
|
79
|
-
|
|
80
|
-
export const gameConfig: Phaser.Types.Core.GameConfig = {
|
|
81
|
-
type: Phaser.AUTO, // WebGL with Canvas fallback
|
|
82
|
-
parent: 'game',
|
|
83
|
-
backgroundColor: '#1a1a2e',
|
|
84
|
-
scale: {
|
|
85
|
-
mode: Phaser.Scale.FIT, // fit WebView, keep aspect
|
|
86
|
-
autoCenter: Phaser.Scale.CENTER_BOTH,
|
|
87
|
-
width: 960,
|
|
88
|
-
height: 540,
|
|
89
|
-
},
|
|
90
|
-
physics: {
|
|
91
|
-
default: 'arcade',
|
|
92
|
-
arcade: { gravity: { x: 0, y: 0 }, debug: false },
|
|
93
|
-
},
|
|
94
|
-
scene: [BootScene, PreloadScene, MenuScene, GameScene, GameOverScene, UIScene],
|
|
95
|
-
};
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
## Scenes — one responsibility each
|
|
99
|
-
|
|
100
|
-
```typescript
|
|
101
|
-
// src/scenes/PreloadScene.ts — load every asset once, show a progress bar
|
|
102
|
-
import Phaser from 'phaser';
|
|
103
|
-
|
|
104
|
-
export class PreloadScene extends Phaser.Scene {
|
|
105
|
-
constructor() { super('PreloadScene'); }
|
|
106
|
-
|
|
107
|
-
preload() {
|
|
108
|
-
const bar = this.add.rectangle(480, 270, 0, 20, 0xc9a227);
|
|
109
|
-
this.load.on('progress', (p: number) => { bar.width = 400 * p; });
|
|
110
|
-
|
|
111
|
-
this.load.image('bg', 'assets/bg.png');
|
|
112
|
-
this.load.image('gold', 'assets/gold.png');
|
|
113
|
-
this.load.atlas('miner', 'assets/miner.png', 'assets/miner.json');
|
|
114
|
-
this.load.audio('grab', 'assets/grab.mp3');
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
create() { this.scene.start('MenuScene'); }
|
|
118
|
-
}
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
```typescript
|
|
122
|
-
// src/scenes/GameScene.ts — orchestrates; heavy math lives in src/domain/
|
|
123
|
-
import Phaser from 'phaser';
|
|
124
|
-
import { Hook } from '../objects/Hook';
|
|
125
|
-
import { Gold } from '../objects/Gold';
|
|
126
|
-
import { rollSpawn } from '../domain/spawn';
|
|
127
|
-
import { calcScore } from '../domain/scoring';
|
|
128
|
-
import { EventBus } from '../bridge/event-bus';
|
|
129
|
-
import { SPAWN_TABLE } from '../config/constants';
|
|
130
|
-
|
|
131
|
-
// @trace.implements=GAME-UC1-SC1
|
|
132
|
-
// @trace.source=specs/game/swing-hook/bdd/GAME-UC1.feature
|
|
133
|
-
export class GameScene extends Phaser.Scene {
|
|
134
|
-
private hook!: Hook;
|
|
135
|
-
private golds!: Phaser.Physics.Arcade.Group;
|
|
136
|
-
private score = 0;
|
|
137
|
-
private rng = new Phaser.Math.RandomDataGenerator(['gold-miner']);
|
|
138
|
-
|
|
139
|
-
constructor() { super('GameScene'); }
|
|
140
|
-
|
|
141
|
-
create() {
|
|
142
|
-
this.add.image(480, 270, 'bg');
|
|
143
|
-
this.scene.launch('UIScene'); // HUD overlay runs in parallel
|
|
144
|
-
|
|
145
|
-
this.hook = new Hook(this, 480, 60);
|
|
146
|
-
this.golds = this.physics.add.group();
|
|
147
|
-
this.spawnField();
|
|
148
|
-
|
|
149
|
-
// Arcade overlap does the collision — no hand-rolled loop
|
|
150
|
-
this.physics.add.overlap(this.hook.tip, this.golds, (_tip, goldObj) => {
|
|
151
|
-
this.hook.grab(goldObj as Gold);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
this.input.on('pointerdown', () => this.hook.drop());
|
|
155
|
-
this.input.keyboard?.on('keydown-SPACE', () => this.hook.drop());
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
update(_t: number, dtMs: number) {
|
|
159
|
-
const dt = dtMs / 1000;
|
|
160
|
-
const banked = this.hook.update(dt); // returns a Gold when fully retracted, else null
|
|
161
|
-
if (banked) {
|
|
162
|
-
this.score += calcScore(banked.value); // pure domain fn
|
|
163
|
-
EventBus.emit('score-changed', this.score); // → UIScene + native bridge
|
|
164
|
-
banked.destroy();
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
private spawnField() {
|
|
169
|
-
for (const spot of rollSpawn(SPAWN_TABLE, this.rng)) { // deterministic given rng
|
|
170
|
-
this.golds.add(new Gold(this, spot.x, spot.y, spot.size));
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
## Game Objects (prefabs) — self-contained Phaser subclasses
|
|
177
|
-
|
|
178
|
-
```typescript
|
|
179
|
-
// src/objects/Gold.ts
|
|
180
|
-
import Phaser from 'phaser';
|
|
181
|
-
|
|
182
|
-
export class Gold extends Phaser.Physics.Arcade.Sprite {
|
|
183
|
-
readonly value: number;
|
|
184
|
-
readonly weight: number;
|
|
185
|
-
|
|
186
|
-
constructor(scene: Phaser.Scene, x: number, y: number, size: number) {
|
|
187
|
-
super(scene, x, y, 'gold');
|
|
188
|
-
scene.add.existing(this);
|
|
189
|
-
scene.physics.add.existing(this);
|
|
190
|
-
this.setDisplaySize(size, size);
|
|
191
|
-
this.value = size * 3;
|
|
192
|
-
this.weight = size / 32; // heavier → slower retract (classic mechanic)
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
```typescript
|
|
198
|
-
// src/objects/Hook.ts — swing → extend → retract state machine
|
|
199
|
-
import Phaser from 'phaser';
|
|
200
|
-
import { Gold } from './Gold';
|
|
201
|
-
import { retractSpeed } from '../domain/hook-physics';
|
|
202
|
-
import { SWING_SPEED, HOOK_SPEED } from '../config/constants';
|
|
203
|
-
|
|
204
|
-
type HookState = 'swing' | 'extend' | 'retract';
|
|
205
|
-
|
|
206
|
-
export class Hook extends Phaser.GameObjects.Container {
|
|
207
|
-
readonly tip: Phaser.GameObjects.Zone; // arcade body used for overlap
|
|
208
|
-
private angle = 0;
|
|
209
|
-
private dir = 1;
|
|
210
|
-
private length = 0;
|
|
211
|
-
private state: HookState = 'swing';
|
|
212
|
-
private grabbed: Gold | null = null;
|
|
213
|
-
|
|
214
|
-
constructor(scene: Phaser.Scene, x: number, y: number) {
|
|
215
|
-
super(scene, x, y);
|
|
216
|
-
scene.add.existing(this);
|
|
217
|
-
this.tip = scene.add.zone(x, y, 16, 16);
|
|
218
|
-
scene.physics.add.existing(this.tip);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
drop() { if (this.state === 'swing') this.state = 'extend'; }
|
|
222
|
-
|
|
223
|
-
grab(gold: Gold) {
|
|
224
|
-
if (this.state !== 'extend' || this.grabbed) return;
|
|
225
|
-
this.grabbed = gold;
|
|
226
|
-
this.state = 'retract';
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/** @returns the banked Gold when fully retracted, else null */
|
|
230
|
-
update(dt: number): Gold | null {
|
|
231
|
-
if (this.state === 'swing') {
|
|
232
|
-
this.angle += SWING_SPEED * this.dir * dt;
|
|
233
|
-
if (Math.abs(this.angle) > 1.3) this.dir *= -1;
|
|
234
|
-
} else if (this.state === 'extend') {
|
|
235
|
-
this.length += HOOK_SPEED * dt;
|
|
236
|
-
if (this.length > 520) this.state = 'retract';
|
|
237
|
-
} else {
|
|
238
|
-
this.length -= retractSpeed(HOOK_SPEED, this.grabbed?.weight ?? 1) * dt;
|
|
239
|
-
if (this.length <= 0) {
|
|
240
|
-
this.length = 0;
|
|
241
|
-
this.state = 'swing';
|
|
242
|
-
const banked = this.grabbed;
|
|
243
|
-
this.grabbed = null;
|
|
244
|
-
this.syncTip();
|
|
245
|
-
return banked;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
this.syncTip();
|
|
249
|
-
return null;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
private syncTip() {
|
|
253
|
-
this.tip.setPosition(this.x + Math.sin(this.angle) * this.length,
|
|
254
|
-
this.y + Math.cos(this.angle) * this.length);
|
|
255
|
-
this.grabbed?.setPosition(this.tip.x, this.tip.y);
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
```
|
|
259
|
-
|
|
260
|
-
## Domain logic — PURE TypeScript, zero Phaser (unit-testable)
|
|
261
|
-
|
|
262
|
-
```typescript
|
|
263
|
-
// src/domain/hook-physics.ts
|
|
264
|
-
// Heavier gold retracts slower. Pure math → trivially testable.
|
|
265
|
-
export function retractSpeed(baseSpeed: number, weight: number): number {
|
|
266
|
-
return baseSpeed / Math.max(weight, 0.1);
|
|
267
|
-
}
|
|
268
|
-
```
|
|
269
|
-
|
|
270
|
-
```typescript
|
|
271
|
-
// src/domain/scoring.ts
|
|
272
|
-
export function calcScore(goldValue: number, comboMultiplier = 1): number {
|
|
273
|
-
return Math.round(goldValue * comboMultiplier);
|
|
274
|
-
}
|
|
275
|
-
```
|
|
276
|
-
|
|
277
|
-
```typescript
|
|
278
|
-
// src/domain/spawn.ts — inject the RNG so spawns are deterministic in tests
|
|
279
|
-
export interface SpawnSpot { x: number; y: number; size: number; }
|
|
280
|
-
export interface SpawnRow { count: number; sizes: number[]; band: [number, number]; }
|
|
281
|
-
|
|
282
|
-
export interface Rng { between(min: number, max: number): number; pick<T>(arr: T[]): T; }
|
|
283
|
-
|
|
284
|
-
export function rollSpawn(table: SpawnRow[], rng: Rng): SpawnSpot[] {
|
|
285
|
-
const spots: SpawnSpot[] = [];
|
|
286
|
-
for (const row of table) {
|
|
287
|
-
for (let i = 0; i < row.count; i++) {
|
|
288
|
-
spots.push({
|
|
289
|
-
x: rng.between(60, 900),
|
|
290
|
-
y: rng.between(row.band[0], row.band[1]),
|
|
291
|
-
size: rng.pick(row.sizes),
|
|
292
|
-
});
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
return spots;
|
|
296
|
-
}
|
|
297
|
-
```
|
|
298
|
-
|
|
299
|
-
## Integration model — **Approach A** (game calls backend directly)
|
|
300
|
-
|
|
301
|
-
```
|
|
302
|
-
Flutter app WebView (this game)
|
|
303
|
-
─────────── ───────────────────
|
|
304
|
-
inject window.GAME_CONFIG ───────▶ host-config.ts (read + validate)
|
|
305
|
-
{ apiBase, token, userId } │
|
|
306
|
-
▼
|
|
307
|
-
api/client.ts ──HTTP+Bearer──▶ Backend
|
|
308
|
-
loadData() / submitResult() ◀──────────
|
|
309
|
-
│
|
|
310
|
-
back / X pressed ◀──onClose()── native-bridge.ts (window control only)
|
|
311
|
-
Navigator.pop()
|
|
312
|
-
```
|
|
313
|
-
|
|
314
|
-
- **Data** (load + submit result) goes over **HTTP straight from the game**, authorized by the token Flutter injected.
|
|
315
|
-
- **The native bridge is thin**: it only tells Flutter *"I'm ready"* and *"close me"*. No game data crosses it.
|
|
316
|
-
|
|
317
|
-
## Host config — Flutter injects it before load
|
|
318
|
-
|
|
319
|
-
```typescript
|
|
320
|
-
// src/contracts/game-contract.ts — the native window-control surface (control only, no data)
|
|
321
|
-
export interface GameContract {
|
|
322
|
-
onReady(): void; // boot succeeded — Flutter may hide its splash
|
|
323
|
-
onClose(): void; // back / X pressed — Flutter pops the WebView route
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
/** Injected by Flutter into the WebView BEFORE the bundle loads. */
|
|
327
|
-
export interface HostConfig {
|
|
328
|
-
apiBase: string; // e.g. https://api.edupia.vn
|
|
329
|
-
token: string; // Bearer token owned by the app
|
|
330
|
-
userId: string;
|
|
331
|
-
sessionId: string;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
declare global {
|
|
335
|
-
interface Window {
|
|
336
|
-
// Android JS interface OR flutter_inappwebview handler — abstracted in native-bridge.ts
|
|
337
|
-
Android?: { postMessage(json: string): void };
|
|
338
|
-
flutter_inappwebview?: { callHandler(name: string, ...args: unknown[]): Promise<unknown> };
|
|
339
|
-
GAME_CONFIG?: HostConfig;
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
```
|
|
343
|
-
|
|
344
|
-
```typescript
|
|
345
|
-
// src/config/host-config.ts — read once, validate, fail loudly if missing
|
|
346
|
-
import type { HostConfig } from '../contracts/game-contract';
|
|
347
|
-
import { bridge } from '../bridge/native-bridge';
|
|
348
|
-
|
|
349
|
-
export function readHostConfig(): HostConfig {
|
|
350
|
-
const cfg = window.GAME_CONFIG;
|
|
351
|
-
const ok = cfg && cfg.apiBase && cfg.token;
|
|
352
|
-
if (!ok) {
|
|
353
|
-
console.error('[host-config] missing GAME_CONFIG — cannot reach backend');
|
|
354
|
-
bridge.onClose(); // bail back to the app instead of a broken game
|
|
355
|
-
throw new Error('Missing HostConfig');
|
|
356
|
-
}
|
|
357
|
-
return cfg!;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// Dev fallback: run `vite` in a plain browser with a .env-driven config
|
|
361
|
-
export function readHostConfigDev(): HostConfig {
|
|
362
|
-
return window.GAME_CONFIG ?? {
|
|
363
|
-
apiBase: import.meta.env.VITE_API_BASE ?? 'http://localhost:8080',
|
|
364
|
-
token: import.meta.env.VITE_DEV_TOKEN ?? 'dev-token',
|
|
365
|
-
userId: 'dev', sessionId: 'dev',
|
|
366
|
-
};
|
|
367
|
-
}
|
|
368
|
-
```
|
|
369
|
-
|
|
370
|
-
## API client — direct fetch with the injected token (Approach A)
|
|
371
|
-
|
|
372
|
-
```typescript
|
|
373
|
-
// src/contracts/api.dto.ts — typed backend contracts
|
|
374
|
-
export interface GameDataDto { level: number; spawnSeed: number; timeLimit: number; highScore: number; }
|
|
375
|
-
export interface ResultDto { sessionId: string; finalScore: number; goldGrabbed: number; durationMs: number; }
|
|
376
|
-
export interface SubmitAck { accepted: boolean; newHighScore: boolean; }
|
|
377
|
-
```
|
|
378
|
-
|
|
379
|
-
```typescript
|
|
380
|
-
// src/api/client.ts — one fetch wrapper; token from HostConfig
|
|
381
|
-
import type { HostConfig } from '../contracts/game-contract';
|
|
382
|
-
|
|
383
|
-
export function createApiClient(cfg: HostConfig) {
|
|
384
|
-
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
385
|
-
const res = await fetch(`${cfg.apiBase}${path}`, {
|
|
386
|
-
...init,
|
|
387
|
-
headers: {
|
|
388
|
-
'Content-Type': 'application/json',
|
|
389
|
-
Authorization: `Bearer ${cfg.token}`, // token owned by the Flutter app
|
|
390
|
-
...(init.headers ?? {}),
|
|
391
|
-
},
|
|
392
|
-
});
|
|
393
|
-
if (res.status === 401) throw new ApiError('unauthorized', 401);
|
|
394
|
-
if (!res.ok) throw new ApiError(`http_${res.status}`, res.status);
|
|
395
|
-
return res.json() as Promise<T>;
|
|
396
|
-
}
|
|
397
|
-
return { request };
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
export class ApiError extends Error {
|
|
401
|
-
constructor(message: string, readonly status: number) { super(message); }
|
|
402
|
-
}
|
|
403
|
-
```
|
|
404
|
-
|
|
405
|
-
```typescript
|
|
406
|
-
// src/api/game.api.ts — the two calls this game needs
|
|
407
|
-
import type { ApiClient } from './client'; // typeof createApiClient return
|
|
408
|
-
import type { GameDataDto, ResultDto, SubmitAck } from '../contracts/api.dto';
|
|
409
|
-
|
|
410
|
-
export function createGameApi(client: ReturnType<typeof import('./client').createApiClient>) {
|
|
411
|
-
return {
|
|
412
|
-
// @trace.implements=GAME-UC4-SC1 (load data for the game)
|
|
413
|
-
// @trace.source=specs/game/load-data/bdd/GAME-UC4.feature
|
|
414
|
-
loadData: (userId: string) =>
|
|
415
|
-
client.request<GameDataDto>(`/v1/gold-miner/data?userId=${encodeURIComponent(userId)}`),
|
|
416
|
-
|
|
417
|
-
// @trace.implements=GAME-UC5-SC1 (submit result to server)
|
|
418
|
-
// @trace.source=specs/game/submit-result/bdd/GAME-UC5.feature
|
|
419
|
-
submitResult: (dto: ResultDto) =>
|
|
420
|
-
client.request<SubmitAck>('/v1/gold-miner/result', {
|
|
421
|
-
method: 'POST',
|
|
422
|
-
body: JSON.stringify(dto),
|
|
423
|
-
}),
|
|
424
|
-
};
|
|
425
|
-
}
|
|
426
|
-
```
|
|
427
|
-
|
|
428
|
-
## Thin native bridge — window control only (ready + close)
|
|
429
|
-
|
|
430
|
-
```typescript
|
|
431
|
-
// src/bridge/native-bridge.ts — abstracts flutter_inappwebview / Android JS interface
|
|
432
|
-
import type { GameContract } from '../contracts/game-contract';
|
|
433
|
-
|
|
434
|
-
class NativeBridge implements GameContract {
|
|
435
|
-
private post(type: string, payload: unknown = {}) {
|
|
436
|
-
// flutter_inappwebview (preferred): Flutter registers a JS handler
|
|
437
|
-
if (window.flutter_inappwebview) {
|
|
438
|
-
void window.flutter_inappwebview.callHandler(type, payload);
|
|
439
|
-
} else if (window.Android) { // webview_flutter JavaScriptChannel
|
|
440
|
-
window.Android.postMessage(JSON.stringify({ type, payload }));
|
|
441
|
-
} else {
|
|
442
|
-
console.debug('[bridge:no-native]', type, payload); // plain browser dev
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
onReady() { this.post('onReady'); }
|
|
446
|
-
onClose() { this.post('onClose'); } // Flutter side: Navigator.pop() → back to app screen
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
export const bridge: GameContract = new NativeBridge();
|
|
450
|
-
```
|
|
451
|
-
|
|
452
|
-
> **Flutter side (reference, `flutter_inappwebview`)** — inject config + handle close:
|
|
453
|
-
> ```dart
|
|
454
|
-
> initialUserScripts: UnmodifiableListView([
|
|
455
|
-
> UserScript(
|
|
456
|
-
> source: "window.GAME_CONFIG = ${jsonEncode(config)};",
|
|
457
|
-
> injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, // before bundle runs
|
|
458
|
-
> ),
|
|
459
|
-
> ]),
|
|
460
|
-
> onWebViewCreated: (c) {
|
|
461
|
-
> c.addJavaScriptHandler(handlerName: 'onClose', callback: (_) { Navigator.pop(context); });
|
|
462
|
-
> c.addJavaScriptHandler(handlerName: 'onReady', callback: (_) => hideSplash());
|
|
463
|
-
> },
|
|
464
|
-
> ```
|
|
465
|
-
|
|
466
|
-
## Wiring it together — Boot & Preload scenes
|
|
467
|
-
|
|
468
|
-
```typescript
|
|
469
|
-
// src/scenes/BootScene.ts — read host config, build API, then preload
|
|
470
|
-
import Phaser from 'phaser';
|
|
471
|
-
import { readHostConfig } from '../config/host-config';
|
|
472
|
-
import { createApiClient } from '../api/client';
|
|
473
|
-
import { createGameApi } from '../api/game.api';
|
|
474
|
-
import { bridge } from '../bridge/native-bridge';
|
|
475
|
-
|
|
476
|
-
export class BootScene extends Phaser.Scene {
|
|
477
|
-
constructor() { super('BootScene'); }
|
|
478
|
-
create() {
|
|
479
|
-
const cfg = readHostConfig(); // throws + onClose() if invalid
|
|
480
|
-
const api = createGameApi(createApiClient(cfg));
|
|
481
|
-
this.registry.set('cfg', cfg); // share across scenes via registry
|
|
482
|
-
this.registry.set('api', api);
|
|
483
|
-
bridge.onReady();
|
|
484
|
-
this.scene.start('PreloadScene');
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
```
|
|
488
|
-
|
|
489
|
-
```typescript
|
|
490
|
-
// src/scenes/PreloadScene.ts (create) — load game data over HTTP, then start the game
|
|
491
|
-
async create() {
|
|
492
|
-
const api = this.registry.get('api');
|
|
493
|
-
const cfg = this.registry.get('cfg');
|
|
494
|
-
try {
|
|
495
|
-
const data = await api.loadData(cfg.userId); // direct backend call (Approach A)
|
|
496
|
-
this.scene.start('GameScene', { data });
|
|
497
|
-
} catch (e) {
|
|
498
|
-
console.error('loadData failed', e);
|
|
499
|
-
this.scene.start('MenuScene', { offline: true }); // graceful fallback
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
```
|
|
503
|
-
|
|
504
|
-
```typescript
|
|
505
|
-
// GameScene — on game over, submit the result straight to the backend
|
|
506
|
-
private async endGame() {
|
|
507
|
-
const api = this.registry.get('api');
|
|
508
|
-
const cfg = this.registry.get('cfg');
|
|
509
|
-
try {
|
|
510
|
-
await api.submitResult({
|
|
511
|
-
sessionId: cfg.sessionId,
|
|
512
|
-
finalScore: this.score,
|
|
513
|
-
goldGrabbed: this.grabbedCount,
|
|
514
|
-
durationMs: this.time.now,
|
|
515
|
-
});
|
|
516
|
-
} catch (e) {
|
|
517
|
-
console.error('submitResult failed', e); // optionally queue for retry
|
|
518
|
-
}
|
|
519
|
-
this.scene.start('GameOverScene', { score: this.score });
|
|
520
|
-
}
|
|
521
|
-
```
|
|
522
|
-
|
|
523
|
-
## Event bus — game ↔ HUD without hard scene references
|
|
524
|
-
|
|
525
|
-
```typescript
|
|
526
|
-
// src/bridge/event-bus.ts
|
|
527
|
-
import Phaser from 'phaser';
|
|
528
|
-
export const EventBus = new Phaser.Events.EventEmitter();
|
|
529
|
-
// GameScene: EventBus.emit('score-changed', score)
|
|
530
|
-
// UIScene: EventBus.on('score-changed', (s) => this.scoreText.setText(`Score: ${s}`))
|
|
531
|
-
```
|
|
532
|
-
|
|
533
|
-
## Debug console — eruda, only with `?debug=1`
|
|
534
|
-
|
|
535
|
-
```typescript
|
|
536
|
-
// src/debug/eruda.ts — lazy import keeps eruda out of the normal bundle path
|
|
537
|
-
export async function maybeLoadDebugConsole(): Promise<void> {
|
|
538
|
-
if (new URLSearchParams(location.search).get('debug') !== '1') return;
|
|
539
|
-
const eruda = (await import('eruda')).default; // dynamic import → its own chunk
|
|
540
|
-
eruda.init();
|
|
541
|
-
}
|
|
542
|
-
```
|
|
543
|
-
|
|
544
|
-
## Unit Test — domain logic under Vitest (no Phaser, no browser)
|
|
545
|
-
|
|
546
|
-
```typescript
|
|
547
|
-
// tests/hook-physics.test.ts
|
|
548
|
-
// @trace.verifies=GAME-UC1
|
|
549
|
-
// @trace.test_type=unit
|
|
550
|
-
import { describe, it, expect } from 'vitest';
|
|
551
|
-
import { retractSpeed } from '../src/domain/hook-physics';
|
|
552
|
-
|
|
553
|
-
describe('retractSpeed', () => {
|
|
554
|
-
it('is slower for heavier gold', () => {
|
|
555
|
-
expect(retractSpeed(320, 2)).toBeLessThan(retractSpeed(320, 1));
|
|
556
|
-
});
|
|
557
|
-
it('clamps tiny weights to avoid divide blow-up', () => {
|
|
558
|
-
expect(Number.isFinite(retractSpeed(320, 0))).toBe(true);
|
|
559
|
-
});
|
|
560
|
-
});
|
|
561
|
-
```
|
|
562
|
-
|
|
563
|
-
```typescript
|
|
564
|
-
// tests/spawn.test.ts — deterministic thanks to an injected RNG
|
|
565
|
-
// @trace.verifies=GAME-UC0
|
|
566
|
-
// @trace.test_type=unit
|
|
567
|
-
import { describe, it, expect } from 'vitest';
|
|
568
|
-
import { rollSpawn, type Rng } from '../src/domain/spawn';
|
|
569
|
-
|
|
570
|
-
const fixedRng: Rng = { between: (min) => min, pick: (arr) => arr[0] };
|
|
571
|
-
|
|
572
|
-
describe('rollSpawn', () => {
|
|
573
|
-
it('produces one spot per count across all rows', () => {
|
|
574
|
-
const spots = rollSpawn([{ count: 3, sizes: [32], band: [200, 400] }], fixedRng);
|
|
575
|
-
expect(spots).toHaveLength(3);
|
|
576
|
-
expect(spots[0]).toEqual({ x: 60, y: 200, size: 32 });
|
|
577
|
-
});
|
|
578
|
-
});
|
|
579
|
-
```
|
|
580
|
-
|
|
581
|
-
## Unit Test — API client (Approach A) with mocked fetch
|
|
582
|
-
|
|
583
|
-
```typescript
|
|
584
|
-
// tests/game-api.test.ts
|
|
585
|
-
// @trace.verifies=GAME-UC5
|
|
586
|
-
// @trace.test_type=unit
|
|
587
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
588
|
-
import { createApiClient } from '../src/api/client';
|
|
589
|
-
import { createGameApi } from '../src/api/game.api';
|
|
590
|
-
|
|
591
|
-
const cfg = { apiBase: 'https://api.test', token: 'tkn', userId: 'u1', sessionId: 's1' };
|
|
592
|
-
|
|
593
|
-
beforeEach(() => vi.restoreAllMocks());
|
|
594
|
-
|
|
595
|
-
describe('submitResult', () => {
|
|
596
|
-
it('POSTs to the result endpoint with the Bearer token from HostConfig', async () => {
|
|
597
|
-
const fetchMock = vi.fn().mockResolvedValue(
|
|
598
|
-
new Response(JSON.stringify({ accepted: true, newHighScore: false }), { status: 200 }),
|
|
599
|
-
);
|
|
600
|
-
vi.stubGlobal('fetch', fetchMock);
|
|
601
|
-
|
|
602
|
-
const api = createGameApi(createApiClient(cfg));
|
|
603
|
-
const ack = await api.submitResult({ sessionId: 's1', finalScore: 120, goldGrabbed: 4, durationMs: 30000 });
|
|
604
|
-
|
|
605
|
-
expect(ack.accepted).toBe(true);
|
|
606
|
-
const [url, init] = fetchMock.mock.calls[0];
|
|
607
|
-
expect(url).toBe('https://api.test/v1/gold-miner/result');
|
|
608
|
-
expect(init.method).toBe('POST');
|
|
609
|
-
expect(init.headers.Authorization).toBe('Bearer tkn');
|
|
610
|
-
});
|
|
611
|
-
|
|
612
|
-
it('throws ApiError(401) when the token is rejected', async () => {
|
|
613
|
-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 401 })));
|
|
614
|
-
const api = createGameApi(createApiClient(cfg));
|
|
615
|
-
await expect(api.loadData('u1')).rejects.toMatchObject({ status: 401 });
|
|
616
|
-
});
|
|
617
|
-
});
|
|
618
|
-
```
|
|
619
|
-
|
|
620
|
-
## Smoke Test — Playwright: stub host config + intercept backend
|
|
621
|
-
|
|
622
|
-
```typescript
|
|
623
|
-
// tests/e2e/play.spec.ts
|
|
624
|
-
// @trace.verifies=GAME-UC0
|
|
625
|
-
// @trace.test_type=integration
|
|
626
|
-
import { test, expect } from '@playwright/test';
|
|
627
|
-
|
|
628
|
-
test('boots with injected config, loads data, closes on back', async ({ page }) => {
|
|
629
|
-
// Emulate what Flutter injects BEFORE the bundle runs
|
|
630
|
-
await page.addInitScript(() => {
|
|
631
|
-
(window as any).GAME_CONFIG = {
|
|
632
|
-
apiBase: 'https://api.test', token: 'tkn', userId: 'u1', sessionId: 's1',
|
|
633
|
-
};
|
|
634
|
-
});
|
|
635
|
-
// No real server needed — intercept the direct backend calls (Approach A)
|
|
636
|
-
await page.route('**/v1/gold-miner/data*', (r) =>
|
|
637
|
-
r.fulfill({ json: { level: 1, spawnSeed: 42, timeLimit: 60, highScore: 0 } }));
|
|
638
|
-
await page.route('**/v1/gold-miner/result', (r) =>
|
|
639
|
-
r.fulfill({ json: { accepted: true, newHighScore: true } }));
|
|
640
|
-
|
|
641
|
-
await page.goto('http://localhost:4173/'); // vite preview
|
|
642
|
-
await expect(page.locator('#game canvas')).toBeVisible();
|
|
643
|
-
await page.locator('#game').click(); // start / drop the hook
|
|
644
|
-
await expect(page.locator('#hud-score')).toHaveText(/Score: \d+/);
|
|
645
|
-
});
|
|
646
|
-
```
|