feat: player movement, trading, lane hazards, win/lose
This commit is contained in:
+153
-2
@@ -1,6 +1,157 @@
|
|||||||
export function onPlayerArrive() {}
|
import { INSYS_LEG_DAYS, WIN_CREDITS, GOODS } from './constants.js';
|
||||||
|
import { advancePlanet, priceOf, applyTrade, marketView } from './economy.js';
|
||||||
|
import { otherEnd } from './galaxy.js';
|
||||||
|
import { rollFor } from './rng.js';
|
||||||
|
import { pushLog } from './world.js';
|
||||||
|
|
||||||
export function playerSystem(world) {
|
export function playerSystem(world) {
|
||||||
const loc = world.player.loc;
|
const loc = world.player.loc;
|
||||||
if (loc.kind === 'docked') return world.galaxy.planets[loc.planetId].systemId;
|
if (loc.kind === 'docked') return world.galaxy.planets[loc.planetId].systemId;
|
||||||
return loc.systemId ?? null;
|
if (loc.kind === 'lane') {
|
||||||
|
const f = (world.t - loc.departT) / (loc.arriveT - loc.departT);
|
||||||
|
return f < 0.5 ? loc.fromSys : loc.toSys; // nearest end, for news purposes
|
||||||
|
}
|
||||||
|
return loc.systemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cargoUsed(player) {
|
||||||
|
return Object.values(player.cargo).reduce((a, b) => a + b, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromDescriptor(loc) {
|
||||||
|
if (loc.kind === 'docked') return { type: 'planet', id: loc.planetId };
|
||||||
|
if (loc.kind === 'at-gate') return { type: 'gate', laneId: loc.laneId };
|
||||||
|
return { type: 'planet', id: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// target: { planetId } to fly to a planet in the current system,
|
||||||
|
// or { laneId } to jump through a lane touching the current system.
|
||||||
|
export function goTo(world, target) {
|
||||||
|
const p = world.player;
|
||||||
|
if (!p.alive || p.won) return false;
|
||||||
|
if (p.loc.kind === 'insys-leg' || p.loc.kind === 'lane') return false;
|
||||||
|
const sys = playerSystem(world);
|
||||||
|
|
||||||
|
if (target.planetId != null) {
|
||||||
|
const planet = world.galaxy.planets[target.planetId];
|
||||||
|
if (!planet || planet.systemId !== sys) return false;
|
||||||
|
if (p.loc.kind === 'docked' && p.loc.planetId === target.planetId) return false;
|
||||||
|
p.loc = {
|
||||||
|
kind: 'insys-leg', systemId: sys,
|
||||||
|
from: fromDescriptor(p.loc), to: { type: 'planet', id: target.planetId },
|
||||||
|
departT: world.t, arriveT: world.t + INSYS_LEG_DAYS,
|
||||||
|
};
|
||||||
|
world.queue.push(p.loc.arriveT, 'player-arrive', {});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.laneId != null) {
|
||||||
|
const lane = world.galaxy.lanes[target.laneId];
|
||||||
|
if (!lane || (lane.a !== sys && lane.b !== sys)) return false;
|
||||||
|
p.intent = { laneId: target.laneId };
|
||||||
|
if (p.loc.kind === 'at-gate' && p.loc.laneId === target.laneId) {
|
||||||
|
startLane(world);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
p.loc = {
|
||||||
|
kind: 'insys-leg', systemId: sys,
|
||||||
|
from: fromDescriptor(p.loc), to: { type: 'gate', laneId: target.laneId },
|
||||||
|
departT: world.t, arriveT: world.t + INSYS_LEG_DAYS,
|
||||||
|
};
|
||||||
|
world.queue.push(p.loc.arriveT, 'player-arrive', {});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLane(world) {
|
||||||
|
const p = world.player;
|
||||||
|
const lane = world.galaxy.lanes[p.intent.laneId];
|
||||||
|
const fromSys = playerSystem(world);
|
||||||
|
p.loc = {
|
||||||
|
kind: 'lane', laneId: lane.id,
|
||||||
|
fromSys, toSys: otherEnd(lane, fromSys),
|
||||||
|
departT: world.t, arriveT: world.t + lane.days,
|
||||||
|
};
|
||||||
|
p.intent = null;
|
||||||
|
world.queue.push(p.loc.arriveT, 'player-arrive', {});
|
||||||
|
pushLog(world, `Jumping to ${world.galaxy.systems[p.loc.toSys].name} (${lane.days}d)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onPlayerArrive(world) {
|
||||||
|
const p = world.player;
|
||||||
|
const loc = p.loc;
|
||||||
|
if (loc.kind === 'insys-leg' && world.t >= loc.arriveT - 1e-9) {
|
||||||
|
if (loc.to.type === 'planet') {
|
||||||
|
p.loc = { kind: 'docked', planetId: loc.to.id };
|
||||||
|
recordDockIntel(world, loc.to.id);
|
||||||
|
pushLog(world, `Docked at ${world.galaxy.planets[loc.to.id].name}`);
|
||||||
|
} else {
|
||||||
|
p.loc = { kind: 'at-gate', systemId: loc.systemId, laneId: loc.to.laneId };
|
||||||
|
if (p.intent && p.intent.laneId === loc.to.laneId) startLane(world);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (loc.kind === 'lane' && world.t >= loc.arriveT - 1e-9) {
|
||||||
|
const lane = world.galaxy.lanes[loc.laneId];
|
||||||
|
const trip = ++p.tripCount;
|
||||||
|
const r = rollFor(`w:${world.seed}:player`, `trip${trip}`);
|
||||||
|
if (r < lane.hazard * 0.04) {
|
||||||
|
p.alive = false;
|
||||||
|
pushLog(world, 'Your ship was destroyed by pirates.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (r < lane.hazard * 0.04 + lane.hazard * 0.15 && cargoUsed(p) > 0) {
|
||||||
|
p.cargo = {};
|
||||||
|
pushLog(world, 'Pirates! You jettisoned your cargo and escaped.');
|
||||||
|
}
|
||||||
|
p.loc = { kind: 'at-gate', systemId: loc.toSys, laneId: loc.laneId };
|
||||||
|
pushLog(world, `Arrived in ${world.galaxy.systems[loc.toSys].name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordDockIntel(world, planetId) {
|
||||||
|
const planet = world.galaxy.planets[planetId];
|
||||||
|
advancePlanet(planet, world.t);
|
||||||
|
const prices = Object.fromEntries(GOODS.map((g) => [g, priceOf(g, planet.stock[g])]));
|
||||||
|
world.player.priceBook[planetId] = { time: world.t, prices };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buyGood(world, good, qty) {
|
||||||
|
const p = world.player;
|
||||||
|
if (p.loc.kind !== 'docked' || !p.alive) return false;
|
||||||
|
const planet = world.galaxy.planets[p.loc.planetId];
|
||||||
|
advancePlanet(planet, world.t);
|
||||||
|
const price = priceOf(good, planet.stock[good]);
|
||||||
|
qty = Math.min(qty, Math.floor(planet.stock[good]), Math.floor(p.credits / price), p.capacity - cargoUsed(p));
|
||||||
|
if (qty < 1) return false;
|
||||||
|
applyTrade(planet, world.t, good, -qty);
|
||||||
|
p.credits = Math.round((p.credits - price * qty) * 100) / 100;
|
||||||
|
p.cargo[good] = (p.cargo[good] || 0) + qty;
|
||||||
|
recordDockIntel(world, planet.id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sellGood(world, good, qty) {
|
||||||
|
const p = world.player;
|
||||||
|
if (p.loc.kind !== 'docked' || !p.alive) return false;
|
||||||
|
qty = Math.min(qty, p.cargo[good] || 0);
|
||||||
|
if (qty < 1) return false;
|
||||||
|
const planet = world.galaxy.planets[p.loc.planetId];
|
||||||
|
const price = applyTrade(planet, world.t, good, qty);
|
||||||
|
p.credits = Math.round((p.credits + price * qty) * 100) / 100;
|
||||||
|
p.cargo[good] -= qty;
|
||||||
|
if (p.cargo[good] === 0) delete p.cargo[good];
|
||||||
|
recordDockIntel(world, planet.id);
|
||||||
|
if (p.credits >= WIN_CREDITS && !p.won) {
|
||||||
|
p.won = true;
|
||||||
|
pushLog(world, `You amassed ${p.credits} credits. Victory!`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playerMarket(world) {
|
||||||
|
const p = world.player;
|
||||||
|
if (p.loc.kind !== 'docked') return null;
|
||||||
|
return marketView(world.galaxy.planets[p.loc.planetId], world.t);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createWorld, advance } from '../src/sim/world.js';
|
||||||
|
import { goTo, buyGood, sellGood, playerSystem, cargoUsed } from '../src/sim/player.js';
|
||||||
|
import { INSYS_LEG_DAYS } from '../src/sim/constants.js';
|
||||||
|
|
||||||
|
function freshWorld() {
|
||||||
|
return createWorld('player-test', { systemCount: 10, npcCount: 5 });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('player', () => {
|
||||||
|
it('buys and sells at the docked planet, conserving value through stock', () => {
|
||||||
|
const w = freshWorld();
|
||||||
|
const planet = w.galaxy.planets[w.player.loc.planetId];
|
||||||
|
const before = w.player.credits;
|
||||||
|
const stockBefore = Math.floor(planet.stock.food);
|
||||||
|
expect(buyGood(w, 'food', 5)).toBe(true);
|
||||||
|
expect(w.player.cargo.food).toBe(5);
|
||||||
|
expect(w.player.credits).toBeLessThan(before);
|
||||||
|
expect(Math.floor(planet.stock.food)).toBe(stockBefore - 5);
|
||||||
|
expect(sellGood(w, 'food', 5)).toBe(true);
|
||||||
|
expect(w.player.cargo.food ?? 0).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects buys beyond capacity or credits', () => {
|
||||||
|
const w = freshWorld();
|
||||||
|
expect(buyGood(w, 'food', 9999)).toBe(true); // clamps to capacity/credits
|
||||||
|
expect(cargoUsed(w.player)).toBeLessThanOrEqual(w.player.capacity);
|
||||||
|
const w2 = freshWorld();
|
||||||
|
w2.player.credits = 0;
|
||||||
|
expect(buyGood(w2, 'food', 1)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('travels to another planet in-system after INSYS_LEG_DAYS', () => {
|
||||||
|
const w = freshWorld();
|
||||||
|
const sys = playerSystem(w);
|
||||||
|
const other = w.galaxy.planets.find((p) => p.systemId === sys && p.id !== w.player.loc.planetId);
|
||||||
|
if (!other) return; // single-planet start system on this seed: nothing to assert
|
||||||
|
expect(goTo(w, { planetId: other.id })).toBe(true);
|
||||||
|
expect(w.player.loc.kind).toBe('insys-leg');
|
||||||
|
advance(w, w.t + INSYS_LEG_DAYS + 0.01);
|
||||||
|
expect(w.player.loc.kind).toBe('docked');
|
||||||
|
expect(w.player.loc.planetId).toBe(other.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jumps to a neighboring system via gate and lane', () => {
|
||||||
|
const w = freshWorld();
|
||||||
|
const sys = playerSystem(w);
|
||||||
|
const laneId = w.galaxy.systems[sys].lanes[0];
|
||||||
|
const lane = w.galaxy.lanes[laneId];
|
||||||
|
const destSys = lane.a === sys ? lane.b : lane.a;
|
||||||
|
expect(goTo(w, { laneId })).toBe(true);
|
||||||
|
advance(w, w.t + INSYS_LEG_DAYS + lane.days * 1.01 + 0.01);
|
||||||
|
expect(playerSystem(w)).toBe(destSys);
|
||||||
|
expect(w.player.loc.kind).toBe('at-gate');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses movement while already moving, and trades while undocked', () => {
|
||||||
|
const w = freshWorld();
|
||||||
|
const sys = playerSystem(w);
|
||||||
|
const laneId = w.galaxy.systems[sys].lanes[0];
|
||||||
|
expect(goTo(w, { laneId })).toBe(true);
|
||||||
|
expect(goTo(w, { laneId })).toBe(false);
|
||||||
|
expect(buyGood(w, 'food', 1)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('docking refreshes the player price book', () => {
|
||||||
|
const w = freshWorld();
|
||||||
|
const sys = playerSystem(w);
|
||||||
|
const other = w.galaxy.planets.find((p) => p.systemId === sys && p.id !== w.player.loc.planetId);
|
||||||
|
if (!other) return;
|
||||||
|
goTo(w, { planetId: other.id });
|
||||||
|
advance(w, w.t + INSYS_LEG_DAYS + 0.01);
|
||||||
|
expect(w.player.priceBook[other.id].time).toBeCloseTo(w.t, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hazard rolls on player lanes are deterministic per world seed', () => {
|
||||||
|
const run = () => {
|
||||||
|
const w = freshWorld();
|
||||||
|
// brute-force the player across the most dangerous lane repeatedly
|
||||||
|
const sys = playerSystem(w);
|
||||||
|
const laneId = w.galaxy.systems[sys].lanes[0];
|
||||||
|
for (let i = 0; i < 5 && w.player.alive; i++) {
|
||||||
|
const cur = playerSystem(w);
|
||||||
|
const lane = w.galaxy.lanes[laneId];
|
||||||
|
if (lane.a !== cur && lane.b !== cur) break;
|
||||||
|
goTo(w, { laneId });
|
||||||
|
advance(w, w.t + INSYS_LEG_DAYS + lane.days * 1.01 + 0.05);
|
||||||
|
}
|
||||||
|
return [w.player.alive, w.player.tripCount, Object.entries(w.player.cargo)];
|
||||||
|
};
|
||||||
|
expect(JSON.stringify(run())).toBe(JSON.stringify(run()));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user