diff --git a/src/sim/news.js b/src/sim/news.js new file mode 100644 index 0000000..569b72f --- /dev/null +++ b/src/sim/news.js @@ -0,0 +1,35 @@ +import { GOODS, NEWS_DAYS_PER_LANE, SNAPSHOT_KEEP } from './constants.js'; +import { advancePlanet, priceOf } from './economy.js'; + +// Daily price snapshots per planet. Knowledge from `hops` lanes away +// is the snapshot from hops * NEWS_DAYS_PER_LANE days ago. +export class NewsLedger { + constructor() { + this.history = new Map(); // planetId -> [{time, prices}] + } + + record(galaxy, t) { + for (const planet of galaxy.planets) { + advancePlanet(planet, t); + const prices = Object.fromEntries(GOODS.map((g) => [g, priceOf(g, planet.stock[g])])); + let arr = this.history.get(planet.id); + if (!arr) this.history.set(planet.id, (arr = [])); + arr.push({ time: t, prices }); + if (arr.length > SNAPSHOT_KEEP) arr.shift(); + } + } + + // Latest snapshot of planetId that has had time to travel `hops` lanes by time t. + // Clamped to t=0: the initial state is universally known. + knownPrices(planetId, hops, t) { + const arr = this.history.get(planetId); + if (!arr || arr.length === 0) return null; + const asOf = Math.max(0, t - hops * NEWS_DAYS_PER_LANE); + let best = null; + for (const snap of arr) { + if (snap.time <= asOf) best = snap; + else break; + } + return best ?? arr[0]; + } +} diff --git a/tests/news.test.js b/tests/news.test.js new file mode 100644 index 0000000..0d8724a --- /dev/null +++ b/tests/news.test.js @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { NewsLedger } from '../src/sim/news.js'; +import { generateGalaxy } from '../src/sim/galaxy.js'; +import { priceOf } from '../src/sim/economy.js'; + +describe('NewsLedger', () => { + it('records snapshots and serves them with lane delay', () => { + const galaxy = generateGalaxy('news-test', { systemCount: 8 }); + const news = new NewsLedger(); + news.record(galaxy, 0); + news.record(galaxy, 1); + news.record(galaxy, 2); + + const pid = galaxy.planets[0].id; + // 0 hops away at t=2: freshest snapshot (t=2) + expect(news.knownPrices(pid, 0, 2).time).toBe(2); + // 2 hops away at t=2: only the t=0 snapshot has reached us + expect(news.knownPrices(pid, 2, 2).time).toBe(0); + // far away early on: clamps to the universally-known t=0 snapshot + expect(news.knownPrices(pid, 5, 1).time).toBe(0); + }); + + it('snapshot prices match the live price curve at record time', () => { + const galaxy = generateGalaxy('news-test', { systemCount: 8 }); + const news = new NewsLedger(); + news.record(galaxy, 0); + const p = galaxy.planets[0]; + const snap = news.knownPrices(p.id, 0, 0); + expect(snap.prices.ore).toBeCloseTo(priceOf('ore', p.stock.ore), 5); + }); + + it('trims history beyond SNAPSHOT_KEEP entries', () => { + const galaxy = generateGalaxy('news-test', { systemCount: 8 }); + const news = new NewsLedger(); + for (let t = 0; t <= 50; t++) news.record(galaxy, t); + // a 45-day-old snapshot is gone; we get the oldest retained one instead + const snap = news.knownPrices(galaxy.planets[0].id, 45, 50); + expect(snap.time).toBeGreaterThanOrEqual(50 - 30); + }); +});