@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/art_key.mjs
DELETED
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
// tests/art_key.mjs
|
|
2
|
-
//
|
|
3
|
-
// Unit smoke for modules/art-pipeline/art-key.js (task 1281, moved BV1.R48) — the GDS-delivered SHARED Gemini
|
|
4
|
-
// key for newcomers. Covers the pure entitlement decision (xenos in; thetes+ out;
|
|
5
|
-
// unknown rank fails closed), the env/file key loader, and the describeArtKey
|
|
6
|
-
// payload shape. The route (routes/me.js) + the local sync (fetch-art-key.js) and
|
|
7
|
-
// the pipeline tier (gen_api.py) are exercised by the endpoint smoke + manual
|
|
8
|
-
// resolution check; here we cover the logic without pg or network.
|
|
9
|
-
//
|
|
10
|
-
// Hermetic: the "not configured" cases unset SHARED_ART_GEMINI_KEY and repoint
|
|
11
|
-
// HOME at an empty temp dir so the ~/.config/otb/gemini-shared.json fallback can't
|
|
12
|
-
// resolve to a real operator file on the test machine.
|
|
13
|
-
//
|
|
14
|
-
// Run: node tests/art_key.mjs
|
|
15
|
-
|
|
16
|
-
import { strict as assert } from 'node:assert';
|
|
17
|
-
import { createRequire } from 'node:module';
|
|
18
|
-
import { mkdtempSync } from 'node:fs';
|
|
19
|
-
import { tmpdir } from 'node:os';
|
|
20
|
-
import { join } from 'node:path';
|
|
21
|
-
|
|
22
|
-
const require = createRequire(import.meta.url);
|
|
23
|
-
const a = require('../modules/art-pipeline/art-key.js');
|
|
24
|
-
const db = require('../modules/lifecycle/db.js');
|
|
25
|
-
|
|
26
|
-
let passed = 0;
|
|
27
|
-
let failed = 0;
|
|
28
|
-
function t(name, fn) {
|
|
29
|
-
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
30
|
-
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Run a thunk with SHARED_ART_GEMINI_KEY + HOME forced to known values, restoring
|
|
34
|
-
// both afterward so cases don't leak into each other.
|
|
35
|
-
function withEnv({ key, emptyHome }, fn) {
|
|
36
|
-
const origKey = process.env.SHARED_ART_GEMINI_KEY;
|
|
37
|
-
const origHome = process.env.HOME;
|
|
38
|
-
if (key === null) delete process.env.SHARED_ART_GEMINI_KEY;
|
|
39
|
-
else if (key !== undefined) process.env.SHARED_ART_GEMINI_KEY = key;
|
|
40
|
-
if (emptyHome) process.env.HOME = mkdtempSync(join(tmpdir(), 'otb-artkey-'));
|
|
41
|
-
try { fn(); }
|
|
42
|
-
finally {
|
|
43
|
-
if (origKey === undefined) delete process.env.SHARED_ART_GEMINI_KEY;
|
|
44
|
-
else process.env.SHARED_ART_GEMINI_KEY = origKey;
|
|
45
|
-
process.env.HOME = origHome;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
console.log('entitlement boundary:');
|
|
50
|
-
|
|
51
|
-
t('SHARED_ART_KEY_MAX_TIER is the xenos tier (1)', () => {
|
|
52
|
-
assert.equal(a.SHARED_ART_KEY_MAX_TIER, db.REQUIRES_RANK_TIER.xenos);
|
|
53
|
-
assert.equal(a.SHARED_ART_KEY_MAX_TIER, 1);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
t('xenos is entitled (reason newcomer)', () => {
|
|
57
|
-
assert.deepEqual(a.entitlementFor('xenos'), { entitled: true, reason: 'newcomer' });
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
t('thetes / metic / archon are NOT entitled (reason rank_above_newcomer)', () => {
|
|
61
|
-
for (const rank of ['thetes', 'metic', 'archon']) {
|
|
62
|
-
assert.deepEqual(a.entitlementFor(rank), { entitled: false, reason: 'rank_above_newcomer' }, rank);
|
|
63
|
-
}
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
t('unknown / absent rank fails closed (reason unknown_rank)', () => {
|
|
67
|
-
for (const rank of [undefined, null, '', 'wizard', 'helot']) {
|
|
68
|
-
assert.deepEqual(a.entitlementFor(rank), { entitled: false, reason: 'unknown_rank' }, String(rank));
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
console.log('\nkey loader (env wins; trimmed):');
|
|
73
|
-
|
|
74
|
-
t('loadSharedGeminiKey returns the env key, trimmed', () => {
|
|
75
|
-
withEnv({ key: ' AIza-test-123 ' }, () => {
|
|
76
|
-
assert.equal(a.loadSharedGeminiKey(), 'AIza-test-123');
|
|
77
|
-
assert.equal(a.sharedGeminiKeyConfigured(), true);
|
|
78
|
-
});
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
t('not configured when env unset and no operator file (empty HOME)', () => {
|
|
82
|
-
withEnv({ key: null, emptyHome: true }, () => {
|
|
83
|
-
assert.equal(a.loadSharedGeminiKey(), null);
|
|
84
|
-
assert.equal(a.sharedGeminiKeyConfigured(), false);
|
|
85
|
-
});
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
console.log('\ndescribeArtKey (the GET /me/art-key payload, sans secret):');
|
|
89
|
-
|
|
90
|
-
t('xenos + configured: entitled, configured, shared_key placeholder is null', () => {
|
|
91
|
-
withEnv({ key: 'AIza-shared' }, () => {
|
|
92
|
-
const d = a.describeArtKey({ rank: 'xenos' });
|
|
93
|
-
assert.equal(d.entitled, true);
|
|
94
|
-
assert.equal(d.configured, true);
|
|
95
|
-
assert.equal(d.reason, 'newcomer');
|
|
96
|
-
assert.equal(d.rank, 'xenos');
|
|
97
|
-
// The pure describe never embeds the secret — the route attaches it.
|
|
98
|
-
assert.equal(d.shared_key, null);
|
|
99
|
-
});
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
t('thetes: not entitled even when a key is configured (the withdraw signal)', () => {
|
|
103
|
-
withEnv({ key: 'AIza-shared' }, () => {
|
|
104
|
-
const d = a.describeArtKey({ rank: 'thetes' });
|
|
105
|
-
assert.equal(d.entitled, false);
|
|
106
|
-
assert.equal(d.reason, 'rank_above_newcomer');
|
|
107
|
-
assert.equal(d.configured, true);
|
|
108
|
-
assert.equal(d.shared_key, null);
|
|
109
|
-
});
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
t('xenos but unconfigured: entitled yet configured=false (sync no-ops)', () => {
|
|
113
|
-
withEnv({ key: null, emptyHome: true }, () => {
|
|
114
|
-
const d = a.describeArtKey({ rank: 'xenos' });
|
|
115
|
-
assert.equal(d.entitled, true);
|
|
116
|
-
assert.equal(d.configured, false);
|
|
117
|
-
});
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
console.log(`\n${passed} passed, ${failed} failed`);
|
|
121
|
-
process.exit(failed === 0 ? 0 : 1);
|
package/tests/day_cycle.mjs
DELETED
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
// tests/day_cycle.mjs
|
|
2
|
-
//
|
|
3
|
-
// The pure math behind the #639 time-of-day lighting. No Phaser, no DOM — the
|
|
4
|
-
// fx/lighting.js glue is a thin shell over these functions, so getting the
|
|
5
|
-
// math right here is most of the feature.
|
|
6
|
-
//
|
|
7
|
-
// Run: node tests/day_cycle.mjs
|
|
8
|
-
|
|
9
|
-
import { strict as assert } from 'node:assert';
|
|
10
|
-
import {
|
|
11
|
-
DEFAULT_PERIOD_MS,
|
|
12
|
-
KEYFRAMES,
|
|
13
|
-
SKY_PHASES,
|
|
14
|
-
wrap01,
|
|
15
|
-
phaseFromClock,
|
|
16
|
-
lerpColor,
|
|
17
|
-
bracket,
|
|
18
|
-
tintAt,
|
|
19
|
-
parseSkyOverride,
|
|
20
|
-
} from '../modules/game/public/game/fx/dayCycle.js';
|
|
21
|
-
|
|
22
|
-
let passed = 0;
|
|
23
|
-
function test(name, fn) {
|
|
24
|
-
try {
|
|
25
|
-
fn();
|
|
26
|
-
console.log(` ok ${name}`);
|
|
27
|
-
passed++;
|
|
28
|
-
} catch (e) {
|
|
29
|
-
console.error(` FAIL ${name}: ${e.message}`);
|
|
30
|
-
process.exitCode = 1;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const chan = (c) => [(c >> 16) & 0xff, (c >> 8) & 0xff, c & 0xff];
|
|
35
|
-
|
|
36
|
-
// ---------- wrap01 -------------------------------------------------------
|
|
37
|
-
|
|
38
|
-
test('wrap01 maps any real into [0,1)', () => {
|
|
39
|
-
assert.equal(wrap01(0), 0);
|
|
40
|
-
assert.equal(wrap01(0.5), 0.5);
|
|
41
|
-
assert.equal(wrap01(1), 0);
|
|
42
|
-
assert.ok(Math.abs(wrap01(1.25) - 0.25) < 1e-9);
|
|
43
|
-
assert.ok(Math.abs(wrap01(-0.25) - 0.75) < 1e-9);
|
|
44
|
-
assert.ok(Math.abs(wrap01(-2.1) - 0.9) < 1e-9);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
// ---------- phaseFromClock ----------------------------------------------
|
|
48
|
-
|
|
49
|
-
test('phaseFromClock is 0 at the epoch and 0.5 at half a period', () => {
|
|
50
|
-
assert.equal(phaseFromClock(0), 0);
|
|
51
|
-
assert.equal(phaseFromClock(DEFAULT_PERIOD_MS / 2), 0.5);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test('phaseFromClock wraps each period', () => {
|
|
55
|
-
assert.equal(phaseFromClock(DEFAULT_PERIOD_MS), 0);
|
|
56
|
-
assert.ok(Math.abs(phaseFromClock(DEFAULT_PERIOD_MS * 1.25) - 0.25) < 1e-9);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test('phaseFromClock is deterministic — shared across clients by construction', () => {
|
|
60
|
-
const now = 1_700_000_123_456;
|
|
61
|
-
assert.equal(phaseFromClock(now), phaseFromClock(now));
|
|
62
|
-
// Two clients a few seconds apart see ~the same phase (period is minutes).
|
|
63
|
-
const skew = phaseFromClock(now + 3000) - phaseFromClock(now);
|
|
64
|
-
assert.ok(Math.abs(skew) < 0.01, `3s skew tiny: ${skew}`);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test('phaseFromClock guards a non-positive period', () => {
|
|
68
|
-
assert.equal(phaseFromClock(1234, 0), 0);
|
|
69
|
-
assert.equal(phaseFromClock(1234, -5), 0);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
// ---------- lerpColor ----------------------------------------------------
|
|
73
|
-
|
|
74
|
-
test('lerpColor returns the endpoints at f=0 and f=1', () => {
|
|
75
|
-
assert.equal(lerpColor(0x000000, 0xffffff, 0), 0x000000);
|
|
76
|
-
assert.equal(lerpColor(0x000000, 0xffffff, 1), 0xffffff);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
test('lerpColor blends channel-by-channel at the midpoint', () => {
|
|
80
|
-
// (0,128,255) is the per-channel midpoint of black and 0x00ffff... use a
|
|
81
|
-
// clean case: midpoint of 0x000000 and 0x804020 is (64,32,16).
|
|
82
|
-
assert.deepEqual(chan(lerpColor(0x000000, 0x804020, 0.5)), [64, 32, 16]);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test('lerpColor clamps f outside [0,1]', () => {
|
|
86
|
-
assert.equal(lerpColor(0x102030, 0xa0b0c0, -1), 0x102030);
|
|
87
|
-
assert.equal(lerpColor(0x102030, 0xa0b0c0, 2), 0xa0b0c0);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
// ---------- bracket / tintAt --------------------------------------------
|
|
91
|
-
|
|
92
|
-
test('tintAt hits each keyframe exactly at its phase', () => {
|
|
93
|
-
for (const kf of KEYFRAMES) {
|
|
94
|
-
const { color, alpha } = tintAt(kf.t);
|
|
95
|
-
assert.equal(color, kf.color, `${kf.name}@${kf.t} color`);
|
|
96
|
-
assert.ok(Math.abs(alpha - kf.alpha) < 1e-9, `${kf.name}@${kf.t} alpha`);
|
|
97
|
-
}
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test('high noon is fully transparent (base art at full strength)', () => {
|
|
101
|
-
assert.equal(tintAt(SKY_PHASES.midday).alpha, 0);
|
|
102
|
-
// The whole noon plateau holds at alpha 0.
|
|
103
|
-
assert.equal(tintAt(0.45).alpha, 0);
|
|
104
|
-
assert.equal(tintAt(0.50).alpha, 0);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test('golden hour is the warm hero state', () => {
|
|
108
|
-
const t = tintAt(SKY_PHASES.golden);
|
|
109
|
-
const [r, g, b] = chan(t.color);
|
|
110
|
-
assert.ok(r > g && g > b, `amber: r>g>b got ${r},${g},${b}`);
|
|
111
|
-
assert.ok(t.alpha > 0.3, `visible: ${t.alpha}`);
|
|
112
|
-
assert.equal(t.name, 'golden');
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
test('night is a dark blue-dominant tint', () => {
|
|
116
|
-
const [r, g, b] = chan(tintAt(0).color);
|
|
117
|
-
assert.ok(b > r && b > g, `blue-dominant: ${r},${g},${b}`);
|
|
118
|
-
assert.ok(tintAt(0).alpha > 0.5, 'strong');
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
test('the wrap segment interpolates nightfall → deep night across midnight', () => {
|
|
122
|
-
// phase 0.94 sits between the 0.88 nightfall keyframe and the 0.00 deep-night
|
|
123
|
-
// keyframe (read circularly). Alpha must land strictly between the two.
|
|
124
|
-
const { alpha } = tintAt(0.94);
|
|
125
|
-
assert.ok(alpha > 0.64 && alpha < 0.72, `between nightfall and deep night: ${alpha}`);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test('bracket picks the wrap segment for late-night phases', () => {
|
|
129
|
-
const b = bracket(0.95);
|
|
130
|
-
assert.equal(b.from.t, 0.88);
|
|
131
|
-
assert.equal(b.to.t, 0.00);
|
|
132
|
-
assert.ok(b.f > 0 && b.f < 1);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
test('tintAt is continuous and total over the whole cycle (no gaps/throws)', () => {
|
|
136
|
-
let prev = tintAt(0);
|
|
137
|
-
for (let i = 1; i <= 1000; i++) {
|
|
138
|
-
const p = i / 1000;
|
|
139
|
-
const cur = tintAt(p);
|
|
140
|
-
assert.ok(cur.alpha >= 0 && cur.alpha <= 1, `alpha in range @${p}`);
|
|
141
|
-
// No discontinuous jump bigger than the largest keyframe-to-keyframe step.
|
|
142
|
-
assert.ok(Math.abs(cur.alpha - prev.alpha) < 0.05, `smooth @${p}`);
|
|
143
|
-
prev = cur;
|
|
144
|
-
}
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
test('tintAt wraps: phase 1.0 equals phase 0.0', () => {
|
|
148
|
-
assert.deepEqual(tintAt(1.0), tintAt(0.0));
|
|
149
|
-
assert.deepEqual(tintAt(1.5), tintAt(0.5));
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
// ---------- parseSkyOverride --------------------------------------------
|
|
153
|
-
|
|
154
|
-
test('parseSkyOverride: absent / blank => null (run the live clock)', () => {
|
|
155
|
-
assert.equal(parseSkyOverride(null), null);
|
|
156
|
-
assert.equal(parseSkyOverride(undefined), null);
|
|
157
|
-
assert.equal(parseSkyOverride(''), null);
|
|
158
|
-
assert.equal(parseSkyOverride(' '), null);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
test('parseSkyOverride: mood names (case-insensitive) => their phase', () => {
|
|
162
|
-
assert.equal(parseSkyOverride('golden'), SKY_PHASES.golden);
|
|
163
|
-
assert.equal(parseSkyOverride('NIGHT'), SKY_PHASES.night);
|
|
164
|
-
assert.equal(parseSkyOverride('Dusk'), SKY_PHASES.dusk);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
test('parseSkyOverride: raw fractions, wrapped into [0,1)', () => {
|
|
168
|
-
assert.equal(parseSkyOverride('0.68'), 0.68);
|
|
169
|
-
assert.equal(parseSkyOverride('0'), 0);
|
|
170
|
-
assert.ok(Math.abs(parseSkyOverride('1.25') - 0.25) < 1e-9);
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
test('parseSkyOverride: garbage => null', () => {
|
|
174
|
-
assert.equal(parseSkyOverride('banana'), null);
|
|
175
|
-
assert.equal(parseSkyOverride('NaN'), null);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
// ---------- data sanity --------------------------------------------------
|
|
179
|
-
|
|
180
|
-
test('KEYFRAMES are sorted, valid colours, alphas in [0,1]', () => {
|
|
181
|
-
for (let i = 0; i < KEYFRAMES.length; i++) {
|
|
182
|
-
const kf = KEYFRAMES[i];
|
|
183
|
-
assert.ok(kf.t >= 0 && kf.t < 1, `t in [0,1): ${kf.t}`);
|
|
184
|
-
assert.ok(kf.alpha >= 0 && kf.alpha <= 1, `alpha in [0,1]: ${kf.alpha}`);
|
|
185
|
-
assert.ok(Number.isInteger(kf.color) && kf.color >= 0 && kf.color <= 0xffffff, 'color 24-bit');
|
|
186
|
-
if (i > 0) assert.ok(kf.t > KEYFRAMES[i - 1].t, `strictly ascending @${i}`);
|
|
187
|
-
}
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
test('SKY_PHASES all resolve into [0,1) and round-trip through tintAt', () => {
|
|
191
|
-
for (const [name, p] of Object.entries(SKY_PHASES)) {
|
|
192
|
-
assert.ok(p >= 0 && p < 1, `${name} phase in range`);
|
|
193
|
-
assert.doesNotThrow(() => tintAt(p), `${name} tints`);
|
|
194
|
-
}
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
console.log(`\nday_cycle: ${passed} passed`);
|
|
@@ -1,233 +0,0 @@
|
|
|
1
|
-
// tests/snapshot_picker.mjs
|
|
2
|
-
//
|
|
3
|
-
// Parity test: verifies that the snapshot renderer's tile-picking logic
|
|
4
|
-
// agrees with the JS runtime autotile pickers for a representative sample
|
|
5
|
-
// of cells in INITIAL_MAP.
|
|
6
|
-
//
|
|
7
|
-
// The snapshot renderer (scripts/snapshots/render-world.mjs) calls the
|
|
8
|
-
// SAME selectBlob47/selectPath16 and neighborsAt functions that the browser
|
|
9
|
-
// client uses — so this test validates that the renderer correctly:
|
|
10
|
-
// 1. Passes the ground grid to ground-layer pickers.
|
|
11
|
-
// 2. Passes the object grid to object-layer pickers.
|
|
12
|
-
// 3. Produces the same tile index as a direct call with the same inputs.
|
|
13
|
-
//
|
|
14
|
-
// If these diverge the in-game render and the snapshot would show different
|
|
15
|
-
// tile choices for the same cell.
|
|
16
|
-
//
|
|
17
|
-
// Usage:
|
|
18
|
-
// node tests/snapshot_picker.mjs
|
|
19
|
-
|
|
20
|
-
import { createRequire } from 'node:module';
|
|
21
|
-
import { selectBlob47, selectPath16 } from '../modules/game/public/game/world/autotile.js';
|
|
22
|
-
import {
|
|
23
|
-
TERRAIN_RENDER,
|
|
24
|
-
OBJECT_RENDER,
|
|
25
|
-
neighborsAt,
|
|
26
|
-
} from '../modules/game/public/game/world/terrain.js';
|
|
27
|
-
|
|
28
|
-
const require = createRequire(import.meta.url);
|
|
29
|
-
const { INITIAL_MAP, WORLD_COLS, WORLD_ROWS, TERRAIN, OBJECT } = require('../modules/game/world/map.js');
|
|
30
|
-
|
|
31
|
-
let passed = 0;
|
|
32
|
-
let failed = 0;
|
|
33
|
-
|
|
34
|
-
function assert(condition, msg) {
|
|
35
|
-
if (condition) {
|
|
36
|
-
passed++;
|
|
37
|
-
} else {
|
|
38
|
-
console.error(` FAIL: ${msg}`);
|
|
39
|
-
failed++;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// ---------- helpers mirrored from render-world.mjs -------------------------
|
|
44
|
-
|
|
45
|
-
function groundIdx(x, y) {
|
|
46
|
-
const terrainId = INITIAL_MAP.ground[y][x];
|
|
47
|
-
const cfg = TERRAIN_RENDER[terrainId];
|
|
48
|
-
if (!cfg || cfg.strategy === 'single') return null;
|
|
49
|
-
const neighbors = neighborsAt(INITIAL_MAP.ground, x, y);
|
|
50
|
-
if (cfg.strategy === 'blob47') return selectBlob47(terrainId, neighbors);
|
|
51
|
-
if (cfg.strategy === 'path16') {
|
|
52
|
-
return selectPath16({
|
|
53
|
-
N: neighbors.N === terrainId ? 1 : 0,
|
|
54
|
-
W: neighbors.W === terrainId ? 1 : 0,
|
|
55
|
-
E: neighbors.E === terrainId ? 1 : 0,
|
|
56
|
-
S: neighbors.S === terrainId ? 1 : 0,
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function objectIdx(x, y) {
|
|
63
|
-
const objId = INITIAL_MAP.objects[y][x];
|
|
64
|
-
if (!objId) return null;
|
|
65
|
-
const cfg = OBJECT_RENDER[objId];
|
|
66
|
-
if (!cfg || cfg.strategy === 'single') return null;
|
|
67
|
-
const neighbors = neighborsAt(INITIAL_MAP.objects, x, y);
|
|
68
|
-
if (cfg.strategy === 'blob47') return selectBlob47(objId, neighbors);
|
|
69
|
-
if (cfg.strategy === 'path16') {
|
|
70
|
-
return selectPath16({
|
|
71
|
-
N: neighbors.N === objId ? 1 : 0,
|
|
72
|
-
W: neighbors.W === objId ? 1 : 0,
|
|
73
|
-
E: neighbors.E === objId ? 1 : 0,
|
|
74
|
-
S: neighbors.S === objId ? 1 : 0,
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
return null;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// ---------- tests -----------------------------------------------------------
|
|
81
|
-
|
|
82
|
-
console.log('[snapshot_picker] running parity tests…\n');
|
|
83
|
-
|
|
84
|
-
// 1. All-grass interior cell (0,0 — surrounded by grass on all sides).
|
|
85
|
-
{
|
|
86
|
-
const idx = groundIdx(1, 1);
|
|
87
|
-
assert(idx !== null, 'grass interior: should return a blob47 index');
|
|
88
|
-
assert(idx >= 0 && idx <= 46, `grass interior: index ${idx} out of range 0..46`);
|
|
89
|
-
|
|
90
|
-
// Direct call with the same neighbors
|
|
91
|
-
const neighbors = neighborsAt(INITIAL_MAP.ground, 1, 1);
|
|
92
|
-
const direct = selectBlob47(INITIAL_MAP.ground[1][1], neighbors);
|
|
93
|
-
assert(idx === direct, `grass interior: picker returned ${idx}, direct call returned ${direct}`);
|
|
94
|
-
console.log(` PASS grass interior (1,1): blob47 idx=${idx}`);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// 2. SEA cell — single-tile strategy, no autotile index.
|
|
98
|
-
{
|
|
99
|
-
const seaRow = WORLD_ROWS - 1;
|
|
100
|
-
const terrainId = INITIAL_MAP.ground[seaRow][0];
|
|
101
|
-
assert(terrainId === TERRAIN.SEA, `row ${seaRow} col 0 should be SEA, got ${terrainId}`);
|
|
102
|
-
const cfg = TERRAIN_RENDER[terrainId];
|
|
103
|
-
assert(cfg.strategy === 'single', `sea: expected single strategy, got ${cfg.strategy}`);
|
|
104
|
-
const idx = groundIdx(0, seaRow);
|
|
105
|
-
assert(idx === null, `sea: single-tile strategy should return null, got ${idx}`);
|
|
106
|
-
console.log(` PASS sea (0,${seaRow}): single tile, no autotile index`);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// 3. Grass→sand boundary cell (first SAND row, leftmost column).
|
|
110
|
-
// At y=6, x=0 the ground is SAND. Its N neighbor is GRASS. The blob47 picker
|
|
111
|
-
// should see a non-all-interior byte and return an edge tile index.
|
|
112
|
-
{
|
|
113
|
-
const x = 0, y = 6;
|
|
114
|
-
const terrainId = INITIAL_MAP.ground[y][x];
|
|
115
|
-
assert(terrainId === TERRAIN.SAND, `(0,6) should be SAND, got ${terrainId}`);
|
|
116
|
-
const idx = groundIdx(x, y);
|
|
117
|
-
assert(idx !== null, 'sand boundary: should have autotile index');
|
|
118
|
-
|
|
119
|
-
const neighbors = neighborsAt(INITIAL_MAP.ground, x, y);
|
|
120
|
-
const direct = selectBlob47(terrainId, neighbors);
|
|
121
|
-
assert(idx === direct, `sand boundary: picker=${idx}, direct=${direct}`);
|
|
122
|
-
// Sand at (0,6) has grass to the north and sea below, so it's not all-interior.
|
|
123
|
-
// We just check it produces a valid range.
|
|
124
|
-
assert(idx >= 0 && idx <= 46, `sand boundary idx ${idx} out of range`);
|
|
125
|
-
console.log(` PASS sand boundary (0,6): blob47 idx=${idx}`);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// 4. Rock object — single tile, no autotile index.
|
|
129
|
-
{
|
|
130
|
-
// Rocks are placed in the grass band (y<6) where the hash hits.
|
|
131
|
-
// Find the first rock in the map.
|
|
132
|
-
let rockX = -1, rockY = -1;
|
|
133
|
-
outer: for (let y = 0; y < 6; y++) {
|
|
134
|
-
for (let x = 0; x < WORLD_COLS; x++) {
|
|
135
|
-
if (INITIAL_MAP.objects[y][x] === OBJECT.ROCK) { rockX = x; rockY = y; break outer; }
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
assert(rockX >= 0, 'should find at least one rock in the grass band');
|
|
139
|
-
if (rockX >= 0) {
|
|
140
|
-
const cfg = OBJECT_RENDER[OBJECT.ROCK];
|
|
141
|
-
assert(cfg.strategy === 'single', `rock: expected single strategy, got ${cfg.strategy}`);
|
|
142
|
-
const idx = objectIdx(rockX, rockY);
|
|
143
|
-
assert(idx === null, `rock (${rockX},${rockY}): single-tile → null, got ${idx}`);
|
|
144
|
-
console.log(` PASS rock (${rockX},${rockY}): single tile, no autotile index`);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// 5. Container panel — single tile, no autotile index.
|
|
149
|
-
{
|
|
150
|
-
const cx = 13, cy = 8;
|
|
151
|
-
const panel = INITIAL_MAP.objects[cy][cx];
|
|
152
|
-
assert(panel && panel.startsWith('container_panel_'), `(13,8) should be a container panel, got ${panel}`);
|
|
153
|
-
const idx = objectIdx(cx, cy);
|
|
154
|
-
assert(idx === null, `container panel: single-tile → null, got ${idx}`);
|
|
155
|
-
console.log(` PASS container panel (${cx},${cy}): single tile, no autotile index`);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// 6. Marble path — path16 autotile. Cells at (13, 10..13).
|
|
159
|
-
// The path runs N→S at x=13, y=10-13. Middle cells should connect north+south.
|
|
160
|
-
{
|
|
161
|
-
const x = 13, y = 11; // interior path cell connects N (y=10) and S (y=12)
|
|
162
|
-
const objId = INITIAL_MAP.objects[y][x];
|
|
163
|
-
assert(objId === OBJECT.PATH_MARBLE, `(13,11) should be PATH_MARBLE, got ${objId}`);
|
|
164
|
-
|
|
165
|
-
const idx = objectIdx(x, y);
|
|
166
|
-
assert(idx !== null, 'path interior: should have path16 index');
|
|
167
|
-
|
|
168
|
-
// Direct call
|
|
169
|
-
const neighbors = neighborsAt(INITIAL_MAP.objects, x, y);
|
|
170
|
-
const direct = selectPath16({
|
|
171
|
-
N: neighbors.N === objId ? 1 : 0,
|
|
172
|
-
W: neighbors.W === objId ? 1 : 0,
|
|
173
|
-
E: neighbors.E === objId ? 1 : 0,
|
|
174
|
-
S: neighbors.S === objId ? 1 : 0,
|
|
175
|
-
});
|
|
176
|
-
assert(idx === direct, `path interior: picker=${idx}, direct=${direct}`);
|
|
177
|
-
|
|
178
|
-
// N and S neighbors are also PATH_MARBLE at (13,10) and (13,12)
|
|
179
|
-
assert(neighbors.N === OBJECT.PATH_MARBLE, `path (13,11): N should be PATH_MARBLE`);
|
|
180
|
-
assert(neighbors.S === OBJECT.PATH_MARBLE, `path (13,11): S should be PATH_MARBLE`);
|
|
181
|
-
// Expected: N+S connected, W+E not → path16 = 0b1001 = 9
|
|
182
|
-
const expected = selectPath16({ N: 1, W: 0, E: 0, S: 1 });
|
|
183
|
-
assert(idx === expected, `path interior: expected N+S connected index ${expected}, got ${idx}`);
|
|
184
|
-
console.log(` PASS marble path interior (13,11): path16 idx=${idx} (N+S connected)`);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// 7. Marble path endpoint — top of the path (13,10): connects S only.
|
|
188
|
-
{
|
|
189
|
-
const x = 13, y = 10;
|
|
190
|
-
const objId = INITIAL_MAP.objects[y][x];
|
|
191
|
-
assert(objId === OBJECT.PATH_MARBLE, `(13,10) should be PATH_MARBLE, got ${objId}`);
|
|
192
|
-
|
|
193
|
-
const neighbors = neighborsAt(INITIAL_MAP.objects, x, y);
|
|
194
|
-
// (13,9) is the container door panel — NOT a path marble
|
|
195
|
-
assert(neighbors.N !== OBJECT.PATH_MARBLE, `path top (13,10): N should not be PATH_MARBLE`);
|
|
196
|
-
assert(neighbors.S === OBJECT.PATH_MARBLE, `path top (13,10): S should be PATH_MARBLE`);
|
|
197
|
-
|
|
198
|
-
const idx = objectIdx(x, y);
|
|
199
|
-
const expected = selectPath16({ N: 0, W: 0, E: 0, S: 1 });
|
|
200
|
-
assert(idx === expected, `path top (13,10): expected S-only idx ${expected}, got ${idx}`);
|
|
201
|
-
console.log(` PASS marble path top (13,10): path16 idx=${idx} (S only)`);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// 8. Ground layer: no object cell pollutes ground autotile neighbors.
|
|
205
|
-
// The rock at (rockX, rockY) must not affect the ground tile choice for
|
|
206
|
-
// any of its neighbors — picker sees the ground grid only.
|
|
207
|
-
{
|
|
208
|
-
let rockX = -1, rockY = -1;
|
|
209
|
-
outer2: for (let y = 0; y < 6; y++) {
|
|
210
|
-
for (let x = 0; x < WORLD_COLS; x++) {
|
|
211
|
-
if (INITIAL_MAP.objects[y][x] === OBJECT.ROCK) { rockX = x; rockY = y; break outer2; }
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
if (rockX >= 0 && rockX + 1 < WORLD_COLS) {
|
|
215
|
-
const rightX = rockX + 1;
|
|
216
|
-
const rightY = rockY;
|
|
217
|
-
const terrainId = INITIAL_MAP.ground[rightY][rightX];
|
|
218
|
-
const groundNeighbors = neighborsAt(INITIAL_MAP.ground, rightX, rightY);
|
|
219
|
-
// The W neighbor of (rightX, rightY) should be a terrain id (grass), not a rock object id.
|
|
220
|
-
assert(
|
|
221
|
-
groundNeighbors.W === TERRAIN.GRASS,
|
|
222
|
-
`ground picks should see ground only: W neighbor of (${rightX},${rightY}) should be GRASS, got ${groundNeighbors.W}`
|
|
223
|
-
);
|
|
224
|
-
console.log(` PASS ground isolation (${rightX},${rightY}): W ground neighbor is ${groundNeighbors.W}, not an object id`);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// ---------- summary ---------------------------------------------------------
|
|
229
|
-
|
|
230
|
-
console.log(`\n[snapshot_picker] ${passed} passed, ${failed} failed`);
|
|
231
|
-
if (failed > 0) {
|
|
232
|
-
process.exit(1);
|
|
233
|
-
}
|
package/tests/sprite_facing.mjs
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
// tests/sprite_facing.mjs
|
|
2
|
-
//
|
|
3
|
-
// Behavior-preserving refactor check (task 1968, ADR 0113): resolveFacing
|
|
4
|
-
// was extracted out of Player.js's _applyIdleTexture/_applyWalkTexture so
|
|
5
|
-
// the sprite Studio can resolve the right-facing mirror identically to the
|
|
6
|
-
// live game. Pure, Phaser-free — pins the exact mapping both callers rely on.
|
|
7
|
-
//
|
|
8
|
-
// Run: node tests/sprite_facing.mjs
|
|
9
|
-
|
|
10
|
-
import { strict as assert } from 'node:assert';
|
|
11
|
-
import { resolveFacing } from '../modules/game/public/game/player/spriteFacing.js';
|
|
12
|
-
|
|
13
|
-
let passed = 0;
|
|
14
|
-
function test(name, fn) {
|
|
15
|
-
try {
|
|
16
|
-
fn();
|
|
17
|
-
console.log(` ok ${name}`);
|
|
18
|
-
passed++;
|
|
19
|
-
} catch (e) {
|
|
20
|
-
console.error(` FAIL ${name}: ${e.message}`);
|
|
21
|
-
process.exitCode = 1;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
test('down/up/left pass through unmirrored', () => {
|
|
26
|
-
assert.deepEqual(resolveFacing('down'), { sourceDir: 'down', flipX: false });
|
|
27
|
-
assert.deepEqual(resolveFacing('up'), { sourceDir: 'up', flipX: false });
|
|
28
|
-
assert.deepEqual(resolveFacing('left'), { sourceDir: 'left', flipX: false });
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test('right resolves to the left-facing art, flipped', () => {
|
|
32
|
-
assert.deepEqual(resolveFacing('right'), { sourceDir: 'left', flipX: true });
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
console.log(`${passed} passed`);
|