@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.
- package/.bongos-core.json +244 -264
- package/.claude/skills/blocker-review/SKILL.md +1 -1
- package/.claude/skills/builder-claim/SKILL.md +2 -0
- package/.claude/skills/builder-exit/SKILL.md +2 -2
- package/.claude/skills/builder-redteam/SKILL.md +3 -3
- package/.claude/skills/builder-setup/SKILL.md +2 -2
- package/.claude/skills/builder-start/SKILL.md +1 -1
- package/.claude/skills/dev/SKILL.md +1 -1
- package/.claude/skills/idea-triage/SKILL.md +2 -2
- package/.claude/skills/ideate/SKILL.md +1 -1
- package/.claude/skills/merge-mode/SKILL.md +1 -1
- package/.claude/skills/new-project/SKILL.md +3 -0
- package/.claude/skills/paint/SKILL.md +1 -1
- package/GOVERNANCE.md +1 -1
- package/README.md +1 -1
- package/docs/adr/0001-stack-choice-single-vps.md +1 -1
- package/docs/adr/0008-google-chat-oauth-user-auth.md +1 -1
- package/docs/adr/0019-red-team-bounty-payout-table.md +3 -3
- package/docs/adr/0020-security-model-public-stub.md +1 -1
- package/docs/adr/0023-kind-multipliers-and-idea-bonus.md +1 -1
- package/docs/adr/0024-cloneable-repo-local-first-memory.md +1 -1
- package/docs/adr/0026-bfg-memory-hygiene-and-cross-builder-learning.md +4 -4
- package/docs/adr/0027-bfg-session-inefficiency-evaluator.md +3 -3
- package/docs/adr/0030-builders-hall-live-updates.md +1 -1
- package/docs/adr/0031-cloud-dev-environments-for-builders.md +4 -4
- package/docs/adr/0032-discord-mirror-of-gds.md +2 -2
- package/docs/adr/0034-thetes-graduated-newcomer-rank.md +1 -1
- package/docs/adr/0044-mediterranean-palette-replacement.md +1 -1
- package/docs/adr/0044-per-box-live-game-preview.md +1 -1
- package/docs/adr/0054-session-token-reward-cost-plus.md +10 -10
- package/docs/adr/0057-container-cost-ledger.md +3 -3
- package/docs/adr/0062-medusa-instance-model-decoupling.md +4 -4
- package/docs/adr/0065-agpl-nonprofit-ai-first-governance.md +2 -2
- package/docs/adr/0085-remove-default-per-builder-spend-cap.md +1 -1
- package/docs/adr/0086-goal-scoped-work-hierarchy.md +1 -1
- package/docs/adr/0096-require-reward-before-workable.md +9 -9
- package/docs/adr/0097-retroactive-reward-backfill.md +3 -3
- package/docs/adr/0100-two-repo-instance-model.md +1 -1
- package/docs/adr/0101-goal-hierarchy-phase-5-judge-membership-reward.md +3 -3
- package/docs/adr/0103-core-first-extraction-cloud-bongos-trunk.md +3 -3
- package/docs/adr/0107-module-upstreaming-contribution-to-core-catalog.md +1 -1
- package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +6 -6
- package/docs/adr/0136-update-channel-subscription-policy.md +51 -0
- package/docs/adr/0137-upstream-publish-policy-manual-owner-gated.md +40 -0
- package/docs/adr/0138-bundled-module-deps-declared-per-module.md +45 -0
- package/docs/adr/README.md +9 -6
- package/docs/architecture.md +2 -2
- package/docs/branding-contract.md +5 -4
- package/docs/canonical-permissions.md +1 -1
- package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
- package/docs/file-map.md +18 -17
- package/docs/handoff-template.md +2 -2
- package/docs/modules-contract.md +6 -1
- package/docs/onboarding/diagrams/README.md +7 -7
- package/docs/onboarding/drachmae-and-karma.md +18 -18
- package/docs/onboarding/primer.md +9 -9
- package/docs/recipes/audit-replay.md +2 -2
- package/docs/recipes/builder-box-lifecycle.md +2 -2
- package/docs/recipes/claude-design-loop.md +1 -1
- package/docs/recipes/ops-gotchas.md +1 -1
- package/docs/recipes/private-npm-distribution.md +17 -0
- package/docs/recipes/staging-environment.md +1 -1
- package/docs/recipes/upgrading-the-core.md +55 -0
- package/docs/research/cursor-competitive-analysis.md +1 -1
- package/docs/research/llm-model-agnostic-and-cheaper-grader.md +1 -1
- package/modules/discord/module.json +2 -1
- package/modules/economy/achievements.js +9 -1
- package/modules/economy/credits.js +10 -1
- package/modules/hall-ui/public/builders.js +71 -4
- package/modules/hall-ui/public/goal-inbox.js +191 -0
- package/modules/hall-ui/public/hall-widgets.js +14 -1
- package/modules/hall-ui/public/index.html +3 -0
- package/modules/hall-ui/public/new-project-wizard.js +1 -0
- package/modules/hall-ui/public/style.css +43 -0
- package/modules/provisioning/CLAUDE.md +15 -0
- package/modules/provisioning/migrations/provisioning_001_tables.sql +160 -0
- package/modules/provisioning/migrations/provisioning_002_standalone_shape.sql +11 -0
- package/modules/provisioning/migrations/provisioning_003_github_app_manifest.sql +95 -0
- package/modules/provisioning/module.json +17 -0
- package/modules/provisioning/onboard-plan.js +37 -0
- package/modules/provisioning/provisioning.js +539 -0
- package/modules/provisioning/rate-limit.js +35 -0
- package/modules/provisioning/routes/provisioning.js +607 -0
- package/modules/provisioning/tests/provisioning.mjs +232 -0
- package/modules/provisioning/ui/.gitkeep +0 -0
- package/modules/security/secret-scrub.js +10 -0
- package/package-lock.json +2 -2
- package/package.json +7 -2
- package/scripts/discord/README.md +1 -1
- package/scripts/gds/claim.js +98 -48
- package/scripts/gds/claude-materialize.js +30 -9
- package/scripts/gds/cutover-codemod.js +241 -0
- package/scripts/gds/fitness.js +54 -0
- package/scripts/gds/init.js +59 -3
- package/scripts/gds/lint-infra-exec.js +70 -0
- package/scripts/gds/lint-workflow-exec.js +63 -0
- package/scripts/gds/mirror-lineage-check.js +14 -5
- package/scripts/gds/mirror-redact.js +8 -0
- package/scripts/gds/onboard.js +4 -0
- package/scripts/gds/package-core.js +18 -3
- package/scripts/gds/publish-manifest.js +60 -2
- package/scripts/gds/run-routine.js +51 -13
- package/scripts/gds/run-unit-tests.js +4 -0
- package/scripts/gds/update-channel.js +186 -0
- package/scripts/gds/upgrade.js +116 -9
- package/src/bongos/auth.js +14 -1
- package/src/bongos/serve-internal.js +10 -1
- package/src/branding.js +1 -0
- package/src/module-api.js +12 -1
- package/src/module-loader/loader.js +36 -4
- package/src/module-loader/manifest-schema.js +16 -1
- package/tests/branding.mjs +11 -0
- package/tests/claim_error_surface.mjs +107 -0
- package/tests/claude_materialize.mjs +20 -0
- package/tests/consumer_layout_boot.mjs +302 -0
- package/tests/cutover_codemod.mjs +184 -0
- package/tests/device_flow_start.mjs +25 -2
- package/tests/hook_core_resolve.mjs +125 -0
- package/tests/infra_exec_paths.mjs +104 -0
- package/tests/init.mjs +47 -0
- package/tests/mirror_lineage_proof.mjs +14 -0
- package/tests/module_loader.mjs +42 -0
- package/tests/module_manifest.mjs +19 -0
- package/tests/onboard.mjs +13 -0
- package/tests/package_core.mjs +8 -0
- package/tests/provisioning_github_app_routes.mjs +15 -0
- package/tests/publish_manifest.mjs +52 -1
- package/tests/run_routine.mjs +26 -1
- package/tests/run_routine_two_root.mjs +77 -0
- package/tests/scrubber_corpus.mjs +17 -0
- package/tests/update_channel.mjs +186 -0
- package/tests/upgrade.mjs +131 -0
- package/tests/workflow_exec_paths.mjs +98 -0
- package/docs/design/container-exterior-tiles.md +0 -49
- package/docs/design/container-exterior.md +0 -81
- package/docs/design/container-interior.md +0 -77
- package/docs/design/world-map-scroll.md +0 -41
- package/docs/recipes/claude-gemini-bongos-doors.md +0 -155
- package/docs/recipes/cloudbongos-staging-gate.md +0 -139
- package/docs/recipes/cloudbongos-standup.md +0 -111
- package/docs/recipes/parallel-load-results-2026-05-30.md +0 -63
- package/docs/recipes/parallel-load-test.md +0 -32
- package/docs/recipes/pixel-art-pipeline.md +0 -411
- package/docs/recipes/staging-data.md +0 -50
- package/migrations/instance/018_pms_v3_planning_seed.sql +0 -131
- package/migrations/instance/080_gds_v4_planning_seed.sql +0 -75
- package/migrations/instance/110_medusa_planning_seed.sql +0 -70
- package/migrations/instance/120_bongos_v1_planning_seed.sql +0 -69
- package/migrations/instance/144_bongos_v1_module_system_criterion.sql +0 -26
- package/migrations/instance/159_bongos_v1_goal_hierarchy_criterion.sql +0 -29
- package/migrations/instance/172_v2_world_planning_seed.sql +0 -163
- package/migrations/instance/173_bongos_v1_c7_core_modularized.sql +0 -33
- package/migrations/instance/182_gdsv4_bongos_consolidation.sql +0 -132
- package/migrations/instance/183_gdsv4_carryover_themed_goals.sql +0 -116
- package/migrations/instance/186_bongos_v1_governance_planning_seed.sql +0 -69
- package/scripts/gds/art-stage.js +0 -152
- package/server.js +0 -212
- package/tests/art_key.mjs +0 -121
- package/tests/day_cycle.mjs +0 -197
- package/tests/snapshot_picker.mjs +0 -233
- package/tests/sprite_facing.mjs +0 -35
- package/tests/terrain_picker.mjs +0 -298
- package/tests/turn_in_place.mjs +0 -280
- package/tests/walkability.mjs +0 -188
package/tests/terrain_picker.mjs
DELETED
|
@@ -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
|
-
}
|
package/tests/turn_in_place.mjs
DELETED
|
@@ -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`);
|