File size: 6,909 Bytes
b8a03fa | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 | import { clamp } from './deterministic.js';
import { emitWorldEvent, findRequired } from './state-utils.js';
import type { CommandResult, ConstructionProjectState, EntityId, UniverseState } from './types.js';
export class ConstructionSystem {
constructor(private state: UniverseState) {}
replaceState(state: UniverseState): void {
this.state = state;
}
start(projectId: EntityId): CommandResult {
const project = findRequired(this.state.construction.projects, projectId, 'Construction project');
if (project.ownerId !== this.state.player.id) return this.failure('Player does not own this construction project.');
if (!this.isOwnedTarget(project)) return this.failure('Construction target is not within an owned space.');
if (project.status !== 'planned' && project.status !== 'paused') return this.failure(`${project.name} cannot be started from '${project.status}'.`);
project.status = 'building';
const event = emitWorldEvent(this.state, 'construction-started', this.state.player.id, project.id, `Construction started: ${project.name}.`, { data: { locationId: project.locationId } });
return { ok: true, message: event.summary, eventIds: [event.id] };
}
deliver(projectId: EntityId, commodityId: EntityId, quantity: number): CommandResult {
const project = findRequired(this.state.construction.projects, projectId, 'Construction project');
if (project.status === 'completed' || project.status === 'cancelled') return this.failure(`${project.name} cannot accept resources.`);
const required = project.requiredResources[commodityId];
if (required === undefined) return this.failure(`${commodityId} is not required by ${project.name}.`);
if (!Number.isInteger(quantity) || quantity <= 0) return this.failure('Delivery quantity must be a positive integer.');
const stack = this.state.player.inventory.find((item) => item.itemId === commodityId);
if (!stack || stack.quantity < quantity) return this.failure(`Player lacks ${quantity} units of ${commodityId}.`);
const delivered = project.deliveredResources[commodityId] ?? 0;
const accepted = Math.min(quantity, required - delivered);
if (accepted <= 0) return this.failure(`${project.name} already has all required ${commodityId}.`);
stack.quantity -= accepted;
project.deliveredResources[commodityId] = delivered + accepted;
this.state.player.inventory = this.state.player.inventory.filter((item) => item.quantity > 0);
this.recalculate(project);
const event = emitWorldEvent(this.state, 'construction-resource-delivered', this.state.player.id, project.id, `Delivered ${accepted} ${commodityId} to ${project.name}.`, { data: { commodityId, quantity: accepted } });
return { ok: true, message: event.summary, eventIds: [event.id] };
}
work(projectId: EntityId, labor: number): CommandResult {
const project = findRequired(this.state.construction.projects, projectId, 'Construction project');
if (project.status !== 'building') return this.failure(`${project.name} is not actively building.`);
if (this.state.player.locationId !== project.locationId) return this.failure('Player must be at the construction location.');
if (!this.hasAllResources(project)) return this.failure(`${project.name} is waiting for resources.`);
if (!Number.isFinite(labor) || labor <= 0) return this.failure('Labor must be positive.');
project.laborCompleted = clamp(project.laborCompleted + labor, 0, project.laborRequired);
this.recalculate(project);
const eventIds: string[] = [];
if (project.progress >= 1) eventIds.push(this.complete(project).id);
return { ok: true, message: `${project.name} is ${Math.round(project.progress * 100)}% complete.`, eventIds };
}
tick(deltaMs: number): void {
const labor = deltaMs / 3_600_000;
for (const project of this.state.construction.projects.filter((candidate) => candidate.status === 'building')) {
const assignedWorkers = this.state.npcs.filter((npc) => npc.alive && npc.currentLocationId === project.locationId && (npc.occupation.includes('engineer') || npc.occupation.includes('technician'))).length;
if (assignedWorkers <= 0 || !this.hasAllResources(project)) continue;
project.laborCompleted = clamp(project.laborCompleted + labor * assignedWorkers, 0, project.laborRequired);
this.recalculate(project);
if (project.progress >= 1) this.complete(project);
}
}
private complete(project: ConstructionProjectState) {
project.status = 'completed';
project.progress = 1;
if (!this.state.construction.completedBlueprintIds.includes(project.blueprintId)) this.state.construction.completedBlueprintIds.push(project.blueprintId);
const owned = this.state.construction.ownedSpaces.find((space) => space.ownerId === project.ownerId && space.targetId === project.locationId);
if (owned && !owned.modificationIds.includes(project.id)) owned.modificationIds.push(project.id);
const room = this.state.rooms.find((candidate) => candidate.id === project.locationId);
if (room && project.resultingInteractionKind && !room.interactionPoints.some((interaction) => interaction.id === `interaction-${project.id}`)) {
room.interactionPoints.push({
id: `interaction-${project.id}`,
name: project.name,
kind: project.resultingInteractionKind,
purpose: `Player-constructed ${project.targetKind}.`,
enabled: true,
permissions: [],
state: { constructedTick: this.state.tick, uses: 0 },
});
}
return emitWorldEvent(this.state, 'construction-completed', project.ownerId, project.id, `Construction completed: ${project.name}.`, { data: { blueprintId: project.blueprintId, locationId: project.locationId } });
}
private recalculate(project: ConstructionProjectState): void {
const resourceEntries = Object.entries(project.requiredResources);
const resourceProgress = resourceEntries.length === 0 ? 1 : resourceEntries.reduce((sum, [commodityId, required]) => sum + clamp((project.deliveredResources[commodityId] ?? 0) / Math.max(1, required), 0, 1), 0) / resourceEntries.length;
const laborProgress = clamp(project.laborCompleted / Math.max(1, project.laborRequired), 0, 1);
project.progress = resourceProgress * 0.4 + laborProgress * 0.6;
}
private hasAllResources(project: ConstructionProjectState): boolean {
return Object.entries(project.requiredResources).every(([commodityId, required]) => (project.deliveredResources[commodityId] ?? 0) >= required);
}
private isOwnedTarget(project: ConstructionProjectState): boolean {
return this.state.construction.ownedSpaces.some((space) => space.ownerId === project.ownerId && (space.targetId === project.locationId || space.targetId === project.targetId));
}
private failure(message: string): CommandResult {
return { ok: false, message, eventIds: [] };
}
}
|