import type ShipPlugin from "@thorium/.server/classes/Plugins/Ship"; import { pubsub } from "@thorium/.server/init/pubsub"; import { t } from "@thorium/.server/init/t"; import type { ComponentIds, ComponentProperties } from "@thorium/ecs-components"; import type { isDestroyed } from "@thorium/ecs-components/isDestroyed"; import { Entity } from "@thorium/utils/ecs"; import { shipObjectives } from "@thorium/utils/flags/shipObjectives"; import { pathfinder } from "@thorium/utils/starmap/pathfinder.server"; import { getCompletePositionFromOrbit, getObjectOffsetPosition, getObjectSystem, } from "@thorium/utils/starmap/position"; import type { Coordinates } from "@thorium/utils/unitTypes"; import { matchSorter } from "match-sorter"; import { Vector3 } from "three"; import z from "zod"; type IsDestroyed = z.infer; const objectDetailsComponents: ComponentIds[] = [ "isShip", "position", "velocity", "rotation", "rotationVelocity", "hull", "mass", "size", "tags", "identity", "theme", "isStar", "isPlanet", "satellite", "temperature", "population", "reputation", ]; export const starmapCore = t.router({ systems: t.procedure .autoPublish(["isSolarSystem"], () => null) .request(({ ctx }) => { if (!ctx.flight) return []; return [...(ctx.ecs.componentCache.get("isSolarSystem") || [])].map((e) => ({ id: e.id, position: e.components.position, identity: e.components.identity, })); }), system: t.procedure .input(z.object({ systemId: z.number().nullish() })) .autoPublish(["isSolarSystem"], () => null) .request(({ ctx, input }) => { if (!ctx.flight) throw new Error("No flight in progress"); if (input?.systemId === null || input?.systemId === undefined) throw new Error("No system id provided"); const data = ctx.flight.ecs.getEntityById(input.systemId); if (!data?.components.isSolarSystem) throw new Error("Not a solar system"); return { id: data.id, components: data.components }; }), /** Includes all the things in a system that isn't a ship */ entities: t.procedure .input(z.object({ systemId: z.number().nullable() })) .autoPublish([], () => null) .request(({ ctx, input }) => { if (!ctx.flight) return []; if (input?.systemId === null || input?.systemId === undefined) return []; const checkEntities = [ ...(ctx.ecs.componentCache.get("position") || []), ...(ctx.ecs.componentCache.get("satellite") || []), ]; const data = checkEntities.reduce((prev: Pick[], entity) => { if (entity.components.isShip || entity.components.isTorpedo || entity.components.isWaypoint) return prev; if ( entity.components.position?.parentId === input.systemId || entity.components.satellite?.parentId === input.systemId || getObjectSystem(entity)?.id === input.systemId ) prev.push({ components: entity.components, id: entity.id }); return prev; }, []); return data; }), /** Includes all the ship in a system or interstellar space */ ships: t.procedure .input(z.object({ systemId: z.number().nullable() })) .filter((publish: { systemId: number | null }, { input }) => { if (!publish) return true; if (!publish.systemId && !input.systemId) return true; if (publish.systemId === input.systemId) return true; return false; }) .autoPublish( ["isShip"], (entity) => entity.components.position && { systemId: entity.components.position.parentId, }, ) .request(({ ctx, input }) => { if (!ctx.flight) return []; const shipEntities = ctx.flight.ecs.componentCache.get("isShip") || []; const data: { id: number; modelUrl?: string; logoUrl?: string; size: number; isDestroyed?: IsDestroyed; /** * Whether the ThreeJS object should render an HTML element on top. * Mostly used for training **/ html: boolean; }[] = []; for (const { components, id } of shipEntities) { if ( components.isShip && ((typeof input?.systemId === "number" && components.position?.parentId === input.systemId) || (input?.systemId == null && components.position?.type === "interstellar")) ) { data.push({ id, modelUrl: components.isShip.assets.model, logoUrl: components.isShip.assets.logo, size: components.size?.length || 50, isDestroyed: components.isDestroyed, html: !!components.tags?.tags.includes("html"), }); } } return data; }), shipsSetPosition: t.procedure .input( z.object({ ships: z .object({ x: z.number(), y: z.number(), z: z.number(), id: z.number(), }) .array(), }), ) .send(({ ctx, input }) => { for (const ship of input.ships) { const entity = ctx.ecs.getEntityById(ship.id); if (entity) { entity.updateComponent("position", { x: ship.x, y: ship.y, z: ship.z, }); entity.addComponent("snapInterpolation", { snapInterpolation: true }); // TODO May 28, 2025 - Also switch which physics world the ship is operating in } } }), torpedos: t.procedure .input(z.object({ systemId: z.number().nullable() })) .filter((publish: { systemId: number | null }, { input }) => { if (!publish) return true; if (!publish.systemId && !input.systemId) return true; if (publish.systemId === input.systemId) return true; return false; }) .autoPublish( ["isTorpedo"], (entity) => entity.components.position && { systemId: entity.components.position.parentId, }, ) .request(({ ctx, input }) => { if (!ctx.flight) return []; const torpedoEntities = ctx.flight.ecs.componentCache.get("isTorpedo") || []; const data: { id: number; color: string; isDestroyed?: IsDestroyed; }[] = []; for (const { components, id } of torpedoEntities) { if ( components.isTorpedo && ((typeof input?.systemId === "number" && components.position?.parentId === input.systemId) || (input?.systemId == null && components.position?.type === "interstellar")) ) { if ( typeof components.isDestroyed?.timeToDestroy === "number" && components.isDestroyed.timer > components.isDestroyed.timeToDestroy ) continue; data.push({ id, color: components.isTorpedo.color, isDestroyed: components.isDestroyed, }); } } return data; }), /** Useful for fetching a single ship when following that ship */ ship: t.procedure .input(z.object({ shipId: z.number().optional().nullish() })) .filter((publish: { shipId: number | null }, { input }) => { if (!input.shipId) return true; if (publish && publish.shipId !== input.shipId) return false; return true; }) .autoPublish(["isShip"], (entity) => entity.components.position && { shipId: entity.id }) .request(({ ctx, input }) => { if (!input.shipId) return null; if (!ctx.flight) return null; const entity = ctx.flight.ecs.getEntityById(input.shipId); if (!entity) return null; return { id: entity.id, systemId: entity.components.position?.parentId, behavior: entity.components.shipBehavior, }; }), /** Useful for displaying properties of any object in the starmap */ object: t.procedure .input(z.object({ objectId: z.number().optional() })) .filter((publish: { objectId: number | null }, { input }) => { if (publish && publish.objectId !== input.objectId) return false; return true; }) .autoPublish(objectDetailsComponents, (entity) => ({ objectId: entity.id })) .request(({ ctx, input }) => { if (!ctx.flight || !input.objectId) return null; const entity = ctx.flight.ecs.getEntityById(input.objectId); if (!entity) return null; const position = getCompletePositionFromOrbit(entity); const system = getObjectSystem(entity); return { id: entity.id, components: objectDetailsComponents.reduce((acc: Partial, key) => { // @ts-expect-error acc[key] = entity.components[key]; return acc; }, {}), position: { x: position.x, y: position.y, z: position.z, parentId: system?.id || null, }, }; }), reputation: t.procedure .input(z.object({ entityId: z.number() })) .filter((publish: { entityId: number | null }, { input }) => { if (publish && publish.entityId !== input.entityId) return false; return true; }) .autoPublish(["reputation"], (entity) => ({ entityId: entity.id })) .request(({ ctx, input }) => { if (!ctx.flight || !input.entityId) return []; const entity = ctx.flight.ecs.getEntityById(input.entityId); if (!entity?.components.reputation) return []; const reputation: { id: number; name: string; value: number }[] = []; for (const id in entity.components.reputation.reputation) { const value = entity.components.reputation.reputation?.[id]; const reputationEntity = ctx.flight.ecs.getEntityById(Number(id)); if (!reputationEntity) continue; const name = reputationEntity.components.identity?.name; if (!name) continue; reputation.push({ id: Number(id), name, value }); } return reputation; }), setReputation: t.procedure .input( z.object({ entityId: z.number(), targetId: z.number(), value: z.number(), }), ) .send(({ ctx, input }) => { if (!ctx.flight) return; const entity = ctx.flight.ecs.getEntityById(input.entityId); if (!entity) return; entity.updateComponent("reputation", { reputation: { ...entity.components.reputation?.reputation, [input.targetId.toString()]: input.value, }, }); // We pretty much always want to make it mutual const targetEntity = ctx.flight.ecs.getEntityById(input.targetId); targetEntity?.updateComponent("reputation", { reputation: { ...targetEntity.components.reputation?.reputation, [entity.id.toString()]: input.value, }, }); pubsub.publish.starmapCore.reputation({ entityId: input.entityId }); pubsub.publish.starmapCore.reputation({ entityId: input.targetId }); }), spawnSearch: t.procedure .input(z.object({ query: z.string().optional(), allPlugins: z.boolean().optional() })) .autoPublish([], () => null) .request(({ ctx, input }) => { if (!input.allPlugins && !ctx.flight) return []; const shipTemplates = ctx.server.plugins .filter((p) => (input.allPlugins ? true : ctx.flight?.pluginIds.includes(p.id))) .reduce((acc: ShipPlugin[], plugin) => { return acc.concat(plugin.aspects.ships); }, []); if (input.query === undefined) { return shipTemplates.map(({ pluginName, name, category, assets: { vanity } }) => ({ id: `${name}-${pluginName}`, pluginName, name, category, vanity, })); } // TODO August 20, 2022: Add faction here too return matchSorter(shipTemplates, input.query, { keys: ["name", "description", "category", "tags"], }) .slice(0, 10) .map(({ pluginName, name, category, assets: { vanity } }) => ({ id: `${name}-${pluginName}`, pluginName, name, category, vanity, })); }), autopilot: t.procedure .input(z.object({ systemId: z.number().nullable() })) .filter((publish: { systemId: number | null }, { input }) => { if (publish && publish.systemId !== input.systemId) return false; return true; }) .autoPublish( ["autopilot"], (entity) => entity.components.position && { systemId: entity.components.position.parentId, }, ) .request(({ ctx, input }) => { const ships: Entity[] = []; for (const system of ctx.ecs.systems) { if (system.constructor.name === "AutoThrustSystem") { for (const [_, entity] of system.entities) { if (entity?.components.position?.parentId === input.systemId) { ships.push(entity); } } } } type AutopilotInfo = { forwardAutopilot: boolean; destinationName: string; destinationPosition: Coordinates | null; destinationSystemPosition: Coordinates | null; locked: boolean; path: { x: number; y: number; z: number }[]; }; return ( ships?.reduce((acc: { [id: number]: AutopilotInfo }, ship) => { const waypointId = ship.components.autopilot?.destinationWaypointId; let destinationName = ""; let waypoint: Entity | null | undefined; if (typeof waypointId === "number") { waypoint = ctx.flight?.ecs.getEntityById(waypointId); destinationName = waypoint?.components.identity?.name.replace(" Waypoint", "").trim() || ""; } const waypointParentId = waypoint?.components.position?.parentId; const waypointSystemPosition = typeof waypointParentId === "number" ? ctx.flight?.ecs.getEntityById(waypointParentId)?.components.position || null : null; acc[ship.id] = { forwardAutopilot: !!ship.components.autopilot?.forwardAutopilot, destinationName, destinationPosition: ship.components.autopilot?.desiredCoordinates || null, destinationSystemPosition: waypointSystemPosition, locked: !!ship.components.autopilot?.desiredCoordinates, path: ship.components.autopilot?.path || [], }; return acc; }, {}) || {} ); }), setDestinations: t.procedure .input( z.object({ ships: z .object({ id: z.number(), position: z.object({ x: z.number(), y: z.number(), z: z.number() }), systemId: z.number().nullable(), }) .array(), }), ) .send(({ ctx, input }) => { const systemIds = new Set(); input.ships.forEach((ship) => { const entity = ctx.flight?.ecs.getEntityById(ship.id); const path = ship.systemId === entity?.components.position?.parentId && ship.systemId ? pathfinder(entity, new Vector3(ship.position.x, ship.position.y, ship.position.z)) : []; const nextCoordinates = path?.shift(); entity?.updateComponent("autopilot", { desiredCoordinates: ship.position, desiredRotation: null, desiredSolarSystemId: ship.systemId, path, nextCoordinates, }); entity?.updateComponent("shipBehavior", { actionTarget: { parentId: ship.systemId, x: ship.position.x, y: ship.position.y, z: ship.position.z, }, behaviorTarget: { parentId: ship.systemId, x: ship.position.x, y: ship.position.y, z: ship.position.z, }, }); if (typeof entity?.components.position?.parentId !== "undefined") { systemIds.add(entity.components.position.parentId); } pubsub.publish.pilot.autopilot.get({ shipId: ship.id }); }); systemIds.forEach((id) => { pubsub.publish.starmapCore.autopilot({ systemId: id }); }); }), // This one is just used for timeline actions setShipDestination: t.procedure .input( z.object({ shipId: z.number(), entityId: z.number().optional(), position: z .object({ parentId: z .union([z.number(), z.object({ name: z.string(), pluginId: z.string() })]) .nullable(), x: z.number(), y: z.number(), z: z.number(), }) .optional(), }), ) .meta({ action: () => { return { position: { name: "Position", type: "starmapCoordinates", helper: "A specific point in space to send the ship. Use as an alternative to Nearby Entity.", }, entityId: { name: "Nearby Entity", helper: "Send the ship somewhere near this entity. This option is preferred.", }, }; }, }) .send(({ ctx, input }) => { const ship = ctx.flight?.ecs.getEntityById(input.shipId); if (!ship) throw new Error("No ship found."); let position = { x: 0, y: 0, z: 0 }; let systemId: number | null = null; let object: Entity | null = null; if ("entityId" in input) { // This ship is being sent close to an object object = ctx.ecs.getEntityById(input.entityId || -1); if (!object) throw new Error("No object found."); const targetParentId = ship.components.position ? ship.components.position.parentId : ship.components.satellite?.parentId || null; const targetPosition = { parentId: targetParentId, type: targetParentId ? "solar" : "interstellar", ...(ship.components.position || getCompletePositionFromOrbit(ship)), } as const; position = getObjectOffsetPosition( object, targetPosition, (ship.components.size?.length || 1 / 1000) * 2, ); const sys = getObjectSystem(object); systemId = sys?.id ?? null; if (sys?.id === object.id) systemId = null; } else if ("position" in input && input.position) { // This waypoint is just being plopped at some random point in space. position = input.position; const parentId = input.position.parentId; if (parentId && typeof parentId === "object") { // This waypoint is probably defined in a timeline action, so we need // to find which system matches the name. const solarSystems = ctx.flight?.ecs.componentCache.get("isSolarSystem") || []; for (const entity of solarSystems) { if (entity.components.identity?.name === parentId.name) { systemId = entity.id; break; } } } else { systemId = parentId; } } else { throw new Error("Either position or entityId are required"); } const path = systemId === ship?.components.position?.parentId && systemId ? pathfinder(ship, new Vector3(position.x, position.y, position.z)) : []; const nextCoordinates = path?.shift(); ship?.updateComponent("autopilot", { desiredCoordinates: position, desiredSolarSystemId: systemId, path, nextCoordinates, }); ship?.updateComponent("shipBehavior", { actionTarget: { parentId: systemId, x: position.x, y: position.y, z: position.z, }, behaviorTarget: { parentId: systemId, x: position.x, y: position.y, z: position.z, }, }); pubsub.publish.pilot.autopilot.get({ shipId: ship.id }); pubsub.publish.starmapCore.autopilot({ systemId }); }), setOrbit: t.procedure .input( z.object({ ships: z.number().array(), objectId: z.number(), }), ) .send(({ ctx, input }) => { const systemIds = new Set(); const orbitedObject = ctx.flight?.ecs.getEntityById(input.objectId); if (!orbitedObject) return; const objectSystem = getObjectSystem(orbitedObject); if (!objectSystem) return; for (const shipId of input.ships) { const entity = ctx.flight?.ecs.getEntityById(shipId); if (!entity) continue; const targetParentId = entity.components.position ? entity.components.position.parentId : entity.components.satellite?.parentId || null; const targetPosition = { parentId: targetParentId, type: targetParentId ? "solar" : "interstellar", ...(entity.components.position || getCompletePositionFromOrbit(entity)), } as const; const position = getObjectOffsetPosition( orbitedObject, targetPosition, (entity.components.size?.length || 1 / 1000) * 2, ); // TODO January 2025: Generate a function which creates an orbit path which the ship can use. entity.updateComponent("autopilot", { desiredCoordinates: position, desiredRotation: null, desiredSolarSystemId: objectSystem.id, }); entity?.updateComponent("shipBehavior", { actionTarget: { parentId: objectSystem.id, x: position.x, y: position.y, z: position.z, }, behaviorTarget: { parentId: objectSystem.id, x: position.x, y: position.y, z: position.z, }, }); if (typeof entity?.components.position?.parentId !== "undefined") { systemIds.add(entity.components.position.parentId); } pubsub.publish.pilot.autopilot.get({ shipId: entity.id }); } systemIds.forEach((id) => { pubsub.publish.starmapCore.autopilot({ systemId: id }); }); }), setFollowShip: t.procedure .meta({ action: true }) .input( z.object({ objective: shipObjectives, ships: z.union([z.number().array(), z.number()]), objectId: z.number(), }), ) .send(({ ctx, input }) => { const systemIds = new Set(); const followedObject = ctx.flight?.ecs.getEntityById(input.objectId); if (!followedObject) return; for (const shipId of Array.isArray(input.ships) ? input.ships : [input.ships]) { const entity = ctx.flight?.ecs.getEntityById(shipId); if (!entity) continue; entity.updateComponent("shipBehavior", { objective: input.objective, behaviorTarget: followedObject.id, }); if (typeof entity?.components.position?.parentId !== "undefined") { systemIds.add(entity.components.position.parentId); } pubsub.publish.pilot.autopilot.get({ shipId: entity.id }); pubsub.publish.ship.get({ shipId: entity.id }); pubsub.publish.starmapCore.ship({ shipId: entity.id }); } systemIds.forEach((id) => { pubsub.publish.starmapCore.autopilot({ systemId: id }); }); }), fireTorpedo: t.procedure .input( z.object({ objectId: z.number(), position: z.object({ x: z.number(), y: z.number(), z: z.number(), parentId: z.number(), }), }), ) .send(({ ctx, input }) => { const torpedoEntity = new Entity(); const target = ctx.flight?.ecs.getEntityById(input.objectId); if (!target) return; const targetPosition = target.components.position ? new Vector3( target.components.position.x, target.components.position.y, target.components.position.z, ) : new Vector3(); torpedoEntity.addComponent("position", { x: input.position.x, y: input.position.y, z: input.position.z, parentId: input.position.parentId, type: "solar", }); // Point the torpedo at the target const directionVector = targetPosition .clone() .sub(new Vector3(input.position.x, input.position.y, input.position.z)) .normalize() .multiplyScalar(50); torpedoEntity.addComponent("velocity", { x: directionVector.x, y: directionVector.y, z: directionVector.z, }); torpedoEntity.addComponent("isTorpedo", { launcherId: -1, targetId: input.objectId, yield: 1, damageType: null, color: "white", guidanceMode: "visual", guidanceRange: 5000, speed: 50, maxForce: 10, maxRange: 25000, }); torpedoEntity.addComponent("mass", { mass: 1500 }); ctx.flight?.ecs.addEntity(torpedoEntity); pubsub.publish.starmapCore.torpedos({ systemId: torpedoEntity.components.position?.parentId || null, }); }), setShipsBehavior: t.procedure .meta({ action: true }) .input( z.object({ shipIds: z.union([z.number().array(), z.number()]), targetId: z.number().optional(), objective: shipObjectives, }), ) .send(({ ctx, input }) => { const ids = Array.isArray(input.shipIds) ? input.shipIds : [input.shipIds]; ids.forEach((shipId) => { const entity = ctx.flight?.ecs.getEntityById(shipId); entity?.updateComponent("shipBehavior", { objective: input.objective, behaviorTarget: input.targetId, }); if (input.objective === "hold") { const position = entity?.components.position; if (position) { entity.updateComponent("shipBehavior", { actionTarget: { parentId: position.parentId || null, x: position.x, y: position.y, z: position.z, }, }); entity.updateComponent("autopilot", { rotationAutopilot: true, forwardAutopilot: true, desiredCoordinates: { x: position.x, y: position.y, z: position.z, }, desiredRotation: null, path: [], nextCoordinates: { x: position.x, y: position.y, z: position.z, }, desiredSolarSystemId: position.parentId || null, }); } } if (input.objective === "patrol") { entity?.updateComponent("shipBehavior", { objective: input.objective, behaviorTarget: { ...entity.components.position! }, }); } pubsub.publish.pilot.autopilot.get({ shipId }); pubsub.publish.ship.get({ shipId }); pubsub.publish.starmapCore.ship({ shipId }); }); }), stream: t.procedure .input(z.object({ systemId: z.number().nullable() })) .dataStream(({ ctx, input }) => { const set = new Set(); for (const entity of ctx.ecs.componentCache.get("isShip") || []) { if ( entity.components.position?.type === "interstellar" && (input.systemId === null || input.systemId === undefined) ) { set.add(entity); } if (entity.components.position?.parentId === input.systemId) { set.add(entity); } } for (const entity of ctx.ecs.componentCache.get("isTorpedo") || []) { if ( entity.components.position?.type === "interstellar" && (input.systemId === null || input.systemId === undefined) ) { set.add(entity); } if (entity.components.position?.parentId === input.systemId) { set.add(entity); } } return set; }), });