116 lines
3.8 KiB
JavaScript
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;
|
|
}
|
|
}
|