// Headless test for the mining ore logic (js/mining/Mining.js): // extraction (1 px/sec of rock → 1 mineral, capped at the hold), // the break threshold (1/3 of the ORIGINAL size lost), // halving (> 64 px) and shattering (≤ 64 px, pieces sucked to the hull), // the full-hold stop. // Run: node --import ./dev/phaser-loader.mjs dev/mining.test.mjs import { readdirSync, readFileSync } from 'node:fs'; import assert from 'node:assert/strict'; const { config } = await import('../js/config/Config.js'); const data = {}; for (const f of readdirSync(new URL('../data', import.meta.url))) { if (f.endsWith('.json')) data[f.slice(0, -5)] = JSON.parse(readFileSync(new URL(`../data/${f}`, import.meta.url), 'utf8')); } config.init(data); const { GameObject } = await import('./phaser-stub.mjs').then((m) => ({ GameObject: m.default.GameObjects.Container })); const { Ship } = await import('../js/entities/Ship.js'); const { AsteroidCluster } = await import('../js/entities/AsteroidCluster.js'); const { Mining } = await import('../js/mining/Mining.js'); function makeImage(x, y, key) { const g = new GameObject(null, x, y); g.key = key; return g; } function makeScene() { return { time: { now: 0, delayedCall: () => ({ destroy() {} }) }, add: { existing: (o) => o, image: (x, y, key, frame) => makeImage(x, y, key), graphics: () => ({ clear() {}, destroy() {} }), circle: (x, y, r, fill, fa) => makeImage(x, y, 'circle'), text: (x, y, s, st) => makeImage(x, y, 'text'), }, make: { graphics: () => ({ clear() {}, destroy() {} }) }, tweens: { add: (o) => { if (o && typeof o.onComplete === 'function') o.onComplete(); return { destroy() {} }; }, killTweensOf: () => {}, }, textures: { exists: () => true }, physics: { add: { existing: (o) => { o.body = { x: 0, y: 0, velocity: { x: 0, y: 0, set() {}, length: () => 0, scale() {} }, acceleration: { set() {}, x: 0, y: 0 }, }; return o; }, }, }, ship: null, }; } function fakeBeam() { return { state: 'steady', outRunning: false, finished: false, update() {}, beginOut() { this.state = 'out'; this.outRunning = true; }, destroy() { this.destroyed = true; }, }; } /** A cluster with ONE rock of `size` px at (1000, 0), ship parked at (300, 0). */ function rig(size) { const scene = makeScene(); const ship = new Ship(scene, 300, 0); scene.ship = ship; const cluster = new AsteroidCluster(scene, { id: `c-${size}`, name: 'Test Field', x: 1000, y: 0, bound: size / 2 + 10, tint: null, groupSpin: 0.1, groupPhase: 0, debrisPhase: 0, debrisSpin: 0, debris: [], asteroids: [{ frame: 2, x: 0, y: 0, size, spin: 0.05, phase: 0.3 }], }, {}); const member = cluster.members[0]; const events = []; const phases = []; const ores = []; // (gained, hold) — the LIVE hold feed (the HUD seam) const depleted = []; // the clusters whose last rock was consumed const mining = new Mining(scene, { onPhase: (p) => { phases.push(p); if (p === 'stopped') ship.setState('normal', 'mining-ended'); // the scene's share }, onEvent: (n, d) => events.push([n, d]), onOre: (gained, hold) => ores.push([gained, hold]), onDepleted: (c) => depleted.push(c), }); ship.onStateChange = (next, prev) => { if (prev === 'mining' && next !== 'mining') mining.stop(); }; const pump = (secs) => { // Steps at the 64 ms extraction cap so scene time and ore time agree // (Mining caps delta at 64 ms — a backgrounded-tab fast-forward guard). const steps = Math.ceil((secs * 1000) / 64); for (let i = 0; i < steps; i++) { scene.time.now += 64; mining.update(scene.time.now, 64); } }; const start = () => { mining.begin(cluster, member); ship.setState('mining', 'mining'); // the scene's 'extending' share mining.state = 'mining'; // fast-forward the arm reach mining.beam = fakeBeam(); }; return { scene, ship, cluster, member, events, phases, ores, depleted, mining, pump, start }; } // --------------------------------------------------------------------------- // Scenario A — the 128 px rock: mines to 85, halves to 42, mines to 28, // shatters into 4 × 7 px pieces that ride home (43 + 14 + 28 = 85 minerals). // --------------------------------------------------------------------------- { const r = rig(128); r.start(); assert.equal(r.mining.rock.original, 128); assert.equal(r.mining.rock.size, 128); r.pump(43); // 43 s → 43 px off → 85 left (the 1/3-lost threshold for 128) assert.equal(r.member.radius * 2, 42, 'latched rock halved to 42'); assert.equal(r.cluster.members.length, 2, 'sibling rock added'); assert.equal(r.ship.minerals, 43, 'minerals mined before the halving'); assert.equal(r.ores.length, 43, 'LIVE: one ore seam per extracted px (the HUD ticks every second)'); assert.deepEqual(r.ores[0], [1, 1], '… first landing: +1, hold at 1'); assert.deepEqual(r.ores[42], [1, 43], '… last: +1, hold at 43'); assert.deepEqual(r.events.at(-1), ['split', { kind: 'divide', pieces: 2, pieceSize: 42, sibling: r.cluster.members[1] }]); assert.equal(r.mining.rock.original, 42, 'fresh ledger: original reset'); assert.equal(r.mining.beam.destroyed ?? false, false, 'beam survives the halving (still latched)'); assert.equal(r.mining.state, 'mining'); const memberImg = r.member.img; const beam = r.mining.beam; r.pump(14); // 14 s → 14 px off → 28 left (the 1/3-lost threshold for 42) assert.equal(r.cluster.members.length, 1, 'mined rock consumed at the shatter'); assert.equal(memberImg.destroyed, true, 'the rock image is gone'); assert.equal(beam.destroyed, true, 'beam cut — its rock is gone'); assert.equal(r.mining.state, 'idle'); assert.equal(r.ship.state, 'normal', 'the ship is free again'); assert.deepEqual(r.events.at(-1), ['split', { kind: 'absorb', pieces: 4, pieceSize: 7 }]); assert.equal(r.depleted.length, 0, 'a sibling rock remains → the field is NOT depleted'); r.pump(3); // the four 7 px pieces (~700 px out) ride the 460 px/s beam home assert.equal(r.ship.minerals, 43 + 14 + 28, 'shattered pieces landed as minerals (capped hold)'); assert.equal(r.ores.length, 43 + 14 + 4, 'LIVE: the landings tick the HUD piece by piece'); assert.deepEqual(r.ores.at(-1), [7, 85], '… last fragment: +7, hold at 85'); assert.deepEqual(r.events.at(-1), ['absorbed', { gained: 28, pieces: 4 }]); assert.equal(r.events.filter(([n]) => n === 'split').length, 2, 'one crack per break (halving + shatter)'); assert.equal(r.depleted.length, 0, 'still one rock left → no depletion'); console.log('A: 128 px rock — halve → shatter → suck in: OK'); } // --------------------------------------------------------------------------- // Scenario B — the 64 px rock: below the small threshold at its break point // (42 ≤ 64) → straight to the shatter: 22 mined + 4 × 10 px = 62 minerals. // --------------------------------------------------------------------------- { const r = rig(64); r.start(); r.pump(22); // 22 s → 42 left → shatters (42 ≤ 64) assert.equal(r.ship.minerals, 22); assert.deepEqual(r.events.at(-1), ['split', { kind: 'absorb', pieces: 4, pieceSize: 10 }]); assert.equal(r.mining.state, 'idle'); r.pump(3); assert.equal(r.ship.minerals, 22 + 40, '4 × 10 px pieces landed'); assert.equal(r.ores.length, 22 + 4, 'LIVE: 22 ticks + 4 landings'); assert.deepEqual(r.ores.at(-1), [10, 62], '… last fragment: +10, hold at 62'); assert.deepEqual(r.events.at(-1), ['absorbed', { gained: 40, pieces: 4 }]); assert.equal(r.events.filter(([n]) => n === 'split').length, 1, 'one crack for the shatter'); console.log('B: 64 px rock — direct shatter: OK'); } // --------------------------------------------------------------------------- // Scenario C — full hold: mining stops (the rock is not ground into waste), // the hold stays at capacity, the rock is untouched. // --------------------------------------------------------------------------- { const r = rig(64); r.ship.minerals = r.ship.stats.mineralStorage; // full hold r.start(); r.pump(2); assert.equal(r.ship.minerals, r.ship.stats.mineralStorage, 'hold capped'); assert.equal(r.member.radius * 2, 64, 'rock untouched — nothing could be loaded'); assert.equal(r.mining.state, 'retracting', 'the run ended (beam retracts)'); assert.equal(r.ores.length, 0, 'LIVE: nothing loaded → no ore seam fired'); assert.equal(r.events.at(-1)[0], 'storageFull', 'the scene is told the hold is full'); console.log('C: full hold — run ends, rock preserved: OK'); } // --------------------------------------------------------------------------- // Scenario D — the hold's load/restore API (the save/load seam): // setMinerals clamps to the capacity, addMinerals never overfills. // --------------------------------------------------------------------------- { const r = rig(64); const cap = r.ship.stats.mineralStorage; r.ship.setMinerals(37); assert.equal(r.ship.minerals, 37); r.ship.setMinerals(99999); // an overfull record can't break the hold assert.equal(r.ship.minerals, cap, 'clamped to the capacity'); r.ship.setMinerals(-5); // a corrupt record can't go negative assert.equal(r.ship.minerals, 0, 'clamped at zero'); r.ship.setMinerals(cap - 10); assert.equal(r.ship.addMinerals(50), 10, 'addMinerals still only fills the room left'); assert.equal(r.ship.minerals, cap); console.log('D: hold restore API — clamped at both ends: OK'); } // --------------------------------------------------------------------------- // Scenario E — depletion: the cluster's LAST rock is consumed → onDepleted // fires exactly once (with the cluster), at the shatter that empties it. // A cluster with rocks left never fires (scenarios A/B assert that). // --------------------------------------------------------------------------- { const r = rig(64); r.start(); assert.equal(r.depleted.length, 0, 'nothing mined yet → no depletion'); r.pump(22); // 22 s → 42 left → shatters; the lone rock was the last one assert.equal(r.cluster.members.length, 0, 'the field is empty'); assert.deepEqual(r.depleted, [r.cluster], 'onDepleted fired once, with the cluster'); assert.equal(r.mining.state, 'idle'); r.pump(3); // the fragments ride home AFTER the field went — the scene assert.equal(r.ship.minerals, 22 + 40, 'suck-in still lands after depletion'); console.log('E: last rock consumed — onDepleted fires once: OK'); } console.log('\nAll mining ore scenarios passed.');