From 767a6309206aa37e7d39a2fe71a96ed246c5fdbc Mon Sep 17 00:00:00 2001 From: Mitchell Foote Date: Sat, 4 Jul 2026 21:40:06 -0600 Subject: [PATCH] feat(Fabrication): started work on fabrication system --- public/cardIcons/Fabrication.svg | 5 + server/app.ts | 2 + server/classes/fabrication.ts | 284 ++++++ server/classes/index.ts | 1 + server/classes/simulator.ts | 5 + server/classes/taskReport.js | 2 +- server/events/fabrication.ts | 533 ++++++++++ server/events/index.js | 1 + server/helpers/defaultSnapshot.js | 2 + server/processes/fabrication.ts | 42 + server/processes/index.js | 1 + server/tasks/fabrication.js | 107 ++ server/tasks/index.js | 2 + server/typeDefs/fabrication.ts | 220 +++++ server/typeDefs/flight.ts | 2 + server/typeDefs/index.ts | 2 + server/typeDefs/inventory.ts | 4 + server/typeDefs/rooms.ts | 1 + .../macros/addFabricationRecipe.jsx | 57 ++ src/components/macros/index.jsx | 4 + .../macros/revealFabricationRecipe.jsx | 20 + .../macros/setFabricationEnabled.jsx | 27 + .../macros/showFabricationRecipeHint.jsx | 21 + .../views/Fabrication/RecipeEditor.tsx | 373 +++++++ src/components/views/Fabrication/core.tsx | 394 ++++++++ .../graphql/addFabricationRecipe.graphql | 6 + .../graphql/cancelFabricationJob.graphql | 3 + .../graphql/clearFabricationJobs.graphql | 3 + .../graphql/completeFabricationJob.graphql | 3 + .../graphql/fabricationAddInventory.graphql | 15 + .../graphql/fabricationInventory.graphql | 26 + .../graphql/fabricationInventorySub.graphql | 17 + .../graphql/fabricationJobs.graphql | 29 + .../graphql/fabricationRecipes.graphql | 34 + .../graphql/fabricationSettings.graphql | 7 + .../fabricationUpdateRoomRoles.graphql | 3 + .../graphql/removeFabricationRecipe.graphql | 3 + .../graphql/revealFabricationRecipe.graphql | 3 + .../graphql/setFabricationEnabled.graphql | 3 + .../graphql/setFabricationJobLimit.graphql | 3 + .../graphql/showFabricationRecipeHint.graphql | 3 + .../graphql/startFabrication.graphql | 13 + .../graphql/updateFabricationRecipe.graphql | 3 + src/components/views/Fabrication/index.tsx | 589 +++++++++++ .../views/Fabrication/recipeTemplates.ts | 529 ++++++++++ src/components/views/Fabrication/shared.ts | 13 + src/components/views/Fabrication/style.scss | 914 ++++++++++++++++++ .../views/Fabrication/trainingSteps.ts | 42 + src/components/views/ShipStructure/core.jsx | 1 + src/components/views/index.ts | 6 +- .../SimulatorConfig/SimulatorProperties.jsx | 1 + .../SimulatorConfig/config/Fabrication.tsx | 582 +++++++++++ .../SimulatorConfig/config/index.jsx | 1 + src/generated/graphql.tsx | 743 +++++++++++++- src/schema.graphql | 127 +++ 55 files changed, 5834 insertions(+), 3 deletions(-) create mode 100644 public/cardIcons/Fabrication.svg create mode 100644 server/classes/fabrication.ts create mode 100644 server/events/fabrication.ts create mode 100644 server/processes/fabrication.ts create mode 100644 server/tasks/fabrication.js create mode 100644 server/typeDefs/fabrication.ts create mode 100644 src/components/macros/addFabricationRecipe.jsx create mode 100644 src/components/macros/revealFabricationRecipe.jsx create mode 100644 src/components/macros/setFabricationEnabled.jsx create mode 100644 src/components/macros/showFabricationRecipeHint.jsx create mode 100644 src/components/views/Fabrication/RecipeEditor.tsx create mode 100644 src/components/views/Fabrication/core.tsx create mode 100644 src/components/views/Fabrication/graphql/addFabricationRecipe.graphql create mode 100644 src/components/views/Fabrication/graphql/cancelFabricationJob.graphql create mode 100644 src/components/views/Fabrication/graphql/clearFabricationJobs.graphql create mode 100644 src/components/views/Fabrication/graphql/completeFabricationJob.graphql create mode 100644 src/components/views/Fabrication/graphql/fabricationAddInventory.graphql create mode 100644 src/components/views/Fabrication/graphql/fabricationInventory.graphql create mode 100644 src/components/views/Fabrication/graphql/fabricationInventorySub.graphql create mode 100644 src/components/views/Fabrication/graphql/fabricationJobs.graphql create mode 100644 src/components/views/Fabrication/graphql/fabricationRecipes.graphql create mode 100644 src/components/views/Fabrication/graphql/fabricationSettings.graphql create mode 100644 src/components/views/Fabrication/graphql/fabricationUpdateRoomRoles.graphql create mode 100644 src/components/views/Fabrication/graphql/removeFabricationRecipe.graphql create mode 100644 src/components/views/Fabrication/graphql/revealFabricationRecipe.graphql create mode 100644 src/components/views/Fabrication/graphql/setFabricationEnabled.graphql create mode 100644 src/components/views/Fabrication/graphql/setFabricationJobLimit.graphql create mode 100644 src/components/views/Fabrication/graphql/showFabricationRecipeHint.graphql create mode 100644 src/components/views/Fabrication/graphql/startFabrication.graphql create mode 100644 src/components/views/Fabrication/graphql/updateFabricationRecipe.graphql create mode 100644 src/components/views/Fabrication/index.tsx create mode 100644 src/components/views/Fabrication/recipeTemplates.ts create mode 100644 src/components/views/Fabrication/shared.ts create mode 100644 src/components/views/Fabrication/style.scss create mode 100644 src/components/views/Fabrication/trainingSteps.ts create mode 100644 src/containers/FlightDirector/SimulatorConfig/config/Fabrication.tsx diff --git a/public/cardIcons/Fabrication.svg b/public/cardIcons/Fabrication.svg new file mode 100644 index 000000000..5f2744a3c --- /dev/null +++ b/public/cardIcons/Fabrication.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/server/app.ts b/server/app.ts index 66c648a1c..1a71ec92e 100644 --- a/server/app.ts +++ b/server/app.ts @@ -78,6 +78,8 @@ class Events extends EventEmitter { dmxConfigs: ClassesImport.DMXConfig[] = []; dmxSets: ClassesImport.DMXSet[] = []; hackingPresets: ClassesImport.HackingPreset[] = []; + fabricationRecipes: ClassesImport.FabricationRecipe[] = []; + fabricationJobs: ClassesImport.FabricationJob[] = []; advancedTrainingProgress: any[] = []; printQueue: { id: string; diff --git a/server/classes/fabrication.ts b/server/classes/fabrication.ts new file mode 100644 index 000000000..3be858300 --- /dev/null +++ b/server/classes/fabrication.ts @@ -0,0 +1,284 @@ +import uuid from "uuid"; + +// The Fabricator lets crews combine up to four cargo stacks from a single +// room into a new item. Recipes are simulator aspects: they're configured on +// the template simulator and cloned onto each flight's simulator by +// addAspects, so per-flight changes (FD edits, secret recipe discovery) +// never touch the template. + +export const MAX_RECIPE_INPUTS = 4; + +export interface FabricationRecipeItem { + name: string; + count: number; + // When false the item must be present in the room but isn't used up — + // a tool or catalyst rather than an ingredient + consumed?: boolean; +} + +export interface FabricationOutputMetadata { + type?: string; + size?: number; + description?: string; + image?: string; + science?: boolean; + defense?: boolean; + // For torpedo outputs: which warhead type lands in the launcher + warheadType?: string; +} + +export interface FabricationRecipeOutput { + name: string; + count: number; + metadata?: FabricationOutputMetadata; +} + +export type FabricationRecipeCategory = + | "repair" + | "weapon" + | "probe" + | "upgrade" + | "science" + | "misc"; + +interface FabricationRecipeParams { + id?: string; + simulatorId?: string; + templateId?: string; + name?: string; + description?: string; + category?: FabricationRecipeCategory; + inputs?: FabricationRecipeItem[]; + output?: FabricationRecipeOutput; + duration?: number; + secret?: boolean; + discovered?: boolean; + hint?: string; + hintVisible?: boolean; + nearMiss?: boolean; + nearMissCount?: number; +} + +function sanitizeItems(items: FabricationRecipeItem[] = []) { + // Merge duplicate names: the crew card combines same-name stacks into one + // slot, so a recipe listing an item twice could never be matched. If any + // duplicate is consumed, the merged stack is consumed. + const merged: FabricationRecipeItem[] = []; + items + .filter(i => i && i.name && i.name.trim()) + .forEach(i => { + const item = { + name: i.name.trim(), + count: Math.max(1, Math.round(i.count) || 1), + consumed: i.consumed !== false, + }; + const existing = merged.find( + m => m.name.toLowerCase() === item.name.toLowerCase(), + ); + if (existing) { + existing.count += item.count; + existing.consumed = existing.consumed || item.consumed; + } else { + merged.push(item); + } + }); + return merged.slice(0, MAX_RECIPE_INPUTS); +} + +export class FabricationRecipe { + id: string; + class: "FabricationRecipe"; + simulatorId: string | null; + templateId: string | null; + name: string; + description: string; + category: FabricationRecipeCategory; + inputs: FabricationRecipeItem[]; + output: FabricationRecipeOutput; + duration: number; + secret: boolean; + discovered: boolean; + // FD-authored clue for a secret recipe, shown to the crew only after the + // FD makes it visible + hint: string; + hintVisible: boolean; + // When enabled, slotting a strict subset of this recipe's components tells + // the crew the mixture is "almost viable" + nearMiss: boolean; + // How many times the crew has come close to this recipe without hitting + // it — surfaced on the FD core so they can decide when to show the hint + nearMissCount: number; + constructor(params: FabricationRecipeParams = {}) { + this.class = "FabricationRecipe"; + this.id = params.id || uuid.v4(); + this.simulatorId = params.simulatorId || null; + this.templateId = params.templateId || null; + this.name = params.name || "New Recipe"; + this.description = params.description || ""; + this.category = params.category || "misc"; + this.inputs = sanitizeItems(params.inputs); + const output = params.output || {name: "", count: 1}; + this.output = { + name: (output.name || "").trim(), + count: Math.max(1, Math.round(output.count) || 1), + metadata: output.metadata || {}, + }; + this.duration = Math.max(5, Math.round(params.duration) || 60); + this.secret = Boolean(params.secret); + this.discovered = Boolean(params.discovered); + this.hint = params.hint || ""; + this.hintVisible = Boolean(params.hintVisible); + this.nearMiss = Boolean(params.nearMiss); + this.nearMissCount = Math.max(0, Math.round(params.nearMissCount) || 0); + } + update({ + name, + description, + category, + inputs, + output, + duration, + secret, + hint, + nearMiss, + }: FabricationRecipeParams) { + if (name || name === "") this.name = name || this.name; + if (description || description === "") this.description = description; + if (category) this.category = category; + if (inputs) this.inputs = sanitizeItems(inputs); + if (output) { + this.output = { + name: (output.name || "").trim(), + count: Math.max(1, Math.round(output.count) || 1), + metadata: output.metadata || this.output.metadata || {}, + }; + } + if (duration) this.duration = Math.max(5, Math.round(duration) || 60); + if (secret === true || secret === false) { + this.secret = secret; + if (!secret) { + this.discovered = false; + this.hintVisible = false; + } + } + if (hint || hint === "") this.hint = hint; + if (nearMiss === true || nearMiss === false) this.nearMiss = nearMiss; + } + reveal() { + this.discovered = true; + } + showHint() { + this.hintVisible = true; + } + // True when the given input stacks exactly match this recipe's inputs, + // regardless of slot order. Names compare case-insensitively; whether an + // item is consumed doesn't affect matching. + matches(inputs: FabricationRecipeItem[]) { + const normalize = (items: FabricationRecipeItem[]) => + items + .map(i => `${i.name.trim().toLowerCase()}|${i.count}`) + .sort() + .join("::"); + if (inputs.length !== this.inputs.length) return false; + return normalize(inputs) === normalize(this.inputs); + } + // True when the given stacks are a strict subset of this recipe's inputs — + // right items, but something is missing or short. Only meaningful for + // secret recipes with near-miss feedback enabled. + isNearMiss(inputs: FabricationRecipeItem[]) { + if (!this.nearMiss || this.discovered) return false; + if (inputs.length === 0 || this.matches(inputs)) return false; + return inputs.every(stack => { + const match = this.inputs.find( + i => i.name.toLowerCase() === stack.name.trim().toLowerCase(), + ); + return match && stack.count <= match.count; + }); + } + // True when the given stacks use exactly this recipe's component names but + // the quantities are wrong — a more precise clue than a near miss. Only + // meaningful for secret recipes with near-miss feedback enabled. + isProportionMiss(inputs: FabricationRecipeItem[]) { + if (!this.nearMiss || this.discovered) return false; + if (inputs.length === 0 || this.matches(inputs)) return false; + const names = (items: FabricationRecipeItem[]) => + items + .map(i => i.name.trim().toLowerCase()) + .sort() + .join("::"); + return names(inputs) === names(this.inputs); + } + recordNearMiss() { + this.nearMissCount += 1; + return this.nearMissCount; + } +} + +export type FabricationJobStatus = "active" | "complete" | "cancelled"; + +interface FabricationJobParams { + id?: string; + simulatorId?: string; + templateId?: string; + recipeId?: string; + recipeName?: string; + roomId?: string; + inputs?: FabricationRecipeItem[]; + output?: FabricationRecipeOutput; + duration?: number; + elapsed?: number; + status?: FabricationJobStatus; + completedTime?: number; +} + +export class FabricationJob { + id: string; + class: "FabricationJob"; + simulatorId: string | null; + templateId: string | null; + recipeId: string | null; + recipeName: string; + roomId: string | null; + // The stacks consumed when the job started, kept so a cancel can refund them + inputs: FabricationRecipeItem[]; + output: FabricationRecipeOutput; + duration: number; + elapsed: number; + status: FabricationJobStatus; + // Timestamp (ms) when the job finished or was cancelled, for auto-cleanup + completedTime: number | null; + constructor(params: FabricationJobParams = {}) { + this.class = "FabricationJob"; + this.id = params.id || uuid.v4(); + this.simulatorId = params.simulatorId || null; + this.templateId = params.templateId || null; + this.recipeId = params.recipeId || null; + this.recipeName = params.recipeName || "Unknown"; + this.roomId = params.roomId || null; + this.inputs = params.inputs || []; + this.output = params.output || {name: "", count: 1}; + this.duration = params.duration || 60; + this.elapsed = params.elapsed || 0; + this.status = params.status || "active"; + this.completedTime = params.completedTime || null; + } + get progress() { + if (this.status === "complete") return 1; + return Math.min(1, this.elapsed / this.duration); + } + tick(seconds = 1) { + if (this.status !== "active") return; + this.elapsed += seconds; + } + complete() { + this.status = "complete"; + this.elapsed = this.duration; + this.completedTime = Date.now(); + } + cancel() { + this.status = "cancelled"; + this.completedTime = Date.now(); + } +} + +export default FabricationRecipe; diff --git a/server/classes/index.ts b/server/classes/index.ts index de1e4e52b..257aff575 100644 --- a/server/classes/index.ts +++ b/server/classes/index.ts @@ -73,4 +73,5 @@ export { default as HullPlating } from "./hullPlating"; export { FirebaseConnector, FirebaseManager } from './FirebaseManager' export { default as AdvancedNavigationAndAstrometrics } from "./advancedNavigationAndAstrometrics"; export { default as Aegis } from "./aegis"; +export { FabricationRecipe, FabricationJob } from "./fabrication"; export { FlightSet } from './flightSets' diff --git a/server/classes/simulator.ts b/server/classes/simulator.ts index 021e92f35..3d5b282df 100644 --- a/server/classes/simulator.ts +++ b/server/classes/simulator.ts @@ -64,6 +64,9 @@ export default class Simulator { recordSnippets: RecordSnippet[]; documents: Document[]; spaceEdventuresId: string | null; + fabricationEnabled: boolean; + // Maximum simultaneous fabrication jobs; 0 means unlimited + fabricationJobLimit: number; constructor(params: Partial = {}, newlyCreated: boolean = false) { this.id = params.id || uuid.v4(); @@ -95,6 +98,8 @@ export default class Simulator { this.missionConfigs = params.missionConfigs || {}; this.bridgeOfficerMessaging = params.bridgeOfficerMessaging ?? true; + this.fabricationEnabled = params.fabricationEnabled ?? true; + this.fabricationJobLimit = params.fabricationJobLimit || 0; this.teams = []; this.training = params.training || false; this.ship = new Ship({...params.ship}, newlyCreated); diff --git a/server/classes/taskReport.js b/server/classes/taskReport.js index 104a9000c..9d5760d76 100644 --- a/server/classes/taskReport.js +++ b/server/classes/taskReport.js @@ -28,7 +28,7 @@ export default class TaskReport { this.systemId = params.systemId || null; this.type = params.type || "default"; this.stepCount = params.stepCount || 8; - this.name = params.name || `${fullType} Report`; + this.name = params.name || `${fullType(this.type)} Report`; this.cleared = params.cleared || false; // Generate the report from the task templates when the task report is created // Tasks is a list of task IDs for tasks that are stored in App.tasks diff --git a/server/events/fabrication.ts b/server/events/fabrication.ts new file mode 100644 index 000000000..46f4560b0 --- /dev/null +++ b/server/events/fabrication.ts @@ -0,0 +1,533 @@ +import uuid from "uuid"; +import App from "../app"; +import {pubsub} from "../helpers/subscriptionManager"; +import * as Classes from "../classes"; +import { + FabricationJob, + FabricationRecipe, + FabricationRecipeItem, + MAX_RECIPE_INPUTS, +} from "../classes/fabrication"; +import {getFabricationSettings} from "../typeDefs/fabrication"; +import type Simulator from "../classes/simulator"; + +// Cap on how many batches a single job can run, to keep durations sane +const MAX_BATCHES = 10; + +// After this many close attempts at the same secret recipe, alert the FD +// so they can decide whether to show the hint +const NEAR_MISS_ALERT_THRESHOLD = 3; + +function publishRecipes() { + pubsub.publish("fabricationRecipesUpdate", App.fabricationRecipes); +} +function publishJobs() { + pubsub.publish("fabricationJobsUpdate", App.fabricationJobs); +} +function publishSettings(simulatorId: string) { + pubsub.publish("fabricationSettingsUpdate", getFabricationSettings(simulatorId)); +} + +function findInventory(simulatorId: string, name: string) { + const compare = name.trim().toLowerCase(); + return App.inventory.find( + i => i.simulatorId === simulatorId && i.name.trim().toLowerCase() === compare, + ); +} + +function findRecipe(simulatorId: string, recipe: string) { + const compare = (recipe || "").trim().toLowerCase(); + return App.fabricationRecipes.find( + r => + r.simulatorId === simulatorId && + (r.id === recipe || r.name.trim().toLowerCase() === compare), + ); +} + +// Rooms tagged with the fabrication role. When any exist, fabrication is +// restricted to them; otherwise any room works. +function fabricationRooms(simulatorId: string) { + return App.rooms.filter( + r => + r.simulatorId === simulatorId && + (r.roles || []).indexOf("fabrication") > -1, + ); +} + +function notifyCore(simulatorId: string, title: string, color: string) { + pubsub.publish("notify", { + id: uuid.v4(), + simulatorId, + type: "Fabrication", + station: "Core", + title, + body: "", + color, + }); + App.handleEvent( + {simulatorId, component: "FabricationCore", title, body: null, color}, + "addCoreFeed", + ); +} + +// Notify every station that carries the Fabrication card +function notifyCrew(simulatorId: string, title: string, body = "") { + const simulator: Simulator = App.simulators.find(s => s.id === simulatorId); + if (!simulator) return; + simulator.stations + .filter(s => s.cards.find(c => c.component === "Fabrication")) + .forEach(s => { + pubsub.publish("notify", { + id: uuid.v4(), + simulatorId, + type: "Fabrication", + station: s.name, + title, + body, + color: "info", + relevantCards: ["Fabrication"], + }); + }); +} + +const COUNTERMEASURE_MATERIALS = [ + "copper", + "titanium", + "carbon", + "plastic", + "plasma", +]; + +// Each fabricated coolant unit refills this fraction of the coolant tank +const COOLANT_PER_UNIT = 0.1; + +// Each fabricated shield booster restores this fraction of shield integrity +const SHIELD_BOOST_PER_UNIT = 0.1; + +// Some outputs skip the cargo hold and integrate straight into the system +// that uses them: torpedo warheads load into the launcher, railgun ammo into +// the magazine, coolant into the tank, shield boosters into the emitters, +// and countermeasure materials into the fabricator's stores. +// Returns the delivery-destination text, or null when the output isn't an +// integrated type (or the ship lacks the system — then it falls back to +// cargo delivery so nothing is lost). +function deliverToSystem(job: FabricationJob): string | null { + const metadata = job.output.metadata || {}; + if (metadata.type === "torpedo") { + const launcher = App.systems.find( + s => s.simulatorId === job.simulatorId && s.class === "Torpedo", + ); + if (!launcher) return null; + for (let i = 0; i < job.output.count; i++) { + launcher.addWarhead({type: metadata.warheadType || "photon"}); + } + pubsub.publish( + "torpedosUpdate", + App.systems.filter(s => s.type === "Torpedo"), + ); + return `loaded into ${launcher.displayName || launcher.name}`; + } + if (metadata.type === "railgunAmmo") { + const railgun = App.systems.find( + s => s.simulatorId === job.simulatorId && s.class === "Railgun", + ); + if (!railgun) return null; + railgun.availableAmmo += job.output.count; + pubsub.publish( + "railgunUpdate", + App.systems.filter(s => s.id === railgun.id), + ); + return `added to the railgun magazine`; + } + if (metadata.type === "coolant") { + const tank = App.systems.find( + s => s.simulatorId === job.simulatorId && s.class === "Coolant", + ); + // A full tank falls back to cargo so the canisters aren't wasted + if (!tank || tank.coolant >= 1) return null; + tank.setCoolant(tank.coolant + job.output.count * COOLANT_PER_UNIT); + pubsub.publish( + "coolantUpdate", + App.systems.filter(s => s.type === "Coolant"), + ); + return `added to the coolant tank (now ${Math.round( + tank.coolant * 100, + )}%)`; + } + if (metadata.type === "shieldBoost") { + const damagedShields = () => + App.systems + .filter( + s => + s.simulatorId === job.simulatorId && + s.type === "Shield" && + s.integrity < 1, + ) + .sort((a, b) => a.integrity - b.integrity); + // All shields at full integrity falls back to cargo, like a full + // coolant tank does + if (damagedShields().length === 0) return null; + // Each unit reinforces whichever shield is weakest at that moment + for (let i = 0; i < job.output.count; i++) { + const weakest = damagedShields()[0]; + if (!weakest) break; + weakest.setIntegrity(weakest.integrity + SHIELD_BOOST_PER_UNIT); + } + pubsub.publish( + "shieldsUpdate", + App.systems.filter(s => s.type === "Shield"), + ); + return "channeled into the shield emitters"; + } + if (metadata.type === "countermeasureMaterial") { + const material = job.output.name.trim().toLowerCase(); + if (!COUNTERMEASURE_MATERIALS.includes(material)) return null; + const countermeasures = App.systems.find( + s => s.simulatorId === job.simulatorId && s.class === "Countermeasures", + ); + if (!countermeasures) return null; + countermeasures.storedMaterials[material] = + (countermeasures.storedMaterials[material] || 0) + job.output.count; + pubsub.publish("countermeasuresUpdate", countermeasures); + return `added to countermeasure material stores`; + } + return null; +} + +// Deposit a finished job's output into its room, creating the inventory item +// if the ship doesn't carry it yet. Shared by the process loop and the +// "complete now" mutation. +export function deliverFabricationJob(job: FabricationJob) { + if (job.status !== "active") return; + let destination = deliverToSystem(job); + if (!destination) { + const existing = findInventory(job.simulatorId, job.output.name); + if (existing) { + existing.updateCount( + job.roomId, + (existing.roomCount[job.roomId] || 0) + job.output.count, + ); + } else { + App.inventory.push( + new Classes.InventoryItem({ + simulatorId: job.simulatorId, + name: job.output.name, + metadata: job.output.metadata || {}, + roomCount: {[job.roomId]: job.output.count}, + }), + ); + } + const room = App.rooms.find(r => r.id === job.roomId); + destination = room ? `delivered (${room.name})` : "delivered"; + pubsub.publish("inventoryUpdate", App.inventory); + } + job.complete(); + notifyCore( + job.simulatorId, + `Fabrication Complete: ${job.output.count} x ${job.output.name} — ${destination}`, + "success", + ); + notifyCrew( + job.simulatorId, + "Fabrication Complete", + `${job.output.count} x ${job.output.name} ${destination}`, + ); + publishJobs(); +} + +App.on("addFabricationRecipe", ({simulatorId, recipe}) => { + App.fabricationRecipes.push( + new FabricationRecipe({...recipe, simulatorId}), + ); + publishRecipes(); +}); + +App.on("updateFabricationRecipe", ({id, recipe}) => { + const existing = App.fabricationRecipes.find(r => r.id === id); + if (!existing) return; + existing.update(recipe); + publishRecipes(); +}); + +App.on("removeFabricationRecipe", ({id}) => { + App.fabricationRecipes = App.fabricationRecipes.filter(r => r.id !== id); + publishRecipes(); +}); + +// `recipe` can be an id or a recipe name so timeline macros keep working +// across flights, where recipe ids differ from the template's. +App.on("revealFabricationRecipe", ({simulatorId, recipe}) => { + const found = findRecipe(simulatorId, recipe); + if (!found || found.discovered || !found.secret) return; + found.reveal(); + notifyCore(simulatorId, `Schematic Unlocked: ${found.name}`, "info"); + notifyCrew( + simulatorId, + "Schematic Unlocked", + `${found.name} has been added to the schematic database.`, + ); + publishRecipes(); +}); + +App.on("showFabricationRecipeHint", ({simulatorId, recipe}) => { + const found = findRecipe(simulatorId, recipe); + if (!found || !found.secret || found.discovered || found.hintVisible) return; + found.showHint(); + notifyCore(simulatorId, `Schematic Hint Shown: ${found.name}`, "info"); + notifyCrew( + simulatorId, + "Schematic Fragment Detected", + "A partial schematic has appeared in the fabricator database.", + ); + publishRecipes(); +}); + +App.on("setFabricationEnabled", ({simulatorId, enabled}) => { + const simulator = App.simulators.find(s => s.id === simulatorId); + if (!simulator) return; + simulator.fabricationEnabled = Boolean(enabled); + notifyCore( + simulatorId, + `Fabricator ${enabled ? "Online" : "Offline"}`, + enabled ? "success" : "danger", + ); + notifyCrew( + simulatorId, + `Fabricator ${enabled ? "Online" : "Offline"}`, + enabled + ? "The fabricator is accepting jobs again." + : "The fabricator is not responding.", + ); + publishSettings(simulatorId); +}); + +App.on("setFabricationJobLimit", ({simulatorId, limit}) => { + const simulator = App.simulators.find(s => s.id === simulatorId); + if (!simulator) return; + simulator.fabricationJobLimit = Math.max(0, Math.round(limit) || 0); + publishSettings(simulatorId); +}); + +App.on("startFabrication", ({simulatorId, roomId, inputs, count, cb}) => { + const simulator = App.simulators.find(s => s.id === simulatorId); + if (simulator && simulator.fabricationEnabled === false) { + return cb("ERROR:The fabricator is offline."); + } + const stacks: FabricationRecipeItem[] = (inputs || []) + .map(i => ({name: (i.name || "").trim(), count: Math.round(i.count) || 0})) + .filter(i => i.name && i.count > 0); + if (stacks.length === 0 || stacks.length > MAX_RECIPE_INPUTS) { + return cb( + `ERROR:The fabricator requires between 1 and ${MAX_RECIPE_INPUTS} component stacks.`, + ); + } + const batches = Math.min(MAX_BATCHES, Math.max(1, Math.round(count) || 1)); + + // If the FD designated fabrication rooms, only those rooms will do, and + // the first one is the default when no room is specified. With no + // designated rooms and no room chosen, the fabricator runs ship-wide: + // components can come from any cargo room aboard. + const designated = fabricationRooms(simulatorId); + let deliveryRoomId: string | null = roomId || null; + if (designated.length > 0) { + if (!deliveryRoomId) { + deliveryRoomId = designated[0].id; + } else if (!designated.find(r => r.id === deliveryRoomId)) { + return cb( + `ERROR:This room has no fabricator. Available in: ${designated + .map(r => r.name) + .join(", ")}.`, + ); + } + } + const shipWide = !deliveryRoomId; + + const recipe = App.fabricationRecipes.find( + r => r.simulatorId === simulatorId && r.matches(stacks), + ); + if (!recipe || !recipe.output.name) { + // Two tiers of feedback for FD-opted-in secret recipes: exactly the + // right component names with wrong quantities, or a strict subset of + // the components. Either counts as a close attempt, and the FD is + // alerted once the crew is clearly circling a recipe. + const secrets = App.fabricationRecipes.filter( + r => r.simulatorId === simulatorId && r.secret, + ); + const proportion = secrets.find(r => r.isProportionMiss(stacks)); + const almost = proportion || secrets.find(r => r.isNearMiss(stacks)); + if (almost) { + const attempts = almost.recordNearMiss(); + if (attempts === NEAR_MISS_ALERT_THRESHOLD) { + notifyCore( + simulatorId, + `Crew Is Close: ${almost.name} — ${attempts} near-miss attempts. Consider showing the hint.`, + "warning", + ); + } + // Keeps the close-attempt count live on the FD core + publishRecipes(); + } + return cb( + proportion + ? "ERROR:The fabricator stutters — the component mixture is right, but the proportions are off." + : almost + ? "ERROR:The fabricator hums for a moment — this mixture is almost viable, but the sequence cannot complete." + : "ERROR:The fabricator cannot synthesize anything from that combination of components.", + ); + } + + const jobLimit = simulator?.fabricationJobLimit || 0; + if (jobLimit > 0) { + const active = App.fabricationJobs.filter( + j => j.simulatorId === simulatorId && j.status === "active", + ).length; + if (active >= jobLimit) { + return cb( + `ERROR:The fabricator is already running at capacity (${jobLimit} concurrent jobs).`, + ); + } + } + + // Every component must be present in the fabrication room before any of + // them are consumed. Consumed components scale with the batch count; + // catalysts only need to be present once. + const requirements = recipe.inputs.map(input => ({ + ...input, + required: input.consumed !== false ? input.count * batches : input.count, + })); + const stockOf = (name: string) => { + const item = findInventory(simulatorId, name); + if (!item) return 0; + if (!shipWide) return item.roomCount[deliveryRoomId] || 0; + return Object.values(item.roomCount).reduce( + (prev: number, next) => prev + (Number(next) || 0), + 0, + ); + }; + const shortage = requirements.find(req => stockOf(req.name) < req.required); + if (shortage) { + return cb( + `ERROR:There is not enough ${shortage.name} ${ + shipWide ? "aboard the ship" : "in this room" + } to fabricate that${batches > 1 ? ` x${batches}` : ""}.`, + ); + } + const consumedStacks = requirements + .filter(req => req.consumed !== false) + .map(req => ({name: req.name, count: req.required, consumed: true})); + // Ship-wide jobs draw from every room that has stock; the room that + // supplies the most components receives the finished output. + const roomContribution: {[contributingRoomId: string]: number} = {}; + consumedStacks.forEach(stack => { + const item = findInventory(simulatorId, stack.name); + if (!shipWide) { + item.updateCount( + deliveryRoomId, + item.roomCount[deliveryRoomId] - stack.count, + ); + return; + } + let remaining = stack.count; + Object.entries(item.roomCount).forEach(([rid, available]) => { + const take = Math.min(Number(available) || 0, remaining); + if (take <= 0) return; + item.updateCount(rid, item.roomCount[rid] - take); + remaining -= take; + roomContribution[rid] = (roomContribution[rid] || 0) + take; + }); + }); + if (shipWide) { + const topRoom = Object.entries(roomContribution).sort( + (a, b) => b[1] - a[1], + )[0]; + deliveryRoomId = + topRoom?.[0] || + // All-catalyst recipes consume nothing: deliver wherever the first + // catalyst sits, or failing that the first room aboard + requirements + .map(req => { + const item = findInventory(simulatorId, req.name); + return ( + item && + Object.keys(item.roomCount).find(rid => item.roomCount[rid] > 0) + ); + }) + .find(Boolean) || + App.rooms.find(r => r.simulatorId === simulatorId)?.id || + null; + } + if (recipe.secret && !recipe.discovered) { + recipe.reveal(); + notifyCore( + simulatorId, + `Secret Schematic Discovered: ${recipe.name}`, + "info", + ); + notifyCrew( + simulatorId, + "Secret Schematic Discovered", + `${recipe.name} has been added to the schematic database.`, + ); + publishRecipes(); + } + const job = new FabricationJob({ + simulatorId, + recipeId: recipe.id, + recipeName: recipe.name, + roomId: deliveryRoomId, + inputs: consumedStacks, + output: { + ...recipe.output, + count: recipe.output.count * batches, + }, + duration: recipe.duration * batches, + }); + App.fabricationJobs.push(job); + notifyCore( + simulatorId, + `Fabrication Started: ${recipe.name}${batches > 1 ? ` x${batches}` : ""}`, + "info", + ); + pubsub.publish("inventoryUpdate", App.inventory); + publishJobs(); + return cb(job.id); +}); + +App.on("cancelFabricationJob", ({id}) => { + const job = App.fabricationJobs.find(j => j.id === id); + if (!job || job.status !== "active") return; + // Refund the consumed components to the fabrication room + job.inputs.forEach(stack => { + const item = findInventory(job.simulatorId, stack.name); + if (item) { + item.updateCount( + job.roomId, + (item.roomCount[job.roomId] || 0) + stack.count, + ); + } else { + App.inventory.push( + new Classes.InventoryItem({ + simulatorId: job.simulatorId, + name: stack.name, + roomCount: {[job.roomId]: stack.count}, + }), + ); + } + }); + job.cancel(); + pubsub.publish("inventoryUpdate", App.inventory); + publishJobs(); +}); + +App.on("completeFabricationJob", ({id}) => { + const job = App.fabricationJobs.find(j => j.id === id); + if (!job) return; + deliverFabricationJob(job); +}); + +App.on("clearFabricationJobs", ({simulatorId}) => { + App.fabricationJobs = App.fabricationJobs.filter( + j => j.simulatorId !== simulatorId || j.status === "active", + ); + publishJobs(); +}); diff --git a/server/events/index.js b/server/events/index.js index cb7577e52..bf9c9b8e4 100644 --- a/server/events/index.js +++ b/server/events/index.js @@ -65,3 +65,4 @@ import "./edVenturesApp"; import "./advancedNavigationAndAstrometrics"; import "./aegis"; import "./advancedTraining"; +import "./fabrication"; diff --git a/server/helpers/defaultSnapshot.js b/server/helpers/defaultSnapshot.js index b53fc76ed..c8afb6139 100644 --- a/server/helpers/defaultSnapshot.js +++ b/server/helpers/defaultSnapshot.js @@ -13637,6 +13637,8 @@ export function getDefaultSnapshot(){return { dmxConfigs: [], dmxDevices: [], hackingPresets: [], + fabricationRecipes: [], + fabricationJobs: [], advancedTrainingProgress: [], printQueue: [], autoUpdate: true, diff --git a/server/processes/fabrication.ts b/server/processes/fabrication.ts new file mode 100644 index 000000000..274dfee88 --- /dev/null +++ b/server/processes/fabrication.ts @@ -0,0 +1,42 @@ +import App from "../app"; +import {pubsub} from "../helpers/subscriptionManager"; +import {deliverFabricationJob} from "../events/fabrication"; + +// Finished and cancelled jobs stay visible for a while so the crew can see +// what was delivered, then get swept away automatically. +const FINISHED_JOB_RETENTION = 1000 * 60 * 10; + +function processFabrication() { + const runningSimulators = App.flights + .filter(f => f.running === true) + .reduce((prev: string[], f) => prev.concat(f.simulators), []); + + let ticked = false; + App.fabricationJobs.forEach(job => { + if (job.status !== "active") return; + if (!runningSimulators.includes(job.simulatorId)) return; + job.tick(1); + ticked = true; + if (job.elapsed >= job.duration) { + // Publishes job and inventory updates itself + deliverFabricationJob(job); + } + }); + + const now = Date.now(); + const swept = App.fabricationJobs.filter( + j => + j.status === "active" || + !j.completedTime || + now - j.completedTime < FINISHED_JOB_RETENTION, + ); + const sweptAny = swept.length !== App.fabricationJobs.length; + App.fabricationJobs = swept; + + if (ticked || sweptAny) { + pubsub.publish("fabricationJobsUpdate", App.fabricationJobs); + } + setTimeout(processFabrication, 1000); +} + +processFabrication(); diff --git a/server/processes/index.js b/server/processes/index.js index cc2c0a003..bf0933c58 100644 --- a/server/processes/index.js +++ b/server/processes/index.js @@ -20,3 +20,4 @@ import "./systems"; import "./advanced-nav"; import "./helium"; import "./aegis"; +import "./fabrication"; diff --git a/server/tasks/fabrication.js b/server/tasks/fabrication.js new file mode 100644 index 000000000..586c25b1b --- /dev/null +++ b/server/tasks/fabrication.js @@ -0,0 +1,107 @@ +import App from "../app"; +import reportReplace from "../helpers/reportReplacer"; +import {randomFromList} from "../classes/generic/damageReports/constants"; + +// The crew's recipe pool for randomly-generated tasks: public recipes plus +// any secrets they've already discovered. +function knownRecipes(simulator) { + return App.fabricationRecipes.filter( + r => r.simulatorId === simulator.id && (!r.secret || r.discovered), + ); +} + +export default [ + { + name: "Fabricate Inventory", + class: "Fabrication", + active({simulator}) { + // Requires a station with the Fabrication card and at least one recipe + return ( + simulator && + simulator.stations.find(s => + s.cards.find(c => c.component === "Fabrication"), + ) && + knownRecipes(simulator).length > 0 + ); + }, + stations({simulator}) { + return ( + simulator && + simulator.stations.filter(s => + s.cards.find(c => c.component === "Fabrication"), + ) + ); + }, + values: { + preamble: { + input: () => "textarea", + value: () => "New equipment must be fabricated.", + }, + recipe: { + input: ({simulator}) => + simulator ? knownRecipes(simulator).map(r => r.name) : "text", + value: ({simulator}) => + simulator + ? randomFromList(knownRecipes(simulator).map(r => r.name)) || "" + : "", + }, + count: { + input: () => "text", + value: () => "1", + }, + }, + instructions({ + simulator, + requiredValues: {preamble, recipe, count}, + task = {}, + }) { + const station = simulator.stations.find(s => + s.cards.find(c => c.component === "Fabrication"), + ); + const recipeObj = App.fabricationRecipes.find( + r => + r.simulatorId === simulator.id && + (r.id === recipe || + r.name.toLowerCase() === (recipe || "").toLowerCase()), + ); + const target = recipeObj + ? `${recipeObj.output.count * (parseInt(count, 10) || 1)} x ${ + recipeObj.output.name + } (schematic: ${recipeObj.name})` + : `${count} x ${recipe}`; + if (station && task.station === station.name) + return reportReplace( + `${preamble} Use the fabricator to produce ${target}.`, + {simulator}, + ); + return reportReplace( + `${preamble} Ask the ${ + station ? `${station.name} Officer` : "person in charge of fabrication" + } to produce ${target}.`, + {simulator}, + ); + }, + verify({simulator, requiredValues: {recipe, count}}) { + const recipeObj = App.fabricationRecipes.find( + r => + r.simulatorId === simulator.id && + (r.id === recipe || + r.name.toLowerCase() === (recipe || "").toLowerCase()), + ); + const outputName = (recipeObj ? recipeObj.output.name : recipe) || ""; + const required = + (parseInt(count, 10) || 1) * (recipeObj ? recipeObj.output.count : 1); + // Completed jobs stick around for a while after delivery, which gives + // the verify loop its window to observe them. + const delivered = App.fabricationJobs + .filter( + j => + j.simulatorId === simulator.id && + j.status === "complete" && + j.output.name.toLowerCase() === outputName.toLowerCase(), + ) + .reduce((prev, next) => prev + next.output.count, 0); + return delivered >= required; + }, + }, +]; diff --git a/server/tasks/index.js b/server/tasks/index.js index 5fb93b9e3..c7125f5bf 100644 --- a/server/tasks/index.js +++ b/server/tasks/index.js @@ -20,6 +20,7 @@ import softwarePanels from "./softwarePanels"; import reactivationCode from "./reactivationCode"; import reactor from "./reactor"; import sensors from "./sensors"; +import fabrication from "./fabrication"; export default [ ...docking, @@ -44,4 +45,5 @@ export default [ ...reactivationCode, ...reactor, ...sensors, + ...fabrication, ]; diff --git a/server/typeDefs/fabrication.ts b/server/typeDefs/fabrication.ts new file mode 100644 index 000000000..afe7b0c46 --- /dev/null +++ b/server/typeDefs/fabrication.ts @@ -0,0 +1,220 @@ +import {gql, withFilter} from "apollo-server-express"; +import {pubsub} from "../helpers/subscriptionManager"; +import App from "../app"; +import uuid from "uuid"; +import mutationHelper from "../helpers/mutationHelper"; +// We define a schema that encompasses all of the types +// necessary for the functionality in this file. +const schema = gql` + enum FABRICATION_CATEGORY { + repair + weapon + probe + upgrade + science + misc + } + enum FABRICATION_JOB_STATUS { + active + complete + cancelled + } + type FabricationRecipeItem { + name: String! + count: Int! + consumed: Boolean! + } + input FabricationRecipeItemInput { + name: String! + count: Int! + consumed: Boolean + } + type FabricationRecipeOutput { + name: String! + count: Int! + metadata: InventoryMetadata + } + input FabricationRecipeOutputInput { + name: String! + count: Int! + metadata: InventoryMetadataInput + } + type FabricationRecipe { + id: ID! + simulatorId: ID + name: String! + description: String! + category: FABRICATION_CATEGORY! + inputs: [FabricationRecipeItem!]! + output: FabricationRecipeOutput! + duration: Int! + secret: Boolean! + discovered: Boolean! + hint: String! + hintVisible: Boolean! + nearMiss: Boolean! + nearMissCount: Int! + } + input FabricationRecipeInput { + name: String + description: String + category: FABRICATION_CATEGORY + inputs: [FabricationRecipeItemInput!] + output: FabricationRecipeOutputInput + duration: Int + secret: Boolean + hint: String + nearMiss: Boolean + } + type FabricationSettings { + id: ID! + enabled: Boolean! + jobLimit: Int! + } + type FabricationJob { + id: ID! + simulatorId: ID + recipeId: ID + recipeName: String! + roomId: ID + room: Room + inputs: [FabricationRecipeItem!]! + output: FabricationRecipeOutput! + duration: Int! + elapsed: Float! + progress: Float! + status: FABRICATION_JOB_STATUS! + } + extend type Query { + fabricationRecipes(simulatorId: ID!): [FabricationRecipe!]! + fabricationJobs(simulatorId: ID!): [FabricationJob!]! + fabricationSettings(simulatorId: ID!): FabricationSettings! + } + extend type Mutation { + """ + Macro: Fabrication: Add Recipe + """ + addFabricationRecipe( + simulatorId: ID! + recipe: FabricationRecipeInput! + ): String + updateFabricationRecipe(id: ID!, recipe: FabricationRecipeInput!): String + removeFabricationRecipe(id: ID!): String + """ + Macro: Fabrication: Reveal Secret Recipe + """ + revealFabricationRecipe(simulatorId: ID!, recipe: String!): String + """ + Macro: Fabrication: Show Recipe Hint + """ + showFabricationRecipeHint(simulatorId: ID!, recipe: String!): String + """ + Macro: Fabrication: Set Fabricator Status + """ + setFabricationEnabled(simulatorId: ID!, enabled: Boolean!): String + setFabricationJobLimit(simulatorId: ID!, limit: Int!): String + startFabrication( + simulatorId: ID! + """ + Omit to fabricate ship-wide (no designated fabrication rooms) or to + default to the first designated fabrication room. + """ + roomId: ID + inputs: [FabricationRecipeItemInput!]! + count: Int + ): String + cancelFabricationJob(id: ID!): String + completeFabricationJob(id: ID!): String + clearFabricationJobs(simulatorId: ID!): String + } + extend type Subscription { + fabricationRecipesUpdate(simulatorId: ID!): [FabricationRecipe!]! + fabricationJobsUpdate(simulatorId: ID!): [FabricationJob!]! + fabricationSettingsUpdate(simulatorId: ID!): FabricationSettings! + } +`; + +export function getFabricationSettings(simulatorId: string) { + const simulator = App.simulators.find(s => s.id === simulatorId); + return { + id: simulatorId, + enabled: simulator ? simulator.fabricationEnabled !== false : true, + jobLimit: simulator?.fabricationJobLimit || 0, + }; +} + +const resolver = { + FabricationJob: { + room(job) { + return App.rooms.find(r => r.id === job.roomId); + }, + progress(job) { + return job.progress; + }, + }, + Query: { + fabricationRecipes(rootQuery, {simulatorId}) { + return App.fabricationRecipes.filter(r => r.simulatorId === simulatorId); + }, + fabricationJobs(rootQuery, {simulatorId}) { + return App.fabricationJobs.filter(j => j.simulatorId === simulatorId); + }, + fabricationSettings(rootQuery, {simulatorId}) { + return getFabricationSettings(simulatorId); + }, + }, + Mutation: mutationHelper(schema), + Subscription: { + fabricationRecipesUpdate: { + resolve(rootValue, {simulatorId}) { + return rootValue.filter(r => r.simulatorId === simulatorId); + }, + subscribe: withFilter( + (_rootValue, {simulatorId}) => { + const id = uuid.v4(); + process.nextTick(() => { + pubsub.publish(id, App.fabricationRecipes); + }); + return pubsub.asyncIterator([id, "fabricationRecipesUpdate"]); + }, + // Always deliver — the resolve function filters by simulator, and an + // empty result still matters (e.g. the last recipe was removed). + () => true, + ), + }, + fabricationJobsUpdate: { + resolve(rootValue, {simulatorId}) { + return rootValue.filter(j => j.simulatorId === simulatorId); + }, + subscribe: withFilter( + (_rootValue, {simulatorId}) => { + const id = uuid.v4(); + process.nextTick(() => { + pubsub.publish(id, App.fabricationJobs); + }); + return pubsub.asyncIterator([id, "fabricationJobsUpdate"]); + }, + () => true, + ), + }, + fabricationSettingsUpdate: { + resolve(rootValue) { + return rootValue; + }, + subscribe: withFilter( + (_rootValue, {simulatorId}) => { + const id = uuid.v4(); + process.nextTick(() => { + pubsub.publish(id, getFabricationSettings(simulatorId)); + }); + return pubsub.asyncIterator([id, "fabricationSettingsUpdate"]); + }, + (rootValue, {simulatorId}) => { + return rootValue?.id === simulatorId; + }, + ), + }, + }, +}; + +export default {schema, resolver}; diff --git a/server/typeDefs/flight.ts b/server/typeDefs/flight.ts index 78e3a2f5e..0e891b1d7 100644 --- a/server/typeDefs/flight.ts +++ b/server/typeDefs/flight.ts @@ -31,6 +31,8 @@ export const aspectList = [ "taskReports", "dmxFixtures", "taskFlows", + "fabricationRecipes", + "fabricationJobs", ]; export function addAspects( diff --git a/server/typeDefs/index.ts b/server/typeDefs/index.ts index 19dd5dcc1..b2931b920 100644 --- a/server/typeDefs/index.ts +++ b/server/typeDefs/index.ts @@ -17,6 +17,7 @@ import enginesTypeDefs from "./engines"; import environmentTypeDefs from "./environment"; import exocompTypeDefs from "./exocomp"; import externalsTypeDefs from "./externals"; +import fabricationTypeDefs from "./fabrication"; import flightTypeDefs from "./flight"; import googleSheetsTypeDefs from "./googleSheets"; import hullPlatingTypeDefs from "./hullPlating"; @@ -86,6 +87,7 @@ export * from "./universe/components"; export const actions = actionsTypeDefs; export const aegis = aegisTypeDefs; export const ambiance = ambianceTypeDefs; +export const fabrication = fabricationTypeDefs; export const assets = assetsTypeDefs; export const clients = clientsTypeDefs; export const commandLine = commandLineTypeDefs; diff --git a/server/typeDefs/inventory.ts b/server/typeDefs/inventory.ts index afe04d01a..22aeac550 100644 --- a/server/typeDefs/inventory.ts +++ b/server/typeDefs/inventory.ts @@ -37,6 +37,8 @@ const schema = gql` science: Boolean # For Probes defense: Boolean + # For fabricated torpedos: photon, quantum, other + warheadType: String } input InventoryCount { @@ -59,6 +61,8 @@ const schema = gql` science: Boolean # For Probes defense: Boolean + # For fabricated torpedos: photon, quantum, other + warheadType: String } type RoomCount { room: Room diff --git a/server/typeDefs/rooms.ts b/server/typeDefs/rooms.ts index d0fd99277..0d7d4ceb1 100644 --- a/server/typeDefs/rooms.ts +++ b/server/typeDefs/rooms.ts @@ -30,6 +30,7 @@ const schema = gql` damageTeam securityTeam medicalTeam + fabrication } extend type Query { rooms(simulatorId: ID, deck: ID, name: String, role: RoomRoles): [Room] diff --git a/src/components/macros/addFabricationRecipe.jsx b/src/components/macros/addFabricationRecipe.jsx new file mode 100644 index 000000000..b7b69f9a6 --- /dev/null +++ b/src/components/macros/addFabricationRecipe.jsx @@ -0,0 +1,57 @@ +import React from "react"; +import {FormGroup} from "helpers/reactstrap"; +import RecipeEditor, { + blankRecipe, + toRecipeInput, +} from "components/views/Fabrication/RecipeEditor"; +import "components/views/Fabrication/style.scss"; + +// Builds a FabricationRecipeInput in the macro args. The editor state is +// kept locally; every change is flushed into the args as the input object +// the mutation expects. +export default ({updateArgs, args}) => { + const [recipe, setRecipe] = React.useState(() => { + const existing = args.recipe; + if (!existing) return blankRecipe(); + return { + name: existing.name || "", + description: existing.description || "", + category: existing.category || "misc", + inputs: (existing.inputs || []).map(i => ({ + name: i.name || "", + count: i.count || 1, + consumed: i.consumed !== false, + })) || [{name: "", count: 1, consumed: true}], + output: { + name: existing.output?.name || "", + count: existing.output?.count || 1, + type: existing.output?.metadata?.type || "", + description: existing.output?.metadata?.description || "", + warheadType: existing.output?.metadata?.warheadType || "", + }, + duration: existing.duration || 60, + secret: Boolean(existing.secret), + hint: existing.hint || "", + nearMiss: Boolean(existing.nearMiss), + }; + }); + return ( + +

+ Add a fabrication recipe to the simulator mid-mission — for example + when the crew receives schematics from an away team or an alien + transmission. Component and output names must match the ship's cargo + item names. +

+ { + setRecipe(next); + updateArgs("recipe", toRecipeInput(next)); + }} + /> +
+ ); +}; diff --git a/src/components/macros/index.jsx b/src/components/macros/index.jsx index de534ffa5..ea1994549 100644 --- a/src/components/macros/index.jsx +++ b/src/components/macros/index.jsx @@ -82,3 +82,7 @@ export * from './advancedNavigation'; export * from './setClientHypercard'; export * from './inventory'; export * from './systems'; +export {default as revealFabricationRecipe} from "./revealFabricationRecipe"; +export {default as showFabricationRecipeHint} from "./showFabricationRecipeHint"; +export {default as setFabricationEnabled} from "./setFabricationEnabled"; +export {default as addFabricationRecipe} from "./addFabricationRecipe"; diff --git a/src/components/macros/revealFabricationRecipe.jsx b/src/components/macros/revealFabricationRecipe.jsx new file mode 100644 index 000000000..31c727e4e --- /dev/null +++ b/src/components/macros/revealFabricationRecipe.jsx @@ -0,0 +1,20 @@ +import React from "react"; +import {FormGroup, Label, Input} from "helpers/reactstrap"; + +export default ({updateArgs, args}) => { + return ( + +

+ Reveal a secret fabrication recipe to the crew. The recipe appears in + the schematic database as if it had been discovered. Use the recipe's + name exactly as it is configured on the simulator. +

+ + updateArgs("recipe", evt.target.value)} + /> +
+ ); +}; diff --git a/src/components/macros/setFabricationEnabled.jsx b/src/components/macros/setFabricationEnabled.jsx new file mode 100644 index 000000000..14e694c4e --- /dev/null +++ b/src/components/macros/setFabricationEnabled.jsx @@ -0,0 +1,27 @@ +import React from "react"; +import {FormGroup, Label, Input} from "helpers/reactstrap"; + +export default ({updateArgs, args}) => { + const value = + args.enabled === true ? "online" : args.enabled === false ? "offline" : ""; + return ( + +

+ Take the ship's fabricator offline (jobs already running keep going, + but no new jobs can start) or bring it back online. +

+ + updateArgs("enabled", evt.target.value === "online")} + > + + + + +
+ ); +}; diff --git a/src/components/macros/showFabricationRecipeHint.jsx b/src/components/macros/showFabricationRecipeHint.jsx new file mode 100644 index 000000000..5cdc10ef8 --- /dev/null +++ b/src/components/macros/showFabricationRecipeHint.jsx @@ -0,0 +1,21 @@ +import React from "react"; +import {FormGroup, Label, Input} from "helpers/reactstrap"; + +export default ({updateArgs, args}) => { + return ( + +

+ Show the crew the hint for a secret fabrication recipe. A "partial + schematic" entry appears in the fabricator database with the hint text + configured on the recipe. Use the recipe's name exactly as it is + configured on the simulator. +

+ + updateArgs("recipe", evt.target.value)} + /> +
+ ); +}; diff --git a/src/components/views/Fabrication/RecipeEditor.tsx b/src/components/views/Fabrication/RecipeEditor.tsx new file mode 100644 index 000000000..953236565 --- /dev/null +++ b/src/components/views/Fabrication/RecipeEditor.tsx @@ -0,0 +1,373 @@ +import React from "react"; +import {Button, Input, Label} from "helpers/reactstrap"; +import { + Fabrication_Category, + FabricationRecipeInput, +} from "generated/graphql"; +import {categoryLabels, MAX_SLOTS} from "./shared"; + +interface EditableItem { + name: string; + count: number; + consumed: boolean; +} + +export interface EditableRecipe { + name: string; + description: string; + category: Fabrication_Category; + inputs: EditableItem[]; + output: { + name: string; + count: number; + type: string; + description: string; + warheadType: string; + }; + duration: number; + secret: boolean; + hint: string; + nearMiss: boolean; +} + +export const countermeasureMaterials = [ + "Copper", + "Titanium", + "Carbon", + "Plastic", + "Plasma", +]; + +export const blankRecipe = (): EditableRecipe => ({ + name: "", + description: "", + category: Fabrication_Category.Misc, + inputs: [{name: "", count: 1, consumed: true}], + output: {name: "", count: 1, type: "", description: "", warheadType: ""}, + duration: 60, + secret: false, + hint: "", + nearMiss: false, +}); + +export function toRecipeInput(recipe: EditableRecipe): FabricationRecipeInput { + return { + name: recipe.name, + description: recipe.description, + category: recipe.category, + inputs: recipe.inputs + .filter(i => i.name.trim()) + .map(i => ({ + name: i.name.trim(), + count: i.count || 1, + consumed: i.consumed !== false, + })), + output: { + name: recipe.output.name.trim(), + count: recipe.output.count || 1, + metadata: { + type: recipe.output.type || undefined, + description: recipe.output.description || undefined, + warheadType: + recipe.output.type === "torpedo" + ? recipe.output.warheadType || "photon" + : undefined, + }, + }, + duration: recipe.duration, + secret: recipe.secret, + hint: recipe.hint, + nearMiss: recipe.nearMiss, + }; +} + +interface RecipeEditorProps { + recipe: EditableRecipe; + // Names of inventory already aboard, offered as autocomplete suggestions + inventoryNames: string[]; + saveLabel?: string; + // Hide the Save/Cancel row for hosts that persist on every change, + // like macro configuration + hideActions?: boolean; + onChange: (recipe: EditableRecipe) => void; + onSave?: () => void; + onCancel?: () => void; +} + +// Form for building or editing a fabrication recipe. Shared between the +// simulator config screen and the Flight Director core so mid-flight recipes +// behave exactly like preconfigured ones. +const RecipeEditor: React.FC = ({ + recipe, + inventoryNames, + saveLabel = "Save Recipe", + hideActions = false, + onChange, + onSave, + onCancel, +}) => { + const set = (values: Partial) => + onChange({...recipe, ...values}); + const setInput = (index: number, values: Partial) => + set({ + inputs: recipe.inputs.map((item, i) => + i === index ? {...item, ...values} : item, + ), + }); + const datalistId = "fabrication-inventory-names"; + const valid = + recipe.name.trim() && + recipe.output.name.trim() && + recipe.inputs.some(i => i.name.trim()); + return ( +
+ + {inventoryNames.map(name => ( + + + set({name: e.target.value})} + /> + + set({description: e.target.value})} + /> +
+
+ + + set({category: e.target.value as Fabrication_Category}) + } + > + {Object.values(Fabrication_Category).map(c => ( + + ))} + +
+
+ + set({duration: parseInt(e.target.value, 10) || 60})} + /> +
+
+ + {recipe.secret && ( +
+ + set({hint: e.target.value})} + /> + +
+ )} + + {recipe.inputs.map((item, index) => ( +
+ setInput(index, {name: e.target.value})} + /> + + setInput(index, {count: parseInt(e.target.value, 10) || 1}) + } + /> + + +
+ ))} + {recipe.inputs.length < MAX_SLOTS && ( + + )} + +
+ {recipe.output.type === "countermeasureMaterial" ? ( + + set({output: {...recipe.output, name: e.target.value}}) + } + > + + {countermeasureMaterials.map(m => ( + + ))} + + ) : ( + + set({output: {...recipe.output, name: e.target.value}}) + } + /> + )} + + set({ + output: { + ...recipe.output, + count: parseInt(e.target.value, 10) || 1, + }, + }) + } + /> +
+
+
+ + + set({output: {...recipe.output, type: e.target.value}}) + } + > + + + + + + + + +
+ {recipe.output.type === "torpedo" && ( +
+ + + set({output: {...recipe.output, warheadType: e.target.value}}) + } + > + + + + +
+ )} +
+ + + set({output: {...recipe.output, description: e.target.value}}) + } + /> +
+
+ {(recipe.output.type === "torpedo" || + recipe.output.type === "railgunAmmo" || + recipe.output.type === "coolant" || + recipe.output.type === "shieldBoost" || + recipe.output.type === "countermeasureMaterial") && ( +

+ This output is delivered directly into the ship's system instead of + the cargo room. If the simulator doesn't have that system (or the + coolant tank / shields are already full), it falls back to cargo. +

+ )} + {!hideActions && ( +
+ + +
+ )} +
+ ); +}; + +export default RecipeEditor; diff --git a/src/components/views/Fabrication/core.tsx b/src/components/views/Fabrication/core.tsx new file mode 100644 index 000000000..9de8a8e28 --- /dev/null +++ b/src/components/views/Fabrication/core.tsx @@ -0,0 +1,394 @@ +import React from "react"; +import {Button, Progress} from "helpers/reactstrap"; +import { + Simulator, + useFabricationInventoryQuery, + useFabricationInventorySubSubscription, + useFabricationRecipesSubscription, + useFabricationJobsSubscription, + useFabricationSettingsSubscription, + useRevealFabricationRecipeMutation, + useShowFabricationRecipeHintMutation, + useSetFabricationEnabledMutation, + useSetFabricationJobLimitMutation, + useCancelFabricationJobMutation, + useCompleteFabricationJobMutation, + useClearFabricationJobsMutation, + useAddFabricationRecipeMutation, + useUpdateFabricationRecipeMutation, + useRemoveFabricationRecipeMutation, + useFabricationAddInventoryMutation, + Fabrication_Job_Status, +} from "generated/graphql"; +import RecipeEditor, { + blankRecipe, + EditableRecipe, + toRecipeInput, +} from "./RecipeEditor"; +import {categoryLabels} from "./shared"; +import "./style.scss"; + +interface FabricationCoreProps { + children?: React.ReactNode; + simulator: Simulator; +} + +// Flight Director core: watch and steer the crew's fabrication. Reveal secret +// schematics, finish or cancel jobs, patch recipes mid-flight, and fix +// missing cargo without leaving the core. +const FabricationCore: React.FC = ({simulator}) => { + const {data: layoutData} = useFabricationInventoryQuery({ + variables: {simulatorId: simulator.id}, + fetchPolicy: "cache-and-network", + }); + const {data: inventorySubData} = useFabricationInventorySubSubscription({ + variables: {simulatorId: simulator.id}, + }); + const {data: recipeData} = useFabricationRecipesSubscription({ + variables: {simulatorId: simulator.id}, + }); + const {data: jobData} = useFabricationJobsSubscription({ + variables: {simulatorId: simulator.id}, + }); + const {data: settingsData} = useFabricationSettingsSubscription({ + variables: {simulatorId: simulator.id}, + }); + const [reveal] = useRevealFabricationRecipeMutation(); + const [showHint] = useShowFabricationRecipeHintMutation(); + const [setEnabled] = useSetFabricationEnabledMutation(); + const [setJobLimit] = useSetFabricationJobLimitMutation(); + const [cancelJob] = useCancelFabricationJobMutation(); + const [completeJob] = useCompleteFabricationJobMutation(); + const [clearJobs] = useClearFabricationJobsMutation(); + const [addRecipe] = useAddFabricationRecipeMutation(); + const [updateRecipe] = useUpdateFabricationRecipeMutation(); + const [removeRecipe] = useRemoveFabricationRecipeMutation(); + const [addInventory] = useFabricationAddInventoryMutation(); + + const [editing, setEditing] = React.useState(null); + const [editingId, setEditingId] = React.useState(null); + + const recipes = recipeData?.fabricationRecipesUpdate || []; + const jobs = jobData?.fabricationJobsUpdate || []; + const settings = settingsData?.fabricationSettingsUpdate; + const enabled = settings ? settings.enabled : true; + const jobLimit = settings?.jobLimit || 0; + const inventory = React.useMemo( + () => inventorySubData?.inventoryUpdate || layoutData?.inventory || [], + [inventorySubData, layoutData], + ); + const decks = layoutData?.decks || []; + + const inventoryNames = React.useMemo( + () => + Array.from(new Set(inventory.map(i => i?.name || "").filter(Boolean))), + [inventory], + ); + const hasItem = (name: string) => + inventoryNames.some(n => n.toLowerCase() === name.trim().toLowerCase()); + + // Recipe inputs that reference cargo the ship doesn't carry at all — + // usually a simulator that wasn't set up for a recipe. One click seeds the + // item into the first room so the crew isn't dead-ended. + const missingItems = Array.from( + new Set( + recipes + .flatMap(r => r.inputs.map(i => i.name)) + .filter(name => !hasItem(name)), + ), + ); + const firstRoom = decks + .concat() + .sort((a, b) => (a?.number || 0) - (b?.number || 0)) + .flatMap(d => d?.rooms || [])[0]; + + const seedItem = (name: string) => { + if (!firstRoom) return; + addInventory({ + variables: { + simulatorId: simulator.id, + name, + metadata: {}, + roomCount: [{room: firstRoom.id, count: 5}], + }, + }); + }; + + const startEdit = (recipeId?: string) => { + const recipe = recipes.find(r => r.id === recipeId); + setEditingId(recipe?.id || null); + setEditing( + recipe + ? { + name: recipe.name, + description: recipe.description, + category: recipe.category, + inputs: recipe.inputs.map(i => ({ + name: i.name, + count: i.count, + consumed: i.consumed !== false, + })), + output: { + name: recipe.output.name, + count: recipe.output.count, + type: recipe.output.metadata?.type || "", + description: recipe.output.metadata?.description || "", + warheadType: recipe.output.metadata?.warheadType || "", + }, + duration: recipe.duration, + secret: recipe.secret, + hint: recipe.hint, + nearMiss: recipe.nearMiss, + } + : blankRecipe(), + ); + }; + + const saveEdit = async () => { + if (!editing) return; + const recipe = toRecipeInput(editing); + if (editingId) { + await updateRecipe({variables: {id: editingId, recipe}}); + } else { + await addRecipe({variables: {simulatorId: simulator.id, recipe}}); + } + setEditing(null); + setEditingId(null); + }; + + const activeJobs = jobs.filter(j => j.status === Fabrication_Job_Status.Active); + const finishedJobs = jobs.filter( + j => j.status !== Fabrication_Job_Status.Active, + ); + + if (editing) { + return ( +
+
+ {editingId ? "Edit Recipe" : "New Recipe"} + {editingId && ( + + )} +
+ { + setEditing(null); + setEditingId(null); + }} + /> +
+ ); + } + + return ( +
+
+ + +
+ {missingItems.length > 0 && ( +
+ Missing cargo for recipes: + {missingItems.map(name => ( +
+ {name} + +
+ ))} +
+ )} +
+ Jobs + {finishedJobs.length > 0 && ( + + )} +
+ {jobs.length === 0 &&

No fabrication jobs.

} + {activeJobs.map(job => ( +
+ + {job.output.count}x {job.output.name} + {job.room ? ` — ${job.room.name}` : ""} + + + + +
+ ))} + {finishedJobs.map(job => ( +
+ + {job.output.count}x {job.output.name} —{" "} + {job.status === Fabrication_Job_Status.Complete + ? "delivered" + : "cancelled"} + +
+ ))} +
+ Recipes + +
+ {recipes.length === 0 && ( +

+ No recipes attached to this simulator. Add one here, or configure + them permanently in Simulator Config → Fabrication. +

+ )} + {recipes.map(recipe => ( +
+
+ startEdit(recipe.id)} className="editable"> + {recipe.name} + + + {categoryLabels[recipe.category] || recipe.category} + +
+
+ {recipe.inputs + .map( + i => + `${i.count}x ${i.name}${i.consumed === false ? " (tool)" : ""}`, + ) + .join(" + ")} + {" → "} + {recipe.output.count}x {recipe.output.name} +
+ {recipe.secret && + (recipe.discovered ? ( + + Secret — discovered + + ) : ( + + Secret — hidden{" "} + {recipe.nearMissCount > 0 && ( + = 3 ? "hot" : "" + }`} + title="Failed attempts that came close to this recipe" + > + {recipe.nearMissCount} close attempt + {recipe.nearMissCount === 1 ? "" : "s"}{" "} + + )} + + {recipe.hint && + (recipe.hintVisible ? ( + Hint shown + ) : ( + + ))} + + ))} +
+ ))} +
+ ); +}; + +export default FabricationCore; diff --git a/src/components/views/Fabrication/graphql/addFabricationRecipe.graphql b/src/components/views/Fabrication/graphql/addFabricationRecipe.graphql new file mode 100644 index 000000000..d8dd46fbc --- /dev/null +++ b/src/components/views/Fabrication/graphql/addFabricationRecipe.graphql @@ -0,0 +1,6 @@ +mutation AddFabricationRecipe( + $simulatorId: ID! + $recipe: FabricationRecipeInput! +) { + addFabricationRecipe(simulatorId: $simulatorId, recipe: $recipe) +} diff --git a/src/components/views/Fabrication/graphql/cancelFabricationJob.graphql b/src/components/views/Fabrication/graphql/cancelFabricationJob.graphql new file mode 100644 index 000000000..97fff4c11 --- /dev/null +++ b/src/components/views/Fabrication/graphql/cancelFabricationJob.graphql @@ -0,0 +1,3 @@ +mutation CancelFabricationJob($id: ID!) { + cancelFabricationJob(id: $id) +} diff --git a/src/components/views/Fabrication/graphql/clearFabricationJobs.graphql b/src/components/views/Fabrication/graphql/clearFabricationJobs.graphql new file mode 100644 index 000000000..a75893c91 --- /dev/null +++ b/src/components/views/Fabrication/graphql/clearFabricationJobs.graphql @@ -0,0 +1,3 @@ +mutation ClearFabricationJobs($simulatorId: ID!) { + clearFabricationJobs(simulatorId: $simulatorId) +} diff --git a/src/components/views/Fabrication/graphql/completeFabricationJob.graphql b/src/components/views/Fabrication/graphql/completeFabricationJob.graphql new file mode 100644 index 000000000..12d3bc65b --- /dev/null +++ b/src/components/views/Fabrication/graphql/completeFabricationJob.graphql @@ -0,0 +1,3 @@ +mutation CompleteFabricationJob($id: ID!) { + completeFabricationJob(id: $id) +} diff --git a/src/components/views/Fabrication/graphql/fabricationAddInventory.graphql b/src/components/views/Fabrication/graphql/fabricationAddInventory.graphql new file mode 100644 index 000000000..4dbdad9b0 --- /dev/null +++ b/src/components/views/Fabrication/graphql/fabricationAddInventory.graphql @@ -0,0 +1,15 @@ +mutation FabricationAddInventory( + $simulatorId: ID + $name: String + $metadata: InventoryMetadataInput + $roomCount: [RoomCountInput] +) { + addInventory( + inventory: { + simulatorId: $simulatorId + name: $name + metadata: $metadata + roomCount: $roomCount + } + ) +} diff --git a/src/components/views/Fabrication/graphql/fabricationInventory.graphql b/src/components/views/Fabrication/graphql/fabricationInventory.graphql new file mode 100644 index 000000000..993f3c8d1 --- /dev/null +++ b/src/components/views/Fabrication/graphql/fabricationInventory.graphql @@ -0,0 +1,26 @@ +query FabricationInventory($simulatorId: ID!) { + decks(simulatorId: $simulatorId) { + id + number + rooms { + id + name + roles + } + } + inventory(simulatorId: $simulatorId) { + id + name + metadata { + type + description + image + } + roomCount { + room { + id + } + count + } + } +} diff --git a/src/components/views/Fabrication/graphql/fabricationInventorySub.graphql b/src/components/views/Fabrication/graphql/fabricationInventorySub.graphql new file mode 100644 index 000000000..40b0825d7 --- /dev/null +++ b/src/components/views/Fabrication/graphql/fabricationInventorySub.graphql @@ -0,0 +1,17 @@ +subscription FabricationInventorySub($simulatorId: ID!) { + inventoryUpdate(simulatorId: $simulatorId) { + id + name + metadata { + type + description + image + } + roomCount { + room { + id + } + count + } + } +} diff --git a/src/components/views/Fabrication/graphql/fabricationJobs.graphql b/src/components/views/Fabrication/graphql/fabricationJobs.graphql new file mode 100644 index 000000000..cce3e85e5 --- /dev/null +++ b/src/components/views/Fabrication/graphql/fabricationJobs.graphql @@ -0,0 +1,29 @@ +subscription FabricationJobs($simulatorId: ID!) { + fabricationJobsUpdate(simulatorId: $simulatorId) { + id + simulatorId + recipeId + recipeName + roomId + room { + id + name + deck { + id + number + } + } + inputs { + name + count + } + output { + name + count + } + duration + elapsed + progress + status + } +} diff --git a/src/components/views/Fabrication/graphql/fabricationRecipes.graphql b/src/components/views/Fabrication/graphql/fabricationRecipes.graphql new file mode 100644 index 000000000..9400b88bc --- /dev/null +++ b/src/components/views/Fabrication/graphql/fabricationRecipes.graphql @@ -0,0 +1,34 @@ +subscription FabricationRecipes($simulatorId: ID!) { + fabricationRecipesUpdate(simulatorId: $simulatorId) { + id + simulatorId + name + description + category + inputs { + name + count + consumed + } + output { + name + count + metadata { + type + size + description + image + science + defense + warheadType + } + } + duration + secret + discovered + hint + hintVisible + nearMiss + nearMissCount + } +} diff --git a/src/components/views/Fabrication/graphql/fabricationSettings.graphql b/src/components/views/Fabrication/graphql/fabricationSettings.graphql new file mode 100644 index 000000000..4577a669f --- /dev/null +++ b/src/components/views/Fabrication/graphql/fabricationSettings.graphql @@ -0,0 +1,7 @@ +subscription FabricationSettings($simulatorId: ID!) { + fabricationSettingsUpdate(simulatorId: $simulatorId) { + id + enabled + jobLimit + } +} diff --git a/src/components/views/Fabrication/graphql/fabricationUpdateRoomRoles.graphql b/src/components/views/Fabrication/graphql/fabricationUpdateRoomRoles.graphql new file mode 100644 index 000000000..e3023a788 --- /dev/null +++ b/src/components/views/Fabrication/graphql/fabricationUpdateRoomRoles.graphql @@ -0,0 +1,3 @@ +mutation FabricationUpdateRoomRoles($roomId: ID!, $roles: [RoomRoles]) { + updateRoomRoles(roomId: $roomId, roles: $roles) +} diff --git a/src/components/views/Fabrication/graphql/removeFabricationRecipe.graphql b/src/components/views/Fabrication/graphql/removeFabricationRecipe.graphql new file mode 100644 index 000000000..2aa1ef655 --- /dev/null +++ b/src/components/views/Fabrication/graphql/removeFabricationRecipe.graphql @@ -0,0 +1,3 @@ +mutation RemoveFabricationRecipe($id: ID!) { + removeFabricationRecipe(id: $id) +} diff --git a/src/components/views/Fabrication/graphql/revealFabricationRecipe.graphql b/src/components/views/Fabrication/graphql/revealFabricationRecipe.graphql new file mode 100644 index 000000000..b56c6dde9 --- /dev/null +++ b/src/components/views/Fabrication/graphql/revealFabricationRecipe.graphql @@ -0,0 +1,3 @@ +mutation RevealFabricationRecipe($simulatorId: ID!, $recipe: String!) { + revealFabricationRecipe(simulatorId: $simulatorId, recipe: $recipe) +} diff --git a/src/components/views/Fabrication/graphql/setFabricationEnabled.graphql b/src/components/views/Fabrication/graphql/setFabricationEnabled.graphql new file mode 100644 index 000000000..94281b642 --- /dev/null +++ b/src/components/views/Fabrication/graphql/setFabricationEnabled.graphql @@ -0,0 +1,3 @@ +mutation SetFabricationEnabled($simulatorId: ID!, $enabled: Boolean!) { + setFabricationEnabled(simulatorId: $simulatorId, enabled: $enabled) +} diff --git a/src/components/views/Fabrication/graphql/setFabricationJobLimit.graphql b/src/components/views/Fabrication/graphql/setFabricationJobLimit.graphql new file mode 100644 index 000000000..402fcb064 --- /dev/null +++ b/src/components/views/Fabrication/graphql/setFabricationJobLimit.graphql @@ -0,0 +1,3 @@ +mutation SetFabricationJobLimit($simulatorId: ID!, $limit: Int!) { + setFabricationJobLimit(simulatorId: $simulatorId, limit: $limit) +} diff --git a/src/components/views/Fabrication/graphql/showFabricationRecipeHint.graphql b/src/components/views/Fabrication/graphql/showFabricationRecipeHint.graphql new file mode 100644 index 000000000..9b297af88 --- /dev/null +++ b/src/components/views/Fabrication/graphql/showFabricationRecipeHint.graphql @@ -0,0 +1,3 @@ +mutation ShowFabricationRecipeHint($simulatorId: ID!, $recipe: String!) { + showFabricationRecipeHint(simulatorId: $simulatorId, recipe: $recipe) +} diff --git a/src/components/views/Fabrication/graphql/startFabrication.graphql b/src/components/views/Fabrication/graphql/startFabrication.graphql new file mode 100644 index 000000000..9d049eccd --- /dev/null +++ b/src/components/views/Fabrication/graphql/startFabrication.graphql @@ -0,0 +1,13 @@ +mutation StartFabrication( + $simulatorId: ID! + $roomId: ID + $inputs: [FabricationRecipeItemInput!]! + $count: Int +) { + startFabrication( + simulatorId: $simulatorId + roomId: $roomId + inputs: $inputs + count: $count + ) +} diff --git a/src/components/views/Fabrication/graphql/updateFabricationRecipe.graphql b/src/components/views/Fabrication/graphql/updateFabricationRecipe.graphql new file mode 100644 index 000000000..41093e88c --- /dev/null +++ b/src/components/views/Fabrication/graphql/updateFabricationRecipe.graphql @@ -0,0 +1,3 @@ +mutation UpdateFabricationRecipe($id: ID!, $recipe: FabricationRecipeInput!) { + updateFabricationRecipe(id: $id, recipe: $recipe) +} diff --git a/src/components/views/Fabrication/index.tsx b/src/components/views/Fabrication/index.tsx new file mode 100644 index 000000000..0b37d73e6 --- /dev/null +++ b/src/components/views/Fabrication/index.tsx @@ -0,0 +1,589 @@ +import React from "react"; +import {Container, Row, Col, Button, Input, Progress} from "helpers/reactstrap"; +import {DeckDropdown, RoomDropdown} from "helpers/shipStructure"; +import Tour from "helpers/tourHelper"; +import { + Simulator, + useFabricationInventoryQuery, + useFabricationInventorySubSubscription, + useFabricationRecipesSubscription, + useFabricationJobsSubscription, + useFabricationSettingsSubscription, + useStartFabricationMutation, + useCancelFabricationJobMutation, + FabricationRecipesSubscription, + RoomRoles, +} from "generated/graphql"; +import {trainingSteps} from "./trainingSteps"; +import {categoryLabels, MAX_SLOTS} from "./shared"; +import "./style.scss"; + +export type FabricationRecipeData = NonNullable< + FabricationRecipesSubscription["fabricationRecipesUpdate"] +>[0]; + +interface Slot { + name: string; + count: number; +} + +interface FabricationProps { + children?: React.ReactNode; + simulator: Simulator; + clientObj?: any; +} + +const Fabrication: React.FC = ({simulator, clientObj}) => { + const {data: layoutData} = useFabricationInventoryQuery({ + variables: {simulatorId: simulator.id}, + fetchPolicy: "cache-and-network", + }); + const {data: inventorySubData} = useFabricationInventorySubSubscription({ + variables: {simulatorId: simulator.id}, + }); + const {data: recipeData} = useFabricationRecipesSubscription({ + variables: {simulatorId: simulator.id}, + }); + const {data: jobData} = useFabricationJobsSubscription({ + variables: {simulatorId: simulator.id}, + }); + const {data: settingsData} = useFabricationSettingsSubscription({ + variables: {simulatorId: simulator.id}, + }); + const [startFabrication] = useStartFabricationMutation(); + const [cancelJob] = useCancelFabricationJobMutation(); + + const [deckId, setDeckId] = React.useState(null); + const [roomId, setRoomId] = React.useState(null); + const [slots, setSlots] = React.useState([]); + const [batches, setBatches] = React.useState(1); + const [selectedRecipe, setSelectedRecipe] = React.useState( + null, + ); + const [search, setSearch] = React.useState(""); + const [message, setMessage] = React.useState<{ + text: string; + error: boolean; + } | null>(null); + const messageTimeout = React.useRef(undefined); + // A short energy surge plays when a job starts; continuous animation + // would wrongly suggest the fabricator can't take another job. A failed + // attempt plays a red misfire instead so the rejection is felt, not + // just read. + const [surging, setSurging] = React.useState(false); + const surgeTimeout = React.useRef(undefined); + const [misfiring, setMisfiring] = React.useState(false); + const misfireTimeout = React.useRef(undefined); + // During a misfire, slots holding components that belong to the closest + // secret recipe (with near-miss feedback enabled) glow amber — a wordless + // "keep these, swap that" + const [resonantSlots, setResonantSlots] = React.useState([]); + + React.useEffect( + () => () => { + window.clearTimeout(messageTimeout.current); + window.clearTimeout(surgeTimeout.current); + window.clearTimeout(misfireTimeout.current); + }, + [], + ); + + const allDecks = React.useMemo(() => layoutData?.decks || [], [layoutData]); + const inventory = React.useMemo( + () => inventorySubData?.inventoryUpdate || layoutData?.inventory || [], + [inventorySubData, layoutData], + ); + const recipes = React.useMemo( + () => recipeData?.fabricationRecipesUpdate || [], + [recipeData], + ); + const jobs = jobData?.fabricationJobsUpdate || []; + const settings = settingsData?.fabricationSettingsUpdate; + const fabricatorOnline = settings ? settings.enabled : true; + + // When the FD tags rooms with the fabrication role, only those rooms can + // fabricate — mirror the server's rule in the room picker. With no tagged + // rooms the fabricator runs ship-wide and room selection disappears. + const isFabricationRoom = (r: any) => + r?.roles?.includes(RoomRoles.Fabrication); + const hasFabricationRooms = React.useMemo( + () => allDecks.some(d => d?.rooms?.some(isFabricationRoom)), + [allDecks], + ); + const shipWide = !hasFabricationRooms; + const decks = React.useMemo(() => { + if (!hasFabricationRooms) return allDecks; + return allDecks + .map(d => + d ? {...d, rooms: d.rooms?.filter(isFabricationRoom)} : d, + ) + .filter(d => d?.rooms && d.rooms.length > 0); + }, [allDecks, hasFabricationRooms]); + + // Designated fabrication rooms come preselected so the crew can start + // fabricating without hunting through the ship layout first. Also recovers + // when the FD re-tags rooms mid-flight and the current selection is no + // longer a fabrication room. + React.useEffect(() => { + if (!hasFabricationRooms) return; + if ( + roomId && + decks.some(d => d?.rooms?.some(r => r?.id === roomId)) + ) + return; + const deck = decks[0]; + const room = deck?.rooms?.[0]; + if (deck?.id && room?.id) { + setDeckId(deck.id); + setRoomId(room.id); + } + }, [hasFabricationRooms, decks, roomId]); + + // Single-deck ships skip the deck dropdown, like Cargo Control does + const effectiveDeckId = decks.length === 1 ? decks[0]?.id : deckId; + + const showMessage = (text: string, error: boolean) => { + window.clearTimeout(messageTimeout.current); + setMessage({text, error}); + messageTimeout.current = window.setTimeout(() => setMessage(null), 8000); + }; + + // Crew can only see public schematics and secrets they've discovered + const knownRecipes = React.useMemo( + () => + recipes + .filter(r => !r.secret || r.discovered) + .filter(r => + search + ? r.name.toLowerCase().includes(search.toLowerCase()) || + r.output.name.toLowerCase().includes(search.toLowerCase()) + : true, + ) + .sort((a, b) => a.name.localeCompare(b.name)), + [recipes, search], + ); + const recipe = knownRecipes.find(r => r.id === selectedRecipe) || null; + + // How many of each item the fabricator can reach: the selected room's + // stock, or the whole ship's when running ship-wide + const roomStock = React.useMemo(() => { + const stock: {[name: string]: number} = {}; + if (!shipWide && !roomId) return stock; + inventory.forEach(item => { + const count = shipWide + ? (item?.roomCount || []).reduce( + (prev, rc) => prev + (rc?.count || 0), + 0, + ) + : item?.roomCount?.find(rc => rc?.room?.id === roomId)?.count || 0; + if (count > 0 && item?.name) stock[item.name] = count; + }); + return stock; + }, [inventory, roomId, shipWide]); + + // Only cargo that appears in some recipe is worth loading — hiding the + // rest keeps the component list approachable. Undiscovered secret + // recipes count too, so their ingredients stay available to experiment + // with. If no recipes are configured at all, show everything. + const usableNames = React.useMemo(() => { + const names = new Set(); + recipes.forEach(r => r.inputs.forEach(i => names.add(i.name.toLowerCase()))); + return names; + }, [recipes]); + const visibleStock = React.useMemo(() => { + const entries = Object.entries(roomStock); + if (recipes.length === 0) return entries; + return entries.filter(([name]) => usableNames.has(name.toLowerCase())); + }, [roomStock, usableNames, recipes.length]); + + const slotted = (name: string) => + slots.find(s => s.name.toLowerCase() === name.toLowerCase())?.count || 0; + + const addToSlot = (name: string) => { + if (slotted(name) >= (roomStock[name] || 0)) return; + setSelectedRecipe(null); + setSlots(current => { + const existing = current.find( + s => s.name.toLowerCase() === name.toLowerCase(), + ); + if (existing) { + return current.map(s => (s === existing ? {...s, count: s.count + 1} : s)); + } + if (current.length >= MAX_SLOTS) return current; + return [...current, {name, count: 1}]; + }); + }; + + const removeFromSlot = (name: string) => { + setSelectedRecipe(null); + setSlots(current => + current + .map(s => (s.name === name ? {...s, count: s.count - 1} : s)) + .filter(s => s.count > 0), + ); + }; + + // Tapping a schematic loads its component list straight into the slots + const loadRecipe = (r: FabricationRecipeData) => { + setSelectedRecipe(r.id); + setSlots(r.inputs.map(i => ({name: i.name, count: i.count}))); + }; + + // Recipes match inventory by name case-insensitively, so the availability + // check must too — otherwise a recipe whose input casing differs from the + // cargo item dims as unavailable even though it fabricates fine + const stockByLowerName = React.useMemo(() => { + const map: {[lower: string]: number} = {}; + Object.entries(roomStock).forEach(([name, count]) => { + const key = name.toLowerCase(); + map[key] = (map[key] || 0) + count; + }); + return map; + }, [roomStock]); + const recipeAvailable = (r: FabricationRecipeData) => + r.inputs.every( + i => (stockByLowerName[i.name.toLowerCase()] || 0) >= i.count, + ); + + // Which slotted components appear in the best-matching undiscovered + // secret recipe with near-miss feedback enabled. Requires at least two + // matches so a single common ingredient can't be used to fish for + // secrets one item at a time. + const findResonance = () => { + let best: string[] = []; + recipes + .filter(r => r.secret && !r.discovered && r.nearMiss) + .forEach(r => { + const inputNames = r.inputs.map(i => i.name.toLowerCase()); + const matched = slots + .filter(s => inputNames.includes(s.name.toLowerCase())) + .map(s => s.name); + if (matched.length > best.length) best = matched; + }); + return best.length >= 2 ? best : []; + }; + + const fabricate = async () => { + if ((!shipWide && !roomId) || slots.length === 0) return; + const {data} = await startFabrication({ + variables: { + simulatorId: simulator.id, + roomId: shipWide ? null : roomId, + inputs: slots.map(s => ({name: s.name, count: s.count})), + count: batches, + }, + }); + const result = data?.startFabrication || ""; + if (result.startsWith("ERROR:")) { + showMessage(result.replace("ERROR:", ""), true); + setSurging(false); + window.clearTimeout(surgeTimeout.current); + setResonantSlots(findResonance()); + setMisfiring(true); + window.clearTimeout(misfireTimeout.current); + misfireTimeout.current = window.setTimeout(() => { + setMisfiring(false); + setResonantSlots([]); + }, 1500); + } else { + showMessage("Fabrication in progress. Components consumed.", false); + setSlots([]); + setBatches(1); + setSelectedRecipe(null); + setMisfiring(false); + setResonantSlots([]); + window.clearTimeout(misfireTimeout.current); + setSurging(true); + window.clearTimeout(surgeTimeout.current); + surgeTimeout.current = window.setTimeout(() => setSurging(false), 3000); + } + }; + + // Secret recipes whose hint the FD has made visible show up as partial + // schematics — a clue, not a working recipe + const hintedRecipes = recipes.filter( + r => r.secret && !r.discovered && r.hintVisible && r.hint, + ); + + const visibleJobs = jobs + .concat() + .sort((a, b) => (a.status === "active" ? -1 : 1) - (b.status === "active" ? -1 : 1)); + + // Drives the queue badge; ongoing progress is shown by the queue's + // progress bars rather than a continuous fabricator animation + const activeJobCount = jobs.filter(j => j.status === "active").length; + + return ( + + + +

Component Source

+
+ {shipWide && ( +

+ Drawing components from every cargo room aboard the ship. +

+ )} + {!shipWide && decks.length > 1 && ( + { + setDeckId(deck); + setRoomId(null); + setSlots([]); + }} + > + {null} + + )} + {!shipWide && ( + { + setRoomId(room); + setSlots([]); + }} + /> + )} +
+
+ {!shipWide && !roomId && ( +

+ Select the room the fabricator should draw components from. +

+ )} + {(shipWide || roomId) && visibleStock.length === 0 && ( +

+ {shipWide + ? "There are no usable components aboard the ship." + : "This room has no usable components."} +

+ )} + {visibleStock.map(([name, count]) => { + const remaining = count - slotted(name); + return ( +
addToSlot(name)} + > + {name} + {remaining} +
+ ); + })} +
+ + +

Fabricator

+
0 ? "charged" : ""}`} + > +
+
+
+
+
+
+ {Array.from({length: MAX_SLOTS}).map((_, index) => { + const slot = slots[index]; + return ( +
slot && removeFromSlot(slot.name)} + > + {slot ? ( + <> + + ✕ + + {slot.name} + x{slot.count} + + ) : ( + Empty Slot + )} +
+ ); + })} +
+
+
+ {recipe ? ( +
+

Projected Output

+

+ {recipe.output.count} x {recipe.output.name} +

+ {recipe.description && ( +

{recipe.description}

+ )} +
+ ) : slots.length > 0 ? ( +
+

+ Output unknown — experimental mix +

+
+ ) : null} +
+

+ Fabrication Queue + {activeJobCount > 0 && — {activeJobCount} running} +

+
+ {visibleJobs.length === 0 && ( +

The fabricator is idle.

+ )} + {visibleJobs.map(job => ( +
+
+ + {job.output.count} x {job.output.name} + + {job.status === "active" ? ( + + {job.room + ? `${job.room.name}, Deck ${job.room.deck?.number}` + : ""} + + ) : ( + + {job.status === "complete" ? "Delivered" : "Cancelled"} + + )} +
+ {job.status === "active" && ( +
+ + {Math.round(job.progress * 100)}% + + +
+ )} +
+ ))} +
+
+
+
+ Quantity + + {batches} + +
+ + {message && ( +

+ {message.text} +

+ )} +
+ + +

Schematic Database

+ setSearch(e.target.value)} + /> +
+ {knownRecipes.length === 0 && hintedRecipes.length === 0 && ( +

No schematics on file.

+ )} + {knownRecipes.map(r => ( +
loadRecipe(r)} + > +
+ {r.name} + + {categoryLabels[r.category] || r.category} + +
+
+ {r.inputs + .map( + i => + `${i.count}x ${i.name}${ + i.consumed === false ? " (tool)" : "" + }`, + ) + .join(" + ")} + {" → "} + {r.output.count}x {r.output.name} +
+ {r.secret &&
Discovered Schematic
} +
+ ))} + {hintedRecipes.map(r => ( +
+
+ Partial Schematic + Unknown +
+
{r.hint}
+
+ ))} +
+ + + + + ); +}; + +export default Fabrication; diff --git a/src/components/views/Fabrication/recipeTemplates.ts b/src/components/views/Fabrication/recipeTemplates.ts new file mode 100644 index 000000000..d9f6f0f54 --- /dev/null +++ b/src/components/views/Fabrication/recipeTemplates.ts @@ -0,0 +1,529 @@ +import {Fabrication_Category} from "generated/graphql"; + +// Starter packs for the Fabrication system. Applying a pack adds its recipes +// to the simulator and seeds the component cargo into a room the configurer +// chooses, so a ship can be fabrication-ready in one click. + +export interface TemplateCargo { + name: string; + count: number; + metadata?: { + type?: string; + description?: string; + science?: boolean; + defense?: boolean; + warheadType?: string; + }; +} + +export interface TemplateRecipe { + name: string; + description: string; + category: Fabrication_Category; + inputs: {name: string; count: number; consumed?: boolean}[]; + output: { + name: string; + count: number; + metadata?: { + type?: string; + description?: string; + science?: boolean; + defense?: boolean; + warheadType?: string; + }; + }; + duration: number; + secret?: boolean; + hint?: string; + nearMiss?: boolean; +} + +export interface RecipeTemplatePack { + id: string; + name: string; + description: string; + cargo: TemplateCargo[]; + recipes: TemplateRecipe[]; +} + +export const recipeTemplatePacks: RecipeTemplatePack[] = [ + { + id: "repair-essentials", + name: "Repair Essentials", + description: + "Hull patches, couplings, shield boosters, and circuit boards for damage control. Includes secret shield-surge and forcefield schematics.", + cargo: [ + {name: "Duranium Plate", count: 12}, + {name: "Optical Cable", count: 12}, + {name: "Plasma Conduit", count: 12}, + {name: "Isolinear Chip", count: 10}, + {name: "Coolant Cell", count: 8}, + { + name: "Micro-Welder", + count: 2, + metadata: {description: "A precision fusion tool. Not consumed by fabrication."}, + }, + ], + recipes: [ + { + name: "Hull Patch Kit", + description: "A pre-formed duranium patch for sealing hull breaches.", + category: Fabrication_Category.Repair, + inputs: [ + {name: "Duranium Plate", count: 2}, + {name: "Micro-Welder", count: 1, consumed: false}, + ], + output: { + name: "Hull Patch Kit", + count: 1, + metadata: {type: "repair", description: "Seals small hull breaches."}, + }, + duration: 45, + }, + { + name: "EPS Coupling", + description: "Replacement coupling for the electro-plasma system.", + category: Fabrication_Category.Repair, + inputs: [ + {name: "Plasma Conduit", count: 1}, + {name: "Optical Cable", count: 1}, + ], + output: { + name: "EPS Coupling", + count: 1, + metadata: {type: "repair", description: "Restores plasma flow to damaged systems."}, + }, + duration: 30, + }, + { + name: "Circuit Board", + description: "General-purpose isolinear circuit board.", + category: Fabrication_Category.Repair, + inputs: [ + {name: "Isolinear Chip", count: 2}, + {name: "Optical Cable", count: 1}, + ], + output: { + name: "Circuit Board", + count: 1, + metadata: {type: "repair", description: "Replaces burned-out control circuitry."}, + }, + duration: 30, + }, + { + name: "Coolant Flush Canister", + description: + "Pressurized canister that refills the ship's coolant tank by 10%.", + category: Fabrication_Category.Repair, + inputs: [{name: "Coolant Cell", count: 2}], + output: { + name: "Coolant Flush Canister", + count: 1, + metadata: {type: "coolant", description: "Refills the coolant tank."}, + }, + duration: 20, + }, + { + name: "Shield Booster Cell", + description: + "A charged cell that restores the weakest shield by 10%.", + category: Fabrication_Category.Repair, + inputs: [ + {name: "Plasma Conduit", count: 1}, + {name: "Isolinear Chip", count: 1}, + ], + output: { + name: "Shield Booster Cell", + count: 1, + metadata: { + type: "shieldBoost", + description: "Restores shield integrity.", + }, + }, + duration: 30, + }, + { + name: "Shield Surge Matrix", + description: + "An overcharged booster array that restores 30% shield integrity in one cycle.", + category: Fabrication_Category.Repair, + inputs: [ + {name: "Plasma Conduit", count: 2}, + {name: "Isolinear Chip", count: 1}, + {name: "Coolant Cell", count: 1}, + ], + output: { + name: "Shield Booster Cell", + count: 3, + metadata: { + type: "shieldBoost", + description: "Restores shield integrity.", + }, + }, + duration: 75, + secret: true, + hint: "Overcharge a booster cell with an extra conduit — and keep it cool.", + nearMiss: true, + }, + { + name: "Emergency Forcefield Emitter", + description: + "A portable emitter that can seal a corridor with a level-3 forcefield.", + category: Fabrication_Category.Repair, + inputs: [ + {name: "Circuit Board", count: 1}, + {name: "EPS Coupling", count: 1}, + {name: "Duranium Plate", count: 1}, + ], + output: { + name: "Emergency Forcefield Emitter", + count: 1, + metadata: {description: "Projects a temporary structural forcefield."}, + }, + duration: 90, + secret: true, + hint: "Two fabricated components, reinforced with raw plating.", + nearMiss: true, + }, + ], + }, + { + id: "weapons-lab", + name: "Weapons Lab", + description: + "Torpedo assembly and security ordnance — photon and quantum torpedos plus secret EMP and tricobalt variants.", + cargo: [ + {name: "Torpedo Casing", count: 10}, + {name: "Photon Warhead", count: 8}, + {name: "Quantum Charge", count: 6}, + {name: "Guidance Module", count: 10}, + {name: "Explosive Compound", count: 16}, + {name: "Steel Casing", count: 10}, + {name: "Power Cell", count: 10}, + ], + recipes: [ + { + name: "Photon Torpedo", + description: "Standard ship-to-ship photon torpedo.", + category: Fabrication_Category.Weapon, + inputs: [ + {name: "Torpedo Casing", count: 1}, + {name: "Photon Warhead", count: 1}, + {name: "Guidance Module", count: 1}, + ], + output: { + name: "Photon Torpedo", + count: 1, + metadata: { + type: "torpedo", + warheadType: "photon", + description: "Standard photon torpedo. Loads into the launcher.", + }, + }, + duration: 60, + }, + { + name: "Quantum Torpedo", + description: + "A zero-point energy torpedo with a heavier punch than a photon.", + category: Fabrication_Category.Weapon, + inputs: [ + {name: "Torpedo Casing", count: 1}, + {name: "Quantum Charge", count: 1}, + {name: "Guidance Module", count: 1}, + ], + output: { + name: "Quantum Torpedo", + count: 1, + metadata: { + type: "torpedo", + warheadType: "quantum", + description: "Quantum torpedo. Loads into the launcher.", + }, + }, + duration: 75, + }, + { + name: "Stun Grenade", + description: "Non-lethal crowd control for security teams.", + category: Fabrication_Category.Weapon, + inputs: [ + {name: "Steel Casing", count: 1}, + {name: "Power Cell", count: 1}, + ], + output: { + name: "Stun Grenade", + count: 2, + metadata: {description: "Non-lethal stun device."}, + }, + duration: 30, + }, + { + name: "Breaching Charge", + description: "Shaped charge for cutting through bulkheads.", + category: Fabrication_Category.Weapon, + inputs: [ + {name: "Explosive Compound", count: 2}, + {name: "Steel Casing", count: 1}, + ], + output: { + name: "Breaching Charge", + count: 1, + metadata: {description: "Cuts through sealed bulkheads and doors."}, + }, + duration: 45, + }, + { + name: "EMP Torpedo", + description: + "Disables enemy systems without structural damage. Ideal for capture operations.", + category: Fabrication_Category.Weapon, + inputs: [ + {name: "Torpedo Casing", count: 1}, + {name: "Power Cell", count: 2}, + {name: "Guidance Module", count: 1}, + ], + output: { + name: "EMP Torpedo", + count: 1, + metadata: { + type: "torpedo", + warheadType: "other", + description: + "Disables systems without destroying them. Loads into the launcher.", + }, + }, + duration: 75, + secret: true, + hint: "A torpedo that disables rather than destroys — replace the warhead with raw energy.", + nearMiss: true, + }, + { + name: "Tricobalt Torpedo", + description: + "A subspace-shockwave device packing triple the standard explosive yield. Not exactly regulation.", + category: Fabrication_Category.Weapon, + inputs: [ + {name: "Torpedo Casing", count: 1}, + {name: "Explosive Compound", count: 3}, + {name: "Guidance Module", count: 1}, + ], + output: { + name: "Tricobalt Torpedo", + count: 1, + metadata: { + type: "torpedo", + warheadType: "other", + description: + "High-yield tricobalt device. Loads into the launcher.", + }, + }, + duration: 90, + secret: true, + hint: "Pack a casing with far more explosive than regulations allow — and give it somewhere to go.", + nearMiss: true, + }, + ], + }, + { + id: "salvage-reclamation", + name: "Salvage Reclamation", + description: + "Refine salvage into countermeasure materials and railgun ammunition — both feed directly into their systems. Includes a secret mass-production process.", + cargo: [ + {name: "Scrap Metal", count: 20}, + {name: "Raw Ore", count: 16}, + {name: "Salvaged Electronics", count: 12}, + {name: "Chemical Sludge", count: 12}, + { + name: "Refinery Catalyst", + count: 1, + metadata: { + description: "Accelerates ore refinement. Not consumed by fabrication.", + }, + }, + ], + recipes: [ + { + name: "Refine Copper", + description: "Smelts salvaged ore into countermeasure-grade copper.", + category: Fabrication_Category.Science, + inputs: [ + {name: "Raw Ore", count: 2}, + {name: "Refinery Catalyst", count: 1, consumed: false}, + ], + output: { + name: "Copper", + count: 2, + metadata: {type: "countermeasureMaterial"}, + }, + duration: 20, + }, + { + name: "Refine Titanium", + description: "Reclaims titanium from structural scrap.", + category: Fabrication_Category.Science, + inputs: [ + {name: "Scrap Metal", count: 2}, + {name: "Refinery Catalyst", count: 1, consumed: false}, + ], + output: { + name: "Titanium", + count: 1, + metadata: {type: "countermeasureMaterial"}, + }, + duration: 30, + }, + { + name: "Extract Carbon", + description: "Extracts carbon from chemical waste.", + category: Fabrication_Category.Science, + inputs: [{name: "Chemical Sludge", count: 1}], + output: { + name: "Carbon", + count: 2, + metadata: {type: "countermeasureMaterial"}, + }, + duration: 20, + }, + { + name: "Polymerize Plastic", + description: "Converts chemical sludge into usable polymer stock.", + category: Fabrication_Category.Science, + inputs: [{name: "Chemical Sludge", count: 2}], + output: { + name: "Plastic", + count: 2, + metadata: {type: "countermeasureMaterial"}, + }, + duration: 20, + }, + { + name: "Condense Plasma", + description: "Charges reclaimed electronics into plasma cells.", + category: Fabrication_Category.Science, + inputs: [ + {name: "Salvaged Electronics", count: 1}, + {name: "Chemical Sludge", count: 1}, + ], + output: { + name: "Plasma", + count: 1, + metadata: {type: "countermeasureMaterial"}, + }, + duration: 40, + }, + { + name: "Railgun Slugs", + description: "Presses scrap metal into railgun slugs.", + category: Fabrication_Category.Weapon, + inputs: [{name: "Scrap Metal", count: 2}], + output: { + name: "Railgun Slugs", + count: 10, + metadata: {type: "railgunAmmo"}, + }, + duration: 30, + }, + { + name: "Munitions Surge", + description: + "A high-yield pressing run that floods the railgun magazine.", + category: Fabrication_Category.Weapon, + inputs: [ + {name: "Scrap Metal", count: 2}, + {name: "Raw Ore", count: 2}, + {name: "Salvaged Electronics", count: 1}, + ], + output: { + name: "Railgun Slugs", + count: 40, + metadata: {type: "railgunAmmo"}, + }, + duration: 90, + secret: true, + hint: "A richer mix of salvage could keep the press running much longer.", + nearMiss: true, + }, + ], + }, + { + id: "medical-supplies", + name: "Medical Supplies", + description: + "Field medical fabrication for sickbay and away teams. Includes a secret experimental serum.", + cargo: [ + {name: "Biogel", count: 12}, + {name: "Sterile Casing", count: 10}, + {name: "Stimulant Compound", count: 8}, + {name: "Herbal Extract", count: 8}, + ], + recipes: [ + { + name: "Medkit", + description: "Standard field medical kit.", + category: Fabrication_Category.Misc, + inputs: [ + {name: "Biogel", count: 1}, + {name: "Sterile Casing", count: 1}, + ], + output: { + name: "Medkit", + count: 1, + metadata: {description: "Treats common injuries in the field."}, + }, + duration: 30, + }, + { + name: "Stim Pack", + description: "Emergency stimulant injector.", + category: Fabrication_Category.Misc, + inputs: [ + {name: "Stimulant Compound", count: 1}, + {name: "Sterile Casing", count: 1}, + ], + output: { + name: "Stim Pack", + count: 1, + metadata: {description: "Keeps an injured crew member on their feet."}, + }, + duration: 30, + }, + { + name: "Antidote", + description: "Broad-spectrum antitoxin.", + category: Fabrication_Category.Misc, + inputs: [ + {name: "Herbal Extract", count: 1}, + {name: "Biogel", count: 1}, + ], + output: { + name: "Antidote", + count: 1, + metadata: {description: "Counteracts most known toxins."}, + }, + duration: 40, + }, + { + name: "Miracle Serum", + description: + "An experimental compound rumored to bring patients back from the brink.", + category: Fabrication_Category.Misc, + inputs: [ + {name: "Stimulant Compound", count: 1}, + {name: "Herbal Extract", count: 1}, + {name: "Biogel", count: 1}, + ], + output: { + name: "Miracle Serum", + count: 1, + metadata: {description: "Stabilizes even critical patients instantly."}, + }, + duration: 120, + secret: true, + hint: "Three ingredients from the medical stores, blended together.", + nearMiss: true, + }, + ], + }, +]; diff --git a/src/components/views/Fabrication/shared.ts b/src/components/views/Fabrication/shared.ts new file mode 100644 index 000000000..a648457d2 --- /dev/null +++ b/src/components/views/Fabrication/shared.ts @@ -0,0 +1,13 @@ +// Constants shared by the crew card, the FD core, and the simulator config +// screen. Keep this module free of component imports so any surface can pull +// it in without dragging the others along. +export const MAX_SLOTS = 4; + +export const categoryLabels: {[key: string]: string} = { + repair: "Repair", + weapon: "Weapons", + probe: "Probes", + upgrade: "Upgrades", + science: "Science", + misc: "General", +}; diff --git a/src/components/views/Fabrication/style.scss b/src/components/views/Fabrication/style.scss new file mode 100644 index 000000000..36c1b8d22 --- /dev/null +++ b/src/components/views/Fabrication/style.scss @@ -0,0 +1,914 @@ +// Fabrication styles: crew card, recipe editor (shared with simulator +// config), and Flight Director core. + +// Recipe editor form, used inside the core and the simulator config screen +.fabrication-recipe-editor { + max-width: 720px; + + label { + margin-top: 10px; + margin-bottom: 2px; + } + + .editor-row { + display: flex; + gap: 12px; + margin-bottom: 6px; + + > div { + flex: 1; + } + + .count-input { + max-width: 72px; + } + } + + .editor-actions { + display: flex; + gap: 8px; + margin-top: 16px; + } + + .component-row { + display: grid; + grid-template-columns: 1fr 72px auto auto; + gap: 12px; + align-items: center; + + .consumed-toggle { + white-space: nowrap; + margin: 0; + } + } + + .secret-options { + padding: 4px 12px; + border-left: 2px solid rgba(255, 210, 92, 0.6); + margin: 8px 0; + + label { + margin-top: 6px; + } + } +} + +// Simulator config panel layout +.fabrication-config { + color: white; + + .config-intro { + opacity: 0.85; + max-width: 1000px; + } + + .config-toolbar { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + padding: 8px 12px; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + background: rgba(0, 0, 0, 0.3); + margin-bottom: 8px; + + .toolbar-item { + display: flex; + align-items: center; + gap: 6px; + margin: 0; + white-space: nowrap; + + input[type="checkbox"] { + position: static; + margin: 0; + } + } + + .job-limit-input { + width: 64px; + display: inline-block; + } + + .toolbar-spacer { + flex: 1; + } + + .template-select { + width: auto; + max-width: 220px; + } + } + + .config-status { + margin: 4px 0; + } + + .config-body { + margin-top: 8px; + } + + .config-recipe-list { + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 6px; + background: rgba(0, 0, 0, 0.3); + max-height: 45vh; + overflow-y: auto; + } + + .config-empty { + opacity: 0.6; + font-style: italic; + padding: 12px; + } + + .config-recipe { + padding: 8px 12px; + cursor: pointer; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + + &:hover { + background: rgba(255, 255, 255, 0.08); + } + + &.selected { + background: rgba(92, 217, 255, 0.15); + } + + .config-recipe-title { + display: flex; + justify-content: space-between; + gap: 8px; + + small { + opacity: 0.6; + white-space: nowrap; + } + } + + .config-recipe-io { + opacity: 0.75; + } + } + + .rooms-header { + margin-top: 16px; + + small { + opacity: 0.6; + font-weight: normal; + } + } + + .config-room-list { + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 6px; + background: rgba(0, 0, 0, 0.3); + max-height: 30vh; + overflow-y: auto; + padding: 8px 12px; + + strong { + display: block; + margin-top: 6px; + opacity: 0.8; + } + + .config-room { + display: block; + margin: 0 0 0 12px; + cursor: pointer; + } + } + + .template-preview { + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 6px; + background: rgba(0, 0, 0, 0.3); + padding: 16px; + max-width: 720px; + + .template-columns { + display: flex; + gap: 32px; + + ul { + padding-left: 20px; + } + } + + .template-room-select { + max-width: 320px; + } + + .template-actions { + display: flex; + gap: 8px; + margin-top: 12px; + } + } + + .remove-recipe { + margin-top: 8px; + } +} + +// Flight Director core panel +.core-fabrication { + height: 100%; + overflow-y: auto; + font-size: 12px; + + .core-section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 8px; + border-bottom: 1px solid rgba(128, 128, 128, 0.5); + } + + .core-settings { + display: flex; + gap: 12px; + align-items: center; + padding-bottom: 4px; + + label { + margin: 0; + } + + .core-job-limit { + width: 50px; + } + } + + .core-hint-shown { + opacity: 0.7; + font-style: italic; + } + + .core-hint { + opacity: 0.6; + font-style: italic; + margin: 4px 0; + } + + .core-missing { + border: 1px solid rgba(255, 193, 7, 0.6); + border-radius: 4px; + padding: 4px; + margin-bottom: 4px; + + .core-missing-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 2px 0; + } + } + + .core-job { + display: grid; + grid-template-columns: 1fr 80px auto auto; + gap: 4px; + align-items: center; + padding: 2px 0; + + &.finished { + opacity: 0.6; + } + } + + .core-recipe { + padding: 4px 0; + border-bottom: 1px solid rgba(128, 128, 128, 0.25); + + .core-recipe-name { + display: flex; + justify-content: space-between; + + .editable { + cursor: pointer; + text-decoration: underline dotted; + } + + .core-recipe-category { + opacity: 0.6; + } + } + + .core-recipe-io { + opacity: 0.8; + } + + .core-recipe-secret { + color: #ffc107; + + &.discovered { + color: #7bd67b; + } + } + + .core-near-miss { + color: #ffdb70; + font-style: italic; + + &.hot { + color: #ff8f5c; + font-weight: bold; + font-style: normal; + } + } + } +} + +// Fabrication crew card: component source | fabricator slots | schematic +// database. The job queue lives inside the materializer chamber in the +// center column. +.card-fabrication { + height: 100%; + display: flex; + flex-direction: column; + color: white; + + h4 { + margin-top: 8px; + } + + .fabrication-content { + flex: 1; + min-height: 0; + + > [class*="col-"] { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + } + } + + .hint { + opacity: 0.6; + font-style: italic; + padding: 8px; + } + + .room-pickers { + display: flex; + gap: 8px; + + .btn-group, + .dropdown { + flex: 1; + } + } + + .cargo-list, + .recipe-list { + flex: 1; + min-height: 0; + overflow-y: auto; + margin-top: 8px; + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 6px; + background: rgba(0, 0, 0, 0.3); + } + + .cargo-item { + display: flex; + justify-content: space-between; + padding: 8px 12px; + cursor: pointer; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + + &:hover { + background: rgba(255, 255, 255, 0.1); + } + + &.depleted { + opacity: 0.4; + cursor: default; + } + + .count { + opacity: 0.7; + } + } + + // Wraps the slots, the drop connector, and the output chamber. The + // conduit cross between the slots feeds a central node; a short drop + // line carries the product into the output chamber below. Animations + // play as a brief surge when a job starts — a continuous animation + // would wrongly suggest the fabricator can't take concurrent jobs. + .fabricator-visual { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + position: relative; + + .conduit { + position: absolute; + background-color: rgba(92, 217, 255, 0.22); + pointer-events: none; + } + + // Components loaded: the conduits and node warm up + &.charged .conduit, + &.charged .conduit-drop { + background-color: rgba(92, 217, 255, 0.45); + } + + &.charged .fab-node { + border-color: rgba(92, 217, 255, 0.9); + box-shadow: 0 0 9px rgba(92, 217, 255, 0.6); + } + + // Job started: a ~3 second energy surge. Side and top conduits flow + // into the node; the bottom conduit and drop line carry the product + // down into the output chamber. + &.surging .conduit-h-left { + background-image: linear-gradient( + 90deg, + transparent 0%, + rgba(92, 217, 255, 0.95) 50%, + transparent 100% + ); + background-size: 18px 100%; + animation: fabrication-flow-right 0.7s linear infinite; + } + + &.surging .conduit-h-right { + background-image: linear-gradient( + 90deg, + transparent 0%, + rgba(92, 217, 255, 0.95) 50%, + transparent 100% + ); + background-size: 18px 100%; + animation: fabrication-flow-left 0.7s linear infinite; + } + + &.surging .conduit-v-top, + &.surging .conduit-v-bottom, + &.surging .conduit-drop { + background-image: linear-gradient( + 180deg, + transparent 0%, + rgba(92, 217, 255, 0.95) 50%, + transparent 100% + ); + background-size: 100% 18px; + animation: fabrication-flow-down 0.7s linear infinite; + } + + &.surging .fab-node { + animation: fabrication-node-pulse 0.75s ease-in-out infinite; + } + + &.surging .output-preview { + border-color: rgba(92, 217, 255, 0.8); + box-shadow: 0 0 12px rgba(92, 217, 255, 0.35) inset; + } + + // Failed attempt: a brief red misfire. The flow keyframes run in + // reverse so the energy reads as rejected — spat back out of the core + &.misfiring .conduit, + &.misfiring .conduit-drop { + background-color: rgba(255, 92, 92, 0.3); + } + + &.misfiring .conduit-h-left { + background-image: linear-gradient( + 90deg, + transparent 0%, + rgba(255, 92, 92, 0.95) 50%, + transparent 100% + ); + background-size: 18px 100%; + animation: fabrication-flow-right 0.7s linear infinite reverse; + } + + &.misfiring .conduit-h-right { + background-image: linear-gradient( + 90deg, + transparent 0%, + rgba(255, 92, 92, 0.95) 50%, + transparent 100% + ); + background-size: 18px 100%; + animation: fabrication-flow-left 0.7s linear infinite reverse; + } + + &.misfiring .conduit-v-top, + &.misfiring .conduit-v-bottom, + &.misfiring .conduit-drop { + background-image: linear-gradient( + 180deg, + transparent 0%, + rgba(255, 92, 92, 0.95) 50%, + transparent 100% + ); + background-size: 100% 18px; + animation: fabrication-flow-down 0.7s linear infinite reverse; + } + + &.misfiring .fab-node { + border-color: rgba(255, 92, 92, 0.9); + animation: fabrication-node-misfire 0.75s ease-in-out infinite; + } + + &.misfiring .output-preview { + border-color: rgba(255, 92, 92, 0.7); + box-shadow: 0 0 12px rgba(255, 92, 92, 0.3) inset; + } + + // Components that belong to a nearby secret recipe resonate amber + // through the red misfire: keep these, swap the rest + &.misfiring .slot.resonant { + border-color: rgba(255, 193, 7, 0.9); + animation: fabrication-slot-resonate 0.75s ease-in-out infinite; + } + } + + .slots { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 26px; + margin-top: 8px; + padding: 4px; + position: relative; + + // The conduit cross runs through the widened gaps between the slots, + // meeting at the central node + .conduit-h-left, + .conduit-h-right { + top: calc(50% - 1px); + height: 2px; + } + + .conduit-h-left { + left: 4px; + right: calc(50% + 14px); + } + + .conduit-h-right { + left: calc(50% + 14px); + right: 4px; + } + + .conduit-v-top, + .conduit-v-bottom { + left: calc(50% - 1px); + width: 2px; + } + + .conduit-v-top { + top: 4px; + bottom: calc(50% + 14px); + } + + .conduit-v-bottom { + top: calc(50% + 14px); + bottom: -8px; + } + + // The junction where the conduits meet — the fabricator's core + .fab-node { + position: absolute; + top: 50%; + left: 50%; + width: 18px; + height: 18px; + transform: translate(-50%, -50%) rotate(45deg); + background: #0a2833; + border: 1px solid rgba(92, 217, 255, 0.6); + box-shadow: 0 0 6px rgba(92, 217, 255, 0.4); + z-index: 2; + pointer-events: none; + } + } + + // Short connector carrying the product from the node down into the + // output chamber — it stops there, nothing draws behind the controls + .conduit-drop { + width: 2px; + height: 14px; + align-self: center; + background-color: rgba(92, 217, 255, 0.22); + pointer-events: none; + } + + .slot { + min-height: 60px; + border: 1px dashed rgba(255, 255, 255, 0.35); + border-radius: 6px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 4px; + + &.filled { + border-style: solid; + border-color: rgba(92, 217, 255, 0.7); + background: rgba(92, 217, 255, 0.12); + cursor: pointer; + animation: fabrication-slot-pop 0.25s ease-out; + position: relative; + + .slot-remove { + position: absolute; + top: 2px; + right: 6px; + font-size: 11px; + opacity: 0.6; + } + + &:hover .slot-remove { + opacity: 1; + } + } + + .slot-empty { + opacity: 0.4; + font-style: italic; + } + + .slot-name { + font-weight: bold; + } + + .slot-count { + opacity: 0.8; + } + } + + // The materializer chamber: the drop conduit feeds into it, it glows + // briefly when a job starts, and finished work materializes inside — + // the projected output sits on top and the job queue lives beneath it, + // scrolling within the chamber so it never grows past its box. + .output-preview { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + text-align: center; + border: 1px solid rgba(92, 217, 255, 0.25); + border-radius: 6px; + background: rgba(0, 0, 0, 0.3); + padding: 8px; + transition: border-color 0.4s, box-shadow 0.4s; + + .chamber-output { + border-bottom: 1px solid rgba(92, 217, 255, 0.2); + padding-bottom: 6px; + margin-bottom: 6px; + } + + .output-label { + margin: 0; + opacity: 0.6; + text-transform: uppercase; + font-size: 0.8em; + } + + .output-name { + font-size: 1.3em; + font-weight: bold; + margin: 4px 0; + } + + .output-description { + opacity: 0.8; + margin-bottom: 0; + } + + .output-unknown { + opacity: 0.6; + font-style: italic; + margin: 4px 0; + } + + .chamber-queue { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + + .queue-label { + margin: 0 0 4px; + opacity: 0.6; + text-transform: uppercase; + font-size: 0.8em; + } + + .job-list { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 4px; + } + } + + .job { + text-align: left; + padding: 4px 8px; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + font-size: 0.9em; + + &.job-complete { + border-color: rgba(92, 255, 133, 0.4); + } + + &.job-cancelled { + opacity: 0.5; + } + + .job-info { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; + + .job-name { + font-weight: bold; + } + + .job-room, + .job-status { + font-size: 0.85em; + opacity: 0.7; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + .job-progress-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 4px; + + .progress { + flex: 1; + } + } + } + } + + .status-message { + text-align: center; + margin-top: 8px; + } + + .recipe-search { + margin-top: 8px; + } + + .recipe { + padding: 8px 12px; + cursor: pointer; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + + &:hover { + background: rgba(255, 255, 255, 0.1); + } + + &.selected { + background: rgba(92, 217, 255, 0.15); + } + + &.unavailable { + opacity: 0.5; + } + + .recipe-title { + display: flex; + justify-content: space-between; + font-weight: bold; + + .recipe-category { + font-weight: normal; + opacity: 0.6; + font-size: 0.85em; + } + } + + .recipe-io { + font-size: 0.85em; + opacity: 0.8; + } + + .recipe-secret { + font-size: 0.75em; + color: #ffd25c; + text-transform: uppercase; + } + + &.partial { + cursor: default; + border-left: 2px solid rgba(255, 210, 92, 0.6); + font-style: italic; + + &:hover { + background: transparent; + } + } + } + + .batch-row { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin-top: 12px; + margin-bottom: 8px; + + .batch-label { + opacity: 0.7; + text-transform: uppercase; + font-size: 0.8em; + } + + .batch-count { + font-size: 1.2em; + font-weight: bold; + min-width: 24px; + text-align: center; + } + } + +} + +@keyframes fabrication-slot-pop { + from { + transform: scale(0.85); + } + to { + transform: scale(1); + } +} + +@keyframes fabrication-flow-down { + from { + background-position: 0 0; + } + to { + background-position: 0 18px; + } +} + +@keyframes fabrication-flow-right { + from { + background-position: 0 0; + } + to { + background-position: 18px 0; + } +} + +@keyframes fabrication-flow-left { + from { + background-position: 0 0; + } + to { + background-position: -18px 0; + } +} + +@keyframes fabrication-node-pulse { + 0%, + 100% { + box-shadow: 0 0 5px rgba(92, 217, 255, 0.4); + } + 50% { + box-shadow: 0 0 18px rgba(92, 217, 255, 0.95); + } +} + +@keyframes fabrication-slot-resonate { + 0%, + 100% { + box-shadow: 0 0 4px rgba(255, 193, 7, 0.4); + } + 50% { + box-shadow: 0 0 14px rgba(255, 193, 7, 0.9); + } +} + +@keyframes fabrication-node-misfire { + 0%, + 100% { + box-shadow: 0 0 5px rgba(255, 92, 92, 0.4); + } + 50% { + box-shadow: 0 0 18px rgba(255, 92, 92, 0.95); + } +} diff --git a/src/components/views/Fabrication/trainingSteps.ts b/src/components/views/Fabrication/trainingSteps.ts new file mode 100644 index 000000000..2514186ac --- /dev/null +++ b/src/components/views/Fabrication/trainingSteps.ts @@ -0,0 +1,42 @@ +export const trainingSteps = [ + { + selector: ".nothing", + content: + "The fabricator combines cargo already aboard the ship into new items — repair parts, weapons, upgrades, and more.", + }, + { + selector: ".room-pickers", + content: + "This is where the fabricator gets its components. If the ship has a dedicated fabrication room it is already selected for you; otherwise the fabricator can reach cargo anywhere on the ship.", + }, + { + selector: ".cargo-list", + content: + "These are the components the fabricator can work with — cargo it can't use in any recipe is filtered out. Tap an item to load it into a fabricator slot. Tap it again to load more than one.", + }, + { + selector: ".slots", + content: + "The fabricator holds up to four different components at a time. Tap a loaded slot (or its ✕) to take a component back out.", + }, + { + selector: ".recipe-list", + content: + "The schematic database lists known recipes. Tap one to load its components automatically. Rumor has it some combinations aren't in the database — experimenting might discover them.", + }, + { + selector: ".batch-row", + content: + "Need more than one? Turn up the quantity. Each extra copy uses another set of components and adds to the build time.", + }, + { + selector: ".fabricate-button", + content: + "When the right components are loaded, start fabrication. The components are consumed, and the finished item is delivered when the cycle completes — some outputs, like torpedos or shield boosters, go straight into the ship's systems.", + }, + { + selector: ".chamber-queue", + content: + "Jobs in progress appear here in the materializer chamber. You can run several at once, and cancel an active job to get its components back.", + }, +]; diff --git a/src/components/views/ShipStructure/core.jsx b/src/components/views/ShipStructure/core.jsx index d08ee0eea..fbfe20ca8 100644 --- a/src/components/views/ShipStructure/core.jsx +++ b/src/components/views/ShipStructure/core.jsx @@ -368,6 +368,7 @@ class DecksCore extends Component { "damageTeam", "securityTeam", "medicalTeam", + "fabrication", ].map(r => (