diff --git a/src/sim/player.js b/src/sim/player.js index e044442..461f4f7 100644 --- a/src/sim/player.js +++ b/src/sim/player.js @@ -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) { const loc = world.player.loc; 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); } diff --git a/tests/player.test.js b/tests/player.test.js new file mode 100644 index 0000000..3073541 --- /dev/null +++ b/tests/player.test.js @@ -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())); + }); +});