@educa-corp/sdd-framework 0.6.0 → 0.7.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.
Files changed (205) hide show
  1. package/bin/gate-trace.js +25 -2
  2. package/bin/index.js +32 -5
  3. package/bin/lint-trace.js +41 -0
  4. package/bin/self-check.js +430 -3
  5. package/bin/trace-schema.json +391 -30
  6. package/core/FRAMEWORK_VERSION +1 -1
  7. package/{commands/extend-prd.md → core/commands/amend-prd.md} +205 -173
  8. package/core/commands/dev-run-test.md +47 -9
  9. package/core/commands/extend-prd.md +39 -12
  10. package/core/commands/generate-bdd.md +43 -4
  11. package/core/commands/generate-code.md +33 -0
  12. package/core/commands/generate-tech-docs.md +34 -2
  13. package/core/commands/qc-run-test.md +29 -3
  14. package/core/commands/refine-prd.md +13 -2
  15. package/core/commands/review-context.md +43 -8
  16. package/core/commands/sync.md +105 -1
  17. package/core/commands/validate-traces.md +284 -11
  18. package/core/rules/workflow.md +34 -0
  19. package/core/steps/context-loader.md +26 -5
  20. package/core/templates/feature.template +1 -1
  21. package/docs/02-concepts/architecture.md +36 -0
  22. package/docs/04-reference/commands.md +148 -134
  23. package/docs/04-reference/trace-schema.md +39 -0
  24. package/docs/explain/02b-extend-prd.md +1 -1
  25. package/docs/explain/02c-amend-prd.md +152 -0
  26. package/docs/explain/28-sync.md +25 -0
  27. package/docs/explain/README.md +136 -135
  28. package/package.json +1 -8
  29. package/commands/debug.md +0 -529
  30. package/commands/debug.tmpl +0 -260
  31. package/commands/define-product.md +0 -438
  32. package/commands/define-product.tmpl +0 -225
  33. package/commands/dev-gen-test.md +0 -700
  34. package/commands/dev-gen-test.tmpl +0 -490
  35. package/commands/dev-run-test.md +0 -435
  36. package/commands/dev-run-test.tmpl +0 -225
  37. package/commands/dev-smoke-test.md +0 -374
  38. package/commands/dev-smoke-test.tmpl +0 -217
  39. package/commands/extend-prd.tmpl +0 -273
  40. package/commands/fix-bug.md +0 -519
  41. package/commands/fix-bug.tmpl +0 -197
  42. package/commands/generate-architecture.md +0 -354
  43. package/commands/generate-architecture.tmpl +0 -197
  44. package/commands/generate-bdd.md +0 -923
  45. package/commands/generate-bdd.tmpl +0 -590
  46. package/commands/generate-code.md +0 -859
  47. package/commands/generate-code.tmpl +0 -649
  48. package/commands/generate-design-spec.md +0 -737
  49. package/commands/generate-design-spec.tmpl +0 -524
  50. package/commands/generate-prd.md +0 -722
  51. package/commands/generate-prd.tmpl +0 -226
  52. package/commands/generate-spec-manifest.md +0 -321
  53. package/commands/generate-spec-manifest.tmpl +0 -164
  54. package/commands/generate-tech-docs.md +0 -920
  55. package/commands/generate-tech-docs.tmpl +0 -273
  56. package/commands/learn.md +0 -399
  57. package/commands/learn.tmpl +0 -130
  58. package/commands/map-testids.md +0 -238
  59. package/commands/map-testids.tmpl +0 -81
  60. package/commands/propose-scenario.md +0 -359
  61. package/commands/propose-scenario.tmpl +0 -202
  62. package/commands/qc-analyze.md +0 -269
  63. package/commands/qc-analyze.tmpl +0 -112
  64. package/commands/qc-design-test.md +0 -226
  65. package/commands/qc-design-test.tmpl +0 -69
  66. package/commands/qc-plan.md +0 -206
  67. package/commands/qc-plan.tmpl +0 -49
  68. package/commands/qc-report.md +0 -217
  69. package/commands/qc-report.tmpl +0 -60
  70. package/commands/qc-review.md +0 -210
  71. package/commands/qc-review.tmpl +0 -53
  72. package/commands/qc-run-test.md +0 -326
  73. package/commands/qc-run-test.tmpl +0 -116
  74. package/commands/refine-prd.md +0 -653
  75. package/commands/refine-prd.tmpl +0 -281
  76. package/commands/report-bug.md +0 -305
  77. package/commands/report-bug.tmpl +0 -148
  78. package/commands/review-code.md +0 -415
  79. package/commands/review-code.tmpl +0 -146
  80. package/commands/review-context.md +0 -902
  81. package/commands/review-context.tmpl +0 -530
  82. package/commands/review-tech-docs.md +0 -561
  83. package/commands/review-tech-docs.tmpl +0 -404
  84. package/commands/setup-ai-first.md +0 -602
  85. package/commands/setup-ai-first.tmpl +0 -450
  86. package/commands/sync.md +0 -430
  87. package/commands/sync.tmpl +0 -429
  88. package/commands/update-framework.md +0 -203
  89. package/commands/update-framework.tmpl +0 -202
  90. package/commands/validate-traces.md +0 -1077
  91. package/commands/validate-traces.tmpl +0 -920
  92. package/hooks/data-guard.js +0 -232
  93. package/hooks/settings.json +0 -19
  94. package/modules/android-compose/module.yaml +0 -13
  95. package/modules/android-compose/stack-profile.yaml +0 -57
  96. package/modules/angular/architecture-snippets/component-patterns.md +0 -187
  97. package/modules/angular/module.yaml +0 -6
  98. package/modules/angular/stack-profile.yaml +0 -38
  99. package/modules/context-engineering/architecture-snippets/context-design.md +0 -119
  100. package/modules/context-engineering/module.yaml +0 -9
  101. package/modules/context-engineering/stack-profile.yaml +0 -61
  102. package/modules/dotnet/architecture-snippets/clean-arch.md +0 -160
  103. package/modules/dotnet/module.yaml +0 -6
  104. package/modules/dotnet/stack-profile.yaml +0 -50
  105. package/modules/flutter/module.yaml +0 -14
  106. package/modules/flutter/stack-profile.yaml +0 -59
  107. package/modules/golang/architecture-snippets/domain-layout.md +0 -283
  108. package/modules/golang/module.yaml +0 -6
  109. package/modules/golang/stack-profile.yaml +0 -40
  110. package/modules/ios-swiftui/module.yaml +0 -13
  111. package/modules/ios-swiftui/stack-profile.yaml +0 -55
  112. package/modules/java-spring/architecture-snippets/layered-arch.md +0 -201
  113. package/modules/java-spring/module.yaml +0 -15
  114. package/modules/java-spring/stack-profile.yaml +0 -28
  115. package/modules/nextjs/architecture-snippets/app-router-patterns.md +0 -269
  116. package/modules/nextjs/module.yaml +0 -14
  117. package/modules/nextjs/stack-profile.yaml +0 -74
  118. package/modules/nuxt/module.yaml +0 -14
  119. package/modules/nuxt/stack-profile.yaml +0 -58
  120. package/modules/phaser-game/architecture-snippets/phaser-scene-patterns.md +0 -646
  121. package/modules/phaser-game/module.yaml +0 -15
  122. package/modules/phaser-game/stack-profile.yaml +0 -90
  123. package/modules/php-laravel/architecture-snippets/service-repository.md +0 -302
  124. package/modules/php-laravel/module.yaml +0 -15
  125. package/modules/php-laravel/stack-profile.yaml +0 -56
  126. package/modules/qc-playwright/stack-profile.yaml +0 -66
  127. package/modules/react/architecture-snippets/hooks-query-patterns.md +0 -254
  128. package/modules/react/module.yaml +0 -14
  129. package/modules/react/stack-profile.yaml +0 -63
  130. package/modules/react-native/module.yaml +0 -14
  131. package/modules/react-native/stack-profile.yaml +0 -56
  132. package/modules/vue/module.yaml +0 -14
  133. package/modules/vue/stack-profile.yaml +0 -65
  134. package/rules/data-protection.md +0 -80
  135. package/rules/workflow.md +0 -99
  136. package/skills/code/SKILL.md +0 -19
  137. package/skills/code/SKILL.tmpl +0 -19
  138. package/skills/debug/SKILL.md +0 -19
  139. package/skills/debug/SKILL.tmpl +0 -19
  140. package/skills/design-spec/SKILL.md +0 -11
  141. package/skills/design-spec/SKILL.tmpl +0 -11
  142. package/skills/discovery/SKILL.md +0 -14
  143. package/skills/discovery/SKILL.tmpl +0 -14
  144. package/skills/prd/SKILL.md +0 -19
  145. package/skills/prd/SKILL.tmpl +0 -19
  146. package/skills/qc/qa-analyst/DOC_GAPS.template.md +0 -63
  147. package/skills/qc/qa-analyst/acceptance-criteria.md +0 -60
  148. package/skills/qc/qa-analyst/business-rules.md +0 -59
  149. package/skills/qc/qa-analyst/data-flow.md +0 -64
  150. package/skills/qc/qa-analyst/spec-breakdown.md +0 -61
  151. package/skills/qc/qa-designer/e2e/journey.md +0 -41
  152. package/skills/qc/qa-designer/exploratory/charter.md +0 -68
  153. package/skills/qc/qa-designer/exploratory/explore-to-functional.md +0 -43
  154. package/skills/qc/qa-designer/functional/api.md +0 -45
  155. package/skills/qc/qa-designer/functional/gui-feature.md +0 -46
  156. package/skills/qc/qa-designer/functional/gui-screen.md +0 -52
  157. package/skills/qc/qa-designer/integration/api.md +0 -42
  158. package/skills/qc/qa-designer/integration/db.md +0 -39
  159. package/skills/qc/qa-designer/integration/gui.md +0 -40
  160. package/skills/qc/qa-designer/integration/kafka.md +0 -40
  161. package/skills/qc/qa-designer/non-functional.md +0 -40
  162. package/skills/qc/qa-planner/test-plan.md +0 -120
  163. package/skills/qc/qa-reviewer/script/e2e.md +0 -87
  164. package/skills/qc/qa-reviewer/script/exploratory.md +0 -45
  165. package/skills/qc/qa-reviewer/script/functional.md +0 -101
  166. package/skills/qc/qa-reviewer/script/integration.md +0 -91
  167. package/skills/qc/qa-reviewer/script/non-functional.md +0 -126
  168. package/skills/qc/qa-reviewer/test-case/e2e.md +0 -73
  169. package/skills/qc/qa-reviewer/test-case/exploratory.md +0 -43
  170. package/skills/qc/qa-reviewer/test-case/functional.md +0 -76
  171. package/skills/qc/qa-reviewer/test-case/integration.md +0 -69
  172. package/skills/qc/qa-reviewer/test-case/non-functional.md +0 -73
  173. package/skills/qc/qa-runner/e2e.md +0 -49
  174. package/skills/qc/qa-runner/exploratory/session.md +0 -36
  175. package/skills/qc/qa-runner/functional/api.md +0 -35
  176. package/skills/qc/qa-runner/functional/gui-feature.md +0 -51
  177. package/skills/qc/qa-runner/functional/gui-screen.md +0 -55
  178. package/skills/qc/qa-runner/integration.md +0 -47
  179. package/skills/qc/qa-runner/non-functional.md +0 -49
  180. package/skills/qc/qa-runner/report/report.md +0 -37
  181. package/skills/setup-ai-first/SKILL.md +0 -19
  182. package/skills/setup-ai-first/SKILL.tmpl +0 -19
  183. package/skills/spec/SKILL.md +0 -19
  184. package/skills/spec/SKILL.tmpl +0 -19
  185. package/skills/test/SKILL.md +0 -18
  186. package/skills/test/SKILL.tmpl +0 -18
  187. package/steps/business-language.md +0 -56
  188. package/steps/capture-lesson.md +0 -112
  189. package/steps/context-loader.md +0 -406
  190. package/steps/gate.md +0 -151
  191. package/steps/report-footer.md +0 -125
  192. package/steps/review-fanout.md +0 -159
  193. package/steps/spawn-agent.md +0 -129
  194. package/steps/trace-mirror.md +0 -53
  195. package/templates/README.md +0 -70
  196. package/templates/architecture.template.md +0 -394
  197. package/templates/ci/trace-gate.yml +0 -146
  198. package/templates/design-spec.template.md +0 -217
  199. package/templates/feature.template +0 -123
  200. package/templates/hooks/pre-push +0 -61
  201. package/templates/platform-guide.template.md +0 -145
  202. package/templates/prd.template.md +0 -283
  203. package/templates/product-definition.template.md +0 -188
  204. package/templates/project-context.yaml +0 -212
  205. 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
- ```