orbit/js/research/ResearchState.js

116 lines
3.8 KiB
JavaScript

/**
* 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;
}
}