import { pubsub } from "@thorium/.server/init/pubsub"; import { t } from "@thorium/.server/init/t"; import { spawnTorpedo } from "@thorium/.server/spawners/torpedo"; import { getCurrentTarget } from "@thorium/.server/systems/PhasersSystem"; import { getInventoryTemplates } from "@thorium/utils/.server/getInventoryTemplates"; import { cancelLoopingSound, playShipSound } from "@thorium/utils/.server/playRangedSound"; import { checkSystemStability } from "@thorium/utils/.server/ship/checkSystemStability"; import { getShipSystem, getShipSystems } from "@thorium/utils/.server/ship/getShipSystem"; import type { ECS, Entity } from "@thorium/utils/ecs"; import { randomFromList } from "@thorium/utils/operations/randomFromList"; import z from "zod"; import { calculateCargoUsed, getRoomBySystem } from "../CargoControl/data.server"; export const targeting = t.router({ targetedContact: t.procedure .input(z.object({ shipId: z.number() })) .filter((publish: { shipId: number }, { input }) => { if (publish && publish.shipId !== input.shipId) return false; return true; }) .autoPublish( ["isTargeting"], (entity) => entity.components.isShipSystem && { shipId: entity.components.isShipSystem.shipId, }, ) .request(({ ctx, input }) => { const system = getShipSystem(ctx.ecs, { systemType: "targeting", shipId: input.shipId, }); const target = system.components.isTargeting?.target; if (typeof target !== "number") return null; const targetEntity = ctx.flight?.ecs.getEntityById(target); return targetEntity ? { id: target, name: targetEntity.components.identity?.name, description: targetEntity.components.identity?.description, } : null; }), setTarget: t.procedure .input(z.object({ shipId: z.number(), target: z.union([z.number(), z.null()]) })) .output(z.object({ shipId: z.number(), targetId: z.number().nullable() })) .meta({ event: true }) .send(({ input, ctx }) => { const { shipId } = input; const targeting = getShipSystem(ctx.ecs, { systemType: "targeting", shipId, }); if (!targeting.components.isTargeting) throw new Error("System is not targeting"); targeting.updateComponent("isTargeting", { target: input.target }); pubsub.publish.targeting.targetedContact({ shipId, }); return { shipId, targetId: input.target }; }), torpedoes: t.router({ list: t.procedure .input(z.object({ shipId: z.number() })) .filter((publish: { shipId: number }, { input }) => { if (publish && publish.shipId !== input.shipId) return false; return true; }) .autoPublish([], () => null) .request(({ ctx, input }) => { return getShipTorpedos(ctx.ecs, input.shipId); }), launchers: t.procedure .input(z.object({ shipId: z.number() })) .filter((publish: { shipId: number }, { input }) => { if (publish && publish.shipId !== input.shipId) return false; return true; }) .autoPublish( ["isTorpedoLauncher"], (entity) => entity.components.isShipSystem && { shipId: entity.components.isShipSystem.shipId, }, ) .request(({ ctx, input: { shipId } }) => { if (!ctx.flight) return []; const systems = getShipSystems(ctx.ecs, { systemType: "TorpedoLauncher", shipId, }).filter((system) => system.components.isShipSystem?.shipId === shipId); return systems.flatMap((system) => { if (!system.components.isTorpedoLauncher) return []; const torpedoEntity = system.components.isTorpedoLauncher?.torpedoEntity; const torpedo = torpedoEntity ? ctx.flight?.ecs.getEntityById(torpedoEntity) : null; return { id: system.id, name: system.components.identity?.name || "Torpedo Launcher", state: system.components.isTorpedoLauncher.status, fireTime: system.components.isTorpedoLauncher.fireTime, loadTime: system.components.isTorpedoLauncher.loadTime, torpedo: torpedo ? { id: torpedo.id, casingColor: torpedo.components.isInventory?.flags.torpedoCasing?.color, warheadColor: torpedo.components.isInventory?.flags.torpedoWarhead?.color, warheadDamageType: torpedo.components.isInventory?.flags.torpedoWarhead?.damageType, guidanceColor: torpedo.components.isInventory?.flags.torpedoGuidance?.color, guidanceMode: torpedo.components.isInventory?.flags.torpedoGuidance?.guidanceMode, } : null, }; }); }), load: t.procedure .input( z.object({ launcherId: z.number(), torpedoId: z.string().nullable(), }), ) .output(z.object({ shipId: z.number() })) .meta({ event: true }) .send(({ input, ctx }) => { const launcher = ctx.ecs.getEntityById(input.launcherId); if (!launcher?.components.isTorpedoLauncher) throw new Error("System is not a torpedo launcher"); checkSystemStability(launcher, "Failed to load torpedo"); if (input.torpedoId && launcher.components.isTorpedoLauncher.status !== "ready") { throw new Error("Torpedo launcher is not ready"); } if (!input.torpedoId && launcher.components.isTorpedoLauncher.status !== "loaded") { throw new Error("Torpedo launcher is not loaded"); } const torpedoEntity = adjustTorpedoInventory(input.torpedoId, launcher); launcher.updateComponent("isTorpedoLauncher", { status: torpedoEntity ? "loading" : "unloading", progress: launcher.components.isTorpedoLauncher.loadTime, torpedoEntity, }); const ship = ctx.ecs.getEntityById(launcher.components.isShipSystem?.shipId || -1); if (ship) { pubsub.publish.targeting.torpedoes.launchers({ shipId: ship.id, }); if (torpedoEntity) { cancelLoopingSound(launcher, "unload"); playShipSound(launcher, ship, "load"); } else { cancelLoopingSound(launcher, "load"); playShipSound(launcher, ship, "unload"); } } return { shipId: ship?.id || -1 }; }), fire: t.procedure .input( z.object({ launcherId: z.number(), }), ) .output(z.object({ shipId: z.number() })) .meta({ event: true }) .send(({ input, ctx }) => { const launcher = ctx.ecs.getEntityById(input.launcherId); if (!launcher?.components.isTorpedoLauncher) throw new Error("System is not a torpedo launcher"); cancelLoopingSound(launcher, "load"); checkSystemStability(launcher, "Failed to fire torpedo"); if (launcher.components.isTorpedoLauncher.status !== "loaded") { throw new Error("Torpedo launcher is not loaded"); } const power = launcher.components.power; const powerLevels = power?.powerLevels || [0]; const currentPower = power?.currentPower || 1; const requiredPower = powerLevels[0]; if (currentPower < requiredPower) throw new Error("Insufficient power to fire."); const inventoryTemplate = ctx.flight?.ecs.getEntityById( launcher.components.isTorpedoLauncher.torpedoEntity!, ); if (!inventoryTemplate) throw new Error("Torpedo not found"); launcher.updateComponent("isTorpedoLauncher", { status: "firing", progress: launcher.components.isTorpedoLauncher.fireTime, firingEnergy: 0, firedClientId: ctx.clientId, }); const ship = ctx.ecs.getEntityById(launcher.components.isShipSystem?.shipId || -1); if (ship) { pubsub.publish.targeting.torpedoes.launchers({ shipId: ship.id, }); playShipSound(launcher, ship, "firingPowerUp"); } return { shipId: ship?.id || -1 }; }), // Triggered by the torpedo launcher ECS system, but can be triggered manually to bypass the firing sequence. fired: t.procedure .input(z.object({ launcherId: z.number() })) .output( z.object({ shipId: z.number(), torpedoId: z.number(), targetId: z.number().nullable() }), ) .meta({ event: true }) .send(({ input, ctx }) => { const launcher = ctx.ecs.getEntityById(input.launcherId); if (!launcher?.components.isTorpedoLauncher) throw new Error("System is not a torpedo launcher"); cancelLoopingSound(launcher, "load"); cancelLoopingSound(launcher, "firingPowerUp"); if (!launcher.components.isTorpedoLauncher.torpedoEntity) { throw new Error("Torpedo launcher is not loaded"); } const torpedo = spawnTorpedo(launcher); launcher.ecs?.addEntity(torpedo); pubsub.publish.starmapCore.torpedos({ systemId: torpedo.components.position?.parentId || null, }); launcher.updateComponent("isTorpedoLauncher", { firedClientId: null, status: "fired", progress: 1000, firingEnergy: 0, torpedoEntity: null, }); const ship = ctx.ecs.getEntityById(launcher.components.isShipSystem?.shipId || -1); if (ship) { pubsub.publish.targeting.torpedoes.launchers({ shipId: ship.id, }); playShipSound(launcher, ship, "fire"); } return { shipId: ship?.id || -1, torpedoId: torpedo.id, targetId: torpedo.components.isTorpedo!.targetId, }; }), }), hull: t.procedure .input(z.object({ shipId: z.number() })) .filter((publish: { shipId: number }, { input }) => { if (publish && publish.shipId !== input.shipId) return false; return true; }) .autoPublish(["hull"], (entity) => ({ shipId: entity.id })) .request(({ ctx, input }) => { const ship = ctx.ecs.getEntityById(input.shipId); return ship?.components.hull?.hull || 0; }), shields: t.router({ get: t.procedure .input(z.object({ shipId: z.number() })) .filter((publish: { shipId: number }, { input }) => { if (publish && publish.shipId !== input.shipId) return false; return true; }) .autoPublish( ["isShields"], (entity) => entity.components.isShipSystem && { shipId: entity.components.isShipSystem.shipId, }, ) .request(({ ctx, input: { shipId } }) => { if (!ctx.flight) return []; const systems = getShipSystems(ctx.ecs, { systemType: "Shields", shipId, }).filter((system) => system.components.isShipSystem?.shipId === shipId); return systems.flatMap((system) => { if (!system.components.isShields) return []; return { id: system.id, state: system.components.isShields.state, strength: system.components.isShields.strength, maxStrength: system.components.isShields.maxStrength, direction: system.components.isShields.direction, frequency: system.components.isShields.frequency, }; }); }), setState: t.procedure .input( z.union([ z.object({ shieldId: z.number(), state: z.enum(["up", "down"]), }), z.object({ shipId: z.number(), state: z.enum(["up", "down"]), }), ]), ) .output(z.object({ shipId: z.number(), state: z.enum(["up", "down"]) })) .meta({ event: true }) .send(({ input, ctx }) => { let shipId: number; if ("shieldId" in input) { const shield = getShipSystem(ctx.ecs, { systemId: input.shieldId, }); if (!shield.components.isShields) throw new Error("System is not a shield generator"); shield.updateComponent("isShields", { state: input.state, }); shipId = shield.components.isShipSystem?.shipId || -1; pubsub.publish.targeting.shields.get({ shipId, }); } else { shipId = input.shipId; const shields = getShipSystems(ctx.ecs, { systemType: "Shields", shipId, }).filter((system) => system.components.isShipSystem?.shipId === shipId); for (const shield of shields) { shield.updateComponent("isShields", { state: input.state, }); } pubsub.publish.targeting.shields.get({ shipId, }); } return { shipId, state: input.state }; }), }), phasers: t.router({ list: t.procedure .input(z.object({ shipId: z.number() })) .filter((publish: { shipId: number }, { input }) => { if (publish && publish.shipId !== input.shipId) return false; return true; }) .autoPublish( ["isPhasers"], (entity) => entity.components.isShipSystem && { shipId: entity.components.isShipSystem.shipId, }, ) .request(({ ctx, input }) => { if (!ctx.flight) return []; const systems = getShipSystems(ctx.flight.ecs, { systemType: "Phasers", shipId: input.shipId, }).filter((system) => system.components.isShipSystem?.shipId === input.shipId); return systems.flatMap((system) => { if (!system.components.isPhasers) return []; const connectedBattery = ctx.ecs.getEntityById( system.components.power?.batterySource || -1, ); return { id: system.id, name: system.components.identity?.name || "Phasers", firePercent: system.components.isPhasers.firePercent, arc: system.components.isPhasers.arc, heading: system.components.isPhasers.headingDegree, pitch: system.components.isPhasers.pitchDegree, maxOutput: connectedBattery?.components.isBattery?.outputRate || 0, maxRange: system.components.isPhasers.maxRange, maxArc: system.components.isPhasers.maxArc, nominalHeat: system.components.heat?.nominalHeat || 0, maxSafeHeat: system.components.heat?.maxSafeHeat || 1, }; }); }), /** * All of the phasers in a system or the same system as the requesting ship * which are currently being fired. */ firing: t.procedure .input( z.object({ systemId: z.number().nullable(), }), ) .filter((publish: { systemId: number | null }, { input }) => { if (!publish) return true; if ("systemId" in input && publish.systemId !== input.systemId) return false; return true; }) .autoPublish(["isPhasers"], (entity) => { const systemId = entity.ecs.getEntityById(entity.components.isShipSystem?.shipId || -1) ?.components.position?.parentId; if (systemId) return { systemId }; return null; }) .request(({ input, ctx }) => { const systemId = input.systemId; // Get all of the ships in the system const ships: Entity[] = []; for (const ship of ctx.flight?.ecs.componentCache.get("isShip") || []) { if (ship.components.position?.parentId === systemId) { ships.push(ship); } } const shipIds = ships.map((ship) => ship.id); // Get all of the ship phasers that are currently firing const firingPhasers: Entity[] = []; const phaserEntities = ctx.flight?.ecs.componentCache.get("isPhasers"); for (const phaser of phaserEntities || []) { if ( shipIds.includes(phaser.components.isShipSystem?.shipId || -1) && phaser.components.isPhasers && phaser.components.isPhasers.firePercent > 0 ) { firingPhasers.push(phaser); } } return firingPhasers.flatMap((phaser) => { const ship = phaser.ecs?.getEntityById(phaser.components.isShipSystem?.shipId || -1); if (!ship) return []; const target = getCurrentTarget(ship); if (!target) return []; return { id: phaser.id, shipId: phaser.components.isShipSystem?.shipId || -1, targetId: target.id, firePercent: phaser.components.isPhasers?.firePercent || 0, }; }); }), setArc: t.procedure .input( z.object({ phaserId: z.number(), arc: z.number(), }), ) .output(z.object({ phaserId: z.number(), arc: z.number(), shipId: z.number() })) .meta({ event: true }) .send(({ input, ctx }) => { const phaser = getShipSystem(ctx.ecs, { systemId: input.phaserId, }); if (!phaser.components.isPhasers) throw new Error("System is not a phaser"); checkSystemStability(phaser, "Failed to set phaser arc"); phaser.updateComponent("isPhasers", { arc: input.arc, }); pubsub.publish.targeting.phasers.list({ shipId: phaser.components.isShipSystem?.shipId || -1, }); return { ...input, shipId: phaser.components.isShipSystem?.shipId || -1 }; }), fire: t.procedure .input( z.object({ phaserId: z.number(), firePercent: z.number(), }), ) .output(z.object({ shipId: z.number(), phaserId: z.number(), firePercent: z.number() })) .meta({ event: true }) .send(({ input, ctx }) => { const phaser = getShipSystem(ctx.ecs, { systemId: input.phaserId, }); if (!phaser.components.isPhasers) throw new Error("System is not a phaser"); checkSystemStability(phaser, "Failed to fire phaser"); // TODO: Check if the phaser has sufficient power // to be able to fire at the requested power level phaser.updateComponent("isPhasers", { firePercent: input.firePercent, }); const batterySource = ctx.ecs.getEntityById(phaser.components.power?.batterySource || -1); const currentPower = batterySource?.components.isBattery?.storage || 0; const ship = ctx.flight?.ecs.getEntityById(phaser.components.isShipSystem?.shipId || -1); pubsub.publish.targeting.phasers.list({ shipId: phaser.components.isShipSystem?.shipId || -1, }); if (input.firePercent === 0 || currentPower < 0.01) { cancelLoopingSound(phaser, "fire"); } else { if (phaser.components.soundEffects?.soundBank.fire) { playShipSound(phaser, ship!, "fire"); } } pubsub.publish.targeting.phasers.firing({ systemId: ship?.components.position?.parentId || null, }); return { shipId: ship?.id || -1, ...input }; }), }), stream: t.procedure.input(z.object({ shipId: z.number() })).dataStream(({ ctx, input }) => { const set = new Set(); for (const entity of ctx.ecs.componentCache.get("isShields") || []) { if (entity.components.isShipSystem?.shipId === input.shipId) { set.add(entity); } } for (const entity of ctx.ecs.componentCache.get("isPhasers") || []) { if (entity.components.isShipSystem?.shipId === input.shipId) { set.add(entity); } } return set; }), }); export function adjustTorpedoInventory(torpedoId: string | null, launcher: Entity) { let adjustment = -1; const ecs = launcher.ecs!; const ship = ecs.getEntityById(launcher.components.isShipSystem?.shipId || -1); if (!ship) throw new Error("Torpedo launcher ship not found"); if (!torpedoId) { adjustment = 1; const torpedo = ecs.getEntityById(launcher.components.isTorpedoLauncher?.torpedoEntity || -1); if (!torpedo) throw new Error("Torpedo not found"); torpedoId = torpedo.components.identity?.name || ""; } const inventoryTemplates = getInventoryTemplates(ecs); const inventoryTemplate = inventoryTemplates[torpedoId]; if (!inventoryTemplate) throw new Error("Torpedo not found"); const torpedo = ecs.getEntityById(inventoryTemplate.entityId); if (!torpedo) throw new Error("Torpedo not found"); const torpedoEntity = adjustment >= 1 ? null : torpedo.id; const torpedoRoom = randomFromList( getRoomBySystem(ship, "torpedoLauncher").filter((room) => { if (adjustment < 1) { return room.contents[torpedoId!]?.count > 0; } const cargoUsed = calculateCargoUsed(room.contents, inventoryTemplates); const volume = room.volume || 0; const torpedoVolume = inventoryTemplates[torpedoId].volume; return cargoUsed + torpedoVolume <= volume; }), ); if (!torpedoRoom) throw new Error("No torpedo room found"); if (adjustment < 1 && torpedoRoom.contents[torpedoId].count <= 0) { throw new Error("No torpedoes available"); } torpedoRoom.contents[torpedoId].count += adjustment; pubsub.publish.cargoControl.rooms({ shipId: ship.id, }); pubsub.publish.targeting.torpedoes.list({ shipId: ship.id, }); return torpedoEntity; } export function getShipTorpedos(ecs: ECS, shipId: number) { const ship = ecs.getEntityById(shipId); const templates = getInventoryTemplates(ecs); const torpedoList: Record< string, { count: number; yield: number; speed: number; energy: number } > = {}; function handleContents( contents: Record< string, { count: number; } >, ) { for (const item in contents) { const template = templates[item]; if (!template || !template.flags.torpedoCasing || !template.flags.torpedoWarhead) continue; if (!torpedoList[item]) { torpedoList[item] = { count: 0, yield: template.flags.torpedoWarhead.yield, speed: template.flags.torpedoCasing.speed, energy: template.flags.torpedoCasing.requiredLaunchEnergyMWs, }; } torpedoList[item].count += contents[item].count; } } if (ship?.components.shipMap) { const torpedoRooms = getRoomBySystem(ship, "torpedoLauncher"); for (const room of torpedoRooms) { handleContents(room.contents); } } else if (ship?.components.cargoContainer) { handleContents(ship.components.cargoContainer.contents); } return torpedoList; }