orbit/dev/mining.test.mjs

246 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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.');