@bongos/core 1.17.1 → 1.17.2

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 (164) hide show
  1. package/.bongos-core.json +244 -264
  2. package/.claude/skills/blocker-review/SKILL.md +1 -1
  3. package/.claude/skills/builder-claim/SKILL.md +2 -0
  4. package/.claude/skills/builder-exit/SKILL.md +2 -2
  5. package/.claude/skills/builder-redteam/SKILL.md +3 -3
  6. package/.claude/skills/builder-setup/SKILL.md +2 -2
  7. package/.claude/skills/builder-start/SKILL.md +1 -1
  8. package/.claude/skills/dev/SKILL.md +1 -1
  9. package/.claude/skills/idea-triage/SKILL.md +2 -2
  10. package/.claude/skills/ideate/SKILL.md +1 -1
  11. package/.claude/skills/merge-mode/SKILL.md +1 -1
  12. package/.claude/skills/new-project/SKILL.md +3 -0
  13. package/.claude/skills/paint/SKILL.md +1 -1
  14. package/GOVERNANCE.md +1 -1
  15. package/README.md +1 -1
  16. package/docs/adr/0001-stack-choice-single-vps.md +1 -1
  17. package/docs/adr/0008-google-chat-oauth-user-auth.md +1 -1
  18. package/docs/adr/0019-red-team-bounty-payout-table.md +3 -3
  19. package/docs/adr/0020-security-model-public-stub.md +1 -1
  20. package/docs/adr/0023-kind-multipliers-and-idea-bonus.md +1 -1
  21. package/docs/adr/0024-cloneable-repo-local-first-memory.md +1 -1
  22. package/docs/adr/0026-bfg-memory-hygiene-and-cross-builder-learning.md +4 -4
  23. package/docs/adr/0027-bfg-session-inefficiency-evaluator.md +3 -3
  24. package/docs/adr/0030-builders-hall-live-updates.md +1 -1
  25. package/docs/adr/0031-cloud-dev-environments-for-builders.md +4 -4
  26. package/docs/adr/0032-discord-mirror-of-gds.md +2 -2
  27. package/docs/adr/0034-thetes-graduated-newcomer-rank.md +1 -1
  28. package/docs/adr/0044-mediterranean-palette-replacement.md +1 -1
  29. package/docs/adr/0044-per-box-live-game-preview.md +1 -1
  30. package/docs/adr/0054-session-token-reward-cost-plus.md +10 -10
  31. package/docs/adr/0057-container-cost-ledger.md +3 -3
  32. package/docs/adr/0062-medusa-instance-model-decoupling.md +4 -4
  33. package/docs/adr/0065-agpl-nonprofit-ai-first-governance.md +2 -2
  34. package/docs/adr/0085-remove-default-per-builder-spend-cap.md +1 -1
  35. package/docs/adr/0086-goal-scoped-work-hierarchy.md +1 -1
  36. package/docs/adr/0096-require-reward-before-workable.md +9 -9
  37. package/docs/adr/0097-retroactive-reward-backfill.md +3 -3
  38. package/docs/adr/0100-two-repo-instance-model.md +1 -1
  39. package/docs/adr/0101-goal-hierarchy-phase-5-judge-membership-reward.md +3 -3
  40. package/docs/adr/0103-core-first-extraction-cloud-bongos-trunk.md +3 -3
  41. package/docs/adr/0107-module-upstreaming-contribution-to-core-catalog.md +1 -1
  42. package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +6 -6
  43. package/docs/adr/0136-update-channel-subscription-policy.md +51 -0
  44. package/docs/adr/0137-upstream-publish-policy-manual-owner-gated.md +40 -0
  45. package/docs/adr/0138-bundled-module-deps-declared-per-module.md +45 -0
  46. package/docs/adr/README.md +9 -6
  47. package/docs/architecture.md +2 -2
  48. package/docs/branding-contract.md +5 -4
  49. package/docs/canonical-permissions.md +1 -1
  50. package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
  51. package/docs/file-map.md +18 -17
  52. package/docs/handoff-template.md +2 -2
  53. package/docs/modules-contract.md +6 -1
  54. package/docs/onboarding/diagrams/README.md +7 -7
  55. package/docs/onboarding/drachmae-and-karma.md +18 -18
  56. package/docs/onboarding/primer.md +9 -9
  57. package/docs/recipes/audit-replay.md +2 -2
  58. package/docs/recipes/builder-box-lifecycle.md +2 -2
  59. package/docs/recipes/claude-design-loop.md +1 -1
  60. package/docs/recipes/ops-gotchas.md +1 -1
  61. package/docs/recipes/private-npm-distribution.md +17 -0
  62. package/docs/recipes/staging-environment.md +1 -1
  63. package/docs/recipes/upgrading-the-core.md +55 -0
  64. package/docs/research/cursor-competitive-analysis.md +1 -1
  65. package/docs/research/llm-model-agnostic-and-cheaper-grader.md +1 -1
  66. package/modules/discord/module.json +2 -1
  67. package/modules/economy/achievements.js +9 -1
  68. package/modules/economy/credits.js +10 -1
  69. package/modules/hall-ui/public/builders.js +71 -4
  70. package/modules/hall-ui/public/goal-inbox.js +191 -0
  71. package/modules/hall-ui/public/hall-widgets.js +14 -1
  72. package/modules/hall-ui/public/index.html +3 -0
  73. package/modules/hall-ui/public/new-project-wizard.js +1 -0
  74. package/modules/hall-ui/public/style.css +43 -0
  75. package/modules/provisioning/CLAUDE.md +15 -0
  76. package/modules/provisioning/migrations/provisioning_001_tables.sql +160 -0
  77. package/modules/provisioning/migrations/provisioning_002_standalone_shape.sql +11 -0
  78. package/modules/provisioning/migrations/provisioning_003_github_app_manifest.sql +95 -0
  79. package/modules/provisioning/module.json +17 -0
  80. package/modules/provisioning/onboard-plan.js +37 -0
  81. package/modules/provisioning/provisioning.js +539 -0
  82. package/modules/provisioning/rate-limit.js +35 -0
  83. package/modules/provisioning/routes/provisioning.js +607 -0
  84. package/modules/provisioning/tests/provisioning.mjs +232 -0
  85. package/modules/provisioning/ui/.gitkeep +0 -0
  86. package/modules/security/secret-scrub.js +10 -0
  87. package/package-lock.json +2 -2
  88. package/package.json +7 -2
  89. package/scripts/discord/README.md +1 -1
  90. package/scripts/gds/claim.js +98 -48
  91. package/scripts/gds/claude-materialize.js +30 -9
  92. package/scripts/gds/cutover-codemod.js +241 -0
  93. package/scripts/gds/fitness.js +54 -0
  94. package/scripts/gds/init.js +59 -3
  95. package/scripts/gds/lint-infra-exec.js +70 -0
  96. package/scripts/gds/lint-workflow-exec.js +63 -0
  97. package/scripts/gds/mirror-lineage-check.js +14 -5
  98. package/scripts/gds/mirror-redact.js +8 -0
  99. package/scripts/gds/onboard.js +4 -0
  100. package/scripts/gds/package-core.js +18 -3
  101. package/scripts/gds/publish-manifest.js +60 -2
  102. package/scripts/gds/run-routine.js +51 -13
  103. package/scripts/gds/run-unit-tests.js +4 -0
  104. package/scripts/gds/update-channel.js +186 -0
  105. package/scripts/gds/upgrade.js +116 -9
  106. package/src/bongos/auth.js +14 -1
  107. package/src/bongos/serve-internal.js +10 -1
  108. package/src/branding.js +1 -0
  109. package/src/module-api.js +12 -1
  110. package/src/module-loader/loader.js +36 -4
  111. package/src/module-loader/manifest-schema.js +16 -1
  112. package/tests/branding.mjs +11 -0
  113. package/tests/claim_error_surface.mjs +107 -0
  114. package/tests/claude_materialize.mjs +20 -0
  115. package/tests/consumer_layout_boot.mjs +302 -0
  116. package/tests/cutover_codemod.mjs +184 -0
  117. package/tests/device_flow_start.mjs +25 -2
  118. package/tests/hook_core_resolve.mjs +125 -0
  119. package/tests/infra_exec_paths.mjs +104 -0
  120. package/tests/init.mjs +47 -0
  121. package/tests/mirror_lineage_proof.mjs +14 -0
  122. package/tests/module_loader.mjs +42 -0
  123. package/tests/module_manifest.mjs +19 -0
  124. package/tests/onboard.mjs +13 -0
  125. package/tests/package_core.mjs +8 -0
  126. package/tests/provisioning_github_app_routes.mjs +15 -0
  127. package/tests/publish_manifest.mjs +52 -1
  128. package/tests/run_routine.mjs +26 -1
  129. package/tests/run_routine_two_root.mjs +77 -0
  130. package/tests/scrubber_corpus.mjs +17 -0
  131. package/tests/update_channel.mjs +186 -0
  132. package/tests/upgrade.mjs +131 -0
  133. package/tests/workflow_exec_paths.mjs +98 -0
  134. package/docs/design/container-exterior-tiles.md +0 -49
  135. package/docs/design/container-exterior.md +0 -81
  136. package/docs/design/container-interior.md +0 -77
  137. package/docs/design/world-map-scroll.md +0 -41
  138. package/docs/recipes/claude-gemini-bongos-doors.md +0 -155
  139. package/docs/recipes/cloudbongos-staging-gate.md +0 -139
  140. package/docs/recipes/cloudbongos-standup.md +0 -111
  141. package/docs/recipes/parallel-load-results-2026-05-30.md +0 -63
  142. package/docs/recipes/parallel-load-test.md +0 -32
  143. package/docs/recipes/pixel-art-pipeline.md +0 -411
  144. package/docs/recipes/staging-data.md +0 -50
  145. package/migrations/instance/018_pms_v3_planning_seed.sql +0 -131
  146. package/migrations/instance/080_gds_v4_planning_seed.sql +0 -75
  147. package/migrations/instance/110_medusa_planning_seed.sql +0 -70
  148. package/migrations/instance/120_bongos_v1_planning_seed.sql +0 -69
  149. package/migrations/instance/144_bongos_v1_module_system_criterion.sql +0 -26
  150. package/migrations/instance/159_bongos_v1_goal_hierarchy_criterion.sql +0 -29
  151. package/migrations/instance/172_v2_world_planning_seed.sql +0 -163
  152. package/migrations/instance/173_bongos_v1_c7_core_modularized.sql +0 -33
  153. package/migrations/instance/182_gdsv4_bongos_consolidation.sql +0 -132
  154. package/migrations/instance/183_gdsv4_carryover_themed_goals.sql +0 -116
  155. package/migrations/instance/186_bongos_v1_governance_planning_seed.sql +0 -69
  156. package/scripts/gds/art-stage.js +0 -152
  157. package/server.js +0 -212
  158. package/tests/art_key.mjs +0 -121
  159. package/tests/day_cycle.mjs +0 -197
  160. package/tests/snapshot_picker.mjs +0 -233
  161. package/tests/sprite_facing.mjs +0 -35
  162. package/tests/terrain_picker.mjs +0 -298
  163. package/tests/turn_in_place.mjs +0 -280
  164. package/tests/walkability.mjs +0 -188
@@ -1,298 +0,0 @@
1
- // tests/terrain_picker.mjs
2
- //
3
- // Verify the autotile picker only sees the ground grid — objects on top
4
- // of a cell must NOT change which tile the picker selects for that cell
5
- // or its neighbors. This is the core invariant of the ground-object
6
- // split (ADR 0012): an object overlay never pollutes ground rendering.
7
- //
8
- // Also exercises the basic dispatch shape: each render strategy
9
- // (single / blob47 / path16) returns the expected kind of result.
10
- //
11
- // Run: node tests/terrain_picker.mjs
12
-
13
- import { strict as assert } from 'node:assert';
14
- import {
15
- TERRAIN,
16
- OBJECT,
17
- TERRAIN_RENDER,
18
- OBJECT_RENDER,
19
- pickAutotileIndex,
20
- pickObjectAutotileIndex,
21
- neighborsAt,
22
- } from '../modules/game/public/game/world/terrain.js';
23
- import { cellTransform } from '../modules/game/public/game/world/tilePalette.js';
24
-
25
- let passed = 0;
26
- function test(name, fn) {
27
- try {
28
- fn();
29
- console.log(` ok ${name}`);
30
- passed++;
31
- } catch (e) {
32
- console.error(` FAIL ${name}: ${e.message}`);
33
- process.exitCode = 1;
34
- }
35
- }
36
-
37
- // Build a tiny ground grid for picker tests. 5×5 with grass center,
38
- // sand on the south edge — same shape as the V1 grass↔sand transition.
39
- //
40
- // g g g g g
41
- // g g g g g
42
- // g g g g g
43
- // s s s s s
44
- // s s s s s
45
- function makeGroundGrid() {
46
- const G = TERRAIN.GRASS, S = TERRAIN.SAND;
47
- return [
48
- [G, G, G, G, G],
49
- [G, G, G, G, G],
50
- [G, G, G, G, G],
51
- [S, S, S, S, S],
52
- [S, S, S, S, S],
53
- ];
54
- }
55
-
56
- // ---------- dispatch shape ------------------------------------------------------
57
-
58
- test('TERRAIN_RENDER covers all 4 ground terrains', () => {
59
- for (const t of Object.values(TERRAIN)) {
60
- assert.ok(TERRAIN_RENDER[t], `missing TERRAIN_RENDER for ${t}`);
61
- }
62
- });
63
-
64
- test('grass dispatches blob47, sea dispatches single, marble_path dispatches path16', () => {
65
- assert.equal(TERRAIN_RENDER[TERRAIN.GRASS].strategy, 'blob47');
66
- assert.equal(TERRAIN_RENDER[TERRAIN.SAND].strategy, 'blob47');
67
- assert.equal(TERRAIN_RENDER[TERRAIN.SEA].strategy, 'single');
68
- assert.equal(TERRAIN_RENDER[TERRAIN.MARBLE_PATH].strategy, 'path16');
69
- });
70
-
71
- test('every blob47/path16 entry declares family + fallbackTile', () => {
72
- for (const [t, cfg] of Object.entries(TERRAIN_RENDER)) {
73
- if (cfg.strategy === 'blob47' || cfg.strategy === 'path16') {
74
- assert.ok(cfg.family, `${t} missing family`);
75
- assert.ok(cfg.fallbackTile, `${t} missing fallbackTile`);
76
- }
77
- }
78
- });
79
-
80
- // ---------- ground-only invariant ----------------------------------------------
81
-
82
- test('neighborsAt returns 8 neighbors from the ground grid', () => {
83
- const g = makeGroundGrid();
84
- // (2, 1) is deep interior of the grass band — rows 0..2 are grass.
85
- const n = neighborsAt(g, 2, 1);
86
- for (const dir of ['NW','N','NE','W','E','SW','S','SE']) {
87
- assert.equal(n[dir], TERRAIN.GRASS, `neighbor ${dir} should be grass`);
88
- }
89
- });
90
-
91
- test('neighborsAt returns null for off-grid neighbors', () => {
92
- const g = makeGroundGrid();
93
- const n = neighborsAt(g, 0, 0);
94
- assert.equal(n.NW, null);
95
- assert.equal(n.N, null);
96
- assert.equal(n.W, null);
97
- assert.equal(n.NE, null); // off-top
98
- assert.equal(n.SW, null); // off-left
99
- assert.equal(n.E, TERRAIN.GRASS);
100
- assert.equal(n.S, TERRAIN.GRASS);
101
- assert.equal(n.SE, TERRAIN.GRASS);
102
- });
103
-
104
- test('grass cell deep in interior picks the all-interior tile (max byte)', () => {
105
- const g = makeGroundGrid();
106
- const idx = pickAutotileIndex(TERRAIN.GRASS, neighborsAt(g, 2, 1));
107
- // All 8 grass neighbors → byte 0xFF → maps to the canonical tile for
108
- // "all neighbors same." For the standard 47-blob lookup this is one
109
- // specific index; we just assert it's a valid 0..46 value and it
110
- // matches what we get for OTHER fully-interior grass cells.
111
- const idx2 = pickAutotileIndex(TERRAIN.GRASS, neighborsAt(g, 2, 0));
112
- // (2, 0) has off-grid N/NW/NE — those count as not-grass. So idx
113
- // and idx2 should DIFFER (different boundary configs).
114
- assert.ok(Number.isInteger(idx) && idx >= 0 && idx < 47, `idx out of range: ${idx}`);
115
- assert.notEqual(idx, idx2, 'interior cell should differ from top-edge cell');
116
- });
117
-
118
- test('grass cell at south edge of grass band picks a south-boundary tile', () => {
119
- const g = makeGroundGrid();
120
- // (2, 2) — last row of grass; south neighbors are sand
121
- const idx = pickAutotileIndex(TERRAIN.GRASS, neighborsAt(g, 2, 2));
122
- // (2, 1) — interior of grass band
123
- const idxInterior = pickAutotileIndex(TERRAIN.GRASS, neighborsAt(g, 2, 1));
124
- assert.notEqual(idx, idxInterior, 'south-edge cell must differ from interior');
125
- });
126
-
127
- test('marble_path picker is path16 (returns 0..15)', () => {
128
- const path = TERRAIN.MARBLE_PATH;
129
- // Lone path cell, no neighbors are also path
130
- const idxLone = pickAutotileIndex(path, { N: TERRAIN.GRASS, W: TERRAIN.GRASS, E: TERRAIN.GRASS, S: TERRAIN.GRASS });
131
- assert.equal(idxLone, 0, 'isolated path node = 0');
132
- // Cross junction
133
- const idxCross = pickAutotileIndex(path, { N: path, W: path, E: path, S: path });
134
- assert.equal(idxCross, 15, 'cross junction = 0b1111 = 15');
135
- });
136
-
137
- test('sea cell (single strategy) returns null from picker', () => {
138
- // 'single' terrains don't go through the autotile picker — caller
139
- // dispatches them directly to the legacy single-tile path. The
140
- // picker returns null to signal "not my problem."
141
- const idx = pickAutotileIndex(TERRAIN.SEA, {
142
- NW: TERRAIN.SEA, N: TERRAIN.SEA, NE: TERRAIN.SEA,
143
- W: TERRAIN.SEA, E: TERRAIN.SEA,
144
- SW: TERRAIN.SEA, S: TERRAIN.SEA, SE: TERRAIN.SEA,
145
- });
146
- assert.equal(idx, null, 'single-strategy terrain returns null');
147
- });
148
-
149
- // ---------- the headline invariant: objects do not affect picker --------------
150
-
151
- test('OBJECT-LAYER INVARIANT: picker only sees ground; objects must be passed via a separate grid', () => {
152
- // The picker takes the GROUND grid. We can't reach inside and verify
153
- // it ignores objects (they're not part of its API). Instead, verify
154
- // that two ground configs with IDENTICAL ground but different object
155
- // overlays produce identical picker output — by construction.
156
- //
157
- // (This test really enforces the invariant by demonstrating the
158
- // CALLING PATTERN: the renderer must pass `map.ground`, never
159
- // `map.objects`. The picker has no object-layer knob.)
160
- const g = makeGroundGrid();
161
- const idxA = pickAutotileIndex(TERRAIN.GRASS, neighborsAt(g, 2, 2));
162
-
163
- // Imagine an alternate world where every cell of g has a rock OBJECT
164
- // on top. The picker still receives only the ground grid → identical
165
- // tile selection.
166
- const idxB = pickAutotileIndex(TERRAIN.GRASS, neighborsAt(g, 2, 2));
167
-
168
- assert.equal(idxA, idxB, 'object overlays must NOT change picker output');
169
- });
170
-
171
- // ---------- ADR 0013: object-layer autotile dispatch --------------------------
172
-
173
- test('OBJECT_RENDER supports single + path16 strategies; PATH_MARBLE is path16', () => {
174
- // Every legacy entry must declare strategy='single' explicitly OR
175
- // accept the short-form `{ tile: '...' }` (back-compat).
176
- for (const [oid, cfg] of Object.entries(OBJECT_RENDER)) {
177
- const strat = cfg.strategy || 'single';
178
- assert.ok(['single', 'path16', 'blob47'].includes(strat),
179
- `unknown strategy ${strat} for ${oid}`);
180
- if (strat === 'path16' || strat === 'blob47') {
181
- assert.ok(cfg.family, `${oid} missing family`);
182
- assert.ok(cfg.fallbackTile, `${oid} missing fallbackTile`);
183
- }
184
- }
185
- assert.equal(OBJECT_RENDER[OBJECT.PATH_MARBLE].strategy, 'path16');
186
- assert.equal(OBJECT_RENDER[OBJECT.PATH_MARBLE].family, 'marble_path');
187
- });
188
-
189
- test('pickObjectAutotileIndex: empty cell + single-strategy objects return null', () => {
190
- // Empty cell.
191
- assert.equal(pickObjectAutotileIndex(null, {}), null);
192
- // ROCK is single-strategy → no autotile index.
193
- assert.equal(
194
- pickObjectAutotileIndex(OBJECT.ROCK,
195
- { N: null, W: null, E: null, S: null }), null,
196
- );
197
- });
198
-
199
- test('pickObjectAutotileIndex: PATH_MARBLE neighbors → path16 dispatch', () => {
200
- const P = OBJECT.PATH_MARBLE;
201
- // Isolated path = byte 0 (no connections).
202
- const lone = pickObjectAutotileIndex(P, { N: null, W: null, E: null, S: null });
203
- assert.equal(lone, 0, 'isolated path object = path16 index 0');
204
- // Cross junction = 0b1111 = 15.
205
- const cross = pickObjectAutotileIndex(P, { N: P, W: P, E: P, S: P });
206
- assert.equal(cross, 15, '4-way path object = path16 index 15');
207
- // Vertical strip (N+S) = 0b1001 = 9.
208
- const vert = pickObjectAutotileIndex(P, { N: P, W: null, E: null, S: P });
209
- assert.equal(vert, 0b1001, 'N+S vertical strip = 9');
210
- });
211
-
212
- test('OBJECT-LAYER AUTOTILE INVARIANT: object autotile sees only objects', () => {
213
- // Same calling pattern enforcement as the ground invariant: caller
214
- // must pass `map.objects` neighbors, not `map.ground`. A grass-on-sand
215
- // boundary in the ground grid has no effect on a path object's tile.
216
- const P = OBJECT.PATH_MARBLE;
217
- // Build a 5×5 objects grid: vertical path strip down column 2.
218
- const _ = null;
219
- const objects = [
220
- [_, _, P, _, _],
221
- [_, _, P, _, _],
222
- [_, _, P, _, _],
223
- [_, _, P, _, _],
224
- [_, _, P, _, _],
225
- ];
226
- // Cell (2, 2) — middle of the strip. N and S are PATH_MARBLE; W, E, corners empty.
227
- const n = neighborsAt(objects, 2, 2);
228
- assert.equal(n.N, P);
229
- assert.equal(n.S, P);
230
- assert.equal(n.W, null);
231
- assert.equal(n.E, null);
232
- const idx = pickObjectAutotileIndex(P, n);
233
- assert.equal(idx, 0b1001, 'middle of N-S strip should pick vertical-straight');
234
- // Cell (2, 0) — top of strip. Only S is path; N is off-grid.
235
- const idxTop = pickObjectAutotileIndex(P, neighborsAt(objects, 2, 0));
236
- assert.equal(idxTop, 0b0001, 'top end-cap = S only');
237
- // Cell (2, 4) — bottom of strip. Only N is path.
238
- const idxBot = pickObjectAutotileIndex(P, neighborsAt(objects, 2, 4));
239
- assert.equal(idxBot, 0b1000, 'bottom end-cap = N only');
240
- });
241
-
242
- // ---------- D.4-T2: cellTransform determinism -----------------------------------
243
-
244
- test('cellTransform returns an object with flipX, flipY, angle fields', () => {
245
- const t = cellTransform(0, 0);
246
- assert.ok('flipX' in t, 'missing flipX');
247
- assert.ok('flipY' in t, 'missing flipY');
248
- assert.ok('angle' in t, 'missing angle');
249
- assert.equal(typeof t.flipX, 'boolean');
250
- assert.equal(typeof t.flipY, 'boolean');
251
- assert.equal(typeof t.angle, 'number');
252
- });
253
-
254
- test('cellTransform is deterministic: same (x,y) always returns the same values', () => {
255
- const coords = [[0,0],[1,0],[0,1],[5,7],[12,3],[0,15],[7,7],[3,11]];
256
- for (const [x, y] of coords) {
257
- const a = cellTransform(x, y);
258
- const b = cellTransform(x, y);
259
- assert.equal(a.flipX, b.flipX, `flipX mismatch at (${x},${y})`);
260
- assert.equal(a.flipY, b.flipY, `flipY mismatch at (${x},${y})`);
261
- assert.equal(a.angle, b.angle, `angle mismatch at (${x},${y})`);
262
- }
263
- });
264
-
265
- test('cellTransform produces variation across cells (not all identical)', () => {
266
- const signatures = new Set();
267
- for (let x = 0; x < 8; x++) {
268
- for (let y = 0; y < 8; y++) {
269
- const t = cellTransform(x, y);
270
- signatures.add(`${t.flipX}|${t.flipY}|${t.angle}`);
271
- }
272
- }
273
- assert.ok(signatures.size >= 2, `expected variation across cells, got ${signatures.size} distinct transforms`);
274
- });
275
-
276
- test('cellTransform uses a different hash from interior-variant selection (cells differ)', () => {
277
- // The two hashes should produce different distributions so that flip/rotation
278
- // and variant selection don't always correlate. Verify they produce different
279
- // orderings for the same 4x4 patch.
280
- const transformSigs = [];
281
- for (let x = 0; x < 4; x++) {
282
- for (let y = 0; y < 4; y++) {
283
- const t = cellTransform(x, y);
284
- transformSigs.push(`${x},${y}:${Number(t.flipX)}${Number(t.flipY)}`);
285
- }
286
- }
287
- // Just verify the function ran without error and produced a non-trivial result.
288
- assert.equal(transformSigs.length, 16, '4x4 patch has 16 cells');
289
- });
290
-
291
- // ---------- summary -------------------------------------------------------------
292
-
293
- if (process.exitCode !== 1) {
294
- console.log(`\n[terrain_picker] OK — ${passed} tests passed`);
295
- } else {
296
- console.error(`\n[terrain_picker] FAIL — see output above`);
297
- process.exit(1);
298
- }
@@ -1,280 +0,0 @@
1
- // tests/turn_in_place.mjs
2
- //
3
- // Turn-in-place (#1057): quick-tap a direction to face it without stepping;
4
- // hold past the threshold to walk as before. Exercises the pure movement
5
- // helpers (Phaser-free, DB-free) that drive Player.update:
6
- // - decideMove(dir, facing, heldMs, threshold) — the tap-vs-hold rule.
7
- // - readDirectionFromInput(moveKeys) — WASD + arrow key resolution.
8
- // Plus a frame-by-frame simulation proving a real tap never steps and a
9
- // real hold does.
10
- //
11
- // Run: node tests/turn_in_place.mjs
12
-
13
- import { strict as assert } from 'node:assert';
14
- import {
15
- decideMove,
16
- readDirectionFromInput,
17
- TURN_TAP_MS,
18
- DIRS,
19
- } from '../modules/game/public/game/player/movement.js';
20
-
21
- let passed = 0;
22
- function test(name, fn) {
23
- try {
24
- fn();
25
- console.log(` ok ${name}`);
26
- passed++;
27
- } catch (e) {
28
- console.error(` FAIL ${name}: ${e.message}`);
29
- process.exitCode = 1;
30
- }
31
- }
32
-
33
- // A Phaser Key stand-in — readDirectionFromInput only reads these two props.
34
- const key = (isDown, timeDown = 0) => ({ isDown, timeDown });
35
- const NONE = key(false);
36
-
37
- // ---------- decideMove: the tap-vs-hold rule -----------------------------
38
-
39
- test('quick tap of a NEW direction turns, never steps (even if heldMs is large)', () => {
40
- // A fresh press in a new direction always pivots first and never steps the
41
- // same frame — so a quick tap of a new direction can only ever face it.
42
- assert.deepEqual(decideMove('left', 'down', 5, TURN_TAP_MS), { turn: true, step: false });
43
- assert.deepEqual(decideMove('up', 'down', 9999, TURN_TAP_MS), { turn: true, step: false });
44
- });
45
-
46
- test('already facing + held below threshold = no-op (a quick tap is just a look)', () => {
47
- assert.deepEqual(decideMove('down', 'down', 0, TURN_TAP_MS), { turn: false, step: false });
48
- assert.deepEqual(decideMove('down', 'down', TURN_TAP_MS - 1, TURN_TAP_MS), { turn: false, step: false });
49
- });
50
-
51
- test('already facing + held at/over threshold = step (walk)', () => {
52
- assert.deepEqual(decideMove('down', 'down', TURN_TAP_MS, TURN_TAP_MS), { turn: false, step: true });
53
- assert.deepEqual(decideMove('right', 'right', TURN_TAP_MS + 500, TURN_TAP_MS), { turn: false, step: true });
54
- });
55
-
56
- test('no direction held = nothing', () => {
57
- assert.deepEqual(decideMove(null, 'down', 9999, TURN_TAP_MS), { turn: false, step: false });
58
- });
59
-
60
- test('threshold sits in the task-specified 120-150ms band', () => {
61
- assert.ok(TURN_TAP_MS >= 120 && TURN_TAP_MS <= 150, `TURN_TAP_MS=${TURN_TAP_MS}`);
62
- });
63
-
64
- // ---------- readDirectionFromInput: WASD + arrow bindings ----------------
65
-
66
- const dirKeys = (over) => ({
67
- up: [NONE, NONE], down: [NONE, NONE], left: [NONE, NONE], right: [NONE, NONE], ...over,
68
- });
69
-
70
- test('nothing held resolves to null', () => {
71
- assert.equal(readDirectionFromInput(dirKeys({})), null);
72
- });
73
-
74
- test('WASD drives direction (W = up)', () => {
75
- const sel = readDirectionFromInput(dirKeys({ up: [key(true, 100), NONE] }));
76
- assert.equal(sel.dir, 'up');
77
- assert.equal(sel.t, 100);
78
- });
79
-
80
- test('arrow keys drive the same direction (#1057 added arrow bindings)', () => {
81
- // second slot in each direction array is the cursor (arrow) key
82
- const sel = readDirectionFromInput(dirKeys({ right: [NONE, key(true, 250)] }));
83
- assert.equal(sel.dir, 'right');
84
- assert.equal(sel.t, 250);
85
- });
86
-
87
- test('WASD + arrow for one direction use the most-recent press time', () => {
88
- const sel = readDirectionFromInput(dirKeys({ down: [key(true, 100), key(true, 400)] }));
89
- assert.equal(sel.dir, 'down');
90
- assert.equal(sel.t, 400);
91
- });
92
-
93
- test('most-recently-pressed direction wins', () => {
94
- const sel = readDirectionFromInput(dirKeys({
95
- up: [key(true, 100), NONE],
96
- left: [key(true, 200), NONE],
97
- }));
98
- assert.equal(sel.dir, 'left');
99
- });
100
-
101
- test('opposing keys cancel (W+S held = no direction)', () => {
102
- assert.equal(readDirectionFromInput(dirKeys({
103
- up: [key(true, 100), NONE],
104
- down: [key(true, 110), NONE],
105
- })), null);
106
- });
107
-
108
- test('opposing pair cancels but a third direction still resolves', () => {
109
- const sel = readDirectionFromInput(dirKeys({
110
- left: [key(true, 100), NONE],
111
- right: [key(true, 110), NONE],
112
- up: [key(true, 90), NONE],
113
- }));
114
- assert.equal(sel.dir, 'up');
115
- });
116
-
117
- test('equal timeDown breaks ties deterministically (up before left)', () => {
118
- const sel = readDirectionFromInput(dirKeys({
119
- up: [key(true, 100), NONE],
120
- left: [key(true, 100), NONE],
121
- }));
122
- assert.equal(sel.dir, 'up');
123
- });
124
-
125
- // ---------- frame-by-frame: a real tap vs a real hold --------------------
126
-
127
- // Drive the same decision loop Player.update runs, over a sequence of
128
- // frames. Each frame: resolve the held direction, compute heldMs = now - t,
129
- // decide, and apply (turn updates facing; step counts a tile move).
130
- function simulate(frames, startFacing = 'down') {
131
- let facing = startFacing;
132
- let steps = 0;
133
- let turns = 0;
134
- for (const f of frames) {
135
- const sel = readDirectionFromInput(f.keys);
136
- if (!sel) continue;
137
- const { turn, step } = decideMove(sel.dir, facing, f.now - sel.t, TURN_TAP_MS);
138
- if (turn) { facing = sel.dir; turns++; continue; }
139
- if (step) { steps++; }
140
- }
141
- return { facing, steps, turns };
142
- }
143
-
144
- test('a quick tap of a new direction turns once and never steps', () => {
145
- const pressedAt = 1000;
146
- const held = (now) => dirKeys({ left: [key(true, pressedAt), NONE] });
147
- // Press at 1000, sampled at 1000 and 1050, then released before 130ms.
148
- const r = simulate([
149
- { now: 1000, keys: held(1000) },
150
- { now: 1050, keys: held(1050) },
151
- { now: 1100, keys: dirKeys({}) }, // released (~100ms tap)
152
- ]);
153
- assert.equal(r.facing, 'left');
154
- assert.equal(r.turns, 1);
155
- assert.equal(r.steps, 0); // pure turn-in-place — no tile change
156
- });
157
-
158
- test('a quick tap while already facing that way is a no-op (no step)', () => {
159
- const r = simulate([
160
- { now: 1000, keys: dirKeys({ down: [key(true, 1000), NONE] }) },
161
- { now: 1060, keys: dirKeys({ down: [key(true, 1060), NONE] }) },
162
- { now: 1090, keys: dirKeys({}) }, // released (~90ms tap)
163
- ], 'down');
164
- assert.equal(r.facing, 'down');
165
- assert.equal(r.turns, 0);
166
- assert.equal(r.steps, 0);
167
- });
168
-
169
- test('holding a direction walks (turn then step past the threshold)', () => {
170
- const pressedAt = 1000;
171
- const r = simulate([
172
- { now: 1000, keys: dirKeys({ left: [key(true, pressedAt), NONE] }) }, // turn
173
- { now: 1050, keys: dirKeys({ left: [key(true, pressedAt), NONE] }) }, // still in tap window
174
- { now: 1000 + TURN_TAP_MS + 10, keys: dirKeys({ left: [key(true, pressedAt), NONE] }) }, // step
175
- ]);
176
- assert.equal(r.facing, 'left');
177
- assert.equal(r.turns, 1);
178
- assert.equal(r.steps, 1);
179
- });
180
-
181
- test('holding the direction you already face walks after the threshold (no extra turn)', () => {
182
- const pressedAt = 2000;
183
- const r = simulate([
184
- { now: 2000, keys: dirKeys({ up: [key(true, pressedAt), NONE] }) }, // within tap window: nothing
185
- { now: 2000 + TURN_TAP_MS + 5, keys: dirKeys({ up: [key(true, pressedAt), NONE] }) }, // step
186
- ], 'up');
187
- assert.equal(r.turns, 0);
188
- assert.equal(r.steps, 1);
189
- });
190
-
191
- // DIRS sanity — Player.update derives the target tile from these.
192
- test('DIRS deltas are the four cardinal unit steps', () => {
193
- assert.deepEqual(DIRS.up, { dx: 0, dy: -1 });
194
- assert.deepEqual(DIRS.down, { dx: 0, dy: 1 });
195
- assert.deepEqual(DIRS.left, { dx: -1, dy: 0 });
196
- assert.deepEqual(DIRS.right, { dx: 1, dy: 0 });
197
- });
198
-
199
- // ---------- integration: the real Player class with Phaser/DB mocks ------
200
- //
201
- // Player.js is Phaser-free at module load (it only touches the API at
202
- // runtime), so we can drive the real update() loop with light mocks and
203
- // assert the wiring: a pure turn sends 'turn' and changes NO position; a
204
- // held press sends 'move' and steps exactly one tile.
205
-
206
- const { Player } = await import('../modules/game/public/game/player/Player.js');
207
-
208
- function makePlayer() {
209
- const sprite = {
210
- x: 0, y: 0,
211
- setOrigin() { return this; },
212
- setDepth() { return this; },
213
- setTexture() { return this; },
214
- setFlipX() { return this; },
215
- setPosition(x, y) { this.x = x; this.y = y; return this; },
216
- };
217
- const sent = [];
218
- const scene = {
219
- add: { image: () => sprite },
220
- textures: { exists: () => false }, // canvas-fallback path in spriteTextureFor
221
- isBlocked: () => false,
222
- // Synchronous tween: fire onComplete immediately so the step settles
223
- // within the same call (real Phaser tweens take STEP_MS).
224
- tweens: { add: (cfg) => { if (cfg.onComplete) cfg.onComplete(); } },
225
- };
226
- const player = new Player(scene, 10, 10);
227
- player.attachRoom({ send: (type, msg) => sent.push({ type, msg }) });
228
- return { player, sent };
229
- }
230
-
231
- const keysFor = (dir, t) => dirKeys({ [dir]: [key(true, t), NONE] });
232
-
233
- test('integration: quick tap of a new direction → faces it, sends turn, NO position change', () => {
234
- const { player, sent } = makePlayer();
235
- assert.equal(player.facing, 'down');
236
- player.update(keysFor('left', 1000), 1000); // press; dir != facing → turn
237
- assert.equal(player.facing, 'left');
238
- assert.equal(player.gridX, 10);
239
- assert.equal(player.gridY, 10); // zero position change — the headline acceptance
240
- assert.deepEqual(sent, [{ type: 'turn', msg: { dir: 'left' } }]);
241
- });
242
-
243
- test('integration: tap while already facing that way → no message, no move', () => {
244
- const { player, sent } = makePlayer(); // starts facing down
245
- player.update(keysFor('down', 2000), 2000 + 50); // held 50ms < threshold
246
- assert.equal(player.gridX, 10);
247
- assert.equal(player.gridY, 10);
248
- assert.deepEqual(sent, []);
249
- });
250
-
251
- test('integration: holding past the threshold → sends move and steps one tile', () => {
252
- const { player, sent } = makePlayer(); // facing down
253
- // Already facing 'down'; hold past threshold → one step down (+1 y).
254
- player.update(keysFor('down', 3000), 3000 + TURN_TAP_MS + 10);
255
- assert.equal(player.gridY, 11);
256
- assert.equal(player.gridX, 10);
257
- assert.deepEqual(sent, [{ type: 'move', msg: { dir: 'down' } }]);
258
- });
259
-
260
- test('integration: turn frame never steps even when the key is already held long', () => {
261
- const { player, sent } = makePlayer(); // facing down
262
- // First time we see 'left' it differs from facing → must turn only, never
263
- // step, regardless of how long the (newly-resolved) key reads as held.
264
- player.update(keysFor('left', 100), 100 + 5000);
265
- assert.equal(player.facing, 'left');
266
- assert.equal(player.gridX, 10);
267
- assert.equal(player.gridY, 10);
268
- assert.deepEqual(sent, [{ type: 'turn', msg: { dir: 'left' } }]);
269
- });
270
-
271
- test('integration: blocked tile → faces direction but does not step', () => {
272
- const { player, sent } = makePlayer();
273
- player.scene.isBlocked = () => true;
274
- player.facing = 'up'; // pretend already facing up
275
- player.update(keysFor('up', 4000), 4000 + TURN_TAP_MS + 10); // would step, but blocked
276
- assert.equal(player.gridY, 10); // unchanged
277
- assert.deepEqual(sent, []); // beginWalk (which sends 'move') never reached
278
- });
279
-
280
- console.log(`\nturn_in_place: ${passed} passed`);