Something went wrong. Try again.
Starship Simulator Controls thoriumsim.com
Something went wrong. Try again.
16 kB · 484 lines
TypeScript
at develop
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485import type TrainingPlugin from "@thorium/.server/classes/Plugins/Training";import type { DataContext } from "@thorium/.server/DataContext";import { pubsub } from "@thorium/.server/init/pubsub";import { t } from "@thorium/.server/init/t";import { spawnTimeline } from "@thorium/.server/spawners/timeline";import type { TrainingVariables } from "@thorium/routes/config/trainings/trainingAvailableVariables";import { staticStations } from "@thorium/routes/flight/staticStations";import { applyCardHighlight } from "@thorium/utils/.server/applyCardHighlight";import { triggerStep } from "@thorium/utils/.server/evaluateEntityQuery";import { selectAvailableTimelines } from "@thorium/utils/.server/executeBlocks";import type { Entity } from "@thorium/utils/ecs";import MarkdownIt from "markdown-it";import z from "zod";const md = MarkdownIt();
const clientSettings = z.object({ soundPlayer: z.boolean(), ambiancePlayer: z.boolean(), musicPlayer: z.boolean(), dialoguePlayer: z.boolean(),});
export type ClientSettings = z.infer<typeof clientSettings>;
export const client = t.router({ connected: t.procedure .input(z.object({ clientId: z.string() })) .meta({ event: true }) .output( z.object({ name: z.string(), clientId: z.string(), flightId: z.string().optional(), shipId: z.number().optional().nullable(), stationId: z.string().optional().nullable(), }), ) .send(({ ctx, input }) => { // This is just used for event triggers const { name } = ctx.server.clients[input.clientId]; const { flightId, shipId, stationId } = ctx.getFlightClient(input.clientId)?.components.flightClient || {}; return { name, clientId: input.clientId, flightId, shipId, stationId }; }), get: t.procedure .input(z.object({ clientId: z.string() })) .filter((publish: { clientId: string } | null, { input }) => { if (!publish) return true; if (publish.clientId === input.clientId) return true; return false; }) .autoPublish(["flightClient"], (entity) => entity.components.flightClient ? { clientId: entity.components.flightClient?.clientId } : null, ) .request(({ ctx, input }) => { const { id, name, connected, settings } = ctx.server.clients[input.clientId]; const { officersLog: _, clientId: _id, ...flightClient } = ctx.getFlightClient(input.clientId)?.components.flightClient || {}; return { id, name, connected, settings, ...flightClient }; }), all: t.procedure .autoPublish(["flightClient"], () => null) .request(({ ctx }) => { const serverClients = Object.values(ctx.server.clients); const clients = serverClients .map((client) => { const flightClient = ctx.getFlightClient(client.id); return { name: client.name, connected: client.connected, settings: client.settings, // oxlint-disable-next-line typescript/no-non-null-asserted-optional-chain ...flightClient?.components.flightClient!, }; }) .filter((client) => client.connected); return clients; }), setName: t.procedure .input(z.object({ clientId: z.string(), name: z.string().min(2) })) .send(({ ctx, input }) => { const client = ctx.getClient(input.clientId); client.name = input.name; pubsub.publish.client.all(); pubsub.publish.client.get({ clientId: client.id });
return { clientId: client.id, name: client.name }; }), setStation: t.procedure .input( z.object({ shipId: z.number().nullable(), originalShipId: z.number().nullable().optional(), stationId: z.string().nullable(), clientId: z.string(), }), ) .meta({ event: true, action: true }) .output( z.object({ name: z.string(), clientId: z.string(), flightId: z.string().optional(), shipId: z.number().optional().nullable(), stationId: z.string().optional().nullable(), }), ) .send(({ ctx, input }) => { const flightClient = ctx.getFlightClient(input.clientId); if (!flightClient) { throw new Error("No flight has been started."); }
// If shipId is null, we're removing ourselves from the flight. if (input.shipId === null) { flightClient.updateComponent("flightClient", { stationId: null, shipId: null, originalShipId: null, }); const clientId = flightClient.components.flightClient!.clientId; pubsub.publish.client.all(); pubsub.publish.client.get({ clientId }); pubsub.publish.station.get({ clientId }); pubsub.publish.theme.get({ clientId }); pubsub.publish.ship.get({ clientId, }); pubsub.publish.ship.get({ shipId: flightClient.components.flightClient?.shipId || -1, });
const { name } = ctx.server.clients[input.clientId]; const { flightId, shipId, stationId } = ctx.getFlightClient(input.clientId)?.components.flightClient || {}; return { name, clientId: input.clientId, flightId, shipId, stationId }; } const ship = ctx.ecs.getEntityById(input.shipId); if (!ship?.components.isShip) { throw new Error("No ship with that ID exists."); } const station = [ ...staticStations, ...(ship.components.stationComplement?.stations || []), ].find((station) => station.name === input.stationId);
if (!station) { throw new Error("No station with that ID exists."); }
flightClient.updateComponent("flightClient", { stationId: input.stationId, shipId: input.shipId, originalShipId: input.originalShipId || input.shipId, });
const clientId = flightClient.components.flightClient!.clientId;
pubsub.publish.client.all(); pubsub.publish.client.get({ clientId }); pubsub.publish.station.get({ clientId }); pubsub.publish.theme.get({ clientId }); pubsub.publish.ship.get({ clientId }); pubsub.publish.ship.player({ shipId: input.shipId }); pubsub.publish.ship.get({ shipId: input.shipId, });
const { name } = ctx.server.clients[input.clientId]; const { flightId, shipId, stationId } = ctx.getFlightClient(input.clientId)?.components.flightClient || {}; return { name, clientId: input.clientId, flightId, shipId, stationId }; }), setCard: t.procedure .meta({ event: true, action: true }) .input( z.object({ clientId: z.string().optional(), shipId: z.number().optional(), station: z.string().optional(), card: z.string(), }), ) .output( z.object({ card: z.string(), clientId: z.string().nullish(), station: z.string().nullish(), shipId: z.number().nullish(), }), ) .send(({ ctx, input }) => { const flightClient = getFlightClient(ctx, input); flightClient.updateComponent("flightClient", { currentCard: input.card });
// Turn off the highlight for this card applyCardHighlight( ctx.ecs, flightClient.components.flightClient?.shipId || -1, flightClient.components.flightClient?.stationId, [input.card], false, );
const clientId = flightClient.components.flightClient?.clientId || ""; pubsub.publish.client.all(); pubsub.publish.client.get({ clientId, }); pubsub.publish.station.get({ clientId, }); return { card: input.card, clientId, station: flightClient.components.flightClient?.stationId, shipId: flightClient.components.flightClient?.shipId, }; }), setSettings: t.procedure .input(z.object({ clientId: z.string(), settings: clientSettings })) .send(({ ctx, input }) => { const client = ctx.getClient(input.clientId);
client.settings = input.settings; pubsub.publish.client.all(); pubsub.publish.client.get({ clientId: input.clientId, }); }), startTraining: t.procedure .input(z.object({ clientId: z.string() })) .meta({ action: true }) .send(async ({ ctx, input }) => { if (!ctx.flight) throw new Error("Flight has not started."); const flightClient = ctx.getFlightClient(input.clientId); if (!flightClient?.components.flightClient) throw new Error("Invalid flight client"); const { shipId, stationId, training: trainingActive } = flightClient.components.flightClient; if (trainingActive) return; if (!shipId) throw new Error("Invalid flight client"); const ship = ctx.ecs.getEntityById(shipId);
// Find a training that is suitable for this client and kick it off const trainingVariables = { clientId: input.clientId, shipId, station: stationId, stationComplement: ship?.components.stationComplement?.name || "", timelineId: -1, } satisfies TrainingVariables;
const timelines = await selectAvailableTimelines( ctx.ecs, ctx.server.plugins.reduce((acc: TrainingPlugin[], p) => { if (ctx.flight?.pluginIds.includes(p.id)) { acc.push(...p.aspects.trainings); } return acc; }, []), ctx.flight?.mode, trainingVariables, ); const timeline = timelines[0]; if (!timeline) throw new Error("No training available.");
// This automatically adds the timeline entity to ECS const training = spawnTimeline(timeline, (entity) => ctx.ecs.addEntity(entity), shipId); trainingVariables.timelineId = training.id; training.addComponent("variables", { variables: [ { name: "clientId", type: "any", value: trainingVariables.clientId }, { name: "shipId", type: "any", value: trainingVariables.shipId }, { name: "station", type: "any", value: trainingVariables.station }, { name: "stationComplement", type: "any", value: trainingVariables.stationComplement, }, { name: "timelineId", type: "any", value: trainingVariables.timelineId, }, ], }); flightClient.updateComponent("flightClient", { training: { text: "", allowAdvance: false, ...flightClient.components.flightClient?.training, timelineId: training.id, rootTimelineId: training.id, }, }); // Trigger the first step await triggerStep( ctx.flight.ecs.getEntityById(training.components.isTimeline?.steps[0] || -1)!, ); pubsub.publish.client.all(); pubsub.publish.client.get({ clientId: flightClient.components.flightClient?.clientId || "", }); pubsub.publish.flight.timelines(); }), stopTraining: t.procedure .input(z.object({ clientId: z.string() })) .meta({ action: true }) .send(({ ctx, input }) => { if (!ctx.flight) throw new Error("Flight has not started."); const flightClient = ctx.getFlightClient(input.clientId); if (!flightClient?.components.flightClient) throw new Error("Invalid flight client"); const { training } = flightClient.components.flightClient; if (!training) throw new Error("No training is active"); if (flightClient.components.flightClient.training?.rootTimelineId) { ctx.ecs.removeEntityById(flightClient.components.flightClient.training.rootTimelineId); } flightClient.updateComponent("flightClient", { training: null }); // TODO September 9, 2026 - There needs to be a more aggressive cleanup configuration. // Some way to remove contacts, objects, conditions, triggers, and child timelines spawned by the timeline. pubsub.publish.client.all(); pubsub.publish.client.get({ clientId: flightClient.components.flightClient?.clientId || "", }); }), setTraining: t.procedure .input( z.object({ clientId: z.string().optional(), station: z.string().optional(), shipId: z.number().optional(), text: z.string(), card: z.string().optional(), selector: z.union([z.string().array(), z.string()]).optional(), mediaUrl: z.string().optional(), allowAdvance: z.boolean().optional(), timelineId: z.number().optional(), }), ) .meta({ action: () => ({ text: { name: "Training Text", type: "textarea", helper: "The text the crew member will see to instruct them.", inputProps: { rows: 5 }, }, card: { helper: "Which card the training will automatically move the crew to.", }, selector: { type: "tags", inputProps: { omitChars: [","], placholder: "Type and press return to add a selector", }, helper: "A CSS selector to the element you would like to highlight during this training step. Omit to center the training text and have no highlight.", }, allowAdvance: { type: "checkbox", helper: "Whether the crew member can advance the training on their own.", }, timelineId: { helper: "The timeline that will be advanced if allow advance is active.", }, }), }) .send(({ ctx, input }) => { const flightClient = getFlightClient(ctx, input); const selector = typeof input.selector === "string" ? [input.selector] : input.selector; flightClient.updateComponent("flightClient", { training: input.text === null ? null : { ...flightClient.components.flightClient?.training, text: md.render(input.text), card: input.card, mediaUrl: input.mediaUrl, selector, allowAdvance: input.allowAdvance || false, ...(input.timelineId ? { timelineId: input.timelineId } : null), }, });
pubsub.publish.client.all(); pubsub.publish.client.get({ clientId: flightClient.components.flightClient?.clientId || "", }); }), login: t.procedure .meta({ event: true }) .input(z.object({ clientId: z.string(), name: z.string() })) .output(z.object({ clientId: z.string(), name: z.string() })) .send(({ ctx, input }) => { const flightClient = ctx.getFlightClient(input.clientId); if (!flightClient) { throw new Error("Flight Client not found."); } flightClient.updateComponent("flightClient", { loginName: input.name, });
pubsub.publish.client.all(); pubsub.publish.client.get({ clientId: input.clientId }); return { clientId: input.clientId, name: input.name }; }), logout: t.procedure.input(z.object({ clientId: z.string() })).send(({ ctx, input }) => { const flightClient = ctx.getFlightClient(input.clientId); if (flightClient) { flightClient.updateComponent("flightClient", { loginName: "", });
pubsub.publish.client.all(); pubsub.publish.client.get({ clientId: input.clientId }); } return null; }), testStation: t.procedure .input(z.object({ clientId: z.string(), component: z.string().nullable() })) .send(({ ctx, input }) => { const flightClient = ctx.getFlightClient(input.clientId); if (!flightClient || !ctx.flight) { throw new Error("No flight has been started."); } const component = input.component; if (component) { flightClient.updateComponent("flightClient", { stationOverride: { name: "Test Station", cards: [ { name: component, component, }, ], widgets: [], messageGroups: [], }, shipId: ctx.flight.playerShips[0].id, loginName: "Test User", }); pubsub.publish.ship.get({ shipId: ctx.flight.playerShips[0].id }); pubsub.publish.ship.player({ shipId: ctx.flight.playerShips[0].id }); } else { flightClient.updateComponent("flightClient", { stationOverride: null, shipId: null, loginName: "", }); } pubsub.publish.client.get({ clientId: input.clientId }); pubsub.publish.station.get({ clientId: input.clientId }); pubsub.publish.theme.get({ clientId: input.clientId }); }),});
function getFlightClient( ctx: DataContext, input: { clientId?: string; station?: string; shipId?: number },) { let flightClient: Entity | null = null; if (input.clientId) { flightClient = ctx.getFlightClient(input.clientId); } else { for (const entity of ctx.ecs.componentCache.get("flightClient") || []) { if ( entity.components.flightClient?.stationId === input.station && entity.components.flightClient?.shipId === input.shipId ) { flightClient = entity; break; } } } if (!flightClient) throw new Error("Flight client not found"); return flightClient;}