/** * ResearchState — the player's research progress: the set of researched * (unlocked) techs + the one in-flight project (maxConcurrent: 1 is baked * into the shape — there is exactly one `active` slot). * * Pure: no Phaser, no config, no DOM — node-testable. Durations are passed * in as milliseconds by the caller (which reads the node data + research. * timeUnit). Save format (record.research): * { unlocked: ["exploration::tether_l1", ...], * active: { category, id, durationMs, remainingMs } | null } * `remainingMs` is captured at save time; on load restoreActive() rebuilds * the startedAt clock from a fresh `now`. */ const key = (category, id) => `${category}::${id}`; export class ResearchState { constructor() { this.unlocked = new Set(); // "category::id" this.active = null; // { category, id, startedAt, durationMs } } unlock(category, id) { this.unlocked.add(key(category, id)); } isUnlocked(category, id) { return this.unlocked.has(key(category, id)); } /** The in-flight project, or null. */ getActive() { return this.active; } /** * Start a project. Fails (returns false) if one is already running or the * node is already researched. durationMs may be 0 (grants instantly — * completes on the next tick). `now` = the scene clock (ms). */ start(category, id, durationMs, now) { if (this.active) return false; if (this.unlocked.has(key(category, id))) return false; this.active = { category, id, startedAt: now, durationMs: Math.max(0, Number(durationMs) || 0) }; return true; } /** { category, id, fraction: 0..1 } for the in-flight project, or null. */ progress(time) { const a = this.active; if (!a) return null; const f = a.durationMs > 0 ? (time - a.startedAt) / a.durationMs : 1; return { category: a.category, id: a.id, fraction: Math.min(1, Math.max(0, f)) }; } /** * Advance the clock. Returns the array of completed projects (at most one — * one at a time) and unlocks them. The scene applies their effects + toasts. */ tick(time) { if (!this.active) return []; if (this.progress(time).fraction >= 1) { const done = { category: this.active.category, id: this.active.id }; this.unlocked.add(key(done.category, done.id)); this.active = null; return [done]; } return []; } /** * Restore an in-flight project from a save: `spec` = * { category, id, durationMs, remainingMs }, remaining as of the save. * Rebuilds startedAt so the project finishes at the same wall time. */ restoreActive(spec, now) { if (!spec || typeof spec.category !== 'string' || typeof spec.id !== 'string') return; const durationMs = Math.max(0, Number(spec.durationMs) || 0); const remainingMs = Math.min(durationMs, Math.max(0, Number(spec.remainingMs) || 0)); this.active = { category: spec.category, id: spec.id, startedAt: (now || 0) - (durationMs - remainingMs), durationMs, }; } /** Snapshot for SaveManager (record.research). `now` = scene clock (ms). */ toJSON(now) { return { unlocked: [...this.unlocked], active: this.active ? { category: this.active.category, id: this.active.id, durationMs: this.active.durationMs, remainingMs: Math.max(0, this.active.durationMs - ((now || 0) - this.active.startedAt)), } : null, }; } /** Rebuild the unlocked set from a save (active is restored via restoreActive — it needs a fresh clock). */ static fromJSON(json) { const s = new ResearchState(); if (json && Array.isArray(json.unlocked)) { for (const k of json.unlocked) if (typeof k === 'string') s.unlocked.add(k); } return s; } reset() { this.unlocked.clear(); this.active = null; } }