snapkitty
hardware
erlang
grisp-shadow-fleet / universe /ShipSystem.ts
SNAPKITTYWEST's picture
push from SNAPKITTYWEST/grisp-shadow-fleet
b8a03fa verified
Raw History Blame Contribute Delete
18.5 kB
import { addVector, clamp, clampMagnitude, distance, magnitude, scaleVector, vector } from './deterministic.js';
import { emitWorldEvent, findRequired } from './state-utils.js';
import type { CommandResult, EntityId, ShipControlInput, ShipState, UniverseState, Vector3 } from './types.js';
const EMPTY_CONTROLS: ShipControlInput = { thrust: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 0 }, brake: false, boost: false };
export class ShipSystem {
private readonly controls = new Map<EntityId, ShipControlInput>();
constructor(private state: UniverseState) {}
replaceState(state: UniverseState): void {
this.state = state;
this.controls.clear();
}
board(shipId: EntityId): CommandResult {
const ship = findRequired(this.state.ships, shipId, 'Ship');
const player = this.state.player;
if (player.boardedShipId) return this.failure('Player is already aboard a ship.');
const accessible = (ship.flightMode === 'docked' && ['room-hangar', 'room-docking'].includes(player.locationId))
|| (ship.flightMode === 'landed' && player.locationId === ship.landedRegionId)
|| (player.traversalMode === 'eva' && distance(player.transform.position, ship.transform.position) <= 12);
if (!accessible) return this.failure(`${ship.name} is not within boarding access.`);
player.boardedShipId = ship.id;
player.locationId = ship.id;
player.layer = 'sovereign-interior';
player.traversalMode = 'ship-boarded';
player.grounded = true;
player.transform.position = { ...ship.transform.position };
if (!ship.passengerIds.includes(player.id)) ship.passengerIds.push(player.id);
const event = emitWorldEvent(this.state, 'ship-boarded', player.id, ship.id, `Player boarded ${ship.name}.`);
return { ok: true, message: event.summary, eventIds: [event.id] };
}
start(shipId: EntityId): CommandResult {
const ship = findRequired(this.state.ships, shipId, 'Ship');
if (this.state.player.boardedShipId !== ship.id) return this.failure('Board the ship before startup.');
if (ship.startupStage === 'ready') return { ok: true, message: `${ship.name} is already ready.`, eventIds: [] };
if (ship.fuel <= 0 || ship.subsystems.reactor.health <= 0.2) return this.failure(`${ship.name} cannot start: reactor or fuel unavailable.`);
ship.startupStage = 'powering';
ship.startupElapsedMs = 0;
ship.subsystems.reactor.online = true;
ship.subsystems.reactor.power = 0.2;
const event = emitWorldEvent(this.state, 'ship-startup-began', this.state.player.id, ship.id, `${ship.name} startup sequence began.`, { persistent: false });
return { ok: true, message: event.summary, eventIds: [event.id] };
}
takeControl(shipId: EntityId): CommandResult {
const ship = findRequired(this.state.ships, shipId, 'Ship');
if (this.state.player.boardedShipId !== ship.id) return this.failure('Player must be aboard the ship.');
if (ship.startupStage !== 'ready') return this.failure('Ship startup must complete before taking control.');
ship.pilotId = this.state.player.id;
this.state.player.pilotedShipId = ship.id;
this.state.player.traversalMode = 'piloting';
const event = emitWorldEvent(this.state, 'ship-control-assumed', this.state.player.id, ship.id, `Player assumed control of ${ship.name}.`);
return { ok: true, message: event.summary, eventIds: [event.id] };
}
setControls(shipId: EntityId, input: ShipControlInput): CommandResult {
const ship = findRequired(this.state.ships, shipId, 'Ship');
if (ship.pilotId !== this.state.player.id || this.state.player.pilotedShipId !== ship.id) return this.failure('Player is not piloting this ship.');
if (ship.flightMode !== 'free-flight' && ship.flightMode !== 'launching') return this.failure('Flight controls require a free-flight ship.');
this.controls.set(ship.id, {
thrust: clampMagnitude(input.thrust, 1), rotation: clampMagnitude(input.rotation, 1),
brake: input.brake, boost: input.boost,
});
return { ok: true, message: 'Ship control input queued.', eventIds: [] };
}
undock(shipId: EntityId): CommandResult {
const ship = findRequired(this.state.ships, shipId, 'Ship');
if (ship.pilotId !== this.state.player.id) return this.failure('Only the active pilot can undock.');
if (ship.startupStage !== 'ready') return this.failure('Ship is not flight ready.');
if (ship.flightMode !== 'docked' && ship.flightMode !== 'landed') return this.failure('Ship is not docked or landed.');
ship.flightMode = 'launching';
ship.dockedAtId = null;
ship.landedRegionId = null;
ship.transform.velocity = vector(0, 0, 4);
const event = emitWorldEvent(this.state, 'ship-undocked', this.state.player.id, ship.id, `${ship.name} released clamps and entered the launch corridor.`);
return { ok: true, message: event.summary, eventIds: [event.id] };
}
dock(shipId: EntityId, dockId: EntityId): CommandResult {
const ship = findRequired(this.state.ships, shipId, 'Ship');
if (ship.pilotId !== this.state.player.id) return this.failure('Only the active pilot can dock.');
if (ship.flightMode !== 'free-flight' && ship.flightMode !== 'docking' && ship.flightMode !== 'launching') return this.failure('Ship is not in a dockable flight mode.');
const dockPosition = this.dockPosition(dockId);
if (!dockPosition) return this.failure(`Dock '${dockId}' does not exist.`);
if (distance(ship.transform.position, dockPosition) > 250) return this.failure('Ship is outside docking capture range.');
if (magnitude(ship.transform.velocity) > 25) return this.failure('Ship relative velocity is too high for docking.');
ship.flightMode = 'docked';
ship.dockedAtId = dockId;
ship.landedRegionId = null;
ship.targetDockId = null;
ship.transform.position = { ...dockPosition };
ship.transform.velocity = vector();
const event = emitWorldEvent(this.state, 'ship-docked', this.state.player.id, ship.id, `${ship.name} docked at ${dockId}.`);
return { ok: true, message: event.summary, eventIds: [event.id] };
}
requestDocking(shipId: EntityId, dockId: EntityId): CommandResult {
const ship = findRequired(this.state.ships, shipId, 'Ship');
if (ship.flightMode !== 'free-flight') return this.failure('Docking request requires free flight.');
if (!this.dockPosition(dockId)) return this.failure(`Dock '${dockId}' does not exist.`);
ship.flightMode = 'docking';
ship.targetDockId = dockId;
const event = emitWorldEvent(this.state, 'docking-clearance-granted', 'agent-002', ship.id, `${ship.name} received docking guidance for ${dockId}.`, { persistent: false });
return { ok: true, message: event.summary, eventIds: [event.id] };
}
land(shipId: EntityId, regionId: EntityId): CommandResult {
const ship = findRequired(this.state.ships, shipId, 'Ship');
const region = findRequired(this.state.planetaryRegions, regionId, 'Planetary region');
if (ship.pilotId !== this.state.player.id) return this.failure('Only the active pilot can land.');
if (ship.flightMode !== 'free-flight') return this.failure('Ship must be in free flight to land.');
if (distance(ship.transform.position, region.center) > region.radiusM + 20_000) return this.failure('Ship has not approached the landing region.');
if (magnitude(ship.transform.velocity) > 160) return this.failure('Ship velocity is too high for landing approach.');
ship.flightMode = 'landed';
ship.landedRegionId = region.id;
ship.dockedAtId = null;
ship.transform.position = { ...region.center, y: region.center.y + 3 };
ship.transform.velocity = vector();
this.state.player.layer = 'planet-surface';
const event = emitWorldEvent(this.state, 'ship-landed', this.state.player.id, ship.id, `${ship.name} landed in ${region.name}.`, { data: { regionId } });
return { ok: true, message: event.summary, eventIds: [event.id] };
}
beginJump(shipId: EntityId, targetId: EntityId): CommandResult {
const ship = findRequired(this.state.ships, shipId, 'Ship');
const targetExists = this.state.celestialBodies.some((body) => body.id === targetId)
|| this.state.discoveries.some((discovery) => discovery.id === targetId)
|| this.state.settlements.some((settlement) => settlement.id === targetId)
|| this.state.jumpConnections.some((connection) => connection.id === targetId);
if (!targetExists) return this.failure(`Jump target '${targetId}' does not exist.`);
if (ship.pilotId !== this.state.player.id || ship.flightMode !== 'free-flight') return this.failure('Active piloting in free flight is required for jump travel.');
if (ship.fuel < 0.08 || ship.subsystems.engines.health < 0.5) return this.failure('Insufficient jump capability.');
const connection = this.state.jumpConnections.find((candidate) => candidate.id === targetId);
if (connection?.requiredDiscoveryId && !this.state.discoveries.find((discovery) => discovery.id === connection.requiredDiscoveryId)?.discovered) {
return this.failure(`${connection.name} has not been discovered and calibrated.`);
}
ship.flightMode = 'jumping';
ship.jumpTargetId = targetId;
ship.jumpElapsedMs = 0;
ship.fuel = clamp(ship.fuel - 0.08, 0, 1);
const event = emitWorldEvent(this.state, 'ship-jump-began', this.state.player.id, ship.id, `${ship.name} entered a continuous travel corridor toward ${targetId}.`);
return { ok: true, message: event.summary, eventIds: [event.id] };
}
exit(shipId: EntityId): CommandResult {
const ship = findRequired(this.state.ships, shipId, 'Ship');
const player = this.state.player;
if (player.boardedShipId !== ship.id) return this.failure('Player is not aboard this ship.');
if (ship.flightMode !== 'docked' && ship.flightMode !== 'landed' && (!player.suit.equipped || !player.suit.sealed)) {
return this.failure('A sealed suit is required to exit a ship in space.');
}
if (ship.pilotId === player.id) ship.pilotId = null;
player.pilotedShipId = null;
player.boardedShipId = null;
ship.passengerIds = ship.passengerIds.filter((id) => id !== player.id);
if (ship.flightMode === 'docked') {
player.locationId = ship.dockedAtId === 'dock-hangar-01' ? 'room-hangar' : 'room-docking';
player.layer = 'sovereign-interior';
player.traversalMode = 'walking';
player.grounded = true;
} else if (ship.flightMode === 'landed' && ship.landedRegionId) {
player.locationId = ship.landedRegionId;
player.layer = 'planet-surface';
player.traversalMode = 'walking';
player.grounded = true;
} else {
player.locationId = ship.id;
player.layer = 'orbital-space';
player.traversalMode = 'eva';
player.grounded = false;
}
player.transform.position = addVector(ship.transform.position, vector(0, 0, -6));
player.transform.velocity = { ...ship.transform.velocity };
const event = emitWorldEvent(this.state, 'ship-exited', player.id, ship.id, `Player exited ${ship.name}.`);
return { ok: true, message: event.summary, eventIds: [event.id] };
}
repair(shipId: EntityId, subsystem: keyof ShipState['subsystems'], amount: number): CommandResult {
const ship = findRequired(this.state.ships, shipId, 'Ship');
if (this.state.player.boardedShipId !== ship.id && distance(this.state.player.transform.position, ship.transform.position) > 10) return this.failure('Ship is outside repair reach.');
const tool = this.state.player.inventory.find((item) => item.tags.includes('repair'));
if (!tool) return this.failure('A repair tool is required.');
ship.subsystems[subsystem].health = clamp(ship.subsystems[subsystem].health + Math.max(0, amount), 0, 1);
const event = emitWorldEvent(this.state, 'ship-subsystem-repaired', this.state.player.id, ship.id, `${ship.name} ${subsystem} repaired.`, { data: { subsystem, amount } });
return { ok: true, message: event.summary, eventIds: [event.id] };
}
applyDamage(shipId: EntityId, amount: number, subsystem?: keyof ShipState['subsystems']): void {
const ship = findRequired(this.state.ships, shipId, 'Ship');
const residual = Math.max(0, amount - ship.shield);
ship.shield = clamp(ship.shield - amount, 0, 1);
ship.hullIntegrity = clamp(ship.hullIntegrity - residual, 0, 1);
if (subsystem) ship.subsystems[subsystem].health = clamp(ship.subsystems[subsystem].health - residual, 0, 1);
emitWorldEvent(this.state, 'ship-damaged', 'environment', ship.id, `${ship.name} sustained damage.`, { severity: amount, data: { amount, subsystem: subsystem ?? null } });
}
tick(deltaMs: number): void {
const deltaSeconds = deltaMs / 1_000;
for (const ship of this.state.ships) {
this.tickStartup(ship, deltaMs);
if (ship.flightMode === 'jumping') {
this.tickJump(ship, deltaMs);
continue;
}
if (ship.flightMode === 'launching' && ship.startupStage === 'ready') {
ship.transform.position = addVector(ship.transform.position, scaleVector(ship.transform.velocity, deltaSeconds));
if (distance(ship.transform.position, this.dockPosition('dock-hangar-01') ?? vector()) > 80) ship.flightMode = 'free-flight';
} else if (ship.flightMode === 'free-flight' || ship.flightMode === 'docking') {
this.tickFlight(ship, deltaSeconds);
}
if (ship.flightMode === 'docking' && ship.targetDockId) this.tickAutodock(ship, deltaSeconds);
if (this.state.player.boardedShipId === ship.id) {
this.state.player.transform.position = { ...ship.transform.position };
this.state.player.transform.velocity = { ...ship.transform.velocity };
}
}
}
private tickStartup(ship: ShipState, deltaMs: number): void {
if (ship.startupStage !== 'powering') return;
ship.startupElapsedMs += deltaMs;
const progress = clamp(ship.startupElapsedMs / 3_000, 0, 1);
ship.subsystems.reactor.power = progress;
if (progress < 0.25) return;
for (const [name, subsystem] of Object.entries(ship.subsystems)) {
if (name === 'reactor') continue;
subsystem.online = subsystem.health > 0.2;
subsystem.power = subsystem.online ? progress : 0;
}
if (progress >= 1) {
ship.startupStage = 'ready';
emitWorldEvent(this.state, 'ship-startup-completed', ship.id, null, `${ship.name} is flight ready.`, { persistent: false });
}
}
private tickFlight(ship: ShipState, deltaSeconds: number): void {
const input = this.controls.get(ship.id) ?? EMPTY_CONTROLS;
if (ship.startupStage === 'ready' && ship.subsystems.engines.online && ship.fuel > 0) {
const boost = input.boost ? 1.5 : 1;
const acceleration = ship.maxThrustN / ship.massKg * boost;
ship.transform.velocity = addVector(ship.transform.velocity, scaleVector(input.thrust, acceleration * deltaSeconds));
ship.transform.angularVelocity = scaleVector(input.rotation, ship.maneuverThrustN / ship.massKg * 0.08);
ship.transform.rotation.pitch += ship.transform.angularVelocity.x * deltaSeconds;
ship.transform.rotation.yaw += ship.transform.angularVelocity.y * deltaSeconds;
ship.transform.rotation.roll += ship.transform.angularVelocity.z * deltaSeconds;
const thrustAmount = magnitude(input.thrust);
ship.fuel = clamp(ship.fuel - thrustAmount * deltaSeconds * 0.00025 * boost, 0, 1);
}
if (input.brake && ship.subsystems.thrusters.online) ship.transform.velocity = scaleVector(ship.transform.velocity, Math.max(0, 1 - deltaSeconds * 0.7));
ship.transform.velocity = clampMagnitude(ship.transform.velocity, 12_000);
ship.transform.position = addVector(ship.transform.position, scaleVector(ship.transform.velocity, deltaSeconds));
ship.shield = clamp(ship.shield + deltaSeconds * 0.002 * ship.subsystems.shields.health, 0, 1);
}
private tickAutodock(ship: ShipState, deltaSeconds: number): void {
const target = ship.targetDockId ? this.dockPosition(ship.targetDockId) : null;
if (!target) return;
const offset = { x: target.x - ship.transform.position.x, y: target.y - ship.transform.position.y, z: target.z - ship.transform.position.z };
const range = magnitude(offset);
if (range <= 4 && magnitude(ship.transform.velocity) <= 25) {
this.dock(ship.id, ship.targetDockId as string);
return;
}
const desired = scaleVector(offset, Math.min(12, range * 0.25) / Math.max(range, 1));
ship.transform.velocity = addVector(scaleVector(ship.transform.velocity, Math.max(0, 1 - deltaSeconds * 1.4)), scaleVector(desired, deltaSeconds));
}
private tickJump(ship: ShipState, deltaMs: number): void {
ship.jumpElapsedMs += deltaMs;
const connection = ship.jumpTargetId ? this.state.jumpConnections.find((candidate) => candidate.id === ship.jumpTargetId) : undefined;
const travelTimeMs = connection?.travelTimeMs ?? 5_000;
if (ship.jumpElapsedMs < travelTimeMs || !ship.jumpTargetId) return;
const celestial = this.state.celestialBodies.find((body) => body.id === ship.jumpTargetId);
const discovery = this.state.discoveries.find((item) => item.id === ship.jumpTargetId);
const settlement = this.state.settlements.find((item) => item.id === ship.jumpTargetId);
const target = celestial?.position ?? discovery?.position ?? settlement?.position ?? connection?.exitPosition;
if (!target) {
ship.flightMode = 'free-flight';
ship.jumpTargetId = null;
return;
}
const approachRegion = celestial ? this.state.planetaryRegions.find((region) => region.planetId === celestial.id) : undefined;
ship.transform.position = approachRegion
? addVector(approachRegion.center, vector(0, 0, approachRegion.radiusM + 5_000))
: connection
? { ...target }
: addVector(target, vector(0, 0, (celestial?.radiusM ?? 1_000) + 15_000));
ship.transform.velocity = vector();
ship.flightMode = 'free-flight';
const targetId = ship.jumpTargetId;
ship.jumpTargetId = null;
ship.jumpElapsedMs = 0;
emitWorldEvent(this.state, 'ship-jump-completed', ship.id, targetId, `${ship.name} emerged near ${targetId}.`);
}
private dockPosition(dockId: EntityId): Vector3 | null {
if (dockId === 'dock-hangar-01') return vector(170, -18, -30);
if (dockId === 'dock-ring-01') return vector(260, 0, 0);
return null;
}
private failure(message: string): CommandResult {
return { ok: false, message, eventIds: [] };
}
}