246 lines
10 KiB
JavaScript
246 lines
10 KiB
JavaScript
// 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.');
|