orbit/js/build/BuildState.js

164 lines
5.8 KiB
JavaScript

/**
* BuildState — the player's build progress (pure data, no scene).
*
* The run's build records, held by the GameScene (js/scenes/GameScene.js)
* so they outlive the surface stay (SurfaceScene) and save with the run
* (js/save/SaveData.js) — the same split as ResearchState:
*
* - `built` — which builds are installed on which planets
* (Map<planetName, Set<buildId>>); one-off builds land here
* on completion and are never built twice on a planet;
* - `active` — the single in-progress build (data/builds.json →
* maxConcurrent: one at a time): { planet, build,
* startedAt, durationMs }.
*
* The build is ticked by whichever scene is awake — the SurfaceScene
* while on the surface (the GameScene sleeps then), the GameScene in
* space — both with the game-loop clock (game.loop.now), the one clock
* that keeps running across scene switches:
* progress(time) → { planet, build, fraction, remainingMs } | null
* tick(time) → [ { planet, build, durationMs }, … ] (marks built)
*
* `remainingMs` is captured at save time; on load restoreActive() rebuilds
* `startedAt` so the build finishes at the same wall time (the loop clock
* is global, so a build started on the surface finishes in space too).
*/
export class BuildState {
constructor() {
this.built = new Map(); // planetName → Set<buildId>
this.active = null; // { planet, build, startedAt, durationMs }
}
// ── built records ────────────────────────────────────────────────────────
markBuilt(planet, build) {
if (!planet || !build) return;
let set = this.built.get(planet);
if (!set) {
set = new Set();
this.built.set(planet, set);
}
set.add(build);
}
isBuilt(planet, build) {
const set = this.built.get(planet);
return !!(set && build && set.has(build));
}
/**
* SHIP-SCOPED builds (data/builds.json → targets: ["ship"], e.g. the
* mining arms/storage): the effect is on the ship, not the planet — so
* "already built" is true if it was installed on ANY planet. The
* console shows such a build BUILT wherever the ship lands (no
* re-buy), and the scene's beginBuild gate mirrors this check.
*/
isBuiltAnywhere(build) {
if (!build) return false;
for (const set of this.built.values()) if (set.has(build)) return true;
return false;
}
buildsOn(planet) {
return [...(this.built.get(planet) ?? [])];
}
// ── the single in-progress build ─────────────────────────────────────────
getActive() {
return this.active;
}
/**
* Claim the build slot. Fails when one is already running, the build is
* already installed on this planet, or the duration is invalid.
*/
start(planet, build, durationMs, now) {
if (this.active) return false;
if (this.isBuilt(planet, build)) return false;
if (typeof durationMs !== 'number' || !(durationMs > 0)) return false;
const t = Number.isFinite(now) ? now : 0;
this.active = { planet, build, startedAt: t, durationMs };
return true;
}
/**
* Progress readout (0…1) for the active build.
* @param {number} time ms on the ticking scene's clock (SurfaceScene)
*/
progress(time) {
if (!this.active) return null;
const { startedAt, durationMs } = this.active;
return {
planet: this.active.planet,
build: this.active.build,
fraction: Math.max(0, Math.min(1, (time - startedAt) / durationMs)),
remainingMs: Math.max(0, durationMs - (time - startedAt)),
};
}
/**
* Complete every build whose deadline has passed (marks it built on the
* planet) and return the completions — the caller applies the effects
* (the scene owns world state).
*/
tick(time) {
const out = [];
if (this.active && time - this.active.startedAt >= this.active.durationMs) {
const a = this.active;
this.active = null;
this.markBuilt(a.planet, a.build);
out.push({ planet: a.planet, build: a.build, durationMs: a.durationMs });
}
return out;
}
// ── save / restore ───────────────────────────────────────────────────────
/**
* @param {number} now ms — capture remainingMs for the active build
*/
toJSON(now) {
return {
built: Object.fromEntries([...this.built.entries()].map(([p, s]) => [p, [...s]])),
active: this.active ? {
planet: this.active.planet,
build: this.active.build,
startedAt: this.active.startedAt,
durationMs: this.active.durationMs,
remainingMs: this.progress(now)?.remainingMs ?? 0,
} : null,
};
}
fromJSON(json) {
const j = json ?? {};
for (const [p, ids] of Object.entries(j.built ?? {})) {
for (const b of ids ?? []) this.markBuilt(p, b);
}
this.active = null; // restoreActive() re-claims it with the live clock
return this;
}
/**
* Rebuild the in-progress build so it finishes at the same wall time.
* Skipped when the build is already installed on the planet (or when
* the save predates the build system).
*/
restoreActive(spec, now) {
if (!spec || !spec.planet || !spec.build) return;
if (this.isBuilt(spec.planet, spec.build)) return;
const remaining = spec.remainingMs;
if (typeof remaining !== 'number' || !(remaining > 0)) return;
const t = Number.isFinite(now) ? now : 0;
this.active = {
planet: spec.planet,
build: spec.build,
startedAt: t - (spec.durationMs - remaining),
durationMs: spec.durationMs,
};
}
reset() {
this.built.clear();
this.active = null;
}
}