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