diff --git a/public/cardIcons/Aegis.svg b/public/cardIcons/Aegis.svg new file mode 100644 index 000000000..e67b73c84 --- /dev/null +++ b/public/cardIcons/Aegis.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/systems/aegis.png b/public/systems/aegis.png new file mode 100644 index 000000000..34360d53e Binary files /dev/null and b/public/systems/aegis.png differ diff --git a/server/app.ts b/server/app.ts index 7fb8503a4..66c648a1c 100644 --- a/server/app.ts +++ b/server/app.ts @@ -78,6 +78,7 @@ class Events extends EventEmitter { dmxConfigs: ClassesImport.DMXConfig[] = []; dmxSets: ClassesImport.DMXSet[] = []; hackingPresets: ClassesImport.HackingPreset[] = []; + advancedTrainingProgress: any[] = []; printQueue: { id: string; asset: string; @@ -215,6 +216,7 @@ class Events extends EventEmitter { events = null, flights = [], motus = [], + advancedTrainingProgress = [], ...snapshot }: Events) { const newFlights = flights.map(({timeouts, ...f}) => f); diff --git a/server/classes/advancedTraining.ts b/server/classes/advancedTraining.ts new file mode 100644 index 000000000..f4f100f2b --- /dev/null +++ b/server/classes/advancedTraining.ts @@ -0,0 +1,289 @@ +import uuid from "uuid"; + +export interface RequiredActionParams { + id?: string; + eventName?: string; + args?: Record | null; +} + +export class RequiredAction { + id: string; + eventName: string; + args: Record | null; + + constructor(params: RequiredActionParams = {}) { + this.id = params.id || uuid.v4(); + this.eventName = params.eventName || ""; + this.args = params.args || null; + } +} + +export interface SubChapterParams { + id?: string; + name?: string; + requiredActions?: RequiredActionParams[]; +} + +export class SubChapter { + id: string; + name: string; + requiredActions: RequiredAction[]; + + constructor(params: SubChapterParams = {}) { + this.id = params.id || uuid.v4(); + this.name = params.name || "New Sub-Chapter"; + this.requiredActions = (params.requiredActions || []).map( + a => new RequiredAction(a), + ); + } + + setName(name: string) { + this.name = name; + } + + setRequiredActions(actions: RequiredActionParams[]) { + this.requiredActions = actions.map(a => new RequiredAction(a)); + } + + addRequiredAction(action: RequiredActionParams) { + this.requiredActions.push(new RequiredAction(action)); + } + + removeRequiredAction(actionId: string) { + this.requiredActions = this.requiredActions.filter(a => a.id !== actionId); + } +} + +export interface ChapterParams { + id?: string; + name?: string; + cardComponent?: string; + mediaAsset?: string | null; + autoOpenMedia?: boolean; + autoAdvance?: boolean; + autoLogin?: "none" | "immediate" | "on-complete" | boolean; + cardSwitchBehavior?: "auto" | "manual"; + mediaSize?: "small" | "medium" | "large"; + mediaPosition?: string; + subChapters?: SubChapterParams[]; +} + +export class Chapter { + id: string; + name: string; + cardComponent: string; + mediaAsset: string | null; + autoOpenMedia: boolean; + autoAdvance: boolean; + autoLogin: "none" | "immediate" | "on-complete"; + cardSwitchBehavior: "auto" | "manual"; + mediaSize: "small" | "medium" | "large"; + mediaPosition: string; + subChapters: SubChapter[]; + + constructor(params: ChapterParams = {}) { + this.id = params.id || uuid.v4(); + this.name = params.name || "New Chapter"; + this.cardComponent = params.cardComponent || ""; + this.mediaAsset = params.mediaAsset || null; + this.autoOpenMedia = params.autoOpenMedia ?? false; + this.autoAdvance = params.autoAdvance ?? false; + // Handle migration from boolean (false → "none", true → "immediate") + const rawLogin = params.autoLogin; + this.autoLogin = + rawLogin === true + ? "immediate" + : rawLogin === "immediate" || rawLogin === "on-complete" + ? rawLogin + : "none"; + this.cardSwitchBehavior = params.cardSwitchBehavior || "manual"; + this.mediaSize = params.mediaSize || "small"; + this.mediaPosition = params.mediaPosition || "bottom-right"; + this.subChapters = (params.subChapters || []).map(s => new SubChapter(s)); + } + + setName(name: string) { + this.name = name; + } + + setCardComponent(component: string) { + this.cardComponent = component; + } + + setMediaAsset(asset: string | null) { + this.mediaAsset = asset; + } + + setAutoOpenMedia(auto: boolean) { + this.autoOpenMedia = auto; + } + + setAutoAdvance(auto: boolean) { + this.autoAdvance = auto; + } + + setAutoLogin(value: "none" | "immediate" | "on-complete") { + this.autoLogin = value; + } + + setCardSwitchBehavior(behavior: "auto" | "manual") { + this.cardSwitchBehavior = behavior; + } + + setMediaSize(size: "small" | "medium" | "large") { + this.mediaSize = size; + } + + setMediaPosition(position: string) { + this.mediaPosition = position; + } + + addSubChapter(subChapter?: SubChapterParams) { + const sc = new SubChapter(subChapter); + this.subChapters.push(sc); + return sc; + } + + removeSubChapter(subChapterId: string) { + this.subChapters = this.subChapters.filter(s => s.id !== subChapterId); + } + + reorderSubChapters(subChapterId: string, newIndex: number) { + const idx = this.subChapters.findIndex(s => s.id === subChapterId); + if (idx === -1) { + return; + } + const [item] = this.subChapters.splice(idx, 1); + this.subChapters.splice(newIndex, 0, item); + } + + getSubChapter(subChapterId: string): SubChapter | undefined { + return this.subChapters.find(s => s.id === subChapterId); + } +} + +export interface AdvancedTrainingConfigParams { + enabled?: boolean; + sequentialChapters?: boolean; + chapters?: ChapterParams[]; + inFlightChapters?: ChapterParams[]; + loginChapter?: ChapterParams | null; + completionChapter?: ChapterParams | null; + stripPosition?: "top" | "bottom"; +} + +export class AdvancedTrainingConfig { + enabled: boolean; + sequentialChapters: boolean; + chapters: Chapter[]; + // Ad-hoc help chapters reachable mid-flight via the question-mark widget. + // Tied to a card component; kept out of the normal sequential progression. + inFlightChapters: Chapter[]; + loginChapter: Chapter | null; + completionChapter: Chapter | null; + stripPosition: "top" | "bottom"; + + constructor(params: AdvancedTrainingConfigParams = {}) { + this.enabled = params.enabled ?? false; + this.sequentialChapters = params.sequentialChapters ?? false; + this.chapters = (params.chapters || []).map(c => new Chapter(c)); + this.inFlightChapters = (params.inFlightChapters || []).map( + c => new Chapter(c), + ); + this.loginChapter = params.loginChapter + ? new Chapter(params.loginChapter) + : null; + this.completionChapter = params.completionChapter + ? new Chapter(params.completionChapter) + : null; + this.stripPosition = params.stripPosition || "bottom"; + } + + setEnabled(enabled: boolean) { + this.enabled = enabled; + } + + setSequentialChapters(sequential: boolean) { + this.sequentialChapters = sequential; + } + + setLoginChapter(params: ChapterParams | null) { + this.loginChapter = params ? new Chapter(params) : null; + } + + setCompletionChapter(params: ChapterParams | null) { + this.completionChapter = params ? new Chapter(params) : null; + } + + addChapter(chapter?: ChapterParams) { + const ch = new Chapter(chapter); + this.chapters.push(ch); + return ch; + } + + removeChapter(chapterId: string) { + this.chapters = this.chapters.filter(c => c.id !== chapterId); + } + + reorderChapters(chapterId: string, newIndex: number) { + const idx = this.chapters.findIndex(c => c.id === chapterId); + if (idx === -1) { + return; + } + const [item] = this.chapters.splice(idx, 1); + this.chapters.splice(newIndex, 0, item); + } + + getChapter(chapterId: string): Chapter | undefined { + return this.chapters.find(c => c.id === chapterId); + } + + addInFlightChapter(chapter?: ChapterParams) { + const ch = new Chapter(chapter); + this.inFlightChapters.push(ch); + return ch; + } + + removeInFlightChapter(chapterId: string) { + this.inFlightChapters = this.inFlightChapters.filter( + c => c.id !== chapterId, + ); + } + + getInFlightChapter(chapterId: string): Chapter | undefined { + return this.inFlightChapters.find(c => c.id === chapterId); + } + + /** + * Find the in-flight help chapter authored for a given card component, if any. + * Used when the crew presses the help widget while on that card. + */ + findInFlightChapterByCard(cardComponent: string): Chapter | undefined { + if (!cardComponent) { + return undefined; + } + return this.inFlightChapters.find(c => c.cardComponent === cardComponent); + } + + /** True if the given chapter ID belongs to the in-flight help chapters. */ + isInFlightChapter(chapterId: string): boolean { + return this.inFlightChapters.some(c => c.id === chapterId); + } + + /** + * Find any chapter by ID, including loginChapter, completionChapter, and + * in-flight help chapters. + */ + findChapter(chapterId: string): Chapter | undefined { + if (this.loginChapter?.id === chapterId) { + return this.loginChapter; + } + if (this.completionChapter?.id === chapterId) { + return this.completionChapter; + } + return ( + this.chapters.find(c => c.id === chapterId) || + this.inFlightChapters.find(c => c.id === chapterId) + ); + } +} diff --git a/server/classes/advancedTrainingProgress.ts b/server/classes/advancedTrainingProgress.ts new file mode 100644 index 000000000..d1f67e5f1 --- /dev/null +++ b/server/classes/advancedTrainingProgress.ts @@ -0,0 +1,124 @@ +import uuid from "uuid"; + +export interface AdvancedTrainingProgressParams { + id?: string; + clientId?: string; + simulatorId?: string; + stationName?: string; + activeChapterId?: string | null; + activeSubChapterId?: string | null; + completedChapterIds?: string[]; + completedSubChapterIds?: string[]; + observedActions?: Record; + globalObservedEvents?: string[]; + mediaViewerOpen?: boolean; + chapterListOpen?: boolean; + inFlightHelp?: boolean; + inFlightHelpCard?: string | null; +} + +export class AdvancedTrainingProgress { + id: string; + clientId: string; + simulatorId: string; + stationName: string; + activeChapterId: string | null; + activeSubChapterId: string | null; + completedChapterIds: string[]; + completedSubChapterIds: string[]; + observedActions: Record; + globalObservedEvents: string[]; + mediaViewerOpen: boolean; + chapterListOpen: boolean; + // True when this session was launched as ad-hoc in-flight help (the crew + // clicked the help button mid-flight) rather than as a full training run. + // In-flight help never auto-advances into the main sequence and never + // auto-closes on chapter completion — it stays until the crew dismisses it. + inFlightHelp: boolean; + // When set, the card component this in-flight help was launched on. Used for + // help shown on a card without a dedicated in-flight chapter: navigating to a + // different card auto-closes the help. Null means no card binding. + inFlightHelpCard: string | null; + + constructor(params: AdvancedTrainingProgressParams = {}) { + this.id = params.id || uuid.v4(); + this.clientId = params.clientId || ""; + this.simulatorId = params.simulatorId || ""; + this.stationName = params.stationName || ""; + this.activeChapterId = params.activeChapterId || null; + this.activeSubChapterId = params.activeSubChapterId || null; + this.completedChapterIds = params.completedChapterIds || []; + this.completedSubChapterIds = params.completedSubChapterIds || []; + this.observedActions = params.observedActions || {}; + this.globalObservedEvents = params.globalObservedEvents || []; + this.mediaViewerOpen = params.mediaViewerOpen ?? false; + this.chapterListOpen = params.chapterListOpen ?? false; + this.inFlightHelp = params.inFlightHelp ?? false; + this.inFlightHelpCard = params.inFlightHelpCard ?? null; + } + + setActiveChapter(chapterId: string | null) { + this.activeChapterId = chapterId; + this.activeSubChapterId = null; + } + + setActiveSubChapter(subChapterId: string | null) { + this.activeSubChapterId = subChapterId; + } + + recordAction(subChapterId: string, eventName: string) { + if (!this.observedActions[subChapterId]) { + this.observedActions[subChapterId] = []; + } + if (!this.observedActions[subChapterId].includes(eventName)) { + this.observedActions[subChapterId].push(eventName); + } + } + + observeEvent(eventName: string) { + if (!this.globalObservedEvents.includes(eventName)) { + this.globalObservedEvents.push(eventName); + } + } + + completeSubChapter(subChapterId: string) { + if (!this.completedSubChapterIds.includes(subChapterId)) { + this.completedSubChapterIds.push(subChapterId); + } + } + + completeChapter(chapterId: string) { + if (!this.completedChapterIds.includes(chapterId)) { + this.completedChapterIds.push(chapterId); + } + } + + isSubChapterComplete(subChapterId: string): boolean { + return this.completedSubChapterIds.includes(subChapterId); + } + + isChapterComplete(chapterId: string): boolean { + return this.completedChapterIds.includes(chapterId); + } + + setMediaViewerOpen(open: boolean) { + this.mediaViewerOpen = open; + } + + setChapterListOpen(open: boolean) { + this.chapterListOpen = open; + } + + reset() { + this.activeChapterId = null; + this.activeSubChapterId = null; + this.completedChapterIds = []; + this.completedSubChapterIds = []; + this.observedActions = {}; + this.globalObservedEvents = []; + this.mediaViewerOpen = false; + this.chapterListOpen = false; + this.inFlightHelp = false; + this.inFlightHelpCard = null; + } +} diff --git a/server/classes/aegis.ts b/server/classes/aegis.ts new file mode 100644 index 000000000..edc849b54 --- /dev/null +++ b/server/classes/aegis.ts @@ -0,0 +1,201 @@ +import uuid from "uuid"; +import {System} from "./generic"; + +export type AegisMode = "screen" | "ecm" | "relay" | "repair"; +export type AegisRelayTarget = "sensors" | "balanced" | "comms"; + +export interface AegisLogEntry { + id: string; + timestamp: string; + type: string; + contents: string; +} + +// How many deployed drones it takes for the swarm to reach full +// effectiveness (mitigation coverage, relay boost, repair rate) +export const AEGIS_FULL_COVERAGE = 60; +// Most damage a fully-crewed, perfectly-focused screen can absorb +const MAX_MITIGATION = 0.85; +// Drones sacrificed per unit of absorbed damage +const DRONE_LOSS_FACTOR = 40; +const LOG_LIMIT = 50; + +export default class Aegis extends System { + maxDrones: number; + droneCount: number; + deployed: boolean; + mode: AegisMode; + fabricating: boolean; + fabricationPaused: boolean; + fabricationProgress: number; + attritionEnabled: boolean; + structuralIntegrity: number; + screenFocus: {x: number; y: number}; + ecmIntensity: number; + relayTarget: AegisRelayTarget; + repairEffort: number; + log: AegisLogEntry[]; + constructor(params: any = {}) { + super({name: "Aegis System", displayName: "Aegis System", ...params}); + this.class = "Aegis"; + this.type = "Aegis"; + this.wing = params.wing || "right"; + this.maxDrones = params.maxDrones || 120; + this.droneCount = params.droneCount || 0; + this.deployed = params.deployed || false; + this.mode = params.mode || "screen"; + this.fabricating = params.fabricating || false; + this.fabricationPaused = params.fabricationPaused || false; + this.fabricationProgress = params.fabricationProgress || 0; + this.attritionEnabled = params.attritionEnabled ?? true; + this.structuralIntegrity = params.structuralIntegrity ?? 1; + this.screenFocus = params.screenFocus || {x: 0, y: 0}; + this.ecmIntensity = params.ecmIntensity ?? 0.5; + this.relayTarget = params.relayTarget || "balanced"; + this.repairEffort = params.repairEffort ?? 0.5; + this.log = params.log || []; + } + get stealthFactor() { + if (this.deployed && this.mode === "ecm") { + return 0.2 + this.ecmIntensity * 0.6; + } + if (this.deployed) { + return 0.15; + } + return 0.05; + } + setMode(mode: AegisMode) { + this.mode = mode; + } + deploy() { + if (this.droneCount === 0) { + return; + } + this.deployed = true; + } + recall() { + this.deployed = false; + } + startFabrication() { + if (this.droneCount >= this.maxDrones) { + return; + } + this.fabricating = true; + } + stopFabrication() { + this.fabricating = false; + } + pauseFabrication(paused: boolean) { + this.fabricationPaused = paused; + } + setAttrition(enabled: boolean) { + this.attritionEnabled = enabled; + } + destroyDrone() { + this.droneCount = Math.max(0, this.droneCount - 1); + if (this.droneCount === 0) { + this.deployed = false; + } + } + setDroneCount(count: number) { + this.droneCount = Math.max(0, Math.min(this.maxDrones, count)); + if (this.droneCount === 0) { + this.deployed = false; + } + } + setMaxDrones(count: number) { + this.maxDrones = Math.max(1, count); + this.droneCount = Math.min(this.droneCount, this.maxDrones); + } + setStructuralIntegrity(integrity: number) { + this.structuralIntegrity = Math.min(1, Math.max(0, integrity)); + } + setScreenFocus(x: number, y: number) { + this.screenFocus = { + x: Math.min(1, Math.max(-1, x)), + y: Math.min(1, Math.max(-1, y)), + }; + } + setEcmIntensity(intensity: number) { + this.ecmIntensity = Math.min(1, Math.max(0, intensity)); + } + setRelayTarget(target: AegisRelayTarget) { + this.relayTarget = target; + } + setRepairEffort(effort: number) { + this.repairEffort = Math.min(1, Math.max(0, effort)); + } + addLog(type: string, contents: string) { + this.log.unshift({ + id: uuid.v4(), + timestamp: new Date().toISOString(), + type, + contents, + }); + this.log = this.log.slice(0, LOG_LIMIT); + } + clearLog() { + this.log = []; + } + // Apply an incoming structural hit from the given bearing (degrees, + // 0 = fore, clockwise). A deployed defensive screen absorbs part of the + // damage — more when the crew's focus is pointed at the attack — at the + // cost of drones, and the outcome is recorded in the impact log. + applyStructuralHit(amount: number, bearing: number) { + const rad = (bearing * Math.PI) / 180; + const attack = {x: Math.sin(rad), y: -Math.cos(rad)}; + let mitigation = 0; + let dronesLost = 0; + if (this.deployed && this.mode === "screen" && this.droneCount > 0) { + const coverage = Math.min(1, this.droneCount / AEGIS_FULL_COVERAGE); + const focusMag = Math.min( + 1, + Math.hypot(this.screenFocus.x, this.screenFocus.y), + ); + const alignment = + focusMag === 0 + ? 0 + : (this.screenFocus.x / focusMag) * attack.x + + (this.screenFocus.y / focusMag) * attack.y; + mitigation = Math.min( + MAX_MITIGATION, + Math.max(0, coverage * (0.4 + 0.35 * alignment * focusMag)), + ); + dronesLost = Math.min( + this.droneCount, + Math.max(1, Math.round(amount * mitigation * DRONE_LOSS_FACTOR)), + ); + for (let i = 0; i < dronesLost; i++) { + this.destroyDrone(); + } + } + const applied = amount * (1 - mitigation); + this.setStructuralIntegrity(this.structuralIntegrity - applied); + const integrityPercent = Math.round(this.structuralIntegrity * 100); + if (mitigation > 0) { + this.addLog( + "screen", + `Defensive screen absorbed ${Math.round( + mitigation * 100, + )}% of an incoming impact (${dronesLost} drone${ + dronesLost === 1 ? "" : "s" + } lost). Structural integrity at ${integrityPercent}%.`, + ); + } else { + this.addLog( + "damage", + `Direct structural impact — no defensive screen. Structural integrity at ${integrityPercent}%.`, + ); + } + if (this.structuralIntegrity === 0) { + this.addLog("critical", "Structural integrity field has collapsed."); + } + return {applied, mitigation, dronesLost}; + } + break(report: string, destroyed: boolean, which: string = "default") { + this.deployed = false; + this.fabricating = false; + this.fabricationProgress = 0; + super.break(report, destroyed, which); + } +} diff --git a/server/classes/index.ts b/server/classes/index.ts index 2eab89b07..de1e4e52b 100644 --- a/server/classes/index.ts +++ b/server/classes/index.ts @@ -72,4 +72,5 @@ export { HackingPreset } from "./computerCore/hackingPreset"; export { default as HullPlating } from "./hullPlating"; export { FirebaseConnector, FirebaseManager } from './FirebaseManager' export { default as AdvancedNavigationAndAstrometrics } from "./advancedNavigationAndAstrometrics"; +export { default as Aegis } from "./aegis"; export { FlightSet } from './flightSets' diff --git a/server/classes/stationSet.ts b/server/classes/stationSet.ts index 087a57e23..66726834c 100644 --- a/server/classes/stationSet.ts +++ b/server/classes/stationSet.ts @@ -1,6 +1,10 @@ import uuid from "uuid"; import App from "../app"; import {pascalCase} from "change-case"; +import { + AdvancedTrainingConfig, + AdvancedTrainingConfigParams, +} from "./advancedTraining"; export class StationSet { id: string; @@ -115,6 +119,7 @@ export class Station { training: string; ambiance: string; layout: string; + advancedTraining: AdvancedTrainingConfig | null; constructor({ name, cards = [], @@ -127,7 +132,13 @@ export class Station { training, ambiance, layout, - }: Partial) { + advancedTraining, + }: Partial & { + advancedTraining?: + | AdvancedTrainingConfigParams + | AdvancedTrainingConfig + | null; + }) { this.class = "Station"; this.name = name || "Station"; this.description = description || ""; @@ -139,6 +150,9 @@ export class Station { this.messageGroups = messageGroups; this.widgets = widgets; this.layout = layout || null; + this.advancedTraining = advancedTraining + ? new AdvancedTrainingConfig(advancedTraining) + : null; this.cards = []; cards.forEach(card => { this.addCard(card); @@ -163,6 +177,9 @@ export class Station { setTraining(training: string) { this.training = training; } + setAdvancedTraining(config: AdvancedTrainingConfigParams | null) { + this.advancedTraining = config ? new AdvancedTrainingConfig(config) : null; + } setTags(tags: string[]) { this.tags = tags.map(t => t.trim()); } diff --git a/server/classes/tacticalMap.js b/server/classes/tacticalMap.js index 8e2a14ed1..9c474387b 100644 --- a/server/classes/tacticalMap.js +++ b/server/classes/tacticalMap.js @@ -41,6 +41,9 @@ class TacticalItem { this.flash = params.flash || false; this.icon = params.icon || null; this.size = params.size || 1; + this.iconWidth = params.iconWidth || 0; + this.iconHeight = params.iconHeight || 0; + this.keepOnScreen = params.keepOnScreen || false; this.speed = params.speed || 1000; this.velocity = params.velocity || {x: 0, y: 0, z: 0}; this.location = params.location || {x: 0, y: 0, z: 0}; @@ -60,6 +63,9 @@ class TacticalItem { flash, icon, size, + iconWidth, + iconHeight, + keepOnScreen, speed, velocity, location, @@ -79,6 +85,9 @@ class TacticalItem { if (flash || flash === false) this.flash = flash; if (icon || icon === "") this.icon = icon; if (size) this.size = size; + if (iconWidth) this.iconWidth = iconWidth; + if (iconHeight) this.iconHeight = iconHeight; + if (keepOnScreen || keepOnScreen === false) this.keepOnScreen = keepOnScreen; if (speed || speed === 0) this.speed = speed; if (velocity) this.velocity = velocity; if (location) this.location = location; diff --git a/server/classes/task.ts b/server/classes/task.ts index daf8fba86..81762c993 100644 --- a/server/classes/task.ts +++ b/server/classes/task.ts @@ -84,6 +84,8 @@ export default class Task { macros: Macro[]; preMacros: Macro[]; assigned: boolean | string; + /** Frozen at creation so random instructions don't re-roll on every read */ + instructions: string | null; constructor(params: Partial = {}) { // The check to see if the task is relevant was already handled // before this task was instantiated @@ -181,6 +183,28 @@ export default class Task { // Task Report Assignment this.assigned = params.assigned || false; + + // Freeze instructions at creation so random generators (Panel Actions, + // reportReplacer tokens) don't re-roll on every GraphQL read. The resolver + // returns this frozen value and falls back to live computation only for + // tasks persisted before this change (where instructions is undefined). + if (params.instructions !== undefined && params.instructions !== null) { + this.instructions = params.instructions; + } else { + try { + this.instructions = definitionObject?.instructions + ? definitionObject.instructions({ + simulator, + requiredValues: this.values, + task: this, + }) + : null; + } catch (e) { + // Some definitions throw if their required system/panel isn't found; + // fall back to live computation in the resolver. + this.instructions = null; + } + } } verify(dismiss) { if (this.verified) return; diff --git a/server/classes/trainingPrerequisites.ts b/server/classes/trainingPrerequisites.ts new file mode 100644 index 000000000..096950100 --- /dev/null +++ b/server/classes/trainingPrerequisites.ts @@ -0,0 +1,18 @@ +/** + * System-level prerequisites for advanced training chapters. + * + * Maps cardComponent names to the mutation/event names that must be observed + * (by any client, including the FD) before that chapter becomes available to + * navigate to during an active training session. + * + * Add entries here when a system only becomes relevant after a specific FD + * action fires mid-flight. Example: + * + * "NavigationAdvanced": ["activateAdvancedNavigation"], + * + * The server watches for these event names globally and marks them in each + * active training session's globalObservedEvents when they fire. + */ +export const CARD_PREREQUISITES: Record = { + // Add entries as needed for your mission systems. +}; diff --git a/server/events/advancedTraining.ts b/server/events/advancedTraining.ts new file mode 100644 index 000000000..a26f6942e --- /dev/null +++ b/server/events/advancedTraining.ts @@ -0,0 +1,416 @@ +import App from "../app"; +import {AdvancedTrainingProgress} from "../classes/advancedTrainingProgress"; +import {CARD_PREREQUISITES} from "../classes/trainingPrerequisites"; +import { + getClientTrainingConfig, + publishProgress, + publishClientChanged, + autoCompleteIfEmpty, + advanceToNextChapter, + activateChapter, +} from "./advancedTrainingHelpers"; +// Registers the FD-intervention and UI-state listeners as a side effect. +import "./advancedTrainingFdEvents"; + +// Ensure the array exists on App +if (!App.advancedTrainingProgress) { + App.advancedTrainingProgress = []; +} + +// Register global listeners for prerequisite events so FD-fired mutations +// (not just crew actions) unlock chapters as expected. +const allPrerequisiteEvents = new Set( + Object.values(CARD_PREREQUISITES).flat(), +); + +for (const eventName of allPrerequisiteEvents) { + App.on(eventName, (args: any) => { + const simulatorId = + args?.simulatorId || + (args?.clientId + ? App.clients.find((c: any) => c.id === args.clientId)?.simulatorId + : null); + if (!simulatorId) { + return; + } + + const affected = (App.advancedTrainingProgress || []).filter( + (p: any) => p.simulatorId === simulatorId, + ); + if (affected.length === 0) { + return; + } + + for (const prog of affected) { + prog.observeEvent(eventName); + } + publishProgress(); + }); +} + +// --- Configuration events --- +// These are handled by explicit mutation resolvers in the typeDef. +// The event handlers here are no-ops; they exist so App.emit doesn't +// throw an "unhandled event" warning. + +App.on("setStationAdvancedTraining", () => {}); +App.on("toggleAdvancedTrainingMode", () => {}); + +// --- Client training session events --- + +App.on("clientStartAdvancedTraining", ({clientId}: any) => { + const client = App.clients.find((c: any) => c.id === clientId); + if (!client) { + return; + } + + const config = getClientTrainingConfig(clientId); + if (!config) { + return; + } + + // Remove any existing progress for this client + App.advancedTrainingProgress = (App.advancedTrainingProgress || []).filter( + (p: any) => p.clientId !== clientId, + ); + + // Create new progress — start at login chapter if configured, else first regular chapter + const startChapter = config.loginChapter || config.chapters[0]; + const firstSubChapter = startChapter?.subChapters?.[0]; + const progress = new AdvancedTrainingProgress({ + clientId: client.id, + simulatorId: client.simulatorId, + stationName: client.station, + activeChapterId: startChapter?.id || null, + activeSubChapterId: firstSubChapter?.id || null, + mediaViewerOpen: !!( + startChapter?.autoOpenMedia && startChapter?.mediaAsset + ), + }); + + App.advancedTrainingProgress.push(progress); + + // Fire immediate auto-login before auto-complete so the clientLogin action is + // tracked against the loginChapter while it's still the active chapter. + // (If it fired after chaining, it would land on whatever chapter ended up active.) + if (config.loginChapter?.autoLogin === "immediate") { + App.emit("clientLogin", {client: clientId, loginName: client.station}); + App.emit("clientAdvancedTrainingAction", { + clientId, + eventName: "clientLogin", + args: null, + }); + } + + // Auto-complete the starting chapter if it has no sub-chapters, then chain + // forward if it also has autoAdvance. Guard on activeChapterId in case the + // clientAdvancedTrainingAction above already advanced the chapter (e.g. a + // clientLogin sub-chapter completed it). + if (startChapter && progress.activeChapterId === startChapter.id) { + if (autoCompleteIfEmpty(progress, startChapter)) { + advanceToNextChapter(progress, config, startChapter, clientId); + } + } + + // Also set the legacy training flag so the system knows + client.setTraining(true); + + publishProgress(); + publishClientChanged(); +}); + +App.on("clientStopAdvancedTraining", ({clientId}: any) => { + const client = App.clients.find((c: any) => c.id === clientId); + if (!client) { + return; + } + + App.advancedTrainingProgress = (App.advancedTrainingProgress || []).filter( + (p: any) => p.clientId !== clientId, + ); + + client.setTraining(false); + + publishProgress(); + publishClientChanged(); +}); + +// --- Action tracking --- + +App.on("clientAdvancedTrainingAction", ({clientId, eventName, args}: any) => { + const progress = (App.advancedTrainingProgress || []).find( + (p: any) => p.clientId === clientId, + ); + if (!progress || !progress.activeChapterId) { + return; + } + + // Track every event globally for prerequisite checking + progress.observeEvent(eventName); + + const config = getClientTrainingConfig(clientId); + if (!config) { + return; + } + + const chapter = config.findChapter(progress.activeChapterId); + if (!chapter) { + return; + } + + // Find the active sub-chapter, or check all incomplete sub-chapters in order + const subChaptersToCheck = progress.activeSubChapterId + ? [chapter.getSubChapter(progress.activeSubChapterId)].filter(Boolean) + : chapter.subChapters.filter( + (sc: any) => !progress.isSubChapterComplete(sc.id), + ); + + for (const subChapter of subChaptersToCheck) { + if (!subChapter) { + continue; + } + + // Check if this action matches any required action in this sub-chapter. + // Match on eventName alone — args like simulatorId, clientId, etc. will + // always differ between the recording session and a live flight, so + // strict arg comparison would never match. + // __videoComplete__ is a synthetic event fired by the media viewer and + // can be added as a required action just like any other event. + const matchingAction = subChapter.requiredActions.find( + (ra: any) => ra.eventName === eventName, + ); + + if (matchingAction) { + progress.recordAction(subChapter.id, eventName); + + // Check if all required actions for this sub-chapter are now done + const allDone = subChapter.requiredActions.every((ra: any) => + (progress.observedActions[subChapter.id] || []).includes(ra.eventName), + ); + + if (allDone) { + progress.completeSubChapter(subChapter.id); + + // Move to next incomplete sub-chapter + const nextSubChapter = chapter.subChapters.find( + (sc: any) => !progress.isSubChapterComplete(sc.id), + ); + progress.setActiveSubChapter(nextSubChapter?.id || null); + + // Check if all sub-chapters for this chapter are complete + const chapterDone = chapter.subChapters.every((sc: any) => + progress.isSubChapterComplete(sc.id), + ); + + if (chapterDone) { + progress.completeChapter(chapter.id); + + // In-flight help is ad-hoc — once finished it stays on screen until + // the crew dismisses it (or navigates away). Never auto-advance into + // the main chapter sequence and never auto-close. + if (progress.inFlightHelp || config.isInFlightChapter(chapter.id)) { + break; + } + + // Auto-login on login chapter completion if configured + if ( + config.loginChapter?.id === chapter.id && + config.loginChapter?.autoLogin === "on-complete" + ) { + const clientObj = App.clients.find((c: any) => c.id === clientId); + if (clientObj) { + App.emit("clientLogin", { + client: clientId, + loginName: clientObj.station, + }); + App.emit("clientAdvancedTrainingAction", { + clientId, + eventName: "clientLogin", + args: null, + }); + } + } + + advanceToNextChapter(progress, config, chapter, clientId); + } + } + + // Only process the first matching sub-chapter + break; + } + } + + publishProgress(); +}); + +// --- Navigation events --- + +App.on("advancedTrainingSetActiveChapter", ({clientId, chapterId}: any) => { + const progress = (App.advancedTrainingProgress || []).find( + (p: any) => p.clientId === clientId, + ); + if (!progress) { + return; + } + + const config = getClientTrainingConfig(clientId); + if (!config) { + return; + } + + const chapter = config.findChapter(chapterId); + if (!chapter) { + return; + } + + // Block navigation to locked chapters in sequential mode + // (skip for login/completion chapters — those are auto-managed) + const isSpecialChapter = + config.loginChapter?.id === chapterId || + config.completionChapter?.id === chapterId; + + if (!isSpecialChapter && config.sequentialChapters) { + const chapterIdx = config.chapters.findIndex( + (c: any) => c.id === chapterId, + ); + if (chapterIdx > 0) { + const prevChapter = config.chapters[chapterIdx - 1]; + if (!progress.isChapterComplete(prevChapter.id)) { + return; + } + } + } + + // Block navigation to chapters whose system prerequisites haven't fired + if (!isSpecialChapter) { + const prerequisites = CARD_PREREQUISITES[chapter.cardComponent] || []; + const unmet = prerequisites.filter( + (evt: string) => !progress.globalObservedEvents.includes(evt), + ); + if (unmet.length > 0) { + return; + } + } + + activateChapter(progress, config, chapter, clientId); + + publishProgress(); +}); + +// --- In-flight help events --- + +App.on("clientRequestTrainingHelp", ({clientId}: any) => { + const client = App.clients.find((c: any) => c.id === clientId); + if (!client) { + return; + } + + const config = getClientTrainingConfig(clientId); + + // No advanced training enabled for this station — fall back to the default + // help/question-mark behavior (legacy tour, handled by clientSetTraining). + if (!config) { + App.handleEvent({client: clientId, training: true}, "clientSetTraining"); + return; + } + + // Resolve the crew's current card component (same source as the currentCard + // resolver: the simulator's per-client card assignment). + const simulator = App.simulators.find( + (s: any) => s.id === client.simulatorId, + ); + const station = simulator?.stations?.find( + (s: any) => s.name === client.station, + ); + const currentCardName = simulator?.clientCards?.[clientId]; + const currentCard = station?.cards?.find( + (c: any) => c.name === currentCardName, + ); + const cardComponent = currentCard?.component || ""; + + // Priority: an in-flight help chapter dedicated to this card, else fall back + // to a regular chapter associated with it. Skip entirely if we can't resolve + // the current card so a chapter with an empty cardComponent is never matched + // by accident. + const inFlightChapter = cardComponent + ? config.findInFlightChapterByCard(cardComponent) + : undefined; + // Fallback: a regular sequence chapter authored for this card, used when the + // card has no dedicated in-flight chapter ("location that doesn't have it + // defined"). + const fallbackChapter = + !inFlightChapter && cardComponent + ? config.chapters.find((c: any) => c.cardComponent === cardComponent) + : undefined; + const target = inFlightChapter || fallbackChapter; + + // No chapter associated with this card — default begin-training behavior + // (full advanced training from the start, via clientSetTraining). + if (!target) { + App.handleEvent({client: clientId, training: true}, "clientSetTraining"); + return; + } + + // Ensure a training session exists without resetting one already in progress. + let progress = (App.advancedTrainingProgress || []).find( + (p: any) => p.clientId === clientId, + ); + if (!progress) { + progress = new AdvancedTrainingProgress({ + clientId: client.id, + simulatorId: client.simulatorId, + stationName: client.station, + }); + App.advancedTrainingProgress.push(progress); + client.setTraining(true); + } + + // Mark this as an in-flight help session so completion never auto-advances or + // auto-closes — it stays until the crew dismisses it. When the help came from + // the fallback (a card without a dedicated in-flight chapter), bind it to the + // current card so navigating away auto-closes it. + progress.inFlightHelp = true; + progress.inFlightHelpCard = fallbackChapter ? cardComponent : null; + + // Jump straight to the target chapter, bypassing sequential/prerequisite + // locks — this is an explicit help request. + activateChapter(progress, config, target, clientId); + + publishProgress(); + publishClientChanged(); +}); + +// In-flight help launched on a card without a dedicated in-flight chapter is +// bound to that card. When the crew navigates to a different card, the ad-hoc +// help is no longer relevant, so auto-close it. (This listener runs alongside +// the primary clientSetCard handler in events/clients.ts.) +App.on("clientSetCard", ({id, card}: any) => { + const progress = (App.advancedTrainingProgress || []).find( + (p: any) => p.clientId === id, + ); + if (!progress || !progress.inFlightHelpCard) { + return; + } + + const client = App.clients.find((c: any) => c.id === id); + if (!client) { + return; + } + + const simulator = App.simulators.find( + (s: any) => s.id === client.simulatorId, + ); + const station = simulator?.stations?.find( + (s: any) => s.name === client.station, + ); + const newCard = station?.cards?.find((c: any) => c.name === card); + const newComponent = newCard?.component || ""; + + // Still on the bound card (e.g. the help itself switched the crew to it) — + // leave the session open. + if (newComponent === progress.inFlightHelpCard) { + return; + } + + App.handleEvent({clientId: id}, "clientStopAdvancedTraining"); +}); diff --git a/server/events/advancedTrainingFdEvents.ts b/server/events/advancedTrainingFdEvents.ts new file mode 100644 index 000000000..de5ccd30d --- /dev/null +++ b/server/events/advancedTrainingFdEvents.ts @@ -0,0 +1,126 @@ +import App from "../app"; +import { + getClientTrainingConfig, + publishProgress, + autoCompleteIfEmpty, + advanceToNextChapter, +} from "./advancedTrainingHelpers"; + +// Flight-Director intervention and UI-state listeners for advanced training. +// Split out from ./advancedTraining (which owns the crew-driven session and +// action-tracking listeners) to keep each file focused. + +// --- FD intervention events --- + +App.on("fdCompleteTrainingSubChapter", ({clientId, subChapterId}: any) => { + const progress = (App.advancedTrainingProgress || []).find( + (p: any) => p.clientId === clientId, + ); + if (!progress) { + return; + } + + progress.completeSubChapter(subChapterId); + + const config = getClientTrainingConfig(clientId); + if (config && progress.activeChapterId) { + const chapter = config.findChapter(progress.activeChapterId); + if (chapter) { + const nextSubChapter = chapter.subChapters.find( + (sc: any) => !progress.isSubChapterComplete(sc.id), + ); + progress.setActiveSubChapter(nextSubChapter?.id || null); + + const chapterDone = chapter.subChapters.every((sc: any) => + progress.isSubChapterComplete(sc.id), + ); + if (chapterDone) { + progress.completeChapter(chapter.id); + + // In-flight help stays on screen until the crew dismisses it; never + // auto-advance into the main chapter sequence. + if (!(progress.inFlightHelp || config.isInFlightChapter(chapter.id))) { + if ( + config.loginChapter?.id === chapter.id && + config.loginChapter?.autoLogin === "on-complete" + ) { + const clientObj = App.clients.find((c: any) => c.id === clientId); + if (clientObj) { + App.emit("clientLogin", { + client: clientId, + loginName: clientObj.station, + }); + } + } + + advanceToNextChapter(progress, config, chapter, clientId); + } + } + } + } + + publishProgress(); +}); + +App.on("fdResetTrainingProgress", ({clientId}: any) => { + const progress = (App.advancedTrainingProgress || []).find( + (p: any) => p.clientId === clientId, + ); + if (!progress) { + return; + } + + const config = getClientTrainingConfig(clientId); + progress.reset(); + + // Re-activate first chapter (login chapter if configured, else first regular) + if (config) { + const startChapter = config.loginChapter || config.chapters[0] || null; + if (startChapter) { + progress.setActiveChapter(startChapter.id); + if (!autoCompleteIfEmpty(progress, startChapter)) { + progress.setActiveSubChapter(startChapter.subChapters[0]?.id || null); + } else { + advanceToNextChapter(progress, config, startChapter, clientId); + } + } + + if (config.loginChapter?.autoLogin === "immediate") { + const client = App.clients.find((c: any) => c.id === clientId); + if (client) { + App.emit("clientLogin", {client: clientId, loginName: client.station}); + App.emit("clientAdvancedTrainingAction", { + clientId, + eventName: "clientLogin", + args: null, + }); + } + } + } + + publishProgress(); +}); + +// --- UI state events --- + +App.on("advancedTrainingToggleMediaViewer", ({clientId, open}: any) => { + const progress = (App.advancedTrainingProgress || []).find( + (p: any) => p.clientId === clientId, + ); + if (!progress) { + return; + } + progress.setMediaViewerOpen(open); + publishProgress(); +}); + +App.on("advancedTrainingToggleChapterList", ({clientId, open}: any) => { + const progress = (App.advancedTrainingProgress || []).find( + (p: any) => p.clientId === clientId, + ); + if (!progress) { + return; + } + progress.setChapterListOpen(open); + publishProgress(); +}); diff --git a/server/events/advancedTrainingHelpers.ts b/server/events/advancedTrainingHelpers.ts new file mode 100644 index 000000000..d40c37a4c --- /dev/null +++ b/server/events/advancedTrainingHelpers.ts @@ -0,0 +1,165 @@ +import App from "../app"; +import {pubsub} from "../helpers/subscriptionManager"; +import {AdvancedTrainingProgress} from "../classes/advancedTrainingProgress"; + +// Shared helpers for the advanced-training event handlers. Kept separate from +// the listener registrations in ./advancedTraining so neither file grows +// unwieldy. + +// Resolve a client's training config from the station they're currently on. +// Returns null unless the station has advanced training explicitly enabled. +export function getClientTrainingConfig(clientId: string) { + const client = App.clients.find((c: any) => c.id === clientId); + if (!client || !client.simulatorId || !client.station) { + return null; + } + + // Look up the station directly on the simulator (not the station set template, + // which has a different simulatorId — the template's, not the flight instance's) + const simulator = App.simulators.find( + (s: any) => s.id === client.simulatorId, + ); + if (!simulator) { + return null; + } + + const station = simulator.stations?.find( + (s: any) => s.name === client.station, + ); + if (!station || !station.advancedTraining?.enabled) { + return null; + } + + return station.advancedTraining; +} + +export function publishProgress() { + pubsub.publish( + "advancedTrainingProgressUpdate", + App.advancedTrainingProgress || [], + ); +} + +export function publishClientChanged() { + pubsub.publish("clientChanged", App.clients); +} + +// Auto-complete a chapter that has no subchapters immediately upon activation. +export function autoCompleteIfEmpty( + progress: AdvancedTrainingProgress, + chapter: any, +) { + if ((chapter.subChapters || []).length === 0) { + progress.completeChapter(chapter.id); + return true; + } + return false; +} + +// Switch the crew's card to the one backing a chapter, when that chapter +// requests automatic card switching. No-ops gracefully if the card isn't on the +// station (e.g. an in-flight help chapter authored for a card added later). +function autoSwitchCardForChapter(chapter: any, clientId: string) { + if (chapter.cardSwitchBehavior !== "auto" || !chapter.cardComponent) { + return; + } + const client = App.clients.find((c: any) => c.id === clientId); + if (!client) { + return; + } + const simulator = App.simulators.find( + (s: any) => s.id === client.simulatorId, + ); + const station = simulator?.stations?.find( + (s: any) => s.name === client.station, + ); + const targetCard = station?.cards?.find( + (c: any) => c.component === chapter.cardComponent, + ); + if (targetCard) { + App.handleEvent({id: clientId, card: targetCard.name}, "clientSetCard", { + clientId, + }); + } +} + +// Advance to the chapter that follows completedChapter, respecting autoAdvance, +// autoOpenMedia, and cardSwitchBehavior. Resets mediaViewerOpen before opening. +export function advanceToNextChapter( + progress: AdvancedTrainingProgress, + config: any, + completedChapter: any, + clientId: string, +) { + if (!completedChapter.autoAdvance) { + return; + } + + let nextChapter: any = null; + + if (config.loginChapter?.id === completedChapter.id) { + nextChapter = config.chapters[0] || null; + } else if (config.completionChapter?.id === completedChapter.id) { + nextChapter = null; + } else { + const chapterIdx = config.chapters.findIndex( + (c: any) => c.id === completedChapter.id, + ); + if (chapterIdx === -1) { + return; + } + nextChapter = config.chapters[chapterIdx + 1] || null; + if (!nextChapter && config.completionChapter) { + nextChapter = config.completionChapter; + } + } + + if (!nextChapter) { + return; + } + + progress.setMediaViewerOpen(false); + progress.setActiveChapter(nextChapter.id); + + if (!autoCompleteIfEmpty(progress, nextChapter)) { + const firstSub = nextChapter.subChapters[0]; + progress.setActiveSubChapter(firstSub?.id || null); + } else { + // nextChapter had no sub-chapters and was immediately auto-completed. + // Chain forward so a sequence of empty autoAdvance chapters doesn't stall. + advanceToNextChapter(progress, config, nextChapter, clientId); + return; + } + + if (nextChapter.autoOpenMedia && nextChapter.mediaAsset) { + progress.setMediaViewerOpen(true); + } + + autoSwitchCardForChapter(nextChapter, clientId); +} + +// Activate a chapter for a client: set it active, seek the first incomplete +// sub-chapter (or auto-complete it if empty), open media if configured, and +// switch the crew's card if the chapter requests auto card switching. Does NOT +// enforce sequential/prerequisite locks — callers gate that themselves. +export function activateChapter( + progress: AdvancedTrainingProgress, + config: any, + chapter: any, + clientId: string, +) { + progress.setActiveChapter(chapter.id); + + if (!autoCompleteIfEmpty(progress, chapter)) { + const firstIncompleteSub = chapter.subChapters.find( + (sc: any) => !progress.isSubChapterComplete(sc.id), + ); + progress.setActiveSubChapter( + firstIncompleteSub?.id || chapter.subChapters[0]?.id || null, + ); + } + + progress.setMediaViewerOpen(!!(chapter.autoOpenMedia && chapter.mediaAsset)); + + autoSwitchCardForChapter(chapter, clientId); +} diff --git a/server/events/aegis.ts b/server/events/aegis.ts new file mode 100644 index 000000000..077833226 --- /dev/null +++ b/server/events/aegis.ts @@ -0,0 +1,186 @@ +import uuid from "uuid"; +import App from "../app"; +import {pubsub} from "../helpers/subscriptionManager"; +import Aegis, { + AegisMode, + AegisRelayTarget, + AEGIS_FULL_COVERAGE, +} from "../classes/aegis"; + +function performAction(id: string, cb: (sys: Aegis) => void) { + const sys = App.systems.find(s => s.id === id); + if (!sys) { + return; + } + cb(sys); + pubsub.publish("aegisUpdate", sys); +} + +function findAegis(simulatorId: string): Aegis | undefined { + return App.systems.find( + s => s.simulatorId === simulatorId && s.class === "Aegis", + ); +} + +function publishPing( + simulatorId: string, + pingType: string, + strength: number, + bearing: number | null = null, +) { + pubsub.publish("aegisPing", { + id: uuid.v4(), + simulatorId, + pingType, + strength, + bearing, + }); +} + +// How much a deployed relay swarm amplifies the given kind of signal +function relayBoost(aegis: Aegis | undefined, kind: AegisRelayTarget) { + if (!aegis || !aegis.deployed || aegis.mode !== "relay") { + return 0; + } + const allocation = + aegis.relayTarget === kind ? 1 : aegis.relayTarget === "balanced" ? 0.5 : 0; + return allocation * Math.min(1, aegis.droneCount / AEGIS_FULL_COVERAGE); +} + +App.on("aegisSetMode", ({id, mode}: {id: string; mode: AegisMode}) => { + performAction(id, sys => sys.setMode(mode)); +}); +App.on("aegisDeploy", ({id}: {id: string}) => { + performAction(id, sys => { + if (sys.damage.damaged) { + return; + } + sys.deploy(); + }); +}); +App.on("aegisRecall", ({id}: {id: string}) => { + performAction(id, sys => sys.recall()); +}); +App.on("aegisStartFabrication", ({id}: {id: string}) => { + performAction(id, sys => { + if (sys.damage.damaged) { + return; + } + sys.startFabrication(); + }); +}); +App.on("aegisStopFabrication", ({id}: {id: string}) => { + performAction(id, sys => sys.stopFabrication()); +}); +App.on( + "aegisPauseFabrication", + ({id, paused}: {id: string; paused: boolean}) => { + performAction(id, sys => sys.pauseFabrication(paused)); + }, +); +App.on("aegisSetAttrition", ({id, enabled}: {id: string; enabled: boolean}) => { + performAction(id, sys => sys.setAttrition(enabled)); +}); +App.on("aegisDestroyDrone", ({id}: {id: string}) => { + performAction(id, sys => sys.destroyDrone()); +}); +App.on("aegisSetDroneCount", ({id, count}: {id: string; count: number}) => { + performAction(id, sys => sys.setDroneCount(count)); +}); +App.on("aegisSetMaxDrones", ({id, count}: {id: string; count: number}) => { + performAction(id, sys => sys.setMaxDrones(count)); +}); +App.on( + "aegisSetScreenFocus", + ({id, x, y}: {id: string; x: number; y: number}) => { + performAction(id, sys => sys.setScreenFocus(x, y)); + }, +); +App.on( + "aegisSetEcmIntensity", + ({id, intensity}: {id: string; intensity: number}) => { + performAction(id, sys => sys.setEcmIntensity(intensity)); + }, +); +App.on( + "aegisSetRelayTarget", + ({id, target}: {id: string; target: AegisRelayTarget}) => { + performAction(id, sys => sys.setRelayTarget(target)); + }, +); +App.on("aegisSetRepairEffort", ({id, effort}: {id: string; effort: number}) => { + performAction(id, sys => sys.setRepairEffort(effort)); +}); +App.on( + "aegisSetStructuralIntegrity", + ({id, integrity}: {id: string; integrity: number}) => { + performAction(id, sys => sys.setStructuralIntegrity(integrity)); + }, +); +App.on( + "aegisHitStructure", + ({id, amount, bearing}: {id: string; amount?: number; bearing?: number}) => { + performAction(id, sys => { + const hitAmount = amount ?? 0.05 + Math.random() * 0.1; + const hitBearing = bearing ?? Math.random() * 360; + sys.applyStructuralHit(hitAmount, hitBearing); + publishPing(sys.simulatorId, "impact", hitAmount, hitBearing); + }); + }, +); +App.on("aegisClearLog", ({id}: {id: string}) => { + performAction(id, sys => sys.clearLog()); +}); + +// --- Cross-system hooks --- +// Ship actions are always visible on the Aegis canvas; a deployed relay +// swarm amplifies them according to its boost target. + +// A queued long range message is actually transmitted off the ship +App.on("longRangeMessageSend", ({id}: {id: string}) => { + const sys = App.systems.find(s => s.id === id); + if (!sys) { + return; + } + const aegis = findAegis(sys.simulatorId); + const boost = relayBoost(aegis, "comms"); + publishPing(sys.simulatorId, "comm", 1 + boost); + if (aegis && boost > 0) { + aegis.addLog( + "relay", + `Relay swarm amplified an outgoing transmission (+${Math.round( + boost * 100, + )}% signal strength).`, + ); + pubsub.publish("aegisUpdate", aegis); + } +}); + +App.on("sensorScanRequest", ({id}: {id: string}) => { + const sys = App.systems.find(s => s.id === id); + if (!sys) { + return; + } + const aegis = findAegis(sys.simulatorId); + const boost = relayBoost(aegis, "sensors"); + publishPing(sys.simulatorId, "scan", 1 + boost); + if (aegis && boost > 0) { + aegis.addLog( + "relay", + `Relay swarm extended a sensor scan (+${Math.round( + boost * 100, + )}% effective range).`, + ); + pubsub.publish("aegisUpdate", aegis); + } +}); + +App.on("pingSensors", ({id}: {id: string}) => { + const sys = App.systems.find(s => s.id === id); + if (!sys) { + return; + } + const boost = relayBoost(findAegis(sys.simulatorId), "sensors"); + // Sonar pings fire frequently — show them but don't log amplification + publishPing(sys.simulatorId, "sonar", 1 + boost); +}); diff --git a/server/events/assets.js b/server/events/assets.js index bcf07dc4b..a2b7d7bc6 100644 --- a/server/events/assets.js +++ b/server/events/assets.js @@ -10,6 +10,15 @@ if (process.env.NODE_ENV === "production") { assetDir = paths.userData + "/assets"; } +// Ensure default asset folders exist +const defaultFolders = ["/Training"]; +for (const folder of defaultFolders) { + const folderPath = `${assetDir}${folder}`; + if (!fs.existsSync(folderPath)) { + fs.mkdirSync(folderPath, {recursive: true}); + } +} + function getFolders(dir, folderList = []) { const folders = fs .readdirSync(dir) diff --git a/server/events/clients.ts b/server/events/clients.ts index a33d17b71..68d1ae178 100644 --- a/server/events/clients.ts +++ b/server/events/clients.ts @@ -221,6 +221,28 @@ App.on("clientOfflineState", ({client, state}) => { }); App.on("clientSetTraining", ({client, training}) => { const clientObj = App.clients.find(c => c.id === client); + if (!clientObj) return; + + // If the station has advanced training enabled, delegate to that system + if (training && clientObj.simulatorId && clientObj.station) { + const simulator = App.simulators.find( + (s: any) => s.id === clientObj.simulatorId, + ); + const station = simulator?.stations?.find( + (s: any) => s.name === clientObj.station, + ); + if (station?.advancedTraining?.enabled) { + App.handleEvent({clientId: client}, "clientStartAdvancedTraining"); + return; + } + } + + // clientStopAdvancedTraining handles setTraining(false) + clientChanged publish + if (!training) { + App.handleEvent({clientId: client}, "clientStopAdvancedTraining"); + return; + } + clientObj.setTraining(training); pubsub.publish("clientChanged", App.clients); }); diff --git a/server/events/index.js b/server/events/index.js index c69ebdd0d..cb7577e52 100644 --- a/server/events/index.js +++ b/server/events/index.js @@ -63,3 +63,5 @@ import "./Countermeasures"; import "./hullPlating"; import "./edVenturesApp"; import "./advancedNavigationAndAstrometrics"; +import "./aegis"; +import "./advancedTraining"; diff --git a/server/events/systems.ts b/server/events/systems.ts index d24af90a3..75c7dce86 100644 --- a/server/events/systems.ts +++ b/server/events/systems.ts @@ -109,6 +109,9 @@ const sendUpdate = sys => { if (sys.class === "AdvancedNavigationAndAstrometrics") { pubsub.publish("advancedNavAndAstrometricsUpdate", sys); } + if (sys.class === "Aegis") { + pubsub.publish("aegisUpdate", sys); + } pubsub.publish("systemsUpdate", App.systems); }; App.on("addExtraReportToSimulator", ({ simulatorId, name, which, cb }) => { diff --git a/server/helpers/defaultSnapshot.js b/server/helpers/defaultSnapshot.js index be275ab00..b53fc76ed 100644 --- a/server/helpers/defaultSnapshot.js +++ b/server/helpers/defaultSnapshot.js @@ -13637,6 +13637,7 @@ export function getDefaultSnapshot(){return { dmxConfigs: [], dmxDevices: [], hackingPresets: [], + advancedTrainingProgress: [], printQueue: [], autoUpdate: true, thoriumId: randomWords(5).join("-"), diff --git a/server/helpers/tacticalBounds.js b/server/helpers/tacticalBounds.js new file mode 100644 index 000000000..ad07502fe --- /dev/null +++ b/server/helpers/tacticalBounds.js @@ -0,0 +1,47 @@ +// Shared "keep on screen" clamp math for Tactical Map objects (server copy). +// +// Tactical items store their position as normalized {x, y, z} fractions where 0 is +// the left/top edge and 1 is the right/bottom edge. When an item has `keepOnScreen` +// enabled we constrain the position so the *entire* scaled icon stays within [0, 1]. +// +// The footprint is computed against the canonical 1920x1080 viewscreen so the clamp is +// identical on the server (authoritative) and on every client, regardless of the actual +// canvas size. +// +// NOTE: This file is intentionally duplicated at +// `src/components/views/TacticalMap/preview/layerComps/clampToBounds.js`. The client +// (Vite, tsconfig include: src) and the server (tsconfig include: server) cannot import +// across that boundary, so keep the two copies in sync. + +export const CANONICAL_WIDTH = 1920; +export const CANONICAL_HEIGHT = 1080; + +export function getFootprint( + item, + canvasWidth = CANONICAL_WIDTH, + canvasHeight = CANONICAL_HEIGHT, +) { + const size = item.size || 1; + const w = ((item.iconWidth || 0) * size) / canvasWidth; + const h = ((item.iconHeight || 0) * size) / canvasHeight; + return {w, h}; +} + +export function clampToBounds(position, footprint) { + const maxX = Math.max(0, 1 - footprint.w); + const maxY = Math.max(0, 1 - footprint.h); + return { + x: Math.min(Math.max(position.x, 0), maxX), + y: Math.min(Math.max(position.y, 0), maxY), + z: position.z, + }; +} + +export function clampItemPosition( + item, + position, + canvasWidth = CANONICAL_WIDTH, + canvasHeight = CANONICAL_HEIGHT, +) { + return clampToBounds(position, getFootprint(item, canvasWidth, canvasHeight)); +} diff --git a/server/processes/aegis.ts b/server/processes/aegis.ts new file mode 100644 index 000000000..0b242dc14 --- /dev/null +++ b/server/processes/aegis.ts @@ -0,0 +1,111 @@ +import App from "../app"; +import {pubsub} from "../helpers/subscriptionManager"; +import Aegis, {AEGIS_FULL_COVERAGE} from "../classes/aegis"; + +// Seconds to fabricate a batch of drones +const FABRICATION_TIME = 45; +// Drones produced per completed fabrication cycle +const BATCH_SIZE = 10; +// Average seconds before a deployed swarm loses one drone to attrition +const ATTRITION_TIME = 180; +// Structural integrity restored per second by a full repair swarm: +// base rate plus up to this much more at maximum effort +const REPAIR_RATE_BASE = 0.0005; +const REPAIR_RATE_EFFORT = 0.002; + +// Working the drones harder (ECM output, repair effort) wears them out faster +function attritionMultiplier(aegis: Aegis) { + if (aegis.mode === "ecm") { + return 0.5 + aegis.ecmIntensity * 2.5; + } + if (aegis.mode === "repair") { + return 0.75 + aegis.repairEffort * 1.5; + } + return 1; +} + +function processAegis() { + App.flights + .filter(f => f.running === true) + .forEach(f => { + f.simulators.forEach((id: string) => { + const aegis: Aegis = App.systems.find( + sys => sys.simulatorId === id && sys.class === "Aegis", + ); + if (!aegis) { + return; + } + + let changed = false; + + // Fabricate drones one batch at a time + const hasPower = + aegis.power.powerLevels.length === 0 || + aegis.power.power >= aegis.power.powerLevels[0]; + if ( + aegis.fabricating && + !aegis.fabricationPaused && + !aegis.damage.damaged && + hasPower && + aegis.droneCount < aegis.maxDrones + ) { + aegis.fabricationProgress = Math.min( + 1, + aegis.fabricationProgress + 1 / FABRICATION_TIME, + ); + if (aegis.fabricationProgress >= 1) { + aegis.droneCount = Math.min( + aegis.maxDrones, + aegis.droneCount + BATCH_SIZE, + ); + aegis.fabricationProgress = 0; + if (aegis.droneCount >= aegis.maxDrones) { + aegis.fabricating = false; + } + } + changed = true; + } + + // Deployed drones slowly succumb to wear and enemy fire + if (aegis.deployed && aegis.attritionEnabled) { + if (Math.random() < attritionMultiplier(aegis) / ATTRITION_TIME) { + aegis.destroyDrone(); + changed = true; + } + } + + // Repair swarm restores structural integrity + if ( + aegis.deployed && + aegis.mode === "repair" && + aegis.droneCount > 0 && + aegis.structuralIntegrity < 1 + ) { + const previous = aegis.structuralIntegrity; + const rate = + (REPAIR_RATE_BASE + aegis.repairEffort * REPAIR_RATE_EFFORT) * + Math.min(1, aegis.droneCount / AEGIS_FULL_COVERAGE); + aegis.setStructuralIntegrity(aegis.structuralIntegrity + rate); + if ( + Math.floor(aegis.structuralIntegrity * 10) > + Math.floor(previous * 10) + ) { + aegis.addLog( + "repair", + `Repair swarm restored structural integrity to ${ + Math.floor(aegis.structuralIntegrity * 10) * 10 + }%.`, + ); + } + changed = true; + } + + if (changed) { + pubsub.publish("aegisUpdate", aegis); + } + }); + }); + setTimeout(processAegis, 1000); +} + +processAegis(); diff --git a/server/processes/index.js b/server/processes/index.js index 9f3acacff..cc2c0a003 100644 --- a/server/processes/index.js +++ b/server/processes/index.js @@ -19,3 +19,4 @@ import "./clientPing"; import "./systems"; import "./advanced-nav"; import "./helium"; +import "./aegis"; diff --git a/server/processes/tacticalMapMove.js b/server/processes/tacticalMapMove.js index 4dc216c6c..b56aae6de 100644 --- a/server/processes/tacticalMapMove.js +++ b/server/processes/tacticalMapMove.js @@ -1,6 +1,7 @@ import App from "../app"; import * as THREE from "three"; import {pubsub} from "../helpers/subscriptionManager"; +import {clampItemPosition} from "../helpers/tacticalBounds"; const interval = 1000 / 5; let lastTime = Date.now(); @@ -42,15 +43,28 @@ const moveTacticalMap = () => { App.tacticalMaps.forEach(m => { m.layers.forEach(l => { l.items.forEach(i => { - i.update({ - location: moveContact( - i.destination, - i.location, - i.speed, - m.frozen, - delta, - ), - }); + let location = moveContact( + i.destination, + i.location, + i.speed, + m.frozen, + delta, + ); + // Authoritative backstop: never let the animated location settle + // off screen when the contact is constrained. Also pull a (possibly + // newly-constrained) destination back on screen so the persisted value + // converges instead of drifting. + if (i.keepOnScreen) { + location = clampItemPosition(i, location); + const destination = clampItemPosition(i, i.destination); + if ( + destination.x !== i.destination.x || + destination.y !== i.destination.y + ) { + i.update({destination}); + } + } + i.update({location}); }); }); m.interval = interval; diff --git a/server/processes/thrusters.js b/server/processes/thrusters.js index bae1ce8d9..5c1da140e 100644 --- a/server/processes/thrusters.js +++ b/server/processes/thrusters.js @@ -1,5 +1,6 @@ import App from "../app"; import {pubsub} from "../helpers/subscriptionManager"; +import {clampItemPosition} from "../helpers/tacticalBounds"; function getMovementDirection(direction, movement) { if (movement === "up") return Math.abs(direction.z < 0 ? direction.z : 0); @@ -190,6 +191,15 @@ const updateThrusters = () => { item.location.x += movement.x; item.location.y += movement.y; } + // Keep the icon on screen if requested. Clamping the stored + // values (rather than only the rendered position) cancels the + // "drift" at the edge: holding thrust into a wall no longer + // accumulates an off-screen position, so reversing thrust moves + // the contact immediately with no dead-zone. + if (item.keepOnScreen) { + item.destination = clampItemPosition(item, item.destination); + item.location = clampItemPosition(item, item.location); + } }); }); }); diff --git a/server/tasks/softwarePanels.js b/server/tasks/softwarePanels.js index 9f86cec64..09936e69c 100644 --- a/server/tasks/softwarePanels.js +++ b/server/tasks/softwarePanels.js @@ -33,7 +33,7 @@ export default [ input: ({simulator}) => simulator ? simulator.panels.map(p => { - const panel = App.softwarePanels.find(pp => (pp.id = p)); + const panel = App.softwarePanels.find(pp => pp.id === p); return {label: panel.name, value: panel.id}; }) : App.softwarePanels.map(panel => ({ diff --git a/server/typeDefs/advancedTraining.ts b/server/typeDefs/advancedTraining.ts new file mode 100644 index 000000000..6f797de69 --- /dev/null +++ b/server/typeDefs/advancedTraining.ts @@ -0,0 +1,331 @@ +import App from "../app"; +import {gql, withFilter} from "apollo-server-express"; +import {pubsub} from "../helpers/subscriptionManager"; +import uuid from "uuid"; +import {AdvancedTrainingConfig} from "../classes/advancedTraining"; + +function getStationConfig(stationSetID: string, stationName: string) { + const stationSet = App.stationSets.find((s: any) => s.id === stationSetID); + if (!stationSet) { + return null; + } + return stationSet.stations.find((s: any) => s.name === stationName); +} + +const schema = gql` + type AdvancedTrainingRequiredAction { + id: ID! + eventName: String! + args: JSON + } + + type AdvancedTrainingSubChapter { + id: ID! + name: String! + requiredActions: [AdvancedTrainingRequiredAction!]! + } + + type AdvancedTrainingChapter { + id: ID! + name: String! + cardComponent: String! + mediaAsset: String + autoOpenMedia: Boolean! + autoAdvance: Boolean! + autoLogin: String! + cardSwitchBehavior: String! + mediaSize: String! + mediaPosition: String! + subChapters: [AdvancedTrainingSubChapter!]! + } + + type AdvancedTrainingConfig { + enabled: Boolean! + sequentialChapters: Boolean! + stripPosition: String! + chapters: [AdvancedTrainingChapter!]! + inFlightChapters: [AdvancedTrainingChapter!]! + loginChapter: AdvancedTrainingChapter + completionChapter: AdvancedTrainingChapter + } + + type AdvancedTrainingProgress { + id: ID! + clientId: ID! + simulatorId: ID! + stationName: String! + activeChapterId: ID + activeSubChapterId: ID + completedChapterIds: [ID!]! + completedSubChapterIds: [ID!]! + observedActions: JSON + globalObservedEvents: [String!]! + mediaViewerOpen: Boolean! + chapterListOpen: Boolean! + } + + input AdvancedTrainingRequiredActionInput { + id: ID + eventName: String! + args: JSON + } + + input AdvancedTrainingSubChapterInput { + id: ID + name: String! + requiredActions: [AdvancedTrainingRequiredActionInput!] + } + + input AdvancedTrainingChapterInput { + id: ID + name: String! + cardComponent: String! + mediaAsset: String + autoOpenMedia: Boolean + autoAdvance: Boolean + autoLogin: String + cardSwitchBehavior: String + mediaSize: String + mediaPosition: String + subChapters: [AdvancedTrainingSubChapterInput!] + } + + input AdvancedTrainingConfigInput { + enabled: Boolean + sequentialChapters: Boolean + stripPosition: String + chapters: [AdvancedTrainingChapterInput!] + inFlightChapters: [AdvancedTrainingChapterInput!] + loginChapter: AdvancedTrainingChapterInput + completionChapter: AdvancedTrainingChapterInput + } + + extend type Station { + advancedTraining: AdvancedTrainingConfig + } + + extend type Client { + advancedTrainingProgress: AdvancedTrainingProgress + } + + extend type Query { + advancedTrainingProgress( + clientId: ID + simulatorId: ID + ): [AdvancedTrainingProgress!]! + } + + extend type Mutation { + """ + Save the full advanced training configuration for a station. + """ + setStationAdvancedTraining( + stationSetID: ID! + stationName: String! + config: AdvancedTrainingConfigInput! + ): String + + """ + Toggle advanced training mode on a station set. + """ + toggleAdvancedTrainingMode( + stationSetID: ID! + stationName: String! + enabled: Boolean! + ): String + + """ + Start an advanced training session for a client. + Sets up progress tracking and activates the first chapter. + """ + clientStartAdvancedTraining(clientId: ID!): String + + """ + Stop advanced training for a client. + """ + clientStopAdvancedTraining(clientId: ID!): String + + """ + Crew pressed the help/question-mark widget. Resolves the crew's current card + and jumps to the in-flight help chapter for that card (or the regular chapter + for it), falling back to the default begin-training behavior if neither exists. + """ + clientRequestTrainingHelp(clientId: ID!): String + + """ + Record a crew action during advanced training. + Checks against required actions and may trigger sub-chapter/chapter completion. + """ + clientAdvancedTrainingAction( + clientId: ID! + eventName: String! + args: JSON + ): String + + """ + Set the active chapter for a client (crew navigation or FD override). + """ + advancedTrainingSetActiveChapter(clientId: ID!, chapterId: ID!): String + + """ + FD force-complete a sub-chapter for a client. + """ + fdCompleteTrainingSubChapter(clientId: ID!, subChapterId: ID!): String + + """ + FD reset all training progress for a client. + """ + fdResetTrainingProgress(clientId: ID!): String + + """ + Toggle the media viewer open/close for a client. + """ + advancedTrainingToggleMediaViewer(clientId: ID!, open: Boolean!): String + + """ + Toggle the chapter list open/close for a client. + """ + advancedTrainingToggleChapterList(clientId: ID!, open: Boolean!): String + } + + extend type Subscription { + advancedTrainingProgressUpdate( + simulatorId: ID + ): [AdvancedTrainingProgress!]! + advancedTrainingConfigUpdate(stationSetID: ID): [StationSet!]! + } +`; + +const resolver = { + Station: { + advancedTraining(station: any) { + return station.advancedTraining || null; + }, + }, + Client: { + advancedTrainingProgress(client: any) { + return ( + App.advancedTrainingProgress?.find( + (p: any) => p.clientId === client.id, + ) || null + ); + }, + }, + Query: { + advancedTrainingProgress(_root: any, {clientId, simulatorId}: any) { + let progress = App.advancedTrainingProgress || []; + if (clientId) { + progress = progress.filter((p: any) => p.clientId === clientId); + } + if (simulatorId) { + progress = progress.filter((p: any) => p.simulatorId === simulatorId); + } + return progress; + }, + }, + Mutation: { + setStationAdvancedTraining( + rootValue: any, + {stationSetID, stationName, config}: any, + ) { + const station = getStationConfig(stationSetID, stationName); + if (!station) { + return ""; + } + station.setAdvancedTraining(config); + pubsub.publish("stationSetUpdate", App.stationSets); + pubsub.publish("advancedTrainingConfigUpdate", App.stationSets); + return ""; + }, + toggleAdvancedTrainingMode( + rootValue: any, + {stationSetID, stationName, enabled}: any, + ) { + const station = getStationConfig(stationSetID, stationName); + if (!station) { + return ""; + } + if (!station.advancedTraining) { + station.advancedTraining = new AdvancedTrainingConfig(); + } + station.advancedTraining.setEnabled(enabled); + pubsub.publish("stationSetUpdate", App.stationSets); + pubsub.publish("advancedTrainingConfigUpdate", App.stationSets); + }, + clientStartAdvancedTraining(rootValue: any, {clientId}: any) { + // Handled by event handler + return ""; + }, + clientStopAdvancedTraining(rootValue: any, {clientId}: any) { + return ""; + }, + clientRequestTrainingHelp(rootValue: any, {clientId}: any) { + // Handled by event handler + return ""; + }, + clientAdvancedTrainingAction( + rootValue: any, + {clientId, eventName, args}: any, + ) { + return ""; + }, + advancedTrainingSetActiveChapter( + rootValue: any, + {clientId, chapterId}: any, + ) { + return ""; + }, + fdCompleteTrainingSubChapter( + rootValue: any, + {clientId, subChapterId}: any, + ) { + return ""; + }, + fdResetTrainingProgress(rootValue: any, {clientId}: any) { + return ""; + }, + advancedTrainingToggleMediaViewer(rootValue: any, {clientId, open}: any) { + return ""; + }, + advancedTrainingToggleChapterList(rootValue: any, {clientId, open}: any) { + return ""; + }, + }, + Subscription: { + advancedTrainingProgressUpdate: { + resolve(rootValue: any, {simulatorId}: any) { + if (simulatorId) { + return rootValue.filter((p: any) => p.simulatorId === simulatorId); + } + return rootValue; + }, + subscribe: withFilter( + () => { + const id = uuid.v4(); + process.nextTick(() => { + pubsub.publish(id, App.advancedTrainingProgress || []); + }); + return pubsub.asyncIterator([id, "advancedTrainingProgressUpdate"]); + }, + (rootValue: any) => !!(rootValue && rootValue.length >= 0), + ), + }, + advancedTrainingConfigUpdate: { + resolve(rootValue: any, {stationSetID}: any) { + if (stationSetID) { + return rootValue.filter((s: any) => s.id === stationSetID); + } + return rootValue; + }, + subscribe: () => { + const id = uuid.v4(); + process.nextTick(() => { + pubsub.publish(id, App.stationSets); + }); + return pubsub.asyncIterator([id, "advancedTrainingConfigUpdate"]); + }, + }, + }, +}; + +export default {schema, resolver}; diff --git a/server/typeDefs/aegis.ts b/server/typeDefs/aegis.ts new file mode 100644 index 000000000..212134246 --- /dev/null +++ b/server/typeDefs/aegis.ts @@ -0,0 +1,144 @@ +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 AEGIS_MODE { + screen + ecm + relay + repair + } + enum AEGIS_RELAY_TARGET { + sensors + balanced + comms + } + type AegisLogEntry { + id: ID! + timestamp: String! + type: String! + contents: String! + } + type AegisPing { + id: ID! + pingType: String! + strength: Float! + bearing: Float + } + type Aegis implements SystemInterface { + id: ID! + simulatorId: ID + class: String + type: String + name: String! + displayName: String! + upgradeName: String + upgraded: Boolean + damage: Damage! + power: Power! + stealthFactor: Float + locations: [Room] + + maxDrones: Int! + droneCount: Int! + deployed: Boolean! + mode: AEGIS_MODE! + fabricating: Boolean! + fabricationPaused: Boolean! + fabricationProgress: Float! + attritionEnabled: Boolean! + structuralIntegrity: Float! + screenFocusX: Float! + screenFocusY: Float! + ecmIntensity: Float! + relayTarget: AEGIS_RELAY_TARGET! + repairEffort: Float! + log: [AegisLogEntry!]! + } + + extend type Query { + aegis(simulatorId: ID!): Aegis + } + extend type Mutation { + aegisSetMode(id: ID!, mode: AEGIS_MODE!): String + aegisDeploy(id: ID!): String + aegisRecall(id: ID!): String + aegisStartFabrication(id: ID!): String + aegisStopFabrication(id: ID!): String + aegisPauseFabrication(id: ID!, paused: Boolean!): String + aegisSetAttrition(id: ID!, enabled: Boolean!): String + aegisDestroyDrone(id: ID!): String + aegisSetDroneCount(id: ID!, count: Int!): String + aegisSetMaxDrones(id: ID!, count: Int!): String + aegisSetScreenFocus(id: ID!, x: Float!, y: Float!): String + aegisSetEcmIntensity(id: ID!, intensity: Float!): String + aegisSetRelayTarget(id: ID!, target: AEGIS_RELAY_TARGET!): String + aegisSetRepairEffort(id: ID!, effort: Float!): String + aegisSetStructuralIntegrity(id: ID!, integrity: Float!): String + aegisHitStructure(id: ID!, amount: Float, bearing: Float): String + aegisClearLog(id: ID!): String + } + extend type Subscription { + aegisUpdate(simulatorId: ID!): Aegis + aegisPing(simulatorId: ID!): AegisPing + } +`; + +const resolver = { + Aegis: { + screenFocusX(sys) { + return sys.screenFocus.x; + }, + screenFocusY(sys) { + return sys.screenFocus.y; + }, + }, + Query: { + aegis(rootQuery, {simulatorId}) { + return App.systems.find( + s => s.simulatorId === simulatorId && s.class === "Aegis", + ); + }, + }, + Mutation: mutationHelper(schema), + Subscription: { + aegisUpdate: { + resolve(rootQuery) { + return rootQuery; + }, + subscribe: withFilter( + (_rootValue, {simulatorId}) => { + const id = uuid.v4(); + process.nextTick(() => { + const data = App.systems.find(s => { + return s.simulatorId === simulatorId && s.class === "Aegis"; + }); + pubsub.publish(id, data); + }); + return pubsub.asyncIterator([id, "aegisUpdate"]); + }, + (rootValue, args) => { + return rootValue?.simulatorId === args?.simulatorId; + }, + ), + }, + aegisPing: { + resolve(rootQuery) { + return rootQuery; + }, + // Transient one-shot events — no initial publish on subscribe + subscribe: withFilter( + () => pubsub.asyncIterator("aegisPing"), + (rootValue, args) => { + return rootValue?.simulatorId === args?.simulatorId; + }, + ), + }, + }, +}; + +export default {schema, resolver}; diff --git a/server/typeDefs/flight.ts b/server/typeDefs/flight.ts index 4fc8b82e8..78e3a2f5e 100644 --- a/server/typeDefs/flight.ts +++ b/server/typeDefs/flight.ts @@ -124,10 +124,13 @@ export function addAspects( // Override the system ID newAspect.id = uuid.v4(); if (isochip) { - isochip.id = uuid.v4(); - isochip.system = newAspect.id; - isochip.simulatorId = sim.id; - data.isochips.push(new Classes.Isochip(isochip)); + // Clone the isochip rather than mutating the original — the template's + // isochip must remain intact so future flights can copy it correctly. + const isochipCopy = cloneDeep(isochip); + isochipCopy.id = uuid.v4(); + isochipCopy.system = newAspect.id; + isochipCopy.simulatorId = sim.id; + data.isochips.push(new Classes.Isochip(isochipCopy)); } if (!isImport) { if (newAspect.power && newAspect.power.powerLevels.length) { @@ -417,8 +420,11 @@ const resolver = { startFlight(rootQuery, {id = uuid.v4(), name, simulators, flightType}) { const simIds = simulators.map( (s: {simulatorId: string; missionId?: string; stationSet: string}) => { - // Create a snapshot restore before the flight is created - App.saveRestore(); + // Save a restore snapshot before creating the flight, but not for + // sandbox/preview flights used during training configuration. + if (!name?.startsWith("__sandbox_")) { + App.saveRestore(); + } const template = cloneDeep( App.simulators.find(sim => sim.id === s.simulatorId), ); diff --git a/server/typeDefs/index.ts b/server/typeDefs/index.ts index bf11fb901..19dd5dcc1 100644 --- a/server/typeDefs/index.ts +++ b/server/typeDefs/index.ts @@ -1,4 +1,5 @@ import actionsTypeDefs from "./actions"; +import aegisTypeDefs from "./aegis"; import ambianceTypeDefs from "./ambiance"; import assetsTypeDefs from "./assets"; import clientsTypeDefs from "./clients"; @@ -79,9 +80,11 @@ import dmxTypeDefs from "./dmx"; import taskFlowTypeDefs from "./taskFlow"; import firebaseConnectionTypeDefs from './firebase' import flightSetsTypeDefs from "./flightSets"; +import advancedTrainingTypeDefs from "./advancedTraining"; export * from "./universe/components"; export const actions = actionsTypeDefs; +export const aegis = aegisTypeDefs; export const ambiance = ambianceTypeDefs; export const assets = assetsTypeDefs; export const clients = clientsTypeDefs; @@ -162,3 +165,4 @@ export const countermeasures = countermeasuresTypeDefs; export const universe = universeTypeDefs; export const dmx = dmxTypeDefs; export const taskFlow = taskFlowTypeDefs; +export const advancedTraining = advancedTrainingTypeDefs; diff --git a/server/typeDefs/tacticalMap.ts b/server/typeDefs/tacticalMap.ts index 775b7e9c3..425daa5ad 100644 --- a/server/typeDefs/tacticalMap.ts +++ b/server/typeDefs/tacticalMap.ts @@ -102,6 +102,10 @@ const schema = gql` icon: String size: Float opacity: Float + # Intrinsic pixel dimensions of the icon image, measured by the client. + # Used to compute the icon footprint for the keepOnScreen clamp. + iconWidth: Float + iconHeight: Float #Animation speed: Float @@ -116,6 +120,8 @@ const schema = gql` thrusters: Boolean rotationMatch: Boolean thrusterControls: ThrusterControls + # When true, the object is constrained so its full icon stays on screen. + keepOnScreen: Boolean } input TacticalItemInput { @@ -132,6 +138,8 @@ const schema = gql` icon: String size: Float opacity: Float + iconWidth: Float + iconHeight: Float #Animation speed: Float @@ -145,6 +153,7 @@ const schema = gql` thrusters: Boolean rotationMatch: Boolean thrusterControls: ThrusterControlsInput + keepOnScreen: Boolean } type TacticalPath { diff --git a/server/typeDefs/tasks.ts b/server/typeDefs/tasks.ts index 72d952cc2..06a7c316d 100644 --- a/server/typeDefs/tasks.ts +++ b/server/typeDefs/tasks.ts @@ -111,6 +111,13 @@ const schema = gql` const resolver = { Task: { instructions(task) { + // Return the frozen value stamped at task creation when available. + // This prevents random generators (Panel Actions operations list, + // reportReplacer tokens) from re-rolling on every subscription push. + // The live fallback handles tasks created before this change. + if (task.instructions !== undefined && task.instructions !== null) { + return task.instructions; + } const {simulatorId, values, definition} = task; const simulator = App.simulators.find(s => s.id === simulatorId); const taskDef = taskDefinitions.find(d => d.name === definition); diff --git a/src/components/client/Card.tsx b/src/components/client/Card.tsx index 445a53020..bc25a50ba 100644 --- a/src/components/client/Card.tsx +++ b/src/components/client/Card.tsx @@ -15,6 +15,7 @@ import {playSound} from "../generic/SoundPlayer"; import {randomFromList} from "helpers/randomFromList"; import styled from "styled-components"; import {Simulator, Station, Flight, Client} from "generated/graphql"; +import AdvancedTrainingBorder from "components/training/AdvancedTrainingBorder"; const Blackout = styled.div` width: 100vw; @@ -186,6 +187,24 @@ const CardFrame: React.FC = props => { training: false, }, }); + const advancedTrainingConfig = (station as any).advancedTraining; + + const cardContent = ( + + ); + return (
= props => { > {client.cracked &&
} - + {advancedTrainingConfig?.enabled ? ( + + {cardContent} + + ) : ( + cardContent + )} {client && } - {simTraining && + {!advancedTrainingConfig?.enabled && + simTraining && stationTraining && client.training && isMedia(stationTraining) && ( diff --git a/src/components/client/queries/simulatorDataFragment.graphql b/src/components/client/queries/simulatorDataFragment.graphql index 20042034a..09aecc982 100644 --- a/src/components/client/queries/simulatorDataFragment.graphql +++ b/src/components/client/queries/simulatorDataFragment.graphql @@ -35,5 +35,94 @@ fragment SimulatorData on Simulator { assigned newStation } + advancedTraining { + enabled + sequentialChapters + stripPosition + chapters { + id + name + cardComponent + mediaAsset + autoOpenMedia + autoAdvance + autoLogin + cardSwitchBehavior + mediaSize + mediaPosition + subChapters { + id + name + requiredActions { + id + eventName + args + } + } + } + inFlightChapters { + id + name + cardComponent + mediaAsset + autoOpenMedia + autoAdvance + autoLogin + cardSwitchBehavior + mediaSize + mediaPosition + subChapters { + id + name + requiredActions { + id + eventName + args + } + } + } + loginChapter { + id + name + cardComponent + mediaAsset + autoOpenMedia + autoAdvance + autoLogin + cardSwitchBehavior + mediaSize + mediaPosition + subChapters { + id + name + requiredActions { + id + eventName + args + } + } + } + completionChapter { + id + name + cardComponent + mediaAsset + autoOpenMedia + autoAdvance + autoLogin + cardSwitchBehavior + mediaSize + mediaPosition + subChapters { + id + name + requiredActions { + id + eventName + args + } + } + } + } } } diff --git a/src/components/layouts/LayoutCorners/settings.jsx b/src/components/layouts/LayoutCorners/settings.jsx index 361b94dfc..ddfe67f35 100644 --- a/src/components/layouts/LayoutCorners/settings.jsx +++ b/src/components/layouts/LayoutCorners/settings.jsx @@ -18,19 +18,17 @@ const Settings = props => { }); }; const startTraining = () => { - const client = props.clientObj.id; - const variables = { - client, - training: true, - }; + // Card-aware help: jumps to the in-flight (or regular) chapter for the + // crew's current card, falling back to begin-training. Resolved server-side. + const clientId = props.clientObj.id; const mutation = gql` - mutation ClientSetTraining($client: ID!, $training: Boolean!) { - clientSetTraining(client: $client, training: $training) + mutation ClientRequestTrainingHelp($clientId: ID!) { + clientRequestTrainingHelp(clientId: $clientId) } `; props.client.mutate({ mutation, - variables, + variables: {clientId}, }); }; return ( diff --git a/src/components/layouts/LayoutOdyssey/widgets.jsx b/src/components/layouts/LayoutOdyssey/widgets.jsx index 87ba32c52..8fdec406b 100644 --- a/src/components/layouts/LayoutOdyssey/widgets.jsx +++ b/src/components/layouts/LayoutOdyssey/widgets.jsx @@ -50,19 +50,17 @@ class WidgetsContainer extends Component { }); }; startTraining = () => { - const client = this.props.clientObj.id; - const variables = { - client, - training: true, - }; + // Card-aware help: jumps to the in-flight (or regular) chapter for the + // crew's current card, falling back to begin-training. Resolved server-side. + const clientId = this.props.clientObj.id; const mutation = gql` - mutation ClientSetTraining($client: ID!, $training: Boolean!) { - clientSetTraining(client: $client, training: $training) + mutation ClientRequestTrainingHelp($clientId: ID!) { + clientRequestTrainingHelp(clientId: $clientId) } `; this.props.client.mutate({ mutation, - variables, + variables: {clientId}, }); }; logout = () => { diff --git a/src/components/training/AdvancedTrainingBorder.scss b/src/components/training/AdvancedTrainingBorder.scss new file mode 100644 index 000000000..41e4b7daa --- /dev/null +++ b/src/components/training/AdvancedTrainingBorder.scss @@ -0,0 +1,360 @@ +// ── Isolation boundary (portal into document.body) ── +.advanced-training-isolation { + all: initial; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 10000001; + pointer-events: none; + isolation: isolate; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 1.4; + color: #f5f5f5; + box-sizing: border-box; + + // Amber instructor-layer accent so training reads as a layer, not bridge UI + --training-accent: #ffb74d; + --training-accent-strong: #ffa726; + --training-accent-soft: rgba(255, 183, 77, 0.18); + --training-accent-edge: rgba(255, 183, 77, 0.45); + --training-surface: rgba(12, 14, 18, 0.88); + --training-text-dim: #b0bec5; + + *, *::before, *::after { + box-sizing: border-box; + } +} + +// ── Outside-click catcher for popovers (transparent, dismiss-only) ── +.training-popover-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 64px; // exclude the bottom strip so strip buttons don't flicker on toggle + pointer-events: all; + background: transparent; + z-index: 1; + + &--top { + top: 64px; // exclude the top strip instead + bottom: 0; + } +} + +// ── Generic popover ── +.training-popover { + position: fixed; + background: var(--training-surface); + border: 1px solid var(--training-accent-edge); + border-radius: 6px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); + pointer-events: all; + z-index: 2; + backdrop-filter: blur(8px); + animation: trainingPopoverIn 0.18s ease-out; +} + +.training-popover--chapters { + bottom: 80px; + left: 16px; + width: 340px; + max-height: 60vh; + overflow: hidden; + display: flex; + flex-direction: column; + + &-top { + bottom: auto; + top: 80px; + } +} + +@keyframes trainingPopoverIn { + 0% { + opacity: 0; + transform: translateY(8px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +// ── Bottom strip (always visible during training) ── +.training-strip { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 64px; + display: flex; + align-items: stretch; + background: var(--training-surface); + border-top: 1px solid var(--training-accent-edge); + box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.45); + pointer-events: all; + z-index: 3; + backdrop-filter: blur(8px); +} + +.training-strip--top { + bottom: auto; + top: 0; + border-top: none; + border-bottom: 1px solid var(--training-accent-edge); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45); + + .training-strip__progress { + bottom: auto; + top: 0; // progress bar sits at the top edge of the top strip + } + + .training-strip__main { + padding: 3px 16px 0 20px; // flip padding so it's above the progress bar + } +} + +.training-strip__main { + flex: 1; + display: flex; + align-items: center; + gap: 16px; + padding: 0 16px 3px 20px; // bottom 3px reserves space for the progress bar + min-width: 0; +} + +.training-strip__text { + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; + flex-shrink: 0; + max-width: 40%; +} + +.training-strip__eyebrow { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1.2px; + color: var(--training-text-dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.training-strip__task { + font-size: 19px; + font-weight: 600; + color: #ffffff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: 0.2px; +} + +.training-strip__chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + flex: 1; + min-width: 0; +} + +.training-chip { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 13px; + padding: 3px 9px; + border-radius: 12px; + white-space: nowrap; + font-family: inherit; + + svg { + flex-shrink: 0; + } + + &.pending { + color: #fff3e0; + background: var(--training-accent-soft); + border: 1px solid var(--training-accent-edge); + + svg { + color: var(--training-accent); + } + } + + &.done { + color: #a5d6a7; + background: rgba(76, 175, 80, 0.12); + border: 1px solid rgba(76, 175, 80, 0.3); + text-decoration: line-through; + opacity: 0.75; + } +} + +.training-strip__controls { + display: flex; + align-items: center; + gap: 4px; + padding: 0 12px 3px 8px; + flex-shrink: 0; +} + +.training-strip__btn { + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid var(--training-accent-edge); + border-radius: 6px; + color: var(--training-accent); + cursor: pointer; + width: 36px; + height: 36px; + padding: 0; + font-family: inherit; + transition: background 0.15s, color 0.15s, border-color 0.15s; + + &:hover:not(:disabled) { + background: var(--training-accent-soft); + color: #fff3e0; + } + + &.active { + background: var(--training-accent-soft); + color: #fff3e0; + border-color: var(--training-accent); + } + + &:disabled { + opacity: 0.3; + cursor: not-allowed; + } +} + +.training-strip__btn--next { + width: auto; + padding: 0 12px; + gap: 6px; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.3px; + background: var(--training-accent-soft); + border-color: var(--training-accent); + color: var(--training-accent-strong); + animation: nextChapterPulse 2s ease-in-out infinite; + + &:hover:not(:disabled) { + background: var(--training-accent); + color: #1a1300; + animation: none; + } +} + +@keyframes nextChapterPulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(255, 183, 77, 0); } + 50% { box-shadow: 0 0 0 5px rgba(255, 183, 77, 0.22); } +} + +.training-strip__btn--exit { + margin-left: 4px; + border-color: rgba(244, 67, 54, 0.4); + color: #ef9a9a; + + &:hover:not(:disabled) { + background: rgba(244, 67, 54, 0.18); + color: #ffcdd2; + } +} + +// ── Progress bar (bottom edge of strip) ── +.training-strip__progress { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: rgba(0, 0, 0, 0.45); + overflow: hidden; +} + +.training-strip__progress-fill { + height: 100%; + background: var(--training-accent); + transition: width 0.4s ease; + + &.complete { + background: #4caf50; + } +} + +// ── Toast notifications (moved to top to avoid colliding with strip) ── +.advanced-training-toasts { + position: fixed; + top: 24px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + pointer-events: none; + z-index: 4; +} + +.training-toast { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + white-space: nowrap; + animation: toastIn 0.3s ease-out; + pointer-events: none; + + &.action { + background: rgba(76, 175, 80, 0.85); + color: #fff; + padding: 6px 12px; + font-size: 12px; + box-shadow: 0 2px 12px rgba(76, 175, 80, 0.4); + } + + &.subChapter { + background: var(--training-accent-strong); + color: #1a1300; + font-size: 14px; + font-weight: 600; + box-shadow: 0 2px 16px rgba(255, 167, 38, 0.5); + } + + &.chapter { + background: linear-gradient(135deg, var(--training-accent), var(--training-accent-strong)); + color: #1a1300; + font-size: 16px; + font-weight: 700; + padding: 10px 20px; + box-shadow: 0 4px 24px rgba(255, 152, 0, 0.55); + letter-spacing: 0.5px; + } +} + +@keyframes toastIn { + 0% { + opacity: 0; + transform: translateY(-12px) scale(0.95); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} diff --git a/src/components/training/AdvancedTrainingBorder.tsx b/src/components/training/AdvancedTrainingBorder.tsx new file mode 100644 index 000000000..315126379 --- /dev/null +++ b/src/components/training/AdvancedTrainingBorder.tsx @@ -0,0 +1,386 @@ +import React, {useCallback, useEffect, useRef} from "react"; +import {createPortal} from "react-dom"; +import {useAdvancedTraining} from "./useAdvancedTraining"; +import AdvancedTrainingChapterList from "./AdvancedTrainingChapterList"; +import AdvancedTrainingMediaViewer from "./AdvancedTrainingMediaViewer"; +import {getActionLabel} from "./actionRegistry"; +import "./AdvancedTrainingBorder.scss"; + +interface AdvancedTrainingBorderProps { + clientId: string; + simulatorId: string; + advancedTrainingConfig: any; + children: React.ReactNode; +} + +function getNextChapter( + config: any, + activeChapterId: string | null, +): any | null { + if (!activeChapterId) { + return null; + } + if (config.loginChapter?.id === activeChapterId) { + return config.chapters[0] || config.completionChapter || null; + } + if (config.completionChapter?.id === activeChapterId) { + return null; + } + const idx = config.chapters.findIndex((c: any) => c.id === activeChapterId); + if (idx === -1) { + return null; + } + return config.chapters[idx + 1] || config.completionChapter || null; +} + +// Collect every sub-chapter across all chapter types for progress calculation +function getAllSubChapters(config: any): any[] { + const subs: any[] = []; + if (config.loginChapter?.subChapters) { + subs.push(...config.loginChapter.subChapters); + } + for (const ch of config.chapters || []) { + if (ch.subChapters) { + subs.push(...ch.subChapters); + } + } + if (config.completionChapter?.subChapters) { + subs.push(...config.completionChapter.subChapters); + } + return subs; +} + +// Selector matching every training-UI element so the document click capture +// records crew interactions with the card, not clicks on training chrome. +const TRAINING_UI_SELECTOR = + ".training-strip, .training-popover, .training-popover-backdrop, .advanced-training-toasts, .advanced-training-media-viewer"; + +const AdvancedTrainingBorder: React.FC = ({ + clientId, + simulatorId, + advancedTrainingConfig, + children, +}) => { + const { + progress, + config, + isInAdvancedTraining, + recordAction, + setActiveChapter, + toggleMediaViewer, + toggleChapterList, + stopTraining, + } = useAdvancedTraining({ + clientId, + simulatorId, + advancedTrainingConfig, + }); + + const onVideoEnd = useCallback( + () => recordAction("__videoComplete__"), + [recordAction], + ); + + // Stable refs so the document listener never needs to re-register + const recordActionRef = useRef(recordAction); + recordActionRef.current = recordAction; + const isActiveRef = useRef(isInAdvancedTraining); + isActiveRef.current = isInAdvancedTraining; + + // Capture clicks on card elements via the document (capture phase). Clicks + // on training UI itself are skipped via TRAINING_UI_SELECTOR. + useEffect(() => { + if (!isInAdvancedTraining) { + return; + } + + const handleDocumentClick = (e: MouseEvent) => { + if (!isActiveRef.current) { + return; + } + const target = e.target as HTMLElement; + + if (target.closest(TRAINING_UI_SELECTOR)) { + return; + } + + const interactive = target.closest( + "button, a, [role='button'], input, select, .btn", + ) as HTMLElement | null; + const el = interactive || target; + + const tag = el.tagName.toLowerCase(); + if ( + ["div", "span", "col", "row", "container", "section"].includes(tag) && + !interactive + ) { + return; + } + + const text = + el.textContent?.trim().replace(/\s+/g, " ").substring(0, 60) || + el.getAttribute("aria-label") || + el.getAttribute("title") || + ""; + + if (!text) { + return; + } + + recordActionRef.current(`click:${text}`, { + text, + tag, + className: el.className + ? String(el.className) + .split(" ") + .filter(Boolean) + .slice(0, 3) + .join(" ") + : null, + }); + }; + + document.addEventListener("click", handleDocumentClick, true); + return () => + document.removeEventListener("click", handleDocumentClick, true); + }, [isInAdvancedTraining]); + + if (!isInAdvancedTraining || !config || !progress) { + return <>{children}; + } + + const activeChapter = + config.chapters.find((c: any) => c.id === progress.activeChapterId) || + (config.inFlightChapters || []).find( + (c: any) => c.id === progress.activeChapterId, + ) || + (config.loginChapter?.id === progress.activeChapterId + ? config.loginChapter + : null) || + (config.completionChapter?.id === progress.activeChapterId + ? config.completionChapter + : null); + + const activeSubChapter = activeChapter?.subChapters?.find( + (sc: any) => sc.id === progress.activeSubChapterId, + ); + const observedForSub = + progress.observedActions?.[progress.activeSubChapterId || ""] || []; + const pendingActions = (activeSubChapter?.requiredActions || []).filter( + (ra: any) => !observedForSub.includes(ra.eventName), + ); + const completedActions = (activeSubChapter?.requiredActions || []).filter( + (ra: any) => observedForSub.includes(ra.eventName), + ); + + const allSubChapters = getAllSubChapters(config); + const totalSubs = allSubChapters.length; + const completedSubs = (progress.completedSubChapterIds || []).length; + const progressPercent = + totalSubs > 0 ? Math.round((completedSubs / totalSubs) * 100) : 0; + + const chapterIsComplete = activeChapter + ? progress.completedChapterIds.includes(activeChapter.id) + : false; + const nextChapter = chapterIsComplete + ? getNextChapter(config, progress.activeChapterId) + : null; + + const heroTaskName = + activeSubChapter?.name ?? + (chapterIsComplete + ? nextChapter + ? "Chapter complete!" + : "All done — great work!" + : activeChapter + ? "Chapter overview" + : "Training complete"); + return ( + <> + {children} + + {createPortal( +
+ {/* Outside-click catcher — only rendered when the chapter list is open. + The media viewer is a draggable floating window and does not need a backdrop. */} + {progress.chapterListOpen && ( +
toggleChapterList(false)} + /> + )} + + {/* Chapter list popover */} + {progress.chapterListOpen && ( +
+ +
+ )} + + {/* Media viewer popover */} + {progress.mediaViewerOpen && activeChapter?.mediaAsset && ( + toggleMediaViewer(false)} + onVideoEnd={onVideoEnd} + size={activeChapter.mediaSize || "small"} + position={activeChapter.mediaPosition || "bottom-right"} + stripPosition={ + (config.stripPosition || "bottom") as "top" | "bottom" + } + /> + )} + + {/* Training strip — position driven by config */} +
+
+
+ {activeChapter && ( + + {activeChapter.name} + + )} + {heroTaskName} +
+ + {(pendingActions.length > 0 || completedActions.length > 0) && ( +
+ {completedActions.map((ra: any) => ( + + + + + {getActionLabel( + ra.eventName, + activeChapter?.cardComponent, + )} + + ))} + {pendingActions.map((ra: any) => ( + + + + + {getActionLabel( + ra.eventName, + activeChapter?.cardComponent, + )} + + ))} +
+ )} +
+ +
+ {chapterIsComplete && nextChapter && ( + + )} + + + +
+ +
+
= 100 ? " complete" : "" + }`} + style={{width: `${progressPercent}%`}} + /> +
+
+
, + document.body, + )} + + ); +}; + +export default AdvancedTrainingBorder; diff --git a/src/components/training/AdvancedTrainingChapterList.scss b/src/components/training/AdvancedTrainingChapterList.scss new file mode 100644 index 000000000..f7d497722 --- /dev/null +++ b/src/components/training/AdvancedTrainingChapterList.scss @@ -0,0 +1,226 @@ +.advanced-training-chapter-list { + // Sized by the .training-popover wrapper now + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + color: #f5f5f5; + font-size: 14px; + font-family: inherit; + + .chapter-list-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--training-accent-edge); + + h4 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--training-accent); + text-transform: uppercase; + letter-spacing: 1px; + font-family: inherit; + // Reset any layout h4 overrides + background: none; + -webkit-background-clip: initial; + -webkit-text-fill-color: var(--training-accent); + -webkit-text-stroke: 0; + text-shadow: none; + } + } + + .chapter-list-body { + flex: 1; + overflow-y: auto; + padding: 8px 0; + } + + .chapter-item { + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + + &.active > .chapter-row { + background: var(--training-accent-soft); + } + + &.locked { + opacity: 0.45; + + > .chapter-row { + cursor: not-allowed; + } + } + + &.prereq-locked { + opacity: 0.55; + + > .chapter-row { + cursor: not-allowed; + } + } + + &.just-completed { + animation: chapterCompleteGlow 1.5s ease-out; + } + } + + @keyframes chapterCompleteGlow { + 0% { + background: rgba(76, 175, 80, 0); + } + 15% { + background: rgba(76, 175, 80, 0.3); + } + 40% { + background: rgba(76, 175, 80, 0.15); + } + 100% { + background: rgba(76, 175, 80, 0); + } + } + + .chapter-row { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + cursor: pointer; + transition: background 0.15s; + + &:hover { + background: rgba(255, 183, 77, 0.08); + } + } + + .chapter-status-icon { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 50%; + flex-shrink: 0; + + &.complete { + color: #4caf50; + } + + &.active { + color: var(--training-accent); + } + + &.pending { + color: #78909c; + } + + &.locked { + color: #78909c; + } + + &.prereq-locked { + color: #607d8b; + } + } + + .chapter-number { + font-size: 12px; + font-weight: 600; + color: inherit; + } + + .chapter-info { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + } + + .chapter-name { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .chapter-card { + font-size: 12px; + color: #90a4ae; + } + + .chapter-play-btn { + background: none; + border: 1px solid var(--training-accent-edge); + border-radius: 50%; + color: var(--training-accent); + cursor: pointer; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 0; + font-family: inherit; + transition: all 0.2s; + + &:hover { + background: var(--training-accent-soft); + color: #fff3e0; + } + } + + .chapter-expand { + display: flex; + color: #78909c; + transition: transform 0.2s; + + &.expanded { + transform: rotate(180deg); + } + } + + .sub-chapter-list { + padding-left: 48px; + padding-bottom: 4px; + } + + .sub-chapter-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 16px 6px 0; + + &.complete { + .sub-chapter-name { + color: #90a4ae; + } + } + } + + .sub-chapter-status { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + flex-shrink: 0; + + &.complete { + color: #4caf50; + } + } + + .sub-chapter-dot { + display: block; + width: 6px; + height: 6px; + border-radius: 50%; + background: #546e7a; + } + + .sub-chapter-name { + font-size: 13px; + color: #cfd8dc; + } +} diff --git a/src/components/training/AdvancedTrainingChapterList.tsx b/src/components/training/AdvancedTrainingChapterList.tsx new file mode 100644 index 000000000..5a89433f8 --- /dev/null +++ b/src/components/training/AdvancedTrainingChapterList.tsx @@ -0,0 +1,262 @@ +import React, {useState, useEffect, useRef} from "react"; +import {getCardLabel} from "./actionRegistry"; +import {CARD_PREREQUISITES} from "./trainingPrerequisites"; +import "./AdvancedTrainingChapterList.scss"; + +interface ChapterListProps { + config: { + sequentialChapters?: boolean; + chapters: any[]; + }; + progress: { + activeChapterId: string | null; + completedChapterIds: string[]; + completedSubChapterIds: string[]; + globalObservedEvents?: string[]; + }; + onSelectChapter: (chapterId: string) => void; +} + +const AdvancedTrainingChapterList: React.FC = ({ + config, + progress, + onSelectChapter, +}) => { + const [expandedChapters, setExpandedChapters] = useState< + Record + >(() => { + // Auto-expand the active chapter + const initial: Record = {}; + if (progress.activeChapterId) { + initial[progress.activeChapterId] = true; + } + return initial; + }); + + // Track recently completed chapters for animation + const [recentlyCompleted, setRecentlyCompleted] = useState>( + new Set(), + ); + const prevCompletedRef = useRef(progress.completedChapterIds); + + useEffect(() => { + const prev = prevCompletedRef.current; + const current = progress.completedChapterIds; + const newlyCompleted = current.filter(id => !prev.includes(id)); + + if (newlyCompleted.length > 0) { + setRecentlyCompleted(s => { + const next = new Set(s); + newlyCompleted.forEach(id => next.add(id)); + return next; + }); + + // Clear animation after it plays + const timeout = setTimeout(() => { + setRecentlyCompleted(s => { + const next = new Set(s); + newlyCompleted.forEach(id => next.delete(id)); + return next; + }); + }, 1500); + + prevCompletedRef.current = current; + return () => clearTimeout(timeout); + } + + prevCompletedRef.current = current; + }, [progress.completedChapterIds]); + + const toggleExpand = (chapterId: string) => { + setExpandedChapters(prev => ({ + ...prev, + [chapterId]: !prev[chapterId], + })); + }; + + const isPrerequisiteLocked = (chapter: any) => { + const prerequisites = CARD_PREREQUISITES[chapter.cardComponent] || []; + if (prerequisites.length === 0) { + return false; + } + const observed = progress.globalObservedEvents || []; + return prerequisites.some((evt: string) => !observed.includes(evt)); + }; + + const getChapterStatus = (chapter: any) => { + if (progress.completedChapterIds.includes(chapter.id)) { + return "complete"; + } + if (progress.activeChapterId === chapter.id) { + return "active"; + } + return "pending"; + }; + + const getSubChapterStatus = (subChapter: any) => { + if (progress.completedSubChapterIds.includes(subChapter.id)) { + return "complete"; + } + return "pending"; + }; + + const isChapterLocked = (idx: number) => { + if (!config.sequentialChapters) { + return false; + } + if (idx === 0) { + return false; + } + const prevChapter = config.chapters[idx - 1]; + return !progress.completedChapterIds.includes(prevChapter.id); + }; + + return ( +
+
+

Training Chapters

+
+
+ {config.chapters.map((chapter: any, idx: number) => { + const status = getChapterStatus(chapter); + const isExpanded = expandedChapters[chapter.id] || false; + const locked = isChapterLocked(idx); + const prereqLocked = + !locked && status === "pending" && isPrerequisiteLocked(chapter); + const isBlocked = locked || prereqLocked; + const justCompleted = recentlyCompleted.has(chapter.id); + + return ( +
+
!isBlocked && toggleExpand(chapter.id)} + > + + {locked ? ( + + + + ) : prereqLocked ? ( + + + + ) : status === "complete" ? ( + + + + ) : status === "active" ? ( + + + + ) : ( + {idx + 1} + )} + +
+ {chapter.name} + + {getCardLabel(chapter.cardComponent)} + +
+ {!isBlocked && ( + + )} + {chapter.subChapters?.length > 0 && !isBlocked && ( + + + + + + )} +
+ {isExpanded && !isBlocked && chapter.subChapters?.length > 0 && ( +
+ {chapter.subChapters.map((sub: any) => { + const subStatus = getSubChapterStatus(sub); + return ( +
+ + {subStatus === "complete" ? ( + + + + ) : ( + + )} + + {sub.name} +
+ ); + })} +
+ )} +
+ ); + })} +
+
+ ); +}; + +export default AdvancedTrainingChapterList; diff --git a/src/components/training/AdvancedTrainingMediaViewer.scss b/src/components/training/AdvancedTrainingMediaViewer.scss new file mode 100644 index 000000000..6e8afd2bc --- /dev/null +++ b/src/components/training/AdvancedTrainingMediaViewer.scss @@ -0,0 +1,96 @@ +.advanced-training-media-viewer { + // Positioned within the .advanced-training-isolation container, which fills + // the viewport. The transform from useState({x, y}) does the actual placement. + position: absolute; + top: 0; + left: 0; + min-width: 240px; + z-index: 3000; + pointer-events: all; + background: rgba(0, 0, 0, 0.95); + border: 1px solid var(--training-accent-edge); + border-radius: 6px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5); + cursor: grab; + user-select: none; + font-family: inherit; + color: #f5f5f5; + + &:active { + cursor: grabbing; + } + + .media-viewer-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + border-bottom: 1px solid var(--training-accent-edge); + background: var(--training-accent-soft); + } + + .media-viewer-title { + font-size: 11px; + color: var(--training-accent); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .media-viewer-close { + background: none; + border: none; + color: #78909c; + cursor: pointer; + padding: 2px; + font-family: inherit; + + &:hover { + color: #ef9a9a; + } + } + + .media-viewer-body { + display: flex; + flex-direction: column; + } + + .media-viewer-player { + width: 100%; + background: #000; + line-height: 0; + + video { + width: 100%; + height: auto; + } + } + + .media-viewer-controls { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 8px; + background: rgba(20, 20, 20, 0.95); + + .media-control { + flex-shrink: 0; + } + + .media-control--volume-range { + flex: 1; + } + + .media-control--current-time, + .media-control--duration { + font-size: 10px; + color: #b0bec5; + min-width: 30px; + text-align: center; + font-family: inherit; + } + + .media-control--volume { + width: 50px; + } + } +} diff --git a/src/components/training/AdvancedTrainingMediaViewer.tsx b/src/components/training/AdvancedTrainingMediaViewer.tsx new file mode 100644 index 000000000..4602c230c --- /dev/null +++ b/src/components/training/AdvancedTrainingMediaViewer.tsx @@ -0,0 +1,374 @@ +import React, { + useRef, + useState, + useEffect, + useCallback, + CSSProperties, +} from "react"; +// @ts-ignore - react-media-player has no type declarations +import {Media, Player, controls, withMediaProps} from "react-media-player"; +import "./AdvancedTrainingMediaViewer.scss"; + +const {CurrentTime, Duration, Volume} = controls; + +// Reuse the same withMediaProps pattern from the legacy training player +const PlayPauseComp: React.FC<{media: any; className?: string}> = ({ + media, + className, +}) => ( + media.playPause()} + > + + {media.isPlaying ? ( + + + + + ) : ( + + )} + +); +const PlayPause = withMediaProps(PlayPauseComp); + +const MuteUnmuteComp: React.FC<{media: any; className?: string}> = ({ + media, + className, +}) => ( + media.muteUnmute()} + > + + + {media.volume > 0 && !media.isMuted && ( + + )} + {(media.volume === 0 || media.isMuted) && ( + + )} + +); +const MuteUnmute = withMediaProps(MuteUnmuteComp); + +const SeekBarComp: React.FC<{media: any; className?: string}> = ({ + media, + className, +}) => { + const [seekValue, setSeekValue] = useState(media.currentTime); + const isPlayingRef = useRef(false); + + useEffect(() => { + setSeekValue(media.currentTime); + }, [media.currentTime]); + + return ( + { + isPlayingRef.current = media.isPlaying; + media.pause(); + }} + onMouseUp={(e: any) => { + media.seekTo(+e.target.value); + if (isPlayingRef.current) { + media.play(); + } + }} + onChange={(e: any) => { + setSeekValue(+e.target.value); + }} + className={className} + style={{ + backgroundSize: (seekValue * 100) / (media.duration || 1) + "% 100%", + }} + /> + ); +}; +const SeekBar = withMediaProps(SeekBarComp); + +interface AdvancedTrainingMediaViewerProps { + src: string; + onClose: () => void; + onVideoEnd?: () => void; + size?: "small" | "medium" | "large"; + position?: string; + stripPosition?: "top" | "bottom"; +} + +const SIZE_WIDTHS = {small: 0.25, medium: 0.4, large: 0.6}; +const STRIP_HEIGHT = 64; +const MARGIN = 16; + +// Return CSS properties that accurately position the viewer using browser layout +// rather than guessing the element height in JavaScript. +function getPositionStyle( + position: string, + size: "small" | "medium" | "large", + stripPosition: "top" | "bottom" = "bottom", +): CSSProperties { + const [vert, horiz] = position.split("-"); + + const style: CSSProperties = {}; + + if (horiz === "left") { + style.left = MARGIN; + } else if (horiz === "right") { + style.right = MARGIN; + } else { + style.left = "50%"; + } // center + + if (vert === "top") { + style.top = stripPosition === "top" ? STRIP_HEIGHT + MARGIN : MARGIN; + } else if (vert === "bottom") { + style.bottom = stripPosition === "bottom" ? STRIP_HEIGHT + MARGIN : MARGIN; + } else { + style.top = "50%"; + } // middle + + const tx = horiz === "center" ? "-50%" : "0px"; + const ty = vert === "middle" ? "-50%" : "0px"; + if (tx !== "0px" || ty !== "0px") { + style.transform = `translate(${tx}, ${ty})`; + } + + // Width is still set via viewerWidth in the component + return style; +} + +const VIDEO_EXTENSIONS = ["mov", "mp4", "ogv", "webm", "m4v"]; + +const AdvancedTrainingMediaViewer: React.FC< + AdvancedTrainingMediaViewerProps +> = ({ + src, + onClose, + onVideoEnd, + size = "small", + position = "bottom-right", + stripPosition = "bottom", +}) => { + // `dragPos` is only set once the user starts dragging. Before that, CSS + // handles placement accurately (no hardcoded height guessing needed). + const [dragPos, setDragPos] = useState<{x: number; y: number} | null>(null); + const [isDragging, setIsDragging] = useState(false); + const dragStartRef = useRef({x: 0, y: 0, posX: 0, posY: 0}); + const viewerRef = useRef(null); + const playerWrapperRef = useRef(null); + const videoEndFiredRef = useRef(false); + // Keep a stable ref to onVideoEnd so the ended-listener effect never needs + // to re-run (and never detaches mid-playback) just because the prop's + // function identity changed due to a parent re-render. + const onVideoEndRef = useRef(onVideoEnd); + useEffect(() => { + onVideoEndRef.current = onVideoEnd; + }); + + const ext = (src.match(/\.([^.]+)$/)?.[1] || "").toLowerCase(); + const isVideo = VIDEO_EXTENSIONS.includes(ext); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + const tag = (e.target as HTMLElement).tagName; + if ( + [ + "INPUT", + "BUTTON", + "SVG", + "CIRCLE", + "POLYGON", + "RECT", + "PATH", + "G", + ].includes(tag) + ) { + return; + } + + // On first drag, capture the element's current pixel position so we can + // switch from CSS-based layout to transform-based drag coordinates. + let startPosX = dragPos?.x ?? 0; + let startPosY = dragPos?.y ?? 0; + if (!dragPos && viewerRef.current) { + const rect = viewerRef.current.getBoundingClientRect(); + startPosX = rect.left; + startPosY = rect.top; + setDragPos({x: startPosX, y: startPosY}); + } + + setIsDragging(true); + dragStartRef.current = { + x: e.clientX, + y: e.clientY, + posX: startPosX, + posY: startPosY, + }; + }, + [dragPos], + ); + + // Fire onVideoEnd once when the media element's ended event fires. + // Works for both video and audio assets. + // + // Intentionally uses [] deps — the viewer is keyed on activeChapterId so it + // remounts on chapter change. Within a chapter this effect must never re-run, + // because tearing down and re-attaching the listener opens a window where the + // ended event can be missed. onVideoEndRef keeps the callback current without + // causing a re-run. + useEffect(() => { + const wrapper = playerWrapperRef.current; + if (!wrapper) { + return; + } + + const attachListener = () => { + const mediaEl = wrapper.querySelector("video, audio"); + if (!mediaEl) { + return; + } + const handleEnded = () => { + if (!videoEndFiredRef.current) { + videoEndFiredRef.current = true; + onVideoEndRef.current?.(); + } + }; + mediaEl.addEventListener("ended", handleEnded); + return () => mediaEl.removeEventListener("ended", handleEnded); + }; + + // The media element may not exist immediately; poll briefly + let cleanup: (() => void) | undefined; + const timer = setTimeout(() => { + cleanup = attachListener(); + }, 200); + + return () => { + clearTimeout(timer); + cleanup?.(); + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!isDragging) { + return; + } + + const handleMouseMove = (e: MouseEvent) => { + const dx = e.clientX - dragStartRef.current.x; + const dy = e.clientY - dragStartRef.current.y; + setDragPos({ + x: Math.max( + 0, + Math.min( + window.innerWidth * (1 - (SIZE_WIDTHS[size] || 0.25)), + dragStartRef.current.posX + dx, + ), + ), + y: Math.max( + 0, + Math.min(window.innerHeight * 0.75, dragStartRef.current.posY + dy), + ), + }); + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [isDragging]); + + const viewerWidth = `${(SIZE_WIDTHS[size] || 0.25) * 100}vw`; + + // Before the user drags: let CSS handle placement precisely. + // After dragging: switch to pixel-based transform coordinates. + const positionStyle: CSSProperties = dragPos + ? { + left: 0, + top: 0, + right: "auto", + bottom: "auto", + transform: `translate(${dragPos.x}px, ${dragPos.y}px)`, + } + : getPositionStyle(position, size, stripPosition); + + return ( +
+
+ Training Media + +
+ + {({playPause}: {playPause: () => void}) => ( +
+
+ playPause()} /> +
+
+ + + + + + +
+
+ )} +
+
+ ); +}; + +export default AdvancedTrainingMediaViewer; diff --git a/src/components/training/actionRegistry.ts b/src/components/training/actionRegistry.ts new file mode 100644 index 000000000..2b3d28588 --- /dev/null +++ b/src/components/training/actionRegistry.ts @@ -0,0 +1,228 @@ +/** + * Action Registry for the Advanced Training System. + * + * Maps GraphQL mutation event names to human-readable labels, organized by + * card component. Used in both the FD record-mode configuration and the + * crew-side progress display. + */ + +export interface ActionDefinition { + eventName: string; + label: string; +} + +export interface CardActions { + cardComponent: string; + cardLabel: string; + actions: ActionDefinition[]; +} + +const actionRegistry: CardActions[] = [ + { + cardComponent: "ShieldControl", + cardLabel: "Shield Control", + actions: [ + {eventName: "shieldRaised", label: "Raise Shields"}, + {eventName: "shieldLowered", label: "Lower Shields"}, + {eventName: "shieldFrequencySet", label: "Adjust Shield Frequency"}, + {eventName: "shieldFrequencySetAll", label: "Set All Shield Frequencies"}, + ], + }, + { + cardComponent: "Navigation", + cardLabel: "Navigation", + actions: [ + {eventName: "navCalculateCourse", label: "Calculate Course"}, + {eventName: "navCancelCalculation", label: "Cancel Calculation"}, + {eventName: "navCourseEntry", label: "Enter Course Coordinates"}, + ], + }, + { + cardComponent: "NavigationAdvanced", + cardLabel: "Advanced Navigation", + actions: [ + {eventName: "rotationSet", label: "Adjust Rotation"}, + {eventName: "setEngineAcceleration", label: "Set Engine Acceleration"}, + ], + }, + { + cardComponent: "Sensors", + cardLabel: "Sensors", + actions: [ + {eventName: "pingSensors", label: "Ping Sensors"}, + {eventName: "setSensorPingMode", label: "Set Ping Mode"}, + {eventName: "sensorScanRequest", label: "Request Sensor Scan"}, + {eventName: "sensorScanCancel", label: "Cancel Sensor Scan"}, + {eventName: "removeProcessedData", label: "Clear Processed Data"}, + { + eventName: "setTargetingCalculatedTarget", + label: "Set Calculated Target", + }, + ], + }, + { + cardComponent: "EngineControl", + cardLabel: "Engine Control", + actions: [{eventName: "setSpeed", label: "Change Engine Speed"}], + }, + { + cardComponent: "ReactorControl", + cardLabel: "Reactor Control", + actions: [ + {eventName: "reactorChangeEfficiency", label: "Set Reactor Efficiency"}, + {eventName: "engineCool", label: "Apply Coolant"}, + {eventName: "reactorSetWingPower", label: "Distribute Wing Power"}, + ], + }, + { + cardComponent: "Targeting", + cardLabel: "Targeting", + actions: [ + {eventName: "targetTargetingContact", label: "Lock Target"}, + {eventName: "untargetTargetingContact", label: "Release Target"}, + {eventName: "targetSystem", label: "Target System"}, + { + eventName: "setTargetingEnteredTarget", + label: "Enter Target Coordinates", + }, + {eventName: "chargePhaserBeam", label: "Charge Phaser"}, + {eventName: "stopChargingPhasers", label: "Stop Charging"}, + {eventName: "dischargePhaserBeam", label: "Fire Phaser"}, + ], + }, + { + cardComponent: "TorpedoLoading", + cardLabel: "Torpedo Loading", + actions: [ + {eventName: "loadWarhead", label: "Load Torpedo"}, + {eventName: "unloadWarhead", label: "Unload Torpedo"}, + {eventName: "fireWarhead", label: "Fire Torpedo"}, + ], + }, + { + cardComponent: "CommShortRange", + cardLabel: "Short Range Comm", + actions: [ + {eventName: "commHail", label: "Initiate Hail"}, + {eventName: "cancelHail", label: "Cancel Hail"}, + {eventName: "commConnectArrow", label: "Connect to Hail"}, + {eventName: "commDisconnectArrow", label: "Disconnect"}, + {eventName: "muteShortRangeComm", label: "Toggle Mute"}, + ], + }, + { + cardComponent: "LongRangeComm", + cardLabel: "Long Range Comm", + actions: [ + {eventName: "longRangeMessageSend", label: "Send Message"}, + {eventName: "deleteLongRangeMessage", label: "Delete Message"}, + ], + }, + { + cardComponent: "Transporters", + cardLabel: "Transporters", + actions: [ + {eventName: "setTransportTarget", label: "Set Transport Target"}, + { + eventName: "setTransportDestination", + label: "Set Transport Destination", + }, + {eventName: "setTransportCharge", label: "Charge Transporters"}, + {eventName: "beginTransportScan", label: "Begin Transport Scan"}, + {eventName: "cancelTransportScan", label: "Cancel Scan"}, + ], + }, +]; + +/** + * Actions always available regardless of which card component is active. + * These cover simulator-wide events like login that aren't tied to a specific card. + */ +const GLOBAL_ACTIONS: ActionDefinition[] = [ + {eventName: "clientLogin", label: "Log In to Station"}, +]; + +export function getGlobalActions(): ActionDefinition[] { + return GLOBAL_ACTIONS; +} + +/** + * Synthetic event fired by the media viewer when a video reaches the end. + * Add this as a requiredAction on a subchapter to require the crew to watch + * the training video before the subchapter counts as complete. + */ +export const VIDEO_COMPLETE_EVENT = "__videoComplete__"; + +/** + * The GraphQL mutation event fired when a crew member logs in to their station. + * Add this as a requiredAction on a login-chapter subchapter to gate completion + * on the crew actually logging in. + */ +export const LOGIN_EVENT = "clientLogin"; + +/** + * Check if an event name represents a click action (vs a GraphQL mutation). + */ +export function isClickAction(eventName: string): boolean { + return eventName.startsWith("click:"); +} + +/** + * Get the human-readable label for an event name, optionally scoped to a card. + */ +export function getActionLabel( + eventName: string, + cardComponent?: string, +): string { + // Handle the video completion sentinel + if (eventName === VIDEO_COMPLETE_EVENT) { + return "Media finishes"; + } + + // Handle click-type actions + if (isClickAction(eventName)) { + const text = eventName.slice("click:".length); + return `Click: ${text}`; + } + + if (cardComponent) { + const card = actionRegistry.find(c => c.cardComponent === cardComponent); + const action = card?.actions.find(a => a.eventName === eventName); + if (action) { + return action.label; + } + } + // Fall back to searching all cards + for (const card of actionRegistry) { + const action = card.actions.find(a => a.eventName === eventName); + if (action) { + return action.label; + } + } + // Last resort: humanize the event name + return eventName + .replace(/([A-Z])/g, " $1") + .replace(/^./, s => s.toUpperCase()) + .trim(); +} + +/** + * Get all actions available for a specific card component. + */ +export function getActionsForCard(cardComponent: string): ActionDefinition[] { + return ( + actionRegistry.find(c => c.cardComponent === cardComponent)?.actions || [] + ); +} + +/** + * Get the card label for a component name. + */ +export function getCardLabel(cardComponent: string): string { + return ( + actionRegistry.find(c => c.cardComponent === cardComponent)?.cardLabel || + cardComponent + ); +} + +export default actionRegistry; diff --git a/src/components/training/index.ts b/src/components/training/index.ts new file mode 100644 index 000000000..8851327b8 --- /dev/null +++ b/src/components/training/index.ts @@ -0,0 +1,10 @@ +export {default as AdvancedTrainingBorder} from "./AdvancedTrainingBorder"; +export {default as AdvancedTrainingChapterList} from "./AdvancedTrainingChapterList"; +export {default as AdvancedTrainingMediaViewer} from "./AdvancedTrainingMediaViewer"; +export {useAdvancedTraining} from "./useAdvancedTraining"; +export { + default as actionRegistry, + getActionLabel, + getActionsForCard, + getCardLabel, +} from "./actionRegistry"; diff --git a/src/components/training/queries.ts b/src/components/training/queries.ts new file mode 100644 index 000000000..8910d2fba --- /dev/null +++ b/src/components/training/queries.ts @@ -0,0 +1,172 @@ +import gql from "graphql-tag.macro"; + +// --- Fragments --- + +export const ADVANCED_TRAINING_PROGRESS_FRAGMENT = gql` + fragment AdvancedTrainingProgressFragment on AdvancedTrainingProgress { + id + clientId + simulatorId + stationName + activeChapterId + activeSubChapterId + completedChapterIds + completedSubChapterIds + observedActions + globalObservedEvents + mediaViewerOpen + chapterListOpen + } +`; + +// --- Mutations --- + +export const START_ADVANCED_TRAINING = gql` + mutation ClientStartAdvancedTraining($clientId: ID!) { + clientStartAdvancedTraining(clientId: $clientId) + } +`; + +export const STOP_ADVANCED_TRAINING = gql` + mutation ClientStopAdvancedTraining($clientId: ID!) { + clientStopAdvancedTraining(clientId: $clientId) + } +`; + +export const CLIENT_REQUEST_TRAINING_HELP = gql` + mutation ClientRequestTrainingHelp($clientId: ID!) { + clientRequestTrainingHelp(clientId: $clientId) + } +`; + +export const ADVANCED_TRAINING_ACTION = gql` + mutation ClientAdvancedTrainingAction( + $clientId: ID! + $eventName: String! + $args: JSON + ) { + clientAdvancedTrainingAction( + clientId: $clientId + eventName: $eventName + args: $args + ) + } +`; + +export const SET_ACTIVE_CHAPTER = gql` + mutation AdvancedTrainingSetActiveChapter($clientId: ID!, $chapterId: ID!) { + advancedTrainingSetActiveChapter(clientId: $clientId, chapterId: $chapterId) + } +`; + +export const TOGGLE_MEDIA_VIEWER = gql` + mutation AdvancedTrainingToggleMediaViewer($clientId: ID!, $open: Boolean!) { + advancedTrainingToggleMediaViewer(clientId: $clientId, open: $open) + } +`; + +export const TOGGLE_CHAPTER_LIST = gql` + mutation AdvancedTrainingToggleChapterList($clientId: ID!, $open: Boolean!) { + advancedTrainingToggleChapterList(clientId: $clientId, open: $open) + } +`; + +// --- FD Mutations --- + +export const FD_ADVANCE_CHAPTER = gql` + mutation FdAdvanceTrainingChapter($clientId: ID!, $chapterId: ID!) { + advancedTrainingSetActiveChapter(clientId: $clientId, chapterId: $chapterId) + } +`; + +export const FD_COMPLETE_SUBCHAPTER = gql` + mutation FdCompleteTrainingSubChapter($clientId: ID!, $subChapterId: ID!) { + fdCompleteTrainingSubChapter( + clientId: $clientId + subChapterId: $subChapterId + ) + } +`; + +export const FD_RESET_PROGRESS = gql` + mutation FdResetTrainingProgress($clientId: ID!) { + fdResetTrainingProgress(clientId: $clientId) + } +`; + +// --- Sandbox Flight (for recording modal) --- + +export const START_SANDBOX_FLIGHT = gql` + mutation StartSandboxFlight($name: String!, $simulators: [SimulatorInput!]!) { + startFlight(name: $name, simulators: $simulators) + } +`; + +export const DELETE_SANDBOX_FLIGHT = gql` + mutation DeleteSandboxFlight($flightId: ID!) { + deleteFlight(flightId: $flightId) + } +`; + +export const SANDBOX_FLIGHT_SIMULATORS = gql` + query SandboxFlightSimulators($flightId: ID!) { + flights(id: $flightId) { + id + simulators { + id + } + } + } +`; + +// --- Config Mutations --- + +export const SET_STATION_ADVANCED_TRAINING = gql` + mutation SetStationAdvancedTraining( + $stationSetID: ID! + $stationName: String! + $config: AdvancedTrainingConfigInput! + ) { + setStationAdvancedTraining( + stationSetID: $stationSetID + stationName: $stationName + config: $config + ) + } +`; + +export const TOGGLE_ADVANCED_TRAINING_MODE = gql` + mutation ToggleAdvancedTrainingMode( + $stationSetID: ID! + $stationName: String! + $enabled: Boolean! + ) { + toggleAdvancedTrainingMode( + stationSetID: $stationSetID + stationName: $stationName + enabled: $enabled + ) + } +`; + +// --- Subscriptions --- + +export const ADVANCED_TRAINING_PROGRESS_SUB = gql` + subscription AdvancedTrainingProgressUpdate($simulatorId: ID) { + advancedTrainingProgressUpdate(simulatorId: $simulatorId) { + ...AdvancedTrainingProgressFragment + } + } + ${ADVANCED_TRAINING_PROGRESS_FRAGMENT} +`; + +// --- Queries --- + +export const ADVANCED_TRAINING_PROGRESS_QUERY = gql` + query AdvancedTrainingProgress($clientId: ID, $simulatorId: ID) { + advancedTrainingProgress(clientId: $clientId, simulatorId: $simulatorId) { + ...AdvancedTrainingProgressFragment + } + } + ${ADVANCED_TRAINING_PROGRESS_FRAGMENT} +`; diff --git a/src/components/training/trainingPrerequisites.ts b/src/components/training/trainingPrerequisites.ts new file mode 100644 index 000000000..875f53e16 --- /dev/null +++ b/src/components/training/trainingPrerequisites.ts @@ -0,0 +1,9 @@ +/** + * Client-side mirror of server/classes/trainingPrerequisites.ts. + * + * Keep in sync with the server version. Used by the chapter list to show + * chapters as "not yet available" until their prerequisites have been observed. + */ +export const CARD_PREREQUISITES: Record = { + // Add entries as needed for your mission systems. +}; diff --git a/src/components/training/useAdvancedTraining.ts b/src/components/training/useAdvancedTraining.ts new file mode 100644 index 000000000..9535e1e2f --- /dev/null +++ b/src/components/training/useAdvancedTraining.ts @@ -0,0 +1,165 @@ +import {useEffect, useCallback, useRef} from "react"; +import {useMutation, useSubscription, useQuery} from "react-apollo"; +import {subscribe} from "helpers/pubsub"; +import { + ADVANCED_TRAINING_ACTION, + ADVANCED_TRAINING_PROGRESS_SUB, + ADVANCED_TRAINING_PROGRESS_QUERY, + START_ADVANCED_TRAINING, + STOP_ADVANCED_TRAINING, + SET_ACTIVE_CHAPTER, + TOGGLE_MEDIA_VIEWER, + TOGGLE_CHAPTER_LIST, +} from "./queries"; + +interface AdvancedTrainingConfig { + enabled: boolean; + sequentialChapters?: boolean; + stripPosition?: string; + chapters: any[]; + inFlightChapters?: any[]; + loginChapter?: any; + completionChapter?: any; +} + +interface UseAdvancedTrainingParams { + clientId: string; + simulatorId: string; + advancedTrainingConfig: AdvancedTrainingConfig | null; +} + +export function useAdvancedTraining({ + clientId, + simulatorId, + advancedTrainingConfig, +}: UseAdvancedTrainingParams) { + const isActive = useRef(false); + + // Mutations + const [recordActionMutation] = useMutation(ADVANCED_TRAINING_ACTION); + const [startTrainingMutation] = useMutation(START_ADVANCED_TRAINING); + const [stopTrainingMutation] = useMutation(STOP_ADVANCED_TRAINING); + const [setActiveChapterMutation] = useMutation(SET_ACTIVE_CHAPTER); + const [toggleMediaMutation] = useMutation(TOGGLE_MEDIA_VIEWER); + const [toggleChapterListMutation] = useMutation(TOGGLE_CHAPTER_LIST); + + // Query for initial state + const {data: queryData} = useQuery(ADVANCED_TRAINING_PROGRESS_QUERY, { + variables: {clientId}, + fetchPolicy: "network-only", + }); + + // Subscription for real-time updates + const {data: subData} = useSubscription(ADVANCED_TRAINING_PROGRESS_SUB, { + variables: {simulatorId}, + }); + + // Prefer subscription data only once it contains a non-empty list, so an + // early empty subscription event doesn't wipe valid initial query data. + const progressList = + (subData?.advancedTrainingProgressUpdate?.length + ? subData.advancedTrainingProgressUpdate + : null) ?? + queryData?.advancedTrainingProgress ?? + []; + const progress = progressList.find((p: any) => p.clientId === clientId); + const isInAdvancedTraining = !!progress; + + isActive.current = isInAdvancedTraining; + + // Record an action (mutation or click) to the server + const recordAction = useCallback( + (eventName: string, args?: any) => { + if (!isActive.current) { + return; + } + recordActionMutation({ + variables: { + clientId, + eventName, + args: args || null, + }, + }); + }, + [clientId, recordActionMutation], + ); + + // Observe mutations from the Apollo client middleware pub/sub + useEffect(() => { + if (!isInAdvancedTraining || !advancedTrainingConfig?.enabled) { + return; + } + + // Ignore training system mutations to prevent infinite loops: + // recordAction sends clientAdvancedTrainingAction, which would fire + // another mutation-event, triggering recordAction again. + const ignoredMutations = new Set([ + "clockSync", + "clientAdvancedTrainingAction", + "clientStartAdvancedTraining", + "clientStopAdvancedTraining", + "clientRequestTrainingHelp", + "advancedTrainingSetActiveChapter", + "advancedTrainingToggleMediaViewer", + "advancedTrainingToggleChapterList", + "fdCompleteTrainingSubChapter", + "fdResetTrainingProgress", + "clientSetTraining", + "clientSetCard", + ]); + + const unsubscribe = subscribe( + "mutation-event", + ({event, args}: {event: string; args: any}) => { + if (ignoredMutations.has(event)) { + return; + } + recordAction(event, args); + }, + ); + + return unsubscribe; + }, [isInAdvancedTraining, advancedTrainingConfig?.enabled, recordAction]); + + // Actions + const startTraining = useCallback(() => { + startTrainingMutation({variables: {clientId}}); + }, [clientId, startTrainingMutation]); + + const stopTraining = useCallback(() => { + stopTrainingMutation({variables: {clientId}}); + }, [clientId, stopTrainingMutation]); + + const setActiveChapter = useCallback( + (chapterId: string) => { + setActiveChapterMutation({variables: {clientId, chapterId}}); + }, + [clientId, setActiveChapterMutation], + ); + + const toggleMediaViewer = useCallback( + (open: boolean) => { + toggleMediaMutation({variables: {clientId, open}}); + }, + [clientId, toggleMediaMutation], + ); + + const toggleChapterList = useCallback( + (open: boolean) => { + toggleChapterListMutation({variables: {clientId, open}}); + }, + [clientId, toggleChapterListMutation], + ); + + return { + progress, + config: advancedTrainingConfig, + isInAdvancedTraining, + recordAction, + startTraining, + stopTraining, + setActiveChapter, + toggleMediaViewer, + toggleChapterList, + }; +} diff --git a/src/components/views/AdvancedTraining/core.scss b/src/components/views/AdvancedTraining/core.scss new file mode 100644 index 000000000..e625676e5 --- /dev/null +++ b/src/components/views/AdvancedTraining/core.scss @@ -0,0 +1,153 @@ +.advanced-training-core { + height: 100%; + overflow-y: auto; + padding: 4px; + font-size: 12px; + + .no-clients-msg { + color: #78909c; + text-align: center; + padding: 12px; + } + + .client-block { + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(0, 188, 212, 0.2); + border-radius: 4px; + padding: 6px; + margin-bottom: 6px; + } + + .client-header-row { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 3px; + } + + .client-name { + font-weight: 600; + color: #e0f7fa; + flex: 1; + } + + .client-station-name { + color: #546e7a; + font-size: 11px; + } + + .active-info { + font-size: 11px; + color: #b0bec5; + margin-bottom: 4px; + padding: 2px 4px; + background: rgba(0, 188, 212, 0.08); + border-left: 2px solid #00bcd4; + border-radius: 2px; + + strong { + color: #e0f7fa; + } + + .card-label { + color: #546e7a; + margin-left: 6px; + } + } + + .chapters-compact { + margin-bottom: 4px; + } + + .ch-row { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + flex-wrap: wrap; + + &.active .ch-name { + color: #00bcd4; + font-weight: 600; + } + + &.completed .ch-name { + color: #4caf50; + } + + &.completed .ch-idx { + color: #4caf50; + } + } + + .ch-idx { + color: #546e7a; + min-width: 14px; + font-size: 11px; + } + + .ch-name { + flex: 1; + color: #b0bec5; + } + + .ch-prog { + font-size: 10px; + color: #546e7a; + } + + .ch-btn { + font-size: 10px; + padding: 0 6px; + line-height: 1.4; + } + + .sc-row { + width: 100%; + display: flex; + align-items: center; + gap: 4px; + padding: 1px 0 1px 20px; + font-size: 11px; + + &.done .sc-name { + color: #546e7a; + text-decoration: line-through; + } + + &.done .sc-check { + color: #4caf50; + } + } + + .sc-check { + color: #546e7a; + font-size: 9px; + min-width: 12px; + } + + .sc-name { + flex: 1; + color: #b0bec5; + } + + .sc-btn { + font-size: 9px; + padding: 0 4px; + line-height: 1.3; + } + + .actions-row { + display: flex; + gap: 4px; + margin-top: 4px; + padding-top: 4px; + border-top: 1px solid rgba(255, 255, 255, 0.05); + + .btn { + font-size: 10px; + padding: 1px 6px; + } + } +} diff --git a/src/components/views/AdvancedTraining/core.tsx b/src/components/views/AdvancedTraining/core.tsx new file mode 100644 index 000000000..5a883709c --- /dev/null +++ b/src/components/views/AdvancedTraining/core.tsx @@ -0,0 +1,279 @@ +import React from "react"; +import {Button, Badge, Progress} from "helpers/reactstrap"; +import {useMutation, useSubscription, useQuery} from "react-apollo"; +import gql from "graphql-tag.macro"; +import { + ADVANCED_TRAINING_PROGRESS_SUB, + FD_ADVANCE_CHAPTER, + FD_COMPLETE_SUBCHAPTER, + FD_RESET_PROGRESS, +} from "components/training/queries"; +import {getCardLabel} from "components/training/actionRegistry"; +import "./core.scss"; + +const CLIENTS_FOR_SIM_QUERY = gql` + query AdvancedTrainingCoreClients($simulatorId: ID!) { + clients(simulatorId: $simulatorId) { + id + label + connected + station { + name + } + } + } +`; + +const CLIENTS_SUB = gql` + subscription AdvancedTrainingCoreClientsSub($simulatorId: ID!) { + clientChanged(simulatorId: $simulatorId) { + id + label + connected + station { + name + } + } + } +`; + +const SIM_STATIONS_QUERY = gql` + query AdvancedTrainingCoreStations($simulatorId: ID!) { + simulators(id: $simulatorId) { + id + stationSets { + id + stations { + name + advancedTraining { + enabled + chapters { + id + name + cardComponent + subChapters { + id + name + requiredActions { + id + eventName + } + } + } + } + } + } + } + } +`; + +interface AdvancedTrainingCoreProps { + simulator: {id: string}; +} + +const AdvancedTrainingCore: React.FC = ({ + simulator, +}) => { + const {data: clientsData} = useQuery(CLIENTS_FOR_SIM_QUERY, { + variables: {simulatorId: simulator.id}, + }); + useSubscription(CLIENTS_SUB, { + variables: {simulatorId: simulator.id}, + }); + + const {data: simData} = useQuery(SIM_STATIONS_QUERY, { + variables: {simulatorId: simulator.id}, + }); + + const {data: progressData} = useSubscription(ADVANCED_TRAINING_PROGRESS_SUB, { + variables: {simulatorId: simulator.id}, + }); + + const [advanceChapter] = useMutation(FD_ADVANCE_CHAPTER); + const [completeSubChapter] = useMutation(FD_COMPLETE_SUBCHAPTER); + const [resetProgress] = useMutation(FD_RESET_PROGRESS); + + const progressList = progressData?.advancedTrainingProgressUpdate || []; + const clients = clientsData?.clients || []; + const sim = simData?.simulators?.[0]; + + const getStationConfig = (stationName: string) => { + if (!sim) { + return null; + } + for (const ss of sim.stationSets || []) { + const station = ss.stations?.find((s: any) => s.name === stationName); + if (station?.advancedTraining?.enabled) { + return station.advancedTraining; + } + } + return null; + }; + + const trainingClients = clients.filter((client: any) => { + if (!client.connected || !client.station) { + return false; + } + return progressList.some((p: any) => p.clientId === client.id); + }); + + if (trainingClients.length === 0) { + return ( +
+

No crew in advanced training.

+
+ ); + } + + return ( +
+ {trainingClients.map((client: any) => { + const progress = progressList.find( + (p: any) => p.clientId === client.id, + ); + const stationName = client.station?.name || client.station || ""; + const config = getStationConfig(stationName); + if (!progress || !config) { + return null; + } + + const chapters = config.chapters || []; + const activeChapter = chapters.find( + (c: any) => c.id === progress.activeChapterId, + ); + + const totalSub = chapters.reduce( + (sum: number, ch: any) => sum + (ch.subChapters?.length || 0), + 0, + ); + const completedSub = progress.completedSubChapterIds?.length || 0; + const pct = + totalSub > 0 ? Math.round((completedSub / totalSub) * 100) : 0; + + return ( +
+
+ {client.label || client.id} + {stationName} + + {pct}% + +
+ + + + {activeChapter && ( +
+ Active: {activeChapter.name} + + {getCardLabel(activeChapter.cardComponent)} + +
+ )} + +
+ {chapters.map((ch: any, idx: number) => { + const isCompleted = progress.completedChapterIds?.includes( + ch.id, + ); + const isActive = progress.activeChapterId === ch.id; + const chSubCount = ch.subChapters?.length || 0; + const chCompleted = + ch.subChapters?.filter((sc: any) => + progress.completedSubChapterIds?.includes(sc.id), + ).length || 0; + + return ( +
+ {idx + 1} + {ch.name} + + {chCompleted}/{chSubCount} + + {!isActive && !isCompleted && ( + + )} + + {isActive && + ch.subChapters?.map((sc: any) => { + const scDone = + progress.completedSubChapterIds?.includes(sc.id); + return ( +
+ + {scDone ? "\u2713" : "\u25CB"} + + {sc.name} + {!scDone && ( + + )} +
+ ); + })} +
+ ); + })} +
+ +
+ +
+
+ ); + })} +
+ ); +}; + +export default AdvancedTrainingCore; diff --git a/src/components/views/Aegis/ActivityLog.tsx b/src/components/views/Aegis/ActivityLog.tsx new file mode 100644 index 000000000..dd2107a94 --- /dev/null +++ b/src/components/views/Aegis/ActivityLog.tsx @@ -0,0 +1,35 @@ +import React from "react"; + +interface LogEntry { + id: string; + timestamp: string; + type: string; + contents: string; +} + +interface ActivityLogProps { + entries: LogEntry[]; +} + +// Crew-facing record of moments where the swarm made a difference — damage +// absorbed, repairs completed, signals amplified — color-coded by entry type. +const ActivityLog: React.FC = ({entries}) => ( +
+
Swarm Activity Log
+
+ {entries.length === 0 && ( +

No recorded activity.

+ )} + {entries.map(entry => ( +
+ + {new Date(entry.timestamp).toLocaleTimeString()} + {" "} + {entry.contents} +
+ ))} +
+
+); + +export default ActivityLog; diff --git a/src/components/views/Aegis/AegisCanvas.tsx b/src/components/views/Aegis/AegisCanvas.tsx new file mode 100644 index 000000000..b0c079de2 --- /dev/null +++ b/src/components/views/Aegis/AegisCanvas.tsx @@ -0,0 +1,226 @@ +import React from "react"; +import useMeasure from "helpers/hooks/useMeasure"; +import {Aegis_Mode, Aegis_Relay_Target} from "generated/graphql"; +import {buildHullProfile, buildTintCanvas} from "./canvas/shipImage"; +import {seeded} from "./canvas/modeParams"; +import { + buildRenderables, + growDronePool, + resolveImpact, + updateFocusOffset, +} from "./canvas/simulation"; +import {drawDrone, drawPings, drawShip, drawStarfield} from "./canvas/render"; +import { + ActivePing, + AegisCanvasHandle, + Drone, + Geometry, + IMPACT_PING_DURATION, + RING_PING_DURATION, +} from "./canvas/types"; + +export type {AegisCanvasHandle, AegisPingEvent} from "./canvas/types"; + +interface AegisCanvasProps { + mode: Aegis_Mode; + droneCount: number; + maxDrones: number; + deployed: boolean; + assetPath: string; + screenFocusX: number; + screenFocusY: number; + ecmIntensity: number; + relayTarget: Aegis_Relay_Target; + repairEffort: number; + structuralIntegrity: number; +} + +// Pseudo-3D drone swarm animation. The component owns three things — the ship +// image data, the drone pool, and the active pings — and the draw loop reads +// the rest of its inputs from a ref so it never restarts mid-flight. All the +// heavy lifting lives in ./canvas/*. +const AegisCanvas = React.forwardRef( + (props, ref) => { + const { + mode, + droneCount, + maxDrones, + deployed, + assetPath, + screenFocusX, + screenFocusY, + ecmIntensity, + relayTarget, + repairEffort, + structuralIntegrity, + } = props; + const [dimRef, dimensions, canvas] = useMeasure(); + const shipImage = React.useRef(null); + // Solid-red copy of the ship sprite, drawn over the ship with an alpha + // proportional to lost integrity so the hull reddens as it weakens + const tintCanvas = React.useRef(null); + const hullProfile = React.useRef<{profile: number[]; avg: number} | null>( + null, + ); + const drones = React.useRef([]); + const pings = React.useRef([]); + // Lerped offset that shifts the screen formation toward the focus side + const focusOffset = React.useRef({x: 0, y: 0}); + + // Mirror props into a ref so the animation loop sees fresh values without + // being torn down and recreated on every prop change. + const stateRef = React.useRef({ + mode, + droneCount, + maxDrones, + deployed, + structuralIntegrity, + controls: { + focusX: screenFocusX, + focusY: screenFocusY, + ecmIntensity, + relayTarget, + repairEffort, + }, + }); + stateRef.current = { + mode, + droneCount, + maxDrones, + deployed, + structuralIntegrity, + controls: { + focusX: screenFocusX, + focusY: screenFocusY, + ecmIntensity, + relayTarget, + repairEffort, + }, + }; + + React.useImperativeHandle(ref, () => ({ + addPing: ping => { + pings.current.push({...ping, start: performance.now()}); + }, + })); + + // Load the ship sprite and derive its hull profile + red tint once. + React.useEffect(() => { + const image = new Image(); + shipImage.current = image; + hullProfile.current = null; + tintCanvas.current = null; + const handleLoad = () => { + const profile = buildHullProfile(image); + if (profile) { + hullProfile.current = { + profile, + avg: profile.reduce((a, b) => a + b, 0) / profile.length, + }; + } + tintCanvas.current = buildTintCanvas(image); + }; + image.addEventListener("load", handleLoad); + image.src = assetPath; + return () => image.removeEventListener("load", handleLoad); + }, [assetPath]); + + React.useEffect(() => { + let animation: number; + let lastTime = performance.now(); + const context = canvas?.getContext("2d"); + + const draw = (now: number) => { + animation = requestAnimationFrame(draw); + if (!canvas || !context) { + return; + } + const dt = Math.min(0.1, (now - lastTime) / 1000); + lastTime = now; + const t = now / 1000; + + const {width, height} = dimensions; + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + context.clearRect(0, 0, width, height); + if (width === 0 || height === 0) { + return; + } + + const geometry: Geometry = { + cx: width / 2, + cy: height / 2, + shipRadius: Math.min(width, height) * 0.22, + width, + height, + }; + const state = stateRef.current; + + drawStarfield(context, geometry, t, seeded); + + // Drop expired pings, then draw the surviving action rings + pings.current = pings.current.filter(ping => { + const duration = + ping.pingType === "impact" + ? IMPACT_PING_DURATION + : RING_PING_DURATION; + return (now - ping.start) / 1000 < duration; + }); + drawPings(context, pings.current, now, geometry); + + // Per-frame easing rates (exponential approach, frame-rate safe) + const rates = { + param: 1 - Math.exp(-dt * 1.2), + deploy: 1 - Math.exp(-dt * 0.9), + life: 1 - Math.exp(-dt * 3), + }; + + growDronePool(drones.current, state); + updateFocusOffset(focusOffset.current, state, rates.param); + const impact = resolveImpact(pings.current, now); + + const renderable = buildRenderables({ + drones: drones.current, + state, + hullProfile: hullProfile.current, + focusOffset: focusOffset.current, + impact, + geometry, + rates, + dt, + t, + }); + + // Z-sort so the ship sits between the far and near halves of the swarm + renderable.sort((a, b) => a.depth - b.depth); + const droneSize = Math.max(1.2, geometry.shipRadius * 0.02); + renderable + .filter(d => d.depth < 0) + .forEach(d => drawDrone(context, d, droneSize)); + drawShip( + context, + shipImage.current, + tintCanvas.current, + geometry, + state.structuralIntegrity, + impact.pulse, + ); + renderable + .filter(d => d.depth >= 0) + .forEach(d => drawDrone(context, d, droneSize)); + }; + animation = requestAnimationFrame(draw); + return () => { + cancelAnimationFrame(animation); + }; + }, [canvas, dimensions]); + + return ; + }, +); + +AegisCanvas.displayName = "AegisCanvas"; + +export default AegisCanvas; diff --git a/src/components/views/Aegis/FocusPad.tsx b/src/components/views/Aegis/FocusPad.tsx new file mode 100644 index 000000000..1e283cd5b --- /dev/null +++ b/src/components/views/Aegis/FocusPad.tsx @@ -0,0 +1,94 @@ +import React from "react"; +import {DraggableCore, DraggableEvent} from "react-draggable"; +import {throttle} from "helpers/debounce"; + +interface FocusPadProps { + x: number; + y: number; + onChange: (x: number, y: number) => void; +} + +// 2D focus pad, modeled on the Thrusters direction pad: drag the knob to a +// point in the unit circle. Unlike thrusters, the knob stays where it is +// released — the focus persists until the crew moves it again. +const FocusPad: React.FC = ({x, y, onChange}) => { + const padRef = React.useRef(null); + const [dragPosition, setDragPosition] = React.useState<{ + x: number; + y: number; + } | null>(null); + const onChangeRef = React.useRef(onChange); + onChangeRef.current = onChange; + const throttledChange = React.useRef( + throttle((nx: number, ny: number) => onChangeRef.current(nx, ny), 100), + ); + + const positionFromEvent = (e: DraggableEvent) => { + const pad = padRef.current; + if (!pad) { + return null; + } + const rect = pad.getBoundingClientRect(); + const clientX = + "clientX" in e ? e.clientX : e.touches && e.touches[0]?.clientX; + const clientY = + "clientY" in e ? e.clientY : e.touches && e.touches[0]?.clientY; + if (typeof clientX !== "number" || typeof clientY !== "number") { + return null; + } + let nx = (clientX - rect.left - rect.width / 2) / (rect.width / 2); + let ny = (clientY - rect.top - rect.height / 2) / (rect.height / 2); + const magnitude = Math.hypot(nx, ny); + if (magnitude > 1) { + nx /= magnitude; + ny /= magnitude; + } + // Snap a small dead zone around each axis to zero so it's easy to pick a + // clean cardinal direction + if (Math.abs(nx) < 0.1) { + nx = 0; + } + if (Math.abs(ny) < 0.1) { + ny = 0; + } + return {x: nx, y: ny}; + }; + + const handleDrag = (e: DraggableEvent) => { + const position = positionFromEvent(e); + if (!position) { + return; + } + setDragPosition(position); + throttledChange.current(position.x, position.y); + }; + + const handleStop = (e: DraggableEvent) => { + const position = positionFromEvent(e) || dragPosition || {x: 0, y: 0}; + setDragPosition(null); + onChangeRef.current(position.x, position.y); + }; + + const shown = dragPosition || {x, y}; + // The whole pad is the drag target so a touch anywhere aims the focus — + // much easier than grabbing the knob on a touch screen + return ( + +
+
+
+
+
+
+
+ + ); +}; + +export default FocusPad; diff --git a/src/components/views/Aegis/SecondaryControls.tsx b/src/components/views/Aegis/SecondaryControls.tsx new file mode 100644 index 000000000..1dcd4750f --- /dev/null +++ b/src/components/views/Aegis/SecondaryControls.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import {Aegis_Mode, Aegis_Relay_Target} from "generated/graphql"; +import {Button, ButtonGroup} from "helpers/reactstrap"; +import FocusPad from "./FocusPad"; +import ThrottledSlider from "./ThrottledSlider"; +import {relayTargets} from "./modeInfo"; + +interface SecondaryControlsProps { + mode: Aegis_Mode; + screenFocusX: number; + screenFocusY: number; + ecmIntensity: number; + relayTarget: Aegis_Relay_Target; + repairEffort: number; + onScreenFocus: (x: number, y: number) => void; + onEcmIntensity: (value: number) => void; + onRelayTarget: (target: Aegis_Relay_Target) => void; + onRepairEffort: (value: number) => void; +} + +// The fine control that swaps in below the mode buttons for the active mode: +// a focus pad for the screen, intensity/effort sliders for ECM/repair, and a +// boost-target picker for the relay. +const SecondaryControls: React.FC = props => { + const {mode} = props; + + if (mode === Aegis_Mode.Screen) { + return ( +
+

Screen Focus

+ +
+ ); + } + + if (mode === Aegis_Mode.Ecm) { + return ( +
+ +
+ ); + } + + if (mode === Aegis_Mode.Relay) { + return ( +
+

Boost Target

+ + {relayTargets.map(({target, label}) => ( + + ))} + +
+ ); + } + + return ( +
+ +
+ ); +}; + +export default SecondaryControls; diff --git a/src/components/views/Aegis/ThrottledSlider.tsx b/src/components/views/Aegis/ThrottledSlider.tsx new file mode 100644 index 000000000..e9db186f3 --- /dev/null +++ b/src/components/views/Aegis/ThrottledSlider.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import {throttle} from "helpers/debounce"; + +interface ThrottledSliderProps { + label: string; + value: number; + warning?: string; + onChange: (value: number) => void; +} + +// Range slider that sends throttled updates while dragging and keeps showing +// the local value until the subscription echo catches up — so the thumb never +// jumps back under the user's finger on a touch screen. +const ThrottledSlider: React.FC = ({ + label, + value, + warning, + onChange, +}) => { + const [local, setLocal] = React.useState(null); + const onChangeRef = React.useRef(onChange); + onChangeRef.current = onChange; + const send = React.useRef( + throttle((v: number) => onChangeRef.current(v), 150), + ); + React.useEffect(() => { + // Once the server value matches what we sent, drop the local override + if (local !== null && Math.abs(local - value) < 0.02) { + setLocal(null); + } + }, [value, local]); + const shown = local ?? value; + return ( + + ); +}; + +export default ThrottledSlider; diff --git a/src/components/views/Aegis/_card.scss b/src/components/views/Aegis/_card.scss new file mode 100644 index 000000000..9c004774e --- /dev/null +++ b/src/components/views/Aegis/_card.scss @@ -0,0 +1,106 @@ +// Card layout: the canvas + activity log column on the left of the crew card. +.card-aegis { + height: 100%; + display: flex; + flex-direction: column; + + .aegis-content { + flex: 1; + min-height: 0; + } + + .aegis-main { + display: flex; + flex-direction: column; + height: 100%; + gap: 8px; + } + + .aegis-canvas { + position: relative; + flex: 1; + min-height: 0; + + .aegis-canvas-element { + width: 100%; + height: 100%; + border: 1px solid rgba(92, 217, 255, 0.3); + border-radius: 6px; + background: radial-gradient( + ellipse at center, + rgba(20, 40, 60, 0.6) 0%, + rgba(5, 10, 20, 0.9) 100% + ); + } + + .aegis-status { + position: absolute; + bottom: 10px; + left: 0; + right: 0; + text-align: center; + font-size: 1.2em; + color: #5cd9ff; + text-transform: uppercase; + letter-spacing: 2px; + pointer-events: none; + } + } + + .aegis-log { + height: 150px; + display: flex; + flex-direction: column; + border: 1px solid rgba(92, 217, 255, 0.3); + border-radius: 6px; + padding: 6px 10px; + background: rgba(5, 10, 20, 0.6); + + h5 { + margin-bottom: 4px; + color: #5cd9ff; + text-transform: uppercase; + letter-spacing: 1px; + font-size: 0.85em; + } + + .aegis-log-entries { + flex: 1; + min-height: 0; + overflow-y: auto; + font-size: 0.85em; + } + + .aegis-log-empty { + opacity: 0.5; + font-style: italic; + } + + // Entry colors keyed to log entry type (screen/damage/critical/...) + .aegis-log-entry { + margin-bottom: 2px; + + .log-time { + opacity: 0.6; + margin-right: 4px; + } + + &.log-screen { + color: #7fe7ff; + } + &.log-damage { + color: #ff7f7f; + } + &.log-critical { + color: #ff4f4f; + font-weight: bold; + } + &.log-repair { + color: #8fff9f; + } + &.log-relay { + color: #9fb7ff; + } + } + } +} diff --git a/src/components/views/Aegis/_controls.scss b/src/components/views/Aegis/_controls.scss new file mode 100644 index 000000000..4914e68dd --- /dev/null +++ b/src/components/views/Aegis/_controls.scss @@ -0,0 +1,139 @@ +// Touch-first control column: every section has a fixed footprint and the +// secondary control absorbs the leftover height, so nothing ever scrolls. +.card-aegis { + .aegis-controls { + display: flex; + flex-direction: column; + gap: 10px; + height: 100%; + min-height: 0; + overflow: hidden; + + .aegis-count { + text-align: center; + + h1 { + font-size: 2.6em; + margin-bottom: 0; + } + + h4 { + margin-bottom: 0; + } + } + + .aegis-fabrication { + .progress { + height: 10px; + } + + p { + margin: 4px 0 0; + text-align: center; + font-size: 0.85em; + } + } + + .aegis-actions { + display: flex; + gap: 8px; + + .btn, + .aegis-deploy { + flex: 1; + } + + .btn { + min-height: 48px; + white-space: normal; + } + } + + .aegis-modes { + h4 { + margin-bottom: 6px; + } + + .aegis-mode-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + + .btn { + min-height: 52px; + white-space: normal; + font-weight: bold; + } + } + + .aegis-mode-description { + margin: 6px 0 0; + min-height: 2.4em; + text-align: center; + font-size: 0.85em; + opacity: 0.8; + } + } + + .aegis-secondary { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + + h4 { + margin-bottom: 0; + } + + // Large touch slider for ECM intensity / repair effort + .aegis-slider { + width: 100%; + text-align: center; + font-size: 1.1em; + + input[type="range"] { + width: 100%; + height: 44px; + -webkit-appearance: none; + appearance: none; + background: transparent; + + &::-webkit-slider-runnable-track { + height: 12px; + border-radius: 6px; + background: rgba(92, 217, 255, 0.25); + border: 1px solid rgba(92, 217, 255, 0.5); + } + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 36px; + height: 36px; + margin-top: -13px; + border-radius: 50%; + background: #5cd9ff; + box-shadow: 0 0 12px rgba(92, 217, 255, 0.8); + cursor: grab; + } + } + + small { + display: block; + } + } + + .aegis-relay-target { + width: 100%; + + .btn { + flex: 1; + min-height: 56px; + } + } + } + } +} diff --git a/src/components/views/Aegis/_core.scss b/src/components/views/Aegis/_core.scss new file mode 100644 index 000000000..0027018ee --- /dev/null +++ b/src/components/views/Aegis/_core.scss @@ -0,0 +1,36 @@ +// Flight Director core panel: a dense control/readout list for the FD. +.core-aegis { + .core-aegis-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + margin-bottom: 2px; + + .input-field { + flex: 1; + } + } + + .btn { + margin-bottom: 2px; + } + + .core-aegis-attrition { + display: block; + margin: 0; + } + + .core-aegis-log-entries { + max-height: 110px; + overflow-y: auto; + font-size: 11px; + + p { + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } +} diff --git a/src/components/views/Aegis/_focus-pad.scss b/src/components/views/Aegis/_focus-pad.scss new file mode 100644 index 000000000..fa040f208 --- /dev/null +++ b/src/components/views/Aegis/_focus-pad.scss @@ -0,0 +1,62 @@ +// Screen Focus pad: a round drag target sized to fill whatever height the +// secondary panel has left over, capped so it stays a comfortable thumb-reach +// circle on large screens. +.focus-pad { + position: relative; + width: min(100%, 240px); + aspect-ratio: 1; + flex: 0 1 auto; + min-height: 130px; + max-height: 240px; + border-radius: 50%; + touch-action: none; + border: 2px solid rgba(92, 217, 255, 0.5); + background: radial-gradient( + circle at center, + rgba(20, 40, 60, 0.8) 0%, + rgba(5, 10, 20, 0.95) 100% + ); + + .focus-pad-label { + position: absolute; + font-size: 0.75em; + letter-spacing: 1px; + color: rgba(92, 217, 255, 0.7); + pointer-events: none; + } + .focus-pad-fore { + top: 4px; + left: 50%; + transform: translateX(-50%); + } + .focus-pad-aft { + bottom: 4px; + left: 50%; + transform: translateX(-50%); + } + .focus-pad-port { + left: 6px; + top: 50%; + transform: translateY(-50%); + } + .focus-pad-starboard { + right: 6px; + top: 50%; + transform: translateY(-50%); + } + + .focus-pad-knob { + position: absolute; + width: 36px; + height: 36px; + border-radius: 50%; + background: #5cd9ff; + box-shadow: 0 0 10px rgba(92, 217, 255, 0.8); + transform: translate(-50%, -50%); + cursor: grab; + + &:active { + cursor: grabbing; + } + } +} diff --git a/src/components/views/Aegis/canvas/modeParams.ts b/src/components/views/Aegis/canvas/modeParams.ts new file mode 100644 index 000000000..0065f246b --- /dev/null +++ b/src/components/views/Aegis/canvas/modeParams.ts @@ -0,0 +1,97 @@ +import {Aegis_Mode, Aegis_Relay_Target} from "generated/graphql"; +import {AegisControls, DroneParams} from "./types"; + +// Deterministic pseudo-random value for a drone index so formations are +// stable across renders and identical on every client. +export const seeded = (i: number, salt: number) => { + const x = Math.sin(i * 127.1 + salt * 311.7) * 43758.5453; + return x - Math.floor(x); +}; + +// Orbit parameters for a drone in each mode. Radius is a multiple of the +// hull's extent at the drone's current screen angle; speed is radians per +// second. The crew's secondary controls reshape each mode's formation. +export function modeParams( + mode: Aegis_Mode, + i: number, + count: number, + controls: AegisControls, +): DroneParams { + const spacing = (Math.PI * 2 * i) / Math.max(1, count); + switch (mode) { + case Aegis_Mode.Ecm: { + // Higher jamming intensity spreads the swarm wider and faster + const intensity = controls.ecmIntensity; + return { + radius: 1.8 + seeded(i, 1) * 0.9, + speed: + (0.5 + seeded(i, 2) * 0.6) * + (0.7 + intensity * 0.8) * + (seeded(i, 3) > 0.5 ? 1 : -1), + inclination: seeded(i, 4) * Math.PI, + node: seeded(i, 5) * Math.PI * 2, + phaseOffset: seeded(i, 8) * Math.PI * 2, + jitter: 0.06 + intensity * 0.2, + conform: 0.5, + }; + } + case Aegis_Mode.Relay: { + // Sensors: flat wide listening disc. Comms: upright transmission ring. + const inclination = + controls.relayTarget === Aegis_Relay_Target.Sensors + ? 1.5 + : controls.relayTarget === Aegis_Relay_Target.Comms + ? 0.25 + : 0.4; + const radiusBonus = + controls.relayTarget === Aegis_Relay_Target.Comms ? 0.2 : 0; + return { + radius: 2.2 + (i % 2) * 0.3 + radiusBonus, + speed: 0.25 * (i % 2 === 0 ? 1 : -1), + inclination, + node: 0, + phaseOffset: spacing, + jitter: 0, + conform: 0.35, + }; + } + case Aegis_Mode.Repair: + return { + radius: 1.05 + (i % 3) * 0.1, + speed: 0.35 * (0.6 + controls.repairEffort) * (i % 2 === 0 ? 1 : -1), + inclination: Math.PI / 2 - 0.4 + seeded(i, 6) * 0.8, + node: Math.floor(i / 4) * (Math.PI / 7), + phaseOffset: (i % 4) * 0.4, + jitter: 0.02, + conform: 1, + }; + case Aegis_Mode.Screen: + default: { + // Pull each drone's orbit plane toward the crew's focus direction so + // the screen huddles to that side while still orbiting + const focusMag = Math.min( + 1, + Math.hypot(controls.focusX, controls.focusY), + ); + let node = spacing; + if (focusMag > 0) { + const focusAngle = Math.atan2(controls.focusY, controls.focusX); + const delta = Math.atan2( + Math.sin(focusAngle - spacing), + Math.cos(focusAngle - spacing), + ); + node = spacing + delta * 0.9 * focusMag; + } + return { + // Focused screens pull tighter and orbit faster for a dramatic huddle + radius: (1.25 + (i % 4) * 0.12) * (1 - 0.2 * focusMag), + speed: 1.1 * (1 + 0.4 * focusMag), + inclination: 0.6 + seeded(i, 7) * 0.9, + node, + phaseOffset: spacing, + jitter: 0, + conform: 0.85, + }; + } + } +} diff --git a/src/components/views/Aegis/canvas/render.ts b/src/components/views/Aegis/canvas/render.ts new file mode 100644 index 000000000..93e5fdcc7 --- /dev/null +++ b/src/components/views/Aegis/canvas/render.ts @@ -0,0 +1,124 @@ +import { + ActivePing, + Geometry, + Renderable, + PING_COLORS, + RING_PING_DURATION, +} from "./types"; + +// Twinkling starfield drawn behind the swarm each frame. +export function drawStarfield( + ctx: CanvasRenderingContext2D, + geometry: Geometry, + t: number, + seeded: (i: number, salt: number) => number, +) { + const {width, height} = geometry; + for (let i = 0; i < 60; i++) { + const sx = seeded(i, 21) * width; + const sy = seeded(i, 22) * height; + const twinkle = 0.3 + 0.5 * Math.abs(Math.sin(t * 0.5 + i)); + ctx.globalAlpha = twinkle; + ctx.fillStyle = "#cfe8ff"; + ctx.fillRect(sx, sy, seeded(i, 23) > 0.8 ? 2 : 1, 1); + } + ctx.globalAlpha = 1; +} + +// Expanding rings for visible ship actions (comms, scans, sonar). Impact +// pings are handled through the swarm, not here, so they're skipped. +export function drawPings( + ctx: CanvasRenderingContext2D, + pings: ActivePing[], + now: number, + geometry: Geometry, +) { + const {cx, cy, width, height, shipRadius} = geometry; + const maxRing = Math.hypot(width, height) / 2; + pings.forEach(ping => { + if (ping.pingType === "impact") { + return; + } + const age = (now - ping.start) / 1000 / RING_PING_DURATION; + const color = PING_COLORS[ping.pingType] || PING_COLORS.scan; + const boost = Math.min(2, ping.strength); + const drawRing = (ringAge: number) => { + if (ringAge < 0 || ringAge >= 1) { + return; + } + const radius = shipRadius * 0.5 + (maxRing - shipRadius * 0.5) * ringAge; + ctx.strokeStyle = `rgba(${color}, ${(1 - ringAge) * 0.35 * boost})`; + ctx.lineWidth = 1 + boost; + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.stroke(); + }; + drawRing(age); + // Amplified signals trail a second ring + if (ping.strength > 1.05) { + drawRing(age - 0.18); + } + }); +} + +// Draw a single drone as a translucent halo, a solid core, and a bright +// center. Flaring drones shift from cyan toward hot orange. (Canvas +// shadowBlur is far too slow for a 120-drone swarm, so glow is faked.) +export function drawDrone( + ctx: CanvasRenderingContext2D, + d: Renderable, + droneSize: number, +) { + const alpha = Math.max(0, Math.min(1, d.alpha)); + const heat = Math.min(1, d.flare); + ctx.fillStyle = + heat > 0.01 + ? `rgb(${Math.round(92 + 163 * heat)}, ${Math.round( + 217 - 47 * heat, + )}, ${Math.round(255 - 165 * heat)})` + : "#5cd9ff"; + ctx.globalAlpha = alpha * 0.3; + ctx.beginPath(); + ctx.arc(d.x, d.y, droneSize * d.scale * 2.4, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = alpha; + ctx.beginPath(); + ctx.arc(d.x, d.y, droneSize * d.scale, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = "#e8fbff"; + ctx.beginPath(); + ctx.arc(d.x, d.y, droneSize * d.scale * 0.5, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = 1; +} + +// Draw the ship sprite, then composite the red tint over it. Tint alpha grows +// as structural integrity drops (colorless at 100%) and flashes brighter for +// a moment when a hit lands. +export function drawShip( + ctx: CanvasRenderingContext2D, + image: HTMLImageElement | null, + tint: HTMLCanvasElement | null, + geometry: Geometry, + integrity: number, + impactPulse: number, +) { + if (!image || !image.complete || image.naturalWidth === 0) { + return; + } + const {cx, cy, shipRadius} = geometry; + const fit = + (shipRadius * 2) / Math.max(image.naturalWidth, image.naturalHeight); + const drawWidth = image.naturalWidth * fit; + const drawHeight = image.naturalHeight * fit; + const left = cx - drawWidth / 2; + const top = cy - drawHeight / 2; + ctx.drawImage(image, left, top, drawWidth, drawHeight); + + const tintAlpha = Math.min(0.85, (1 - integrity) * 0.3 + impactPulse * 0.3); + if (tint && tintAlpha > 0.01) { + ctx.globalAlpha = tintAlpha; + ctx.drawImage(tint, left, top, drawWidth, drawHeight); + ctx.globalAlpha = 1; + } +} diff --git a/src/components/views/Aegis/canvas/shipImage.ts b/src/components/views/Aegis/canvas/shipImage.ts new file mode 100644 index 000000000..c3daedf5c --- /dev/null +++ b/src/components/views/Aegis/canvas/shipImage.ts @@ -0,0 +1,111 @@ +// Helpers that derive drawing data from the ship's "top" sprite: a radial +// hull-extent profile (so drone paths trace the real silhouette) and a +// solid-red tint copy (so the hull can redden as integrity drops). + +// Number of angular buckets in the hull profile (one every 5 degrees) +export const PROFILE_BUCKETS = 72; +// Hull extent used before the ship image's profile is available +export const FALLBACK_HULL = 0.5; + +// Reads the ship PNG's alpha channel and returns, for each angle around the +// image center, how far the hull's opaque pixels extend — normalized so 1.0 +// is half the image's larger dimension, the same scale the ship is drawn at. +// This lets drone paths trace long or wide hulls instead of a fixed circle. +export function buildHullProfile(image: HTMLImageElement): number[] | null { + try { + const maxDim = Math.max(image.naturalWidth, image.naturalHeight); + if (!maxDim) { + return null; + } + // Downscale large sprites — 160px is plenty for a 72-bucket profile + const scale = Math.min(1, 160 / maxDim); + const w = Math.max(1, Math.round(image.naturalWidth * scale)); + const h = Math.max(1, Math.round(image.naturalHeight * scale)); + const offscreen = document.createElement("canvas"); + offscreen.width = w; + offscreen.height = h; + const ctx = offscreen.getContext("2d"); + if (!ctx) { + return null; + } + ctx.drawImage(image, 0, 0, w, h); + const {data} = ctx.getImageData(0, 0, w, h); + const cx = w / 2; + const cy = h / 2; + const norm = Math.max(w, h) / 2; + const profile: number[] = new Array(PROFILE_BUCKETS).fill(0); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + // Skip near-transparent pixels — they aren't part of the hull + if (data[(y * w + x) * 4 + 3] < 64) { + continue; + } + const dx = x + 0.5 - cx; + const dy = y + 0.5 - cy; + const bucket = + ((Math.round((Math.atan2(dy, dx) / (Math.PI * 2)) * PROFILE_BUCKETS) % + PROFILE_BUCKETS) + + PROFILE_BUCKETS) % + PROFILE_BUCKETS; + const dist = Math.sqrt(dx * dx + dy * dy) / norm; + // Keep the farthest opaque pixel for each angular bucket + if (dist > profile[bucket]) { + profile[bucket] = dist; + } + } + } + const filled = profile.filter(v => v > 0); + if (filled.length === 0) { + return null; + } + // Angles with no opaque pixels get the mean so smoothing blends them in + const mean = filled.reduce((a, b) => a + b, 0) / filled.length; + let result = profile.map(v => (v > 0 ? v : mean)); + // Circular moving average so the swarm path is smooth, not pixel-jagged + for (let pass = 0; pass < 2; pass++) { + const src = result; + result = src.map((_, i) => { + let sum = 0; + for (let k = -2; k <= 2; k++) { + sum += src[(i + k + PROFILE_BUCKETS) % PROFILE_BUCKETS]; + } + return sum / 5; + }); + } + return result.map(v => Math.max(0.12, v)); + } catch { + // Tainted canvas (cross-origin asset) — fall back to circular paths + return null; + } +} + +// Linearly interpolated hull extent at an arbitrary screen angle +export function sampleProfile(profile: number[], angle: number) { + const t = (angle / (Math.PI * 2)) * PROFILE_BUCKETS; + const i0 = + ((Math.floor(t) % PROFILE_BUCKETS) + PROFILE_BUCKETS) % PROFILE_BUCKETS; + const i1 = (i0 + 1) % PROFILE_BUCKETS; + const frac = t - Math.floor(t); + return profile[i0] * (1 - frac) + profile[i1] * frac; +} + +// Builds a solid-red copy of the sprite (red only where the ship is opaque) +// that the draw loop composites over the hull at an integrity-driven alpha. +export function buildTintCanvas( + image: HTMLImageElement, +): HTMLCanvasElement | null { + const tint = document.createElement("canvas"); + tint.width = image.naturalWidth; + tint.height = image.naturalHeight; + const ctx = tint.getContext("2d"); + if (!ctx) { + return null; + } + ctx.drawImage(image, 0, 0); + // source-atop paints the fill only over existing (opaque) pixels, so the + // red follows the silhouette instead of filling a rectangle + ctx.globalCompositeOperation = "source-atop"; + ctx.fillStyle = "rgb(255, 45, 30)"; + ctx.fillRect(0, 0, tint.width, tint.height); + return tint; +} diff --git a/src/components/views/Aegis/canvas/simulation.ts b/src/components/views/Aegis/canvas/simulation.ts new file mode 100644 index 000000000..e8780d547 --- /dev/null +++ b/src/components/views/Aegis/canvas/simulation.ts @@ -0,0 +1,195 @@ +import {Aegis_Mode} from "generated/graphql"; +import {modeParams, seeded} from "./modeParams"; +import {sampleProfile, FALLBACK_HULL} from "./shipImage"; +import { + ActivePing, + Drone, + DrawState, + Geometry, + ImpactState, + IMPACT_PING_DURATION, + Renderable, +} from "./types"; + +// Per-frame easing rates (already converted from time constants) +interface Rates { + param: number; + deploy: number; + life: number; +} + +// How far around the impact bearing (radians) the swarm reacts +const IMPACT_CONE = 1.1; + +// Add drones to the pool until it matches the configured fleet size. New +// drones start docked (deploy/life factor 0) and ease out from the ship. +export function growDronePool(drones: Drone[], state: DrawState) { + while (drones.length < state.maxDrones) { + const i = drones.length; + drones.push({ + phase: seeded(i, 9) * Math.PI * 2, + params: modeParams(state.mode, i, state.maxDrones, state.controls), + deployFactor: 0, + lifeFactor: 0, + }); + } +} + +// Smoothly drift the whole screen formation toward the crew's focus side. +export function updateFocusOffset( + focusOffset: {x: number; y: number}, + state: DrawState, + paramRate: number, +) { + const focusing = state.mode === Aegis_Mode.Screen && state.deployed; + const targetX = focusing ? state.controls.focusX * 0.6 : 0; + const targetY = focusing ? state.controls.focusY * 0.6 : 0; + focusOffset.x += (targetX - focusOffset.x) * paramRate; + focusOffset.y += (targetY - focusOffset.y) * paramRate; +} + +// Find the active structural impact, if any, and derive the screen-space +// angle it came from plus a 0..1 pulse that fades over the flare's lifetime. +export function resolveImpact(pings: ActivePing[], now: number): ImpactState { + const impact = pings.find( + ping => + ping.pingType === "impact" && + (now - ping.start) / 1000 < IMPACT_PING_DURATION, + ); + if (!impact) { + return {impact: undefined, angle: 0, pulse: 0}; + } + // Bearing is degrees (0 = fore, clockwise); convert to a screen angle + const angle = + impact.bearing !== null + ? Math.atan2( + -Math.cos((impact.bearing * Math.PI) / 180), + Math.sin((impact.bearing * Math.PI) / 180), + ) + : 0; + const pulse = 1 - (now - impact.start) / 1000 / IMPACT_PING_DURATION; + return {impact, angle, pulse}; +} + +// 0..1 heat for a drone based on how close it is to the impact bearing. +function impactFlare(px: number, py: number, impact: ImpactState) { + if (!impact.impact) { + return 0; + } + const droneAngle = Math.atan2(py, px); + const angleDiff = Math.abs( + Math.atan2( + Math.sin(droneAngle - impact.angle), + Math.cos(droneAngle - impact.angle), + ), + ); + if (angleDiff >= IMPACT_CONE) { + return 0; + } + return impact.pulse * (1 - angleDiff / IMPACT_CONE) * 1.2; +} + +// Advance every drone one frame and resolve it to screen space. Mutates the +// drone objects (phase, params, ease factors) and returns the drawable list. +export function buildRenderables(params: { + drones: Drone[]; + state: DrawState; + hullProfile: {profile: number[]; avg: number} | null; + focusOffset: {x: number; y: number}; + impact: ImpactState; + geometry: Geometry; + rates: Rates; + dt: number; + t: number; +}): Renderable[] { + const { + drones, + state, + hullProfile, + focusOffset, + impact, + geometry, + rates, + dt, + t, + } = params; + const {cx, cy, shipRadius} = geometry; + const renderable: Renderable[] = []; + + drones.forEach((drone, i) => { + const active = i < state.droneCount; + const target = modeParams( + state.mode, + i, + Math.max(1, state.droneCount), + state.controls, + ); + + // Newly fabricated drones launch from the ship instead of popping in + if (active && state.deployed && drone.lifeFactor < 0.05) { + drone.deployFactor = 0; + } + + // Ease the live orbit params toward the current mode's target + const p = drone.params; + (Object.keys(p) as (keyof typeof p)[]).forEach(key => { + p[key] += (target[key] - p[key]) * rates.param; + }); + drone.phase += p.speed * dt; + const wantDeployed = state.deployed && active ? 1 : 0; + drone.deployFactor += (wantDeployed - drone.deployFactor) * rates.deploy; + // Drones fade out both when destroyed and when recalled, so a recalled + // swarm vanishes as it dives back toward the ship + drone.lifeFactor += (wantDeployed - drone.lifeFactor) * rates.life; + + if (drone.lifeFactor < 0.01) { + return; + } + + const angle = drone.phase + p.phaseOffset; + const r = p.radius * drone.deployFactor; + // Point on the orbit circle, tilted by inclination, then rotated around + // the screen normal by the node angle + const ox = Math.cos(angle) * r; + const oy = Math.sin(angle) * r; + const ty = oy * Math.cos(p.inclination); + const depth = + Math.sin(angle) * Math.sin(p.inclination) + + p.jitter * Math.sin(t * 1.7 + i * 5.9); + const sx = ox * Math.cos(p.node) - ty * Math.sin(p.node); + const sy = ox * Math.sin(p.node) + ty * Math.cos(p.node); + + // Scale the planar offset by how far the hull actually extends toward + // this screen angle, so paths trace long or wide ships rather than a + // fixed circle + const hull = hullProfile; + const hullDist = hull + ? hull.avg + + (sampleProfile(hull.profile, Math.atan2(sy, sx)) - hull.avg) * p.conform + : FALLBACK_HULL; + const jx = p.jitter * Math.sin(t * 2.1 + i * 7.3); + const jy = p.jitter * Math.sin(t * 2.7 + i * 3.1); + const px = sx * hullDist + jx + focusOffset.x * drone.deployFactor; + const py = sy * hullDist + jy + focusOffset.y * drone.deployFactor; + + // Drones near an incoming impact flare hot and get shoved outward as they + // absorb it — the impact reads through the swarm itself + const flare = drone.deployFactor > 0.5 ? impactFlare(px, py, impact) : 0; + const shove = 1 + flare * 0.3; + + renderable.push({ + x: cx + px * shipRadius * shove, + y: cy + py * shipRadius * shove, + depth, + alpha: + (0.45 + (0.55 * (depth + 1)) / 2) * + drone.lifeFactor * + Math.min(1, drone.deployFactor * 3 + 0.15) + + flare, + scale: (0.6 + (0.4 * (depth + 1)) / 2) * (1 + flare * 0.5), + flare, + }); + }); + + return renderable; +} diff --git a/src/components/views/Aegis/canvas/types.ts b/src/components/views/Aegis/canvas/types.ts new file mode 100644 index 000000000..209b75d51 --- /dev/null +++ b/src/components/views/Aegis/canvas/types.ts @@ -0,0 +1,100 @@ +import {Aegis_Mode, Aegis_Relay_Target} from "generated/graphql"; + +// Orbit shape for a single drone. The animation lerps each drone's live +// params toward the target params for its current mode, so formation changes +// are smooth rather than instantaneous. +export interface DroneParams { + radius: number; + speed: number; + inclination: number; + node: number; + phaseOffset: number; + jitter: number; + // How strongly the path follows the hull silhouette (1 = hug the outline, + // 0 = circular orbit at the hull's average extent). + conform: number; +} + +export interface Drone { + phase: number; + params: DroneParams; + // 0..1 ease used when the swarm launches/recalls (drives orbit radius) + deployFactor: number; + // 0..1 ease used when a drone fabricates in or is destroyed (drives alpha) + lifeFactor: number; +} + +// The crew's secondary-control values, mirrored into the animation loop. +export interface AegisControls { + focusX: number; + focusY: number; + ecmIntensity: number; + relayTarget: Aegis_Relay_Target; + repairEffort: number; +} + +// Snapshot of the system state the draw loop reads each frame. +export interface DrawState { + mode: Aegis_Mode; + droneCount: number; + maxDrones: number; + deployed: boolean; + structuralIntegrity: number; + controls: AegisControls; +} + +// A transient ship action surfaced to the canvas (transmission, scan, sonar, +// or structural impact). Pushed in via the imperative handle below. +export interface AegisPingEvent { + pingType: string; + strength: number; + bearing: number | null; +} + +export interface AegisCanvasHandle { + addPing: (ping: AegisPingEvent) => void; +} + +// A ping with the timestamp it was received, used to drive its animation. +export interface ActivePing extends AegisPingEvent { + start: number; +} + +// The currently-active structural impact (if any) plus its derived geometry, +// so the swarm and hull can react to it without recomputing per drone. +export interface ImpactState { + impact: ActivePing | undefined; + angle: number; + pulse: number; +} + +// Canvas geometry recomputed each frame from the measured element size. +export interface Geometry { + cx: number; + cy: number; + shipRadius: number; + width: number; + height: number; +} + +// A drone resolved to screen space, ready to z-sort and draw. +export interface Renderable { + x: number; + y: number; + depth: number; + alpha: number; + scale: number; + // 0..1 heat from a nearby impact; tints the drone and enlarges it + flare: number; +} + +// RGB triples (as "r, g, b" strings) for each kind of expanding action ring +export const PING_COLORS: {[key: string]: string} = { + comm: "255, 205, 120", + scan: "120, 215, 255", + sonar: "150, 255, 200", +}; +// Seconds an expanding action ring stays on screen +export const RING_PING_DURATION = 1.8; +// Seconds a structural impact flare lasts +export const IMPACT_PING_DURATION = 0.9; diff --git a/src/components/views/Aegis/core.tsx b/src/components/views/Aegis/core.tsx new file mode 100644 index 000000000..a2762bfcf --- /dev/null +++ b/src/components/views/Aegis/core.tsx @@ -0,0 +1,237 @@ +import React from "react"; +import { + Simulator, + Aegis_Mode, + useAegisSubscription, + useAegisSetModeMutation, + useAegisRecallMutation, + useAegisPauseFabricationMutation, + useAegisSetAttritionMutation, + useAegisDestroyDroneMutation, + useAegisSetDroneCountMutation, + useAegisSetStructuralIntegrityMutation, + useAegisHitStructureMutation, + useAegisClearLogMutation, +} from "generated/graphql"; +import {Button, Input} from "reactstrap"; +import {InputField} from "components/generic/core"; +import "./style.scss"; + +interface AegisCoreProps { + children: React.ReactNode; + simulator: Simulator; +} + +function describeCrewSetting(aegis: { + mode: Aegis_Mode; + screenFocusX: number; + screenFocusY: number; + ecmIntensity: number; + relayTarget: string; + repairEffort: number; +}) { + switch (aegis.mode) { + case Aegis_Mode.Screen: { + const magnitude = Math.min( + 1, + Math.hypot(aegis.screenFocusX, aegis.screenFocusY), + ); + if (magnitude < 0.05) { + return "Focus: even coverage"; + } + const bearing = + (Math.round( + (Math.atan2(aegis.screenFocusX, -aegis.screenFocusY) * 180) / Math.PI, + ) + + 360) % + 360; + return `Focus: ${bearing}° at ${Math.round(magnitude * 100)}%`; + } + case Aegis_Mode.Ecm: + return `Jamming: ${Math.round(aegis.ecmIntensity * 100)}%`; + case Aegis_Mode.Relay: + return `Boosting: ${aegis.relayTarget}`; + case Aegis_Mode.Repair: + return `Effort: ${Math.round(aegis.repairEffort * 100)}%`; + default: + return ""; + } +} + +const AegisCore: React.FC = props => { + const {simulator} = props; + const {loading, data} = useAegisSubscription({ + variables: {simulatorId: simulator.id}, + }); + const [setMode] = useAegisSetModeMutation(); + const [recall] = useAegisRecallMutation(); + const [pauseFabrication] = useAegisPauseFabricationMutation(); + const [setAttrition] = useAegisSetAttritionMutation(); + const [destroyDrone] = useAegisDestroyDroneMutation(); + const [setDroneCount] = useAegisSetDroneCountMutation(); + const [setIntegrity] = useAegisSetStructuralIntegrityMutation(); + const [hitStructure] = useAegisHitStructureMutation(); + const [clearLog] = useAegisClearLogMutation(); + + if (loading || !data) { + return null; + } + const {aegisUpdate: aegis} = data; + if (!aegis) { + return
No Aegis System
; + } + + return ( +
+
+ Drones: + { + setDroneCount({ + variables: {id: aegis.id, count: parseInt(`${value}`, 10) || 0}, + }); + }} + > + {aegis.droneCount} / {aegis.maxDrones} + +
+
+ Status: + + {aegis.deployed ? "Deployed" : "Docked"} + +
+
+ Mode: + + setMode({ + variables: {id: aegis.id, mode: e.target.value as Aegis_Mode}, + }) + } + > + + + + + +
+
+ Fabrication: + + {aegis.fabricating && !aegis.fabricationPaused + ? `${Math.round(aegis.fabricationProgress * 100)}%` + : aegis.fabricationPaused + ? "Paused" + : "Idle"} + +
+
+ Crew setting: + {describeCrewSetting(aegis)} +
+
+ Integrity: + { + setIntegrity({ + variables: { + id: aegis.id, + integrity: (parseInt(`${value}`, 10) || 0) / 100, + }, + }); + }} + > + {Math.round(aegis.structuralIntegrity * 100)}% + + +
+ + + + + +
+
+ Log: + +
+
+ {aegis.log.slice(0, 8).map(entry => ( +

+ {entry.contents} +

+ ))} +
+
+
+ ); +}; + +export default AegisCore; diff --git a/src/components/views/Aegis/graphql/aegis.graphql b/src/components/views/Aegis/graphql/aegis.graphql new file mode 100644 index 000000000..9d0727f4a --- /dev/null +++ b/src/components/views/Aegis/graphql/aegis.graphql @@ -0,0 +1,35 @@ +subscription Aegis($simulatorId: ID!) { + aegisUpdate(simulatorId: $simulatorId) { + id + simulatorId + name + displayName + damage { + damaged + } + power { + power + powerLevels + } + maxDrones + droneCount + deployed + mode + fabricating + fabricationPaused + fabricationProgress + attritionEnabled + structuralIntegrity + screenFocusX + screenFocusY + ecmIntensity + relayTarget + repairEffort + log { + id + timestamp + type + contents + } + } +} diff --git a/src/components/views/Aegis/graphql/aegisClearLog.graphql b/src/components/views/Aegis/graphql/aegisClearLog.graphql new file mode 100644 index 000000000..629058807 --- /dev/null +++ b/src/components/views/Aegis/graphql/aegisClearLog.graphql @@ -0,0 +1,3 @@ +mutation AegisClearLog($id: ID!) { + aegisClearLog(id: $id) +} diff --git a/src/components/views/Aegis/graphql/aegisDeploy.graphql b/src/components/views/Aegis/graphql/aegisDeploy.graphql new file mode 100644 index 000000000..6ceddf521 --- /dev/null +++ b/src/components/views/Aegis/graphql/aegisDeploy.graphql @@ -0,0 +1,3 @@ +mutation AegisDeploy($id: ID!) { + aegisDeploy(id: $id) +} diff --git a/src/components/views/Aegis/graphql/aegisDestroyDrone.graphql b/src/components/views/Aegis/graphql/aegisDestroyDrone.graphql new file mode 100644 index 000000000..90ad1abd1 --- /dev/null +++ b/src/components/views/Aegis/graphql/aegisDestroyDrone.graphql @@ -0,0 +1,3 @@ +mutation AegisDestroyDrone($id: ID!) { + aegisDestroyDrone(id: $id) +} diff --git a/src/components/views/Aegis/graphql/aegisHitStructure.graphql b/src/components/views/Aegis/graphql/aegisHitStructure.graphql new file mode 100644 index 000000000..993526210 --- /dev/null +++ b/src/components/views/Aegis/graphql/aegisHitStructure.graphql @@ -0,0 +1,3 @@ +mutation AegisHitStructure($id: ID!, $amount: Float, $bearing: Float) { + aegisHitStructure(id: $id, amount: $amount, bearing: $bearing) +} diff --git a/src/components/views/Aegis/graphql/aegisPauseFabrication.graphql b/src/components/views/Aegis/graphql/aegisPauseFabrication.graphql new file mode 100644 index 000000000..26d131eee --- /dev/null +++ b/src/components/views/Aegis/graphql/aegisPauseFabrication.graphql @@ -0,0 +1,3 @@ +mutation AegisPauseFabrication($id: ID!, $paused: Boolean!) { + aegisPauseFabrication(id: $id, paused: $paused) +} diff --git a/src/components/views/Aegis/graphql/aegisPing.graphql b/src/components/views/Aegis/graphql/aegisPing.graphql new file mode 100644 index 000000000..b3298b793 --- /dev/null +++ b/src/components/views/Aegis/graphql/aegisPing.graphql @@ -0,0 +1,8 @@ +subscription AegisPing($simulatorId: ID!) { + aegisPing(simulatorId: $simulatorId) { + id + pingType + strength + bearing + } +} diff --git a/src/components/views/Aegis/graphql/aegisRecall.graphql b/src/components/views/Aegis/graphql/aegisRecall.graphql new file mode 100644 index 000000000..a3c2e595a --- /dev/null +++ b/src/components/views/Aegis/graphql/aegisRecall.graphql @@ -0,0 +1,3 @@ +mutation AegisRecall($id: ID!) { + aegisRecall(id: $id) +} diff --git a/src/components/views/Aegis/graphql/aegisSetAttrition.graphql b/src/components/views/Aegis/graphql/aegisSetAttrition.graphql new file mode 100644 index 000000000..2d0b6834d --- /dev/null +++ b/src/components/views/Aegis/graphql/aegisSetAttrition.graphql @@ -0,0 +1,3 @@ +mutation AegisSetAttrition($id: ID!, $enabled: Boolean!) { + aegisSetAttrition(id: $id, enabled: $enabled) +} diff --git a/src/components/views/Aegis/graphql/aegisSetDroneCount.graphql b/src/components/views/Aegis/graphql/aegisSetDroneCount.graphql new file mode 100644 index 000000000..b710f31d8 --- /dev/null +++ b/src/components/views/Aegis/graphql/aegisSetDroneCount.graphql @@ -0,0 +1,3 @@ +mutation AegisSetDroneCount($id: ID!, $count: Int!) { + aegisSetDroneCount(id: $id, count: $count) +} diff --git a/src/components/views/Aegis/graphql/aegisSetEcmIntensity.graphql b/src/components/views/Aegis/graphql/aegisSetEcmIntensity.graphql new file mode 100644 index 000000000..1ac72a66b --- /dev/null +++ b/src/components/views/Aegis/graphql/aegisSetEcmIntensity.graphql @@ -0,0 +1,3 @@ +mutation AegisSetEcmIntensity($id: ID!, $intensity: Float!) { + aegisSetEcmIntensity(id: $id, intensity: $intensity) +} diff --git a/src/components/views/Aegis/graphql/aegisSetMaxDrones.graphql b/src/components/views/Aegis/graphql/aegisSetMaxDrones.graphql new file mode 100644 index 000000000..b215e54c7 --- /dev/null +++ b/src/components/views/Aegis/graphql/aegisSetMaxDrones.graphql @@ -0,0 +1,3 @@ +mutation AegisSetMaxDrones($id: ID!, $count: Int!) { + aegisSetMaxDrones(id: $id, count: $count) +} diff --git a/src/components/views/Aegis/graphql/aegisSetMode.graphql b/src/components/views/Aegis/graphql/aegisSetMode.graphql new file mode 100644 index 000000000..cd62ebd8e --- /dev/null +++ b/src/components/views/Aegis/graphql/aegisSetMode.graphql @@ -0,0 +1,3 @@ +mutation AegisSetMode($id: ID!, $mode: AEGIS_MODE!) { + aegisSetMode(id: $id, mode: $mode) +} diff --git a/src/components/views/Aegis/graphql/aegisSetRelayTarget.graphql b/src/components/views/Aegis/graphql/aegisSetRelayTarget.graphql new file mode 100644 index 000000000..6820a36f1 --- /dev/null +++ b/src/components/views/Aegis/graphql/aegisSetRelayTarget.graphql @@ -0,0 +1,3 @@ +mutation AegisSetRelayTarget($id: ID!, $target: AEGIS_RELAY_TARGET!) { + aegisSetRelayTarget(id: $id, target: $target) +} diff --git a/src/components/views/Aegis/graphql/aegisSetRepairEffort.graphql b/src/components/views/Aegis/graphql/aegisSetRepairEffort.graphql new file mode 100644 index 000000000..9204238af --- /dev/null +++ b/src/components/views/Aegis/graphql/aegisSetRepairEffort.graphql @@ -0,0 +1,3 @@ +mutation AegisSetRepairEffort($id: ID!, $effort: Float!) { + aegisSetRepairEffort(id: $id, effort: $effort) +} diff --git a/src/components/views/Aegis/graphql/aegisSetScreenFocus.graphql b/src/components/views/Aegis/graphql/aegisSetScreenFocus.graphql new file mode 100644 index 000000000..085954932 --- /dev/null +++ b/src/components/views/Aegis/graphql/aegisSetScreenFocus.graphql @@ -0,0 +1,3 @@ +mutation AegisSetScreenFocus($id: ID!, $x: Float!, $y: Float!) { + aegisSetScreenFocus(id: $id, x: $x, y: $y) +} diff --git a/src/components/views/Aegis/graphql/aegisSetStructuralIntegrity.graphql b/src/components/views/Aegis/graphql/aegisSetStructuralIntegrity.graphql new file mode 100644 index 000000000..013fc86f7 --- /dev/null +++ b/src/components/views/Aegis/graphql/aegisSetStructuralIntegrity.graphql @@ -0,0 +1,3 @@ +mutation AegisSetStructuralIntegrity($id: ID!, $integrity: Float!) { + aegisSetStructuralIntegrity(id: $id, integrity: $integrity) +} diff --git a/src/components/views/Aegis/graphql/aegisStartFabrication.graphql b/src/components/views/Aegis/graphql/aegisStartFabrication.graphql new file mode 100644 index 000000000..f74037630 --- /dev/null +++ b/src/components/views/Aegis/graphql/aegisStartFabrication.graphql @@ -0,0 +1,3 @@ +mutation AegisStartFabrication($id: ID!) { + aegisStartFabrication(id: $id) +} diff --git a/src/components/views/Aegis/graphql/aegisStopFabrication.graphql b/src/components/views/Aegis/graphql/aegisStopFabrication.graphql new file mode 100644 index 000000000..74562aeac --- /dev/null +++ b/src/components/views/Aegis/graphql/aegisStopFabrication.graphql @@ -0,0 +1,3 @@ +mutation AegisStopFabrication($id: ID!) { + aegisStopFabrication(id: $id) +} diff --git a/src/components/views/Aegis/index.tsx b/src/components/views/Aegis/index.tsx new file mode 100644 index 000000000..2ca2ec123 --- /dev/null +++ b/src/components/views/Aegis/index.tsx @@ -0,0 +1,203 @@ +import React from "react"; +import { + Simulator, + useAegisSubscription, + useAegisPingSubscription, + useAegisSetModeMutation, + useAegisDeployMutation, + useAegisRecallMutation, + useAegisStartFabricationMutation, + useAegisStopFabricationMutation, + useAegisSetScreenFocusMutation, + useAegisSetEcmIntensityMutation, + useAegisSetRelayTargetMutation, + useAegisSetRepairEffortMutation, +} from "generated/graphql"; +import {Container, Row, Col, Button, Progress} from "helpers/reactstrap"; +import TourHelper from "helpers/tourHelper"; +import DamageOverlay from "../helpers/DamageOverlay"; +import AegisCanvas, {AegisCanvasHandle} from "./AegisCanvas"; +import SecondaryControls from "./SecondaryControls"; +import ActivityLog from "./ActivityLog"; +import {modeInfo} from "./modeInfo"; +import {trainingSteps} from "./trainingSteps"; +import "./style.scss"; + +interface AegisProps { + children: React.ReactNode; + simulator: Simulator; +} + +// Crew station for the Aegis drone swarm: a live canvas + activity log on the +// left, and the touch-first control column (fabrication, deploy, mode, and the +// active mode's fine control) on the right. +const Aegis: React.FC = props => { + const {simulator} = props; + const {loading, data} = useAegisSubscription({ + variables: {simulatorId: simulator.id}, + }); + const {data: pingData} = useAegisPingSubscription({ + variables: {simulatorId: simulator.id}, + }); + const [setMode] = useAegisSetModeMutation(); + const [deploy] = useAegisDeployMutation(); + const [recall] = useAegisRecallMutation(); + const [startFabrication] = useAegisStartFabricationMutation(); + const [stopFabrication] = useAegisStopFabricationMutation(); + const [setScreenFocus] = useAegisSetScreenFocusMutation(); + const [setEcmIntensity] = useAegisSetEcmIntensityMutation(); + const [setRelayTarget] = useAegisSetRelayTargetMutation(); + const [setRepairEffort] = useAegisSetRepairEffortMutation(); + const canvasRef = React.useRef(null); + + // Forward transient ship-action pings from the subscription to the canvas + React.useEffect(() => { + const ping = pingData?.aegisPing; + if (ping) { + canvasRef.current?.addPing({ + pingType: ping.pingType, + strength: ping.strength, + bearing: ping.bearing ?? null, + }); + } + }, [pingData]); + + if (loading || !data) { + return
No Aegis System
; + } + const {aegisUpdate: aegis} = data; + if (!aegis) { + return
No Aegis System
; + } + + const fabricationBlocked = + aegis.fabricationPaused || aegis.droneCount >= aegis.maxDrones; + const activeMode = modeInfo.find(m => m.mode === aegis.mode); + return ( + + + + +
+ +
+ {aegis.deployed ? activeMode?.label : "Swarm Docked"} +
+
+ + + +
+

+ {aegis.droneCount} / {aegis.maxDrones} +

+

Drones Ready

+
+
+ + {aegis.fabricating && !aegis.fabricationPaused + ? `${Math.round(aegis.fabricationProgress * 100)}%` + : ""} + + {aegis.fabricationPaused && ( +

Fabrication halted by engineering

+ )} +
+
+ {aegis.fabricating ? ( + + ) : ( + + )} + + {aegis.deployed ? ( + + ) : ( + + )} + +
+
+

Swarm Mode

+
+ {modeInfo.map(({mode, label}) => ( + + ))} +
+

{activeMode?.description}

+
+ + setScreenFocus({variables: {id: aegis.id, x, y}}) + } + onEcmIntensity={intensity => + setEcmIntensity({variables: {id: aegis.id, intensity}}) + } + onRelayTarget={target => + setRelayTarget({variables: {id: aegis.id, target}}) + } + onRepairEffort={effort => + setRepairEffort({variables: {id: aegis.id, effort}}) + } + /> + +
+ +
+ ); +}; + +export default Aegis; diff --git a/src/components/views/Aegis/modeInfo.ts b/src/components/views/Aegis/modeInfo.ts new file mode 100644 index 000000000..6129432e1 --- /dev/null +++ b/src/components/views/Aegis/modeInfo.ts @@ -0,0 +1,36 @@ +import {Aegis_Mode, Aegis_Relay_Target} from "generated/graphql"; + +// Crew-facing label and one-line description for each swarm mode. +export const modeInfo: { + mode: Aegis_Mode; + label: string; + description: string; +}[] = [ + { + mode: Aegis_Mode.Screen, + label: "Defensive Screen", + description: "Tight orbit. Drones deflect incoming attacks.", + }, + { + mode: Aegis_Mode.Ecm, + label: "Interference", + description: "Wide erratic pattern. Jams enemy sensors and targeting.", + }, + { + mode: Aegis_Mode.Relay, + label: "Sensor Relay", + description: "Distributed halo. Extends sensor and comm range.", + }, + { + mode: Aegis_Mode.Repair, + label: "Repair Swarm", + description: "Hull-hugging clusters. Assists damage repair.", + }, +]; + +// Boost-target options for the Sensor Relay secondary control. +export const relayTargets: {target: Aegis_Relay_Target; label: string}[] = [ + {target: Aegis_Relay_Target.Sensors, label: "Sensors"}, + {target: Aegis_Relay_Target.Balanced, label: "Balanced"}, + {target: Aegis_Relay_Target.Comms, label: "Comms"}, +]; diff --git a/src/components/views/Aegis/style.scss b/src/components/views/Aegis/style.scss new file mode 100644 index 000000000..a33da14c8 --- /dev/null +++ b/src/components/views/Aegis/style.scss @@ -0,0 +1,5 @@ +// Aegis styles, split by area. See each partial for details. +@import "card"; // canvas + activity log column +@import "controls"; // touch-first crew control column +@import "focus-pad"; // Screen Focus drag pad +@import "core"; // Flight Director core panel diff --git a/src/components/views/Aegis/trainingSteps.ts b/src/components/views/Aegis/trainingSteps.ts new file mode 100644 index 000000000..109d233ed --- /dev/null +++ b/src/components/views/Aegis/trainingSteps.ts @@ -0,0 +1,38 @@ +// Guided-tour steps for the crew card, keyed to elements by CSS selector. +export const trainingSteps = [ + { + selector: ".nothing", + content: + "The Aegis System controls a fleet of small computer-controlled utility drones. The drones fly in formation around your ship and can protect it, interfere with enemy systems, boost your sensors, or assist with hull repairs.", + }, + { + selector: ".aegis-fabrication", + content: + "Drones are fabricated in batches of ten by the ship. Click this button to begin fabrication. Keep an eye on your drone count - drones deployed in space slowly wear out and can be destroyed, so you may need to fabricate replacements.", + }, + { + selector: ".aegis-deploy", + content: + "Once you have drones ready, click this button to launch the swarm. You can recall the swarm at any time to protect it.", + }, + { + selector: ".aegis-modes", + content: + "These buttons control the swarm's behavior. Defensive Screen keeps the drones in a tight orbit to deflect incoming attacks. Interference spreads them wide to jam enemy sensors. Sensor Relay forms a halo that extends your sensor range. Repair Swarm brings the drones close to the hull to assist with repairs.", + }, + { + selector: ".aegis-secondary", + content: + "Each mode has a fine control. Aim the defensive screen toward the direction attacks are coming from, tune jamming intensity, choose what the relay boosts, or set how hard the repair swarm works. Working the drones harder wears them out faster.", + }, + { + selector: ".aegis-canvas", + content: + "This display shows your drones flying around the ship. Watch how their formation changes when you select a different mode. The ship itself glows red as its structural integrity weakens, and drones flare up when they absorb an incoming hit. Ship actions like outgoing transmissions and sensor scans appear here too.", + }, + { + selector: ".aegis-log", + content: + "The activity log records where your decisions made a difference - damage your screen absorbed, repairs completed, and signals your relay amplified.", + }, +]; diff --git a/src/components/views/TacticalMap/objectConfig.jsx b/src/components/views/TacticalMap/objectConfig.jsx index 6404e05b8..2d9abca21 100644 --- a/src/components/views/TacticalMap/objectConfig.jsx +++ b/src/components/views/TacticalMap/objectConfig.jsx @@ -331,6 +331,7 @@ const ObjectSettings = ({ flash, ijkl, wasd, + keepOnScreen, opacity, updateObject, //thrusters, @@ -441,6 +442,19 @@ const ObjectSettings = ({ IJKL Keys + + + diff --git a/src/components/views/TacticalMap/preview/index.jsx b/src/components/views/TacticalMap/preview/index.jsx index 301944572..38220bdf6 100644 --- a/src/components/views/TacticalMap/preview/index.jsx +++ b/src/components/views/TacticalMap/preview/index.jsx @@ -2,6 +2,7 @@ import React, {Component} from "react"; import layerComps from "./layerComps"; import {withApollo} from "react-apollo"; import gql from "graphql-tag.macro"; +import {clampItemPosition} from "./layerComps/clampToBounds"; class TacticalMapPreview extends Component { keypress = evt => { const distance = 0.005; @@ -39,15 +40,15 @@ class TacticalMapPreview extends Component { (wasd.indexOf(evt.code) > -1 && i.wasd) || (ijkl.indexOf(evt.code) > -1 && i.ijkl) ) { - this.props.updateObject( - "destination", - { - x: i.destination.x + movement.x, - y: i.destination.y + movement.y, - z: i.destination.z, - }, - i, - ); + let destination = { + x: i.destination.x + movement.x, + y: i.destination.y + movement.y, + z: i.destination.z, + }; + if (i.keepOnScreen) { + destination = clampItemPosition(i, destination); + } + this.props.updateObject("destination", destination, i); } }); } diff --git a/src/components/views/TacticalMap/preview/layerComps/IconMarkup.jsx b/src/components/views/TacticalMap/preview/layerComps/IconMarkup.jsx index e770c6c73..4bc07030d 100644 --- a/src/components/views/TacticalMap/preview/layerComps/IconMarkup.jsx +++ b/src/components/views/TacticalMap/preview/layerComps/IconMarkup.jsx @@ -1,4 +1,5 @@ import React from "react"; +import {clampItemPosition} from "./clampToBounds"; const IconMarkup = ({ mouseDown, @@ -20,10 +21,30 @@ const IconMarkup = ({ core, isSelected, interval, + keepOnScreen, + iconWidth, + iconHeight, + onIconLoad, }) => { if (core) { opacity = Math.max(0.5, opacity); } + // Defensive render-time clamp: even if a stored position somehow drifted off + // screen, a keepOnScreen icon is never displayed clipping past the edge. + if (keepOnScreen) { + const footprintItem = {size, iconWidth, iconHeight}; + if (location) { + location = clampItemPosition(footprintItem, location); + } + if (destination) { + destination = clampItemPosition(footprintItem, { + x: destination.x + movement.x, + y: destination.y + movement.y, + z: destination.z, + }); + movement = {x: 0, y: 0, z: 0}; + } + } return [ location ? (
onIconLoad(evt.target) : undefined} /> )}
 {
+    if (!naturalWidth || !naturalHeight) return;
+    const {id, layerId, iconWidth, iconHeight} = this.props;
+    if (iconWidth !== naturalWidth) {
+      this.props.updateObject("iconWidth", naturalWidth, {id, layerId});
+    }
+    if (iconHeight !== naturalHeight) {
+      this.props.updateObject("iconHeight", naturalHeight, {id, layerId});
+    }
+  };
   render() {
     const {destination} = this.state;
     const {
@@ -84,6 +104,9 @@ export default class TacticalIcon extends Component {
       interval,
       movement = {x: 0, y: 0, z: 0},
       isSelected,
+      keepOnScreen,
+      iconWidth,
+      iconHeight,
     } = this.props;
     if (icon) {
       return (
@@ -107,6 +130,10 @@ export default class TacticalIcon extends Component {
           fontSize={fontSize}
           label={label}
           core={core}
+          keepOnScreen={keepOnScreen}
+          iconWidth={iconWidth}
+          iconHeight={iconHeight}
+          onIconLoad={this.handleIconLoad}
         />
       );
     }
diff --git a/src/components/views/TacticalMap/preview/layerComps/clampToBounds.js b/src/components/views/TacticalMap/preview/layerComps/clampToBounds.js
new file mode 100644
index 000000000..82221262c
--- /dev/null
+++ b/src/components/views/TacticalMap/preview/layerComps/clampToBounds.js
@@ -0,0 +1,55 @@
+// Shared "keep on screen" clamp math for Tactical Map objects.
+//
+// Tactical items store their position as normalized {x, y, z} fractions where 0 is
+// the left/top edge and 1 is the right/bottom edge, rendered with
+// `translate(x*100%, y*100%)`. When an item has `keepOnScreen` enabled we constrain
+// the position so the *entire* scaled icon stays within [0, 1].
+//
+// The footprint is computed against the canonical 1920x1080 viewscreen so the clamp
+// is identical on the server (authoritative) and on every client, regardless of the
+// actual canvas size (the rendered position is normalized, so it is scale-invariant).
+//
+// NOTE: This file is intentionally duplicated at
+// `server/helpers/tacticalBounds.js`. The client (Vite, tsconfig include: src) and the
+// server (tsconfig include: server) cannot import across that boundary, so keep the two
+// copies in sync.
+
+export const CANONICAL_WIDTH = 1920;
+export const CANONICAL_HEIGHT = 1080;
+
+// Returns the normalized {w, h} footprint of the scaled icon. `iconWidth`/`iconHeight`
+// are the icon image's intrinsic pixel dimensions (measured once on the client).
+export function getFootprint(
+  item,
+  canvasWidth = CANONICAL_WIDTH,
+  canvasHeight = CANONICAL_HEIGHT,
+) {
+  const size = item.size || 1;
+  const w = ((item.iconWidth || 0) * size) / canvasWidth;
+  const h = ((item.iconHeight || 0) * size) / canvasHeight;
+  return {w, h};
+}
+
+// Clamps a normalized position so the icon's footprint stays fully on screen.
+export function clampToBounds(position, footprint) {
+  const maxX = Math.max(0, 1 - footprint.w);
+  const maxY = Math.max(0, 1 - footprint.h);
+  return {
+    x: Math.min(Math.max(position.x, 0), maxX),
+    y: Math.min(Math.max(position.y, 0), maxY),
+    z: position.z,
+  };
+}
+
+// Convenience: clamp a position for a given item using its stored footprint.
+export function clampItemPosition(
+  item,
+  position,
+  canvasWidth = CANONICAL_WIDTH,
+  canvasHeight = CANONICAL_HEIGHT,
+) {
+  return clampToBounds(
+    position,
+    getFootprint(item, canvasWidth, canvasHeight),
+  );
+}
diff --git a/src/components/views/TacticalMap/preview/layerComps/clampToBounds.test.js b/src/components/views/TacticalMap/preview/layerComps/clampToBounds.test.js
new file mode 100644
index 000000000..54d5fc79d
--- /dev/null
+++ b/src/components/views/TacticalMap/preview/layerComps/clampToBounds.test.js
@@ -0,0 +1,72 @@
+import {describe, it, expect} from "vitest";
+import {getFootprint, clampToBounds, clampItemPosition} from "./clampToBounds";
+
+describe("getFootprint", () => {
+  it("normalizes the scaled icon against the canonical viewscreen", () => {
+    const fp = getFootprint({size: 1, iconWidth: 192, iconHeight: 108});
+    expect(fp.w).toBeCloseTo(0.1);
+    expect(fp.h).toBeCloseTo(0.1);
+  });
+
+  it("scales with the icon size", () => {
+    const fp = getFootprint({size: 2, iconWidth: 192, iconHeight: 108});
+    expect(fp.w).toBeCloseTo(0.2);
+    expect(fp.h).toBeCloseTo(0.2);
+  });
+
+  it("treats missing dimensions as zero footprint", () => {
+    const fp = getFootprint({size: 1});
+    expect(fp).toEqual({w: 0, h: 0});
+  });
+});
+
+describe("clampToBounds", () => {
+  const footprint = {w: 0.1, h: 0.1};
+
+  it("leaves an in-bounds position untouched", () => {
+    expect(clampToBounds({x: 0.5, y: 0.5, z: 0}, footprint)).toEqual({
+      x: 0.5,
+      y: 0.5,
+      z: 0,
+    });
+  });
+
+  it("clamps past the right/bottom edge to keep the full icon visible", () => {
+    expect(clampToBounds({x: 1.5, y: 2, z: 0}, footprint)).toEqual({
+      x: 0.9,
+      y: 0.9,
+      z: 0,
+    });
+  });
+
+  it("clamps past the left/top edge to zero", () => {
+    expect(clampToBounds({x: -1, y: -0.3, z: 0}, footprint)).toEqual({
+      x: 0,
+      y: 0,
+      z: 0,
+    });
+  });
+
+  it("pins an oversized icon (footprint > 1) to the top-left", () => {
+    expect(clampToBounds({x: 0.5, y: 0.5, z: 0}, {w: 1.5, h: 2})).toEqual({
+      x: 0,
+      y: 0,
+      z: 0,
+    });
+  });
+
+  it("preserves the z coordinate", () => {
+    expect(clampToBounds({x: 0.5, y: 0.5, z: 0.42}, footprint).z).toBe(0.42);
+  });
+});
+
+describe("clampItemPosition", () => {
+  it("clamps using the item's stored footprint", () => {
+    const item = {size: 1, iconWidth: 192, iconHeight: 108};
+    expect(clampItemPosition(item, {x: 1, y: 1, z: 0})).toEqual({
+      x: 0.9,
+      y: 0.9,
+      z: 0,
+    });
+  });
+});
diff --git a/src/components/views/TacticalMap/preview/layerComps/objects.jsx b/src/components/views/TacticalMap/preview/layerComps/objects.jsx
index a42a423e8..18e3b01d3 100644
--- a/src/components/views/TacticalMap/preview/layerComps/objects.jsx
+++ b/src/components/views/TacticalMap/preview/layerComps/objects.jsx
@@ -2,6 +2,12 @@ import React from "react";
 import TacticalIcon from "./TacticalIcon";
 import Selection from "./select";
 import useInterval from "helpers/hooks/useInterval";
+import {clampItemPosition} from "./clampToBounds";
+
+// Extra slack (in px) added to the off-screen deletion boundary so contacts that
+// sit right at the edge — especially keepOnScreen ones pinned against it — are not
+// deleted by accident during a multi-select drag.
+const DELETE_MARGIN = 40;
 
 const Objects = ({
   id,
@@ -40,16 +46,30 @@ const Objects = ({
         x = x + movement.x;
         y = y + movement.y;
 
+        // Constrained contacts are clamped back on screen instead of being
+        // dragged off and deleted.
+        if (item.keepOnScreen) {
+          updateObject(
+            "destination",
+            clampItemPosition(item, {x, y, z}),
+            item,
+            speed,
+          );
+          return;
+        }
+
         const el = document.getElementById(`tactical-icon-${item.id}`);
         const elBounds = el.getBoundingClientRect();
         const leftBound =
           (-1 * (elBounds.width / item.size + canvasBounds.left)) /
-          canvasBounds.width;
-        const rightBound = 1 + 20 / canvasBounds.width;
+            canvasBounds.width -
+          DELETE_MARGIN / canvasBounds.width;
+        const rightBound = 1 + DELETE_MARGIN / canvasBounds.width;
         const topBound =
           (-1 * (elBounds.height / item.size + canvasBounds.top)) /
-          canvasBounds.height;
-        const bottomBound = 1 + 20 / canvasBounds.height;
+            canvasBounds.height -
+          DELETE_MARGIN / canvasBounds.height;
+        const bottomBound = 1 + DELETE_MARGIN / canvasBounds.height;
         if (
           x > rightBound ||
           x < leftBound ||
diff --git a/src/components/views/TacticalMap/queries/tacticalMap.graphql b/src/components/views/TacticalMap/queries/tacticalMap.graphql
index b86f0021e..222bd5154 100644
--- a/src/components/views/TacticalMap/queries/tacticalMap.graphql
+++ b/src/components/views/TacticalMap/queries/tacticalMap.graphql
@@ -19,6 +19,9 @@ subscription TacticalMapUpdate($id: ID!) {
         fontColor
         icon
         size
+        iconWidth
+        iconHeight
+        keepOnScreen
         speed
         velocity {
           x
diff --git a/src/components/views/index.ts b/src/components/views/index.ts
index 0d0ffa902..35a854383 100644
--- a/src/components/views/index.ts
+++ b/src/components/views/index.ts
@@ -128,6 +128,7 @@ const HullPlating = React.lazy(() => import("./HullPlating"));
 const EdVenturesApp = React.lazy(() => import("./EdVenturesApp"));
 const AdvancedNavigation = React.lazy(() => import("./AdvancedNavAndAstrometrics/AdvancedNavigationCard"));
 const Astrometrics = React.lazy(() => import("./AdvancedNavAndAstrometrics/AstrometricsCard"));
+const Aegis = React.lazy(() => import("./Aegis"));
 // Cores
 const EngineControlCore = React.lazy(() => import("./EngineControl/core"));
 const TransporterCore = React.lazy(() => import("./Transporters/core"));
@@ -230,6 +231,8 @@ const HullPlatingCore = React.lazy(() => import("./HullPlating/core"));
 const EdVenturesAppCore = React.lazy(() => import("./EdVenturesApp/core"));
 const AdvancedNavigationCore = React.lazy(() => import("./AdvancedNavAndAstrometrics/CoreAdvancedNavigation"));
 const AstrometricsCore = React.lazy(() => import("./AdvancedNavAndAstrometrics/CoreAstrometrics"));
+const AegisCore = React.lazy(() => import("./Aegis/core"));
+const AdvancedTrainingCore = React.lazy(() => import("./AdvancedTraining/core"));
 // Widgets
 const ComposerWidget = React.lazy(() => import("./LongRangeComm/Composer"));
 const CalculatorWidget = React.lazy(() => import("./Widgets/calculator"));
@@ -340,7 +343,8 @@ const Views = {
   HullPlating,
   EdVenturesApp,
   AdvancedNavigation,
-  Astrometrics
+  Astrometrics,
+  Aegis,
 };
 
 export const Widgets = {
@@ -543,7 +547,9 @@ export const Cores = {
   HullPlatingCore,
   EdVenturesAppCore,
   AdvancedNavigationCore,
-  AstrometricsCore
+  AstrometricsCore,
+  AegisCore,
+  AdvancedTrainingCore
 };
 
 export default Views;
diff --git a/src/components/viewscreens/TacticalMap/index.jsx b/src/components/viewscreens/TacticalMap/index.jsx
index a52740252..d62d98f7d 100644
--- a/src/components/viewscreens/TacticalMap/index.jsx
+++ b/src/components/viewscreens/TacticalMap/index.jsx
@@ -26,6 +26,9 @@ const fragment = gql`
         fontColor
         icon
         size
+        iconWidth
+        iconHeight
+        keepOnScreen
         speed
         velocity {
           x
diff --git a/src/containers/FlightDirector/AdvancedTrainingDashboard/AdvancedTrainingDashboard.scss b/src/containers/FlightDirector/AdvancedTrainingDashboard/AdvancedTrainingDashboard.scss
new file mode 100644
index 000000000..b3c270f6b
--- /dev/null
+++ b/src/containers/FlightDirector/AdvancedTrainingDashboard/AdvancedTrainingDashboard.scss
@@ -0,0 +1,181 @@
+.advanced-training-dashboard {
+  padding: 20px;
+
+  .dashboard-title {
+    color: #00bcd4;
+    font-weight: 600;
+    margin-bottom: 20px;
+    text-transform: uppercase;
+    letter-spacing: 1px;
+  }
+
+  .no-clients {
+    text-align: center;
+    padding: 40px 20px;
+    color: #b0bec5;
+  }
+
+  .client-training-card {
+    background: rgba(0, 0, 0, 0.3);
+    border: 1px solid rgba(0, 188, 212, 0.2);
+  }
+
+  .client-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    background: rgba(0, 188, 212, 0.08);
+    border-bottom: 1px solid rgba(0, 188, 212, 0.2);
+  }
+
+  .client-info {
+    display: flex;
+    flex-direction: column;
+  }
+
+  .client-label {
+    font-weight: 600;
+    color: #e0f7fa;
+  }
+
+  .client-station {
+    font-size: 12px;
+    color: #78909c;
+  }
+
+  .client-body {
+    padding: 12px;
+  }
+
+  .progress-section {
+    margin-bottom: 12px;
+  }
+
+  .progress-label {
+    color: #78909c;
+    display: block;
+    margin-bottom: 4px;
+  }
+
+  .section-label {
+    color: #546e7a;
+    text-transform: uppercase;
+    font-size: 10px;
+    letter-spacing: 0.5px;
+  }
+
+  .active-chapter {
+    margin-bottom: 12px;
+    padding: 8px;
+    background: rgba(0, 188, 212, 0.08);
+    border-radius: 4px;
+    border-left: 3px solid #00bcd4;
+
+    .chapter-name {
+      font-weight: 600;
+      color: #e0f7fa;
+    }
+
+    .chapter-card {
+      font-size: 12px;
+      color: #78909c;
+    }
+  }
+
+  .chapter-list {
+    margin-bottom: 12px;
+  }
+
+  .chapter-row {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    padding: 6px 4px;
+    border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+    flex-wrap: wrap;
+
+    &.active {
+      background: rgba(0, 188, 212, 0.05);
+
+      .chapter-name-text {
+        color: #00bcd4;
+        font-weight: 600;
+      }
+    }
+
+    &.completed {
+      .chapter-name-text {
+        color: #4caf50;
+      }
+
+      .chapter-index {
+        color: #4caf50;
+      }
+    }
+  }
+
+  .chapter-index {
+    color: #546e7a;
+    font-size: 12px;
+    min-width: 18px;
+  }
+
+  .chapter-name-text {
+    flex: 1;
+    font-size: 13px;
+    color: #b0bec5;
+  }
+
+  .chapter-progress-text {
+    font-size: 11px;
+    color: #546e7a;
+  }
+
+  .chapter-action-btn {
+    font-size: 11px;
+    padding: 1px 8px;
+  }
+
+  .sub-chapter-row {
+    width: 100%;
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    padding: 3px 4px 3px 26px;
+    font-size: 12px;
+
+    &.completed {
+      .sub-name {
+        color: #78909c;
+        text-decoration: line-through;
+      }
+      .sub-check {
+        color: #4caf50;
+      }
+    }
+  }
+
+  .sub-check {
+    color: #546e7a;
+    font-size: 10px;
+    min-width: 14px;
+  }
+
+  .sub-name {
+    flex: 1;
+    color: #b0bec5;
+  }
+
+  .sub-action-btn {
+    font-size: 10px;
+    padding: 0px 6px;
+  }
+
+  .intervention-actions {
+    display: flex;
+    gap: 8px;
+    margin-top: 8px;
+    padding-top: 8px;
+    border-top: 1px solid rgba(255, 255, 255, 0.05);
+  }
+}
diff --git a/src/containers/FlightDirector/AdvancedTrainingDashboard/index.tsx b/src/containers/FlightDirector/AdvancedTrainingDashboard/index.tsx
new file mode 100644
index 000000000..138d80353
--- /dev/null
+++ b/src/containers/FlightDirector/AdvancedTrainingDashboard/index.tsx
@@ -0,0 +1,311 @@
+import React from "react";
+import {
+  Container,
+  Row,
+  Col,
+  Card,
+  CardBody,
+  CardHeader,
+  Button,
+  Progress,
+  Badge,
+} from "helpers/reactstrap";
+import {useQuery, useMutation, useSubscription} from "react-apollo";
+import gql from "graphql-tag.macro";
+import {
+  ADVANCED_TRAINING_PROGRESS_SUB,
+  FD_ADVANCE_CHAPTER,
+  FD_COMPLETE_SUBCHAPTER,
+  FD_RESET_PROGRESS,
+} from "components/training/queries";
+import {getActionLabel, getCardLabel} from "components/training/actionRegistry";
+import "./AdvancedTrainingDashboard.scss";
+
+const CLIENTS_QUERY = gql`
+  query AdvancedTrainingClients {
+    clients(all: true) {
+      id
+      label
+      connected
+      simulatorId
+      station
+      training
+      simulator {
+        id
+        name
+        stationSets {
+          id
+          stations {
+            name
+            advancedTraining {
+              enabled
+              chapters {
+                id
+                name
+                cardComponent
+                subChapters {
+                  id
+                  name
+                  requiredActions {
+                    id
+                    eventName
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+`;
+
+const CLIENTS_SUB = gql`
+  subscription AdvancedTrainingClientsSub {
+    clientChanged {
+      id
+      label
+      connected
+      simulatorId
+      station
+      training
+    }
+  }
+`;
+
+const AdvancedTrainingDashboard: React.FC = () => {
+  const {data: clientsData} = useQuery(CLIENTS_QUERY, {
+    fetchPolicy: "network-only",
+  });
+  useSubscription(CLIENTS_SUB);
+
+  const {data: progressData} = useSubscription(ADVANCED_TRAINING_PROGRESS_SUB, {
+    variables: {},
+  });
+
+  const [advanceChapter] = useMutation(FD_ADVANCE_CHAPTER);
+  const [completeSubChapter] = useMutation(FD_COMPLETE_SUBCHAPTER);
+  const [resetProgress] = useMutation(FD_RESET_PROGRESS);
+
+  const progressList = progressData?.advancedTrainingProgressUpdate || [];
+
+  const clients = clientsData?.clients || [];
+
+  // Find clients that have advanced training configured
+  const trainingClients = clients.filter((client: any) => {
+    if (!client.connected || !client.station || !client.simulatorId) {
+      return false;
+    }
+    const progress = progressList.find((p: any) => p.clientId === client.id);
+    return !!progress;
+  });
+
+  const getClientConfig = (client: any) => {
+    const sim = client.simulator;
+    if (!sim) {
+      return null;
+    }
+    for (const ss of sim.stationSets || []) {
+      const station = ss.stations?.find((s: any) => s.name === client.station);
+      if (station?.advancedTraining?.enabled) {
+        return station.advancedTraining;
+      }
+    }
+    return null;
+  };
+
+  return (
+    
+      

Advanced Training Dashboard

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

No crew members are currently in advanced training.

+ + Crew members can start advanced training from their login screen + when it is configured for their station. + +
+ )} + + + {trainingClients.map((client: any) => { + const progress = progressList.find( + (p: any) => p.clientId === client.id, + ); + const config = getClientConfig(client); + if (!progress || !config) { + return null; + } + + const chapters = config.chapters || []; + const activeChapter = chapters.find( + (c: any) => c.id === progress.activeChapterId, + ); + + const totalSubChapters = chapters.reduce( + (sum: number, ch: any) => sum + (ch.subChapters?.length || 0), + 0, + ); + const completedSubChapters = + progress.completedSubChapterIds?.length || 0; + const overallPercent = + totalSubChapters > 0 + ? Math.round((completedSubChapters / totalSubChapters) * 100) + : 0; + + return ( + + + +
+ + {client.label || client.id} + + {client.station} +
+ + {overallPercent}% + +
+ + {/* Overall progress */} +
+ + Overall: {completedSubChapters}/{totalSubChapters}{" "} + sub-tasks + + +
+ + {/* Active chapter */} + {activeChapter && ( +
+ Active Chapter: +
{activeChapter.name}
+
+ {getCardLabel(activeChapter.cardComponent)} +
+
+ )} + + {/* Chapter list */} +
+ {chapters.map((ch: any, idx: number) => { + const isCompleted = + progress.completedChapterIds?.includes(ch.id); + const isActive = progress.activeChapterId === ch.id; + const chSubCount = ch.subChapters?.length || 0; + const chCompleted = + ch.subChapters?.filter((sc: any) => + progress.completedSubChapterIds?.includes(sc.id), + ).length || 0; + + return ( +
+ {idx + 1} + {ch.name} + + {chCompleted}/{chSubCount} + + {!isActive && !isCompleted && ( + + )} + + {/* Sub-chapters for active chapter */} + {isActive && + ch.subChapters?.map((sc: any) => { + const scCompleted = + progress.completedSubChapterIds?.includes( + sc.id, + ); + return ( +
+ + {scCompleted ? "\u2713" : "\u25CB"} + + {sc.name} + {!scCompleted && ( + + )} +
+ ); + })} +
+ ); + })} +
+ + {/* Actions */} +
+ +
+
+
+ + ); + })} +
+
+ ); +}; + +export default AdvancedTrainingDashboard; diff --git a/src/containers/FlightDirector/SimulatorConfig/config/Stations/AdvancedTrainingConfig.tsx b/src/containers/FlightDirector/SimulatorConfig/config/Stations/AdvancedTrainingConfig.tsx new file mode 100644 index 000000000..406071d55 --- /dev/null +++ b/src/containers/FlightDirector/SimulatorConfig/config/Stations/AdvancedTrainingConfig.tsx @@ -0,0 +1,191 @@ +import React from "react"; +import {Button, Label, CustomInput, Container} from "helpers/reactstrap"; +import {useMutation} from "react-apollo"; +import {useParams, useNavigate} from "react-router-dom"; +import {useStationSetConfigSubscription} from "generated/graphql"; +import {TOGGLE_ADVANCED_TRAINING_MODE} from "components/training/queries"; +import {useAdvancedTrainingConfigEditor} from "./useAdvancedTrainingConfigEditor"; +import AdvancedTrainingEditor from "./AdvancedTrainingEditor"; + +const AdvancedTrainingConfig: React.FC = () => { + const { + simulatorId, + stationSetId, + stationName: encodedStationName, + } = useParams(); + const stationName = decodeURI(encodedStationName || ""); + const navigate = useNavigate(); + + const {data: stationData} = useStationSetConfigSubscription(); + const stationSets = stationData?.stationSetUpdate?.filter( + (s: any) => s?.simulator?.id === simulatorId, + ); + const stationSet = stationSets?.find((s: any) => s?.id === stationSetId); + const station = stationSet?.stations?.find( + (s: any) => s?.name === stationName, + ); + + const advancedTraining = (station as any)?.advancedTraining; + const enabled = advancedTraining?.enabled ?? false; + const sequentialChapters = advancedTraining?.sequentialChapters ?? false; + const chapters = advancedTraining?.chapters ?? []; + const inFlightChapters = advancedTraining?.inFlightChapters ?? []; + const stationCards = station?.cards || []; + + const [toggleMode] = useMutation(TOGGLE_ADVANCED_TRAINING_MODE); + + const editor = useAdvancedTrainingConfigEditor({ + advancedTraining, + chapters, + inFlightChapters, + sequentialChapters, + enabled, + stationCards, + stationSetId, + stationName, + }); + + const handleToggle = () => { + if (!stationSetId || !stationName) { + return; + } + toggleMode({ + variables: {stationSetID: stationSetId, stationName, enabled: !enabled}, + }); + }; + + const goBack = () => { + navigate( + `/config/simulator/${simulatorId}/Stations/${stationSetId}/${encodeURI( + stationName, + )}`, + ); + }; + + if (!station) { + return ( + +

Loading station data...

+ +
+ ); + } + + return ( + +
+ +

Advanced Training — {stationName}

+
+ + + + {enabled && ( +
+ {!editor.isEditing ? ( + <> +
+ {chapters.length} chapter + {chapters.length !== 1 ? "s" : ""} configured + {advancedTraining?.loginChapter && ( + + + login chapter + + )} + {advancedTraining?.completionChapter && ( + + + completion chapter + + )} + {inFlightChapters.length > 0 && ( + + + {inFlightChapters.length} in-flight help chapter + {inFlightChapters.length !== 1 ? "s" : ""} + + )} +
+ {chapters.map((ch: any, idx: number) => ( +
+ {idx + 1}. {ch.name}{" "} + + ({ch.cardComponent}, {ch.subChapters?.length || 0}{" "} + sub-tasks) + +
+ ))} + {inFlightChapters.map((ch: any) => ( +
+ ⚑ {ch.name}{" "} + + ({ch.cardComponent}, {ch.subChapters?.length || 0}{" "} + sub-tasks) + +
+ ))} + + + ) : ( + + )} +
+ )} +
+ ); +}; + +export default AdvancedTrainingConfig; diff --git a/src/containers/FlightDirector/SimulatorConfig/config/Stations/AdvancedTrainingEditor.tsx b/src/containers/FlightDirector/SimulatorConfig/config/Stations/AdvancedTrainingEditor.tsx new file mode 100644 index 000000000..9b795238f --- /dev/null +++ b/src/containers/FlightDirector/SimulatorConfig/config/Stations/AdvancedTrainingEditor.tsx @@ -0,0 +1,425 @@ +import React from "react"; +import { + Button, + Input, + Label, + FormGroup, + Modal, + ModalHeader, + ModalBody, + ModalFooter, +} from "helpers/reactstrap"; +import FileExplorer from "components/views/TacticalMap/fileExplorer"; +import {ChapterEditor, emptyChapter} from "./ChapterEditor"; +import InFlightChaptersSection from "./InFlightChaptersSection"; +import RecordActionsModal from "./RecordActionsModal"; +import type {useAdvancedTrainingConfigEditor} from "./useAdvancedTrainingConfigEditor"; + +interface AdvancedTrainingEditorProps { + editor: ReturnType; + stationCards: any[]; + sequentialChapters: boolean; + simulatorId: string; + stationSetId: string; + stationName: string; +} + +// The "edit chapters" UI for the Advanced Training config page: training-wide +// settings, the optional login/completion chapters, the regular chapter list, +// the in-flight help section, and the media-picker / action-recording modals. +// All state and handlers come from the editor hook passed in as a prop. +const AdvancedTrainingEditor: React.FC = ({ + editor, + stationCards, + sequentialChapters, + simulatorId, + stationSetId, + stationName, +}) => { + const { + displayChapters, + editingInFlightChapters, + editingSequential, + setEditingSequential, + editingStripPosition, + setEditingStripPosition, + editingLogin, + setEditingLoginChapter, + editingCompletion, + setEditingCompletionChapter, + expandedChapter, + toggleExpand, + setExpandedChapter, + mediaPickerChapter, + setMediaPickerChapter, + saveEditing, + cancelEditing, + addChapter, + removeChapter, + updateChapter, + addInFlightChapter, + removeInFlightChapter, + updateInFlightChapter, + addSubChapter, + removeSubChapter, + updateSubChapter, + recordingSubChapter, + recordingChapter, + recordingSubChapterData, + startRecording, + cancelRecording, + saveRecording, + } = editor; + + return ( + <> + + +
+ Training bar: + + +
+
+ + {/* Login chapter */} +
+ + {editingLogin && ( + <> +
+
+ Station Login +
+ {(["none", "immediate", "on-complete"] as const).map(opt => ( + + ))} +
+ toggleExpand(editingLogin.id)} + onUpdate={updates => + setEditingLoginChapter((prev: any) => ({...prev, ...updates})) + } + onAddSubChapter={() => addSubChapter(editingLogin.id, "login")} + onRemoveSubChapter={subId => + removeSubChapter(editingLogin.id, subId, "login") + } + onUpdateSubChapter={(subId, updates) => + updateSubChapter(editingLogin.id, subId, updates, "login") + } + onStartRecording={subId => startRecording(editingLogin.id, subId)} + onSetMediaPicker={() => + setMediaPickerChapter(`login:${editingLogin.id}`) + } + showCardSelector={false} + isLoginChapter + /> + + )} +
+ + {/* Regular chapters */} + {displayChapters?.map((chapter: any, chIdx: number) => ( + toggleExpand(chapter.id)} + onUpdate={updates => updateChapter(chapter.id, updates)} + onRemove={() => removeChapter(chapter.id)} + onAddSubChapter={() => addSubChapter(chapter.id)} + onRemoveSubChapter={subId => removeSubChapter(chapter.id, subId)} + onUpdateSubChapter={(subId, updates) => + updateSubChapter(chapter.id, subId, updates) + } + onStartRecording={subId => startRecording(chapter.id, subId)} + onSetMediaPicker={() => setMediaPickerChapter(chapter.id)} + /> + ))} + + {/* In-flight help chapters */} + addSubChapter(chapterId, "inflight")} + onRemoveSubChapter={(chapterId, subId) => + removeSubChapter(chapterId, subId, "inflight") + } + onUpdateSubChapter={(chapterId, subId, updates) => + updateSubChapter(chapterId, subId, updates, "inflight") + } + onStartRecording={(chapterId, subId) => + startRecording(chapterId, subId) + } + onSetMediaPicker={chapterId => + setMediaPickerChapter(`inflight:${chapterId}`) + } + /> + + {/* Completion chapter */} +
+ + {editingCompletion && ( + toggleExpand(editingCompletion.id)} + onUpdate={updates => + setEditingCompletionChapter((prev: any) => ({ + ...prev, + ...updates, + })) + } + onAddSubChapter={() => + addSubChapter(editingCompletion.id, "completion") + } + onRemoveSubChapter={subId => + removeSubChapter(editingCompletion.id, subId, "completion") + } + onUpdateSubChapter={(subId, updates) => + updateSubChapter( + editingCompletion.id, + subId, + updates, + "completion", + ) + } + onStartRecording={subId => + startRecording(editingCompletion.id, subId) + } + onSetMediaPicker={() => + setMediaPickerChapter(`completion:${editingCompletion.id}`) + } + showCardSelector={false} + /> + )} +
+ +
+ + + +
+ + {/* Media picker modal */} + setMediaPickerChapter(null)} + > + setMediaPickerChapter(null)}> + Select Training Media + + + { + if (mediaPickerChapter) { + const isLogin = mediaPickerChapter.startsWith("login:"); + const isCompletion = + mediaPickerChapter.startsWith("completion:"); + const isInFlight = mediaPickerChapter.startsWith("inflight:"); + if (isLogin) { + setEditingLoginChapter((prev: any) => ({ + ...prev, + mediaAsset: container.fullPath, + })); + } else if (isCompletion) { + setEditingCompletionChapter((prev: any) => ({ + ...prev, + mediaAsset: container.fullPath, + })); + } else if (isInFlight) { + updateInFlightChapter( + mediaPickerChapter.slice("inflight:".length), + {mediaAsset: container.fullPath}, + ); + } else { + updateChapter(mediaPickerChapter, { + mediaAsset: container.fullPath, + }); + } + } + setMediaPickerChapter(null); + }} + /> + + + + + + + {/* Record actions modal */} + + + ); +}; + +export default AdvancedTrainingEditor; diff --git a/src/containers/FlightDirector/SimulatorConfig/config/Stations/CardPreviewErrorBoundary.tsx b/src/containers/FlightDirector/SimulatorConfig/config/Stations/CardPreviewErrorBoundary.tsx new file mode 100644 index 000000000..ded1f64c2 --- /dev/null +++ b/src/containers/FlightDirector/SimulatorConfig/config/Stations/CardPreviewErrorBoundary.tsx @@ -0,0 +1,46 @@ +import React from "react"; + +// Error boundary around the live card preview in the Record Actions modal. +// A card component can throw when rendered outside a real flight; this shows the +// error instead of crashing the modal, and reminds the FD they can still pick +// actions from the list. +class CardPreviewErrorBoundary extends React.Component< + {children: React.ReactNode; cardName: string}, + {error: Error | null} +> { + state = {error: null as Error | null}; + + static getDerivedStateFromError(error: Error) { + return {error}; + } + + render() { + if (this.state.error) { + return ( +
+

Unable to render card preview for "{this.props.cardName}".

+

+ {this.state.error.message} +

+

+ You can still select actions from the list on the right. +

+
+ ); + } + return this.props.children; + } +} + +export default CardPreviewErrorBoundary; diff --git a/src/containers/FlightDirector/SimulatorConfig/config/Stations/ChapterEditor.tsx b/src/containers/FlightDirector/SimulatorConfig/config/Stations/ChapterEditor.tsx new file mode 100644 index 000000000..023e68968 --- /dev/null +++ b/src/containers/FlightDirector/SimulatorConfig/config/Stations/ChapterEditor.tsx @@ -0,0 +1,443 @@ +import React from "react"; +import { + Button, + Input, + Label, + FormGroup, + Card, + CardBody, + CardHeader, + Collapse, +} from "helpers/reactstrap"; +import { + getActionLabel, + VIDEO_COMPLETE_EVENT, + LOGIN_EVENT, +} from "components/training/actionRegistry"; + +// 3x3 grid of anchor points for positioning a chapter's media overlay. +const MEDIA_POSITIONS = [ + "top-left", + "top-center", + "top-right", + "middle-left", + "middle-center", + "middle-right", + "bottom-left", + "bottom-center", + "bottom-right", +]; + +// A blank chapter, used when the FD adds a new chapter of any kind. +export function emptyChapter(id: string, name: string) { + return { + id, + name, + cardComponent: "", + mediaAsset: null, + autoOpenMedia: false, + autoAdvance: false, + autoLogin: "none", + cardSwitchBehavior: "manual", + mediaSize: "small", + mediaPosition: "bottom-right", + subChapters: [], + }; +} + +// Normalize a chapter (and its nested sub-chapters/actions) into the exact shape +// the server mutation expects, dropping any extra client-only fields. +export function serializeChapter(ch: any) { + return { + id: ch.id, + name: ch.name, + cardComponent: ch.cardComponent || "", + mediaAsset: ch.mediaAsset || null, + autoOpenMedia: ch.autoOpenMedia ?? false, + autoAdvance: ch.autoAdvance ?? false, + autoLogin: ch.autoLogin ?? "none", + cardSwitchBehavior: ch.cardSwitchBehavior || "manual", + mediaSize: ch.mediaSize || "small", + mediaPosition: ch.mediaPosition || "bottom-right", + subChapters: (ch.subChapters || []).map((sc: any) => ({ + id: sc.id, + name: sc.name, + requiredActions: (sc.requiredActions || []).map((ra: any) => ({ + id: ra.id, + eventName: ra.eventName, + args: ra.args || null, + })), + })), + }; +} + +interface SyntheticActionToggleProps { + sub: any; + eventName: string; + idPrefix: string; + label: string; + onUpdateSubChapter: (subId: string, updates: any) => void; +} + +// Checkbox that adds/removes a single synthetic required action (e.g. "media +// finished" or "logged in") on a sub-chapter. These actions have no card UI to +// click, so the FD toggles them directly. +const SyntheticActionToggle: React.FC = ({ + sub, + eventName, + idPrefix, + label, + onUpdateSubChapter, +}) => { + const current = sub.requiredActions || []; + const checked = current.some((ra: any) => ra.eventName === eventName); + return ( +
+ +
+ ); +}; + +interface ChapterEditorProps { + chapter: any; + index: number; + label?: string; + stationCards: any[]; + // When provided, the Card selector uses this explicit list instead of the + // station's cards (e.g. in-flight chapters that can target any card component, + // including ones not on this station). Each entry is {value, label}. + cardOptions?: {value: string; label: string}[]; + isExpanded: boolean; + onToggleExpand: () => void; + onUpdate: (updates: any) => void; + onRemove?: () => void; + onAddSubChapter: () => void; + onRemoveSubChapter: (subId: string) => void; + onUpdateSubChapter: (subId: string, updates: any) => void; + onStartRecording: (subId: string) => void; + onSetMediaPicker: () => void; + showCardSelector?: boolean; + isLoginChapter?: boolean; +} + +export const ChapterEditor: React.FC = ({ + chapter, + index, + label, + stationCards, + cardOptions, + isExpanded, + onToggleExpand, + onUpdate, + onRemove, + onAddSubChapter, + onRemoveSubChapter, + onUpdateSubChapter, + onStartRecording, + onSetMediaPicker, + showCardSelector = true, + isLoginChapter = false, +}) => { + const cardSelectOptions = + cardOptions || + stationCards.map((c: any) => ({ + value: c.component, + label: `${c.name} (${c.component})`, + })); + return ( + + + + {label || `${index + 1}. ${chapter.name}`} + {showCardSelector && ( + + {chapter.cardComponent} + + )} + + {onRemove && ( + + )} + + + + + + onUpdate({name: e.target.value})} + /> + + {showCardSelector && ( + + + onUpdate({cardComponent: e.target.value})} + > + + {cardSelectOptions.map((c: {value: string; label: string}) => ( + + ))} + + + )} + + +
+ + {chapter.mediaAsset && ( + + )} +
+
+ {chapter.mediaAsset && ( + +
+ + onUpdate({mediaSize: e.target.value})} + > + + + + +
+
+ +
+ {MEDIA_POSITIONS.map(pos => ( +
+
+ {chapter.mediaPosition || "bottom-right"} +
+
+
+ )} + + + + {showCardSelector && ( + + )} + + + {/* Sub-chapters */} +
+ + {(chapter.subChapters || []).map((sub: any, sIdx: number) => ( +
+
+ + {index + 1}.{sIdx + 1} + + + onUpdateSubChapter(sub.id, {name: e.target.value}) + } + style={{flex: 1}} + /> + + +
+ {chapter.mediaAsset && ( + + )} + {isLoginChapter && ( + + )} + {sub.requiredActions?.length > 0 && ( +
+ Required:{" "} + {sub.requiredActions + .map((ra: any) => + getActionLabel(ra.eventName, chapter.cardComponent), + ) + .join(", ")} +
+ )} +
+ ))} + +
+
+
+
+ ); +}; diff --git a/src/containers/FlightDirector/SimulatorConfig/config/Stations/InFlightChaptersSection.tsx b/src/containers/FlightDirector/SimulatorConfig/config/Stations/InFlightChaptersSection.tsx new file mode 100644 index 000000000..fc06dbf91 --- /dev/null +++ b/src/containers/FlightDirector/SimulatorConfig/config/Stations/InFlightChaptersSection.tsx @@ -0,0 +1,116 @@ +import React from "react"; +import {Button} from "helpers/reactstrap"; +import Views from "components/views/index"; +import {ChapterEditor} from "./ChapterEditor"; + +/** + * In-flight help chapters are tied to a card component and reached mid-flight + * via the question-mark help widget (rather than the normal sequential flow). + * Unlike regular chapters, they may target ANY card component — including cards + * not currently on this station — so an FD can pre-author help for a card or + * system added later in the flight. The card picker is therefore built from the + * full set of known card views, not just the station's cards. + */ + +interface InFlightChaptersSectionProps { + chapters: any[]; + stationCards: any[]; + expandedChapter: string | null; + onToggleExpand: (chapterId: string) => void; + onAdd: () => void; + onRemove: (chapterId: string) => void; + onUpdate: (chapterId: string, updates: any) => void; + onAddSubChapter: (chapterId: string) => void; + onRemoveSubChapter: (chapterId: string, subId: string) => void; + onUpdateSubChapter: (chapterId: string, subId: string, updates: any) => void; + onStartRecording: (chapterId: string, subId: string) => void; + onSetMediaPicker: (chapterId: string) => void; +} + +// Mirrors CardsTable's view list: every known card component, minus the ones +// that aren't selectable as a station card. +function buildCardOptions( + stationCards: any[], +): {value: string; label: string}[] { + const onStation = new Set( + (stationCards || []).map((c: any) => c.component).filter(Boolean), + ); + return Object.keys(Views) + .filter(v => v !== "Offline" && v !== "Login" && v !== "Viewscreen") + .sort() + .map(component => ({ + value: component, + // ✅ marks components already present on this station (same cue as the + // Add-Card picker), while still allowing off-station components. + label: `${onStation.has(component) ? "✅ " : ""}${component}`, + })); +} + +const InFlightChaptersSection: React.FC = ({ + chapters, + stationCards, + expandedChapter, + onToggleExpand, + onAdd, + onRemove, + onUpdate, + onAddSubChapter, + onRemoveSubChapter, + onUpdateSubChapter, + onStartRecording, + onSetMediaPicker, +}) => { + const cardOptions = React.useMemo( + () => buildCardOptions(stationCards), + [stationCards], + ); + + return ( +
+
+ In-Flight Help Chapters +
+
+ Reached mid-flight by pressing the help (question-mark) widget while on + the chapter's card. Excluded from the normal sequence and from the + overall progress bar. Can target any card, including ones not on this + station. +
+ + {chapters.map((chapter: any, idx: number) => ( + onToggleExpand(chapter.id)} + onUpdate={updates => onUpdate(chapter.id, updates)} + onRemove={() => onRemove(chapter.id)} + onAddSubChapter={() => onAddSubChapter(chapter.id)} + onRemoveSubChapter={subId => onRemoveSubChapter(chapter.id, subId)} + onUpdateSubChapter={(subId, updates) => + onUpdateSubChapter(chapter.id, subId, updates) + } + onStartRecording={subId => onStartRecording(chapter.id, subId)} + onSetMediaPicker={() => onSetMediaPicker(chapter.id)} + /> + ))} + + +
+ ); +}; + +export default InFlightChaptersSection; diff --git a/src/containers/FlightDirector/SimulatorConfig/config/Stations/RecordActionsModal.tsx b/src/containers/FlightDirector/SimulatorConfig/config/Stations/RecordActionsModal.tsx new file mode 100644 index 000000000..9c28d45ab --- /dev/null +++ b/src/containers/FlightDirector/SimulatorConfig/config/Stations/RecordActionsModal.tsx @@ -0,0 +1,295 @@ +import React, {useCallback, Suspense} from "react"; +import { + Modal, + ModalHeader, + ModalBody, + ModalFooter, + Button, + Label, +} from "helpers/reactstrap"; +import Views from "components/views"; +import { + getActionsForCard, + getGlobalActions, + getActionLabel, +} from "components/training/actionRegistry"; +import CardPreviewErrorBoundary from "./CardPreviewErrorBoundary"; +import {useSandboxFlight} from "./useSandboxFlight"; +import {useActionRecorder} from "./useActionRecorder"; + +interface RecordActionsModalProps { + isOpen: boolean; + chapter: any; + existingActions: any[]; + simulatorId: string; + stationSetId: string; + stationName: string; + onSave: (actions: any[]) => void; + onCancel: () => void; +} + +// Shared style for the centered status messages inside the preview pane. +const previewMessageStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + justifyContent: "center", + height: "100%", + color: "#888", +}; + +const RecordActionsModal: React.FC = ({ + isOpen, + chapter, + existingActions, + simulatorId, + stationSetId, + stationName, + onSave, + onCancel, +}) => { + const { + sandboxFlightId, + sandboxSimulatorId, + sandboxReady, + simulator, + cleanupSandbox, + } = useSandboxFlight({isOpen, simulatorId, stationSetId}); + + const {recordedActions, lastCaptured, captureClick, addAction, removeAction} = + useActionRecorder({isOpen, existingActions}); + + const handleSave = useCallback(() => { + cleanupSandbox(); + onSave(recordedActions); + }, [cleanupSandbox, onSave, recordedActions]); + + const handleCancel = useCallback(() => { + cleanupSandbox(); + onCancel(); + }, [cleanupSandbox, onCancel]); + + const cardComponentName = chapter?.cardComponent; + const availableActions = [ + ...getGlobalActions(), + ...(cardComponentName ? getActionsForCard(cardComponentName) : []), + ]; + + const CardComponent = cardComponentName + ? (Views as any)[cardComponentName] + : null; + + // Build preview props using the sandbox simulator + const effectiveSimId = sandboxSimulatorId || simulatorId; + const previewProps = { + simulator: simulator || { + id: effectiveSimId, + name: "Preview", + alertlevel: "5", + }, + station: {name: stationName, cards: []}, + flight: {id: sandboxFlightId || "preview"}, + clientObj: { + id: "preview-client", + simulatorId: effectiveSimId, + station: stationName, + training: false, + offlineState: null, + }, + cardName: cardComponentName, + changeCard: () => {}, + }; + + return ( + + + + ● REC + + Record Required Actions + {chapter && ( + + Card: {cardComponentName} + + )} + + +
+ {/* Left: Card Preview — interactive for recording */} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
+ {/* Recording flash overlay */} + {lastCaptured && ( +
+ Captured: {getActionLabel(lastCaptured, cardComponentName)} +
+ )} + + {!sandboxReady || !simulator ? ( +
+ Initializing sandbox environment... +
+ ) : CardComponent ? ( + Loading card preview...
+ } + > + +
+ +
+
+ + ) : ( +
+ No card component selected for this chapter. +
+ )} +
+ + {/* Right: Action Picker */} +
+

+ Interact with the card on the left to automatically record + actions, or manually select them below. +

+ +
+ +
+ {availableActions.length === 0 && ( + + No actions registered for this card component. + + )} + {availableActions.map(action => { + const existing = recordedActions.find( + (ra: any) => ra.eventName === action.eventName, + ); + const isSelected = !!existing; + return ( + + ); + })} +
+
+ +
+ + {recordedActions.length === 0 && ( +

+ No actions recorded yet. Click buttons on the card or select + actions above. +

+ )} + {recordedActions.map((action: any) => ( +
+ + {getActionLabel(action.eventName, cardComponentName)} + + +
+ ))} +
+
+
+ + + + + + + + ); +}; + +export default RecordActionsModal; diff --git a/src/containers/FlightDirector/SimulatorConfig/config/Stations/StationConfig.tsx b/src/containers/FlightDirector/SimulatorConfig/config/Stations/StationConfig.tsx index 7de32dd4e..a07849c78 100644 --- a/src/containers/FlightDirector/SimulatorConfig/config/Stations/StationConfig.tsx +++ b/src/containers/FlightDirector/SimulatorConfig/config/Stations/StationConfig.tsx @@ -1,5 +1,5 @@ import React from "react"; -import {Container, Row, Col} from "helpers/reactstrap"; +import {Container, Row, Col, Button} from "helpers/reactstrap"; import {Widgets} from "components/views/index"; import ExtraMessageGroups from "./messageGroups"; import {capitalCase} from "change-case"; @@ -16,6 +16,7 @@ import { useReorderStationWidgetsMutation, } from "generated/graphql"; import {useParams} from "react-router"; +import {Link} from "react-router-dom"; import CardsTable from "./CardsTable"; const Layouts = Object.keys(LayoutList).filter( @@ -154,6 +155,20 @@ const ConfigStation: React.FC = ({simulator, station}) => { +
+ +
diff --git a/src/containers/FlightDirector/SimulatorConfig/config/Stations/useActionRecorder.ts b/src/containers/FlightDirector/SimulatorConfig/config/Stations/useActionRecorder.ts new file mode 100644 index 000000000..2d6b4856f --- /dev/null +++ b/src/containers/FlightDirector/SimulatorConfig/config/Stations/useActionRecorder.ts @@ -0,0 +1,154 @@ +import React, {useState, useEffect, useRef, useCallback} from "react"; +import {subscribe} from "helpers/pubsub"; + +interface UseActionRecorderParams { + isOpen: boolean; + existingActions: any[]; +} + +// How long the "Captured: …" flash stays on screen, in ms. +const FLASH_DURATION = 1500; + +// Owns the list of recorded required actions for the Record Actions modal. +// Captures actions two ways: by subscribing to every server mutation-event +// while open, and via captureClick for direct clicks on the card preview. +// Exposes manual add/remove plus a transient "lastCaptured" flash value. +export function useActionRecorder({ + isOpen, + existingActions, +}: UseActionRecorderParams) { + const [recordedActions, setRecordedActions] = + useState(existingActions); + const [lastCaptured, setLastCaptured] = useState(null); + const lastCapturedTimeout = useRef | null>( + null, + ); + + // Flash the just-captured action, replacing any pending flash. + const flash = useCallback((eventName: string) => { + setLastCaptured(eventName); + if (lastCapturedTimeout.current) { + clearTimeout(lastCapturedTimeout.current); + } + lastCapturedTimeout.current = setTimeout( + () => setLastCaptured(null), + FLASH_DURATION, + ); + }, []); + + // Append an action unless one with the same eventName is already recorded. + const recordUnique = useCallback((eventName: string, args: any) => { + setRecordedActions(prev => { + if (prev.find((a: any) => a.eventName === eventName)) { + return prev; + } + return [...prev, {id: `ra-${Date.now()}`, eventName, args: args || null}]; + }); + }, []); + + // Reset to the chapter's existing actions each time the modal opens. + useEffect(() => { + if (!isOpen) { + return; + } + setRecordedActions(existingActions); + setLastCaptured(null); + }, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps + + // Capture every server mutation-event as a recordable action while open. + useEffect(() => { + if (!isOpen) { + return; + } + + const unsubscribe = subscribe( + "mutation-event", + ({event, args}: {event: string; args: any}) => { + if ( + event === "clockSync" || + event === "startFlight" || + event === "deleteFlight" + ) { + return; + } + recordUnique(event, args); + flash(event); + }, + ); + + return () => { + unsubscribe(); + if (lastCapturedTimeout.current) { + clearTimeout(lastCapturedTimeout.current); + } + }; + }, [isOpen, recordUnique, flash]); + + // Capture a click on the card preview as a recordable action. + const captureClick = useCallback( + (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + + // Walk up to find the closest interactive element + const interactive = target.closest( + "button, a, [role='button'], input, select, .btn", + ) as HTMLElement | null; + const el = interactive || target; + + const tag = el.tagName.toLowerCase(); + // Ignore clicks on generic containers + if ( + ["div", "span", "col", "row", "container", "section"].includes(tag) && + !interactive + ) { + return; + } + + const text = + el.textContent?.trim().replace(/\s+/g, " ").substring(0, 60) || + el.getAttribute("aria-label") || + el.getAttribute("title") || + ""; + + if (!text) { + return; + } + + const clickEventName = `click:${text}`; + const clickArgs = { + text, + tag, + className: el.className + ? String(el.className) + .split(" ") + .filter(Boolean) + .slice(0, 3) + .join(" ") + : null, + }; + + recordUnique(clickEventName, clickArgs); + flash(clickEventName); + }, + [recordUnique, flash], + ); + + const addAction = useCallback( + (eventName: string) => { + recordUnique(eventName, null); + }, + [recordUnique], + ); + + const removeAction = useCallback((actionId: string) => { + setRecordedActions(prev => prev.filter((a: any) => a.id !== actionId)); + }, []); + + return { + recordedActions, + lastCaptured, + captureClick, + addAction, + removeAction, + }; +} diff --git a/src/containers/FlightDirector/SimulatorConfig/config/Stations/useAdvancedTrainingConfigEditor.ts b/src/containers/FlightDirector/SimulatorConfig/config/Stations/useAdvancedTrainingConfigEditor.ts new file mode 100644 index 000000000..87fc1a0da --- /dev/null +++ b/src/containers/FlightDirector/SimulatorConfig/config/Stations/useAdvancedTrainingConfigEditor.ts @@ -0,0 +1,358 @@ +import {useState, useCallback} from "react"; +import {useMutation} from "react-apollo"; +import {SET_STATION_ADVANCED_TRAINING} from "components/training/queries"; +import {emptyChapter, serializeChapter} from "./ChapterEditor"; + +// Which collection a sub-chapter edit targets. `undefined` means a regular +// chapter in the main sequence. +type SpecialKind = "login" | "completion" | "inflight"; + +interface UseAdvancedTrainingConfigEditorParams { + advancedTraining: any; + chapters: any[]; + inFlightChapters: any[]; + sequentialChapters: boolean; + enabled: boolean; + stationCards: any[]; + stationSetId?: string; + stationName: string; +} + +// Owns the entire "edit chapters" state machine for the Advanced Training config +// page: the working copies of every chapter collection, expansion/recording UI +// state, and all the add/remove/update handlers. Kept out of the page component +// so the JSX stays readable. Editing works on deep clones; nothing is persisted +// until saveEditing fires the mutation. +export function useAdvancedTrainingConfigEditor({ + advancedTraining, + chapters, + inFlightChapters, + sequentialChapters, + enabled, + stationCards, + stationSetId, + stationName, +}: UseAdvancedTrainingConfigEditorParams) { + const [saveConfig] = useMutation(SET_STATION_ADVANCED_TRAINING); + + // Local editing state + const [editingChapters, setEditingChapters] = useState(null); + const [editingInFlightChapters, setEditingInFlightChapters] = useState< + any[] | null + >(null); + const [editingSequential, setEditingSequential] = useState( + null, + ); + const [editingStripPosition, setEditingStripPosition] = useState< + "top" | "bottom" | null + >(null); + const [editingLoginChapter, setEditingLoginChapter] = useState< + any | null | undefined + >(undefined); + const [editingCompletionChapter, setEditingCompletionChapter] = useState< + any | null | undefined + >(undefined); + const [expandedChapter, setExpandedChapter] = useState(null); + const [recordingSubChapter, setRecordingSubChapter] = useState( + null, + ); + const [mediaPickerChapter, setMediaPickerChapter] = useState( + null, + ); + + const isEditing = editingChapters !== null; + const displayChapters = isEditing ? editingChapters : chapters; + + // undefined = not editing; null = editing with no special chapter; object = editing with chapter + const editingLogin = + editingLoginChapter !== undefined + ? editingLoginChapter + : advancedTraining?.loginChapter ?? null; + const editingCompletion = + editingCompletionChapter !== undefined + ? editingCompletionChapter + : advancedTraining?.completionChapter ?? null; + + // Expand the given chapter, or collapse it if it's already expanded. + const toggleExpand = useCallback((chapterId: string) => { + setExpandedChapter(prev => (prev === chapterId ? null : chapterId)); + }, []); + + const startEditing = () => { + setEditingChapters(JSON.parse(JSON.stringify(chapters))); + setEditingInFlightChapters(JSON.parse(JSON.stringify(inFlightChapters))); + setEditingSequential(sequentialChapters); + setEditingStripPosition(advancedTraining?.stripPosition || "bottom"); + setEditingLoginChapter( + advancedTraining?.loginChapter + ? JSON.parse(JSON.stringify(advancedTraining.loginChapter)) + : null, + ); + setEditingCompletionChapter( + advancedTraining?.completionChapter + ? JSON.parse(JSON.stringify(advancedTraining.completionChapter)) + : null, + ); + }; + + const cancelEditing = () => { + setEditingChapters(null); + setEditingInFlightChapters(null); + setEditingSequential(null); + setEditingStripPosition(null); + setEditingLoginChapter(undefined); + setEditingCompletionChapter(undefined); + setExpandedChapter(null); + }; + + const saveEditing = () => { + if (!stationSetId || !stationName || !editingChapters) { + return; + } + saveConfig({ + variables: { + stationSetID: stationSetId, + stationName, + config: { + enabled, + sequentialChapters: editingSequential ?? sequentialChapters, + stripPosition: + editingStripPosition ?? advancedTraining?.stripPosition ?? "bottom", + chapters: editingChapters.map(serializeChapter), + inFlightChapters: (editingInFlightChapters || []).map( + serializeChapter, + ), + loginChapter: editingLogin ? serializeChapter(editingLogin) : null, + completionChapter: editingCompletion + ? serializeChapter(editingCompletion) + : null, + }, + }, + }); + setEditingChapters(null); + setEditingInFlightChapters(null); + setEditingLoginChapter(undefined); + setEditingCompletionChapter(undefined); + setExpandedChapter(null); + }; + + // --- Regular chapter list handlers --- + const addChapter = () => { + if (!editingChapters) { + return; + } + const newChapter = emptyChapter(`ch-${Date.now()}`, "New Chapter"); + (newChapter as any).cardComponent = stationCards[0]?.component || ""; + setEditingChapters([...editingChapters, newChapter]); + setExpandedChapter(newChapter.id); + }; + + const removeChapter = (chapterId: string) => { + if (!editingChapters) { + return; + } + setEditingChapters(editingChapters.filter((c: any) => c.id !== chapterId)); + if (expandedChapter === chapterId) { + setExpandedChapter(null); + } + }; + + const updateChapter = useCallback( + (chapterId: string, updates: any) => { + if (!editingChapters) { + return; + } + setEditingChapters( + editingChapters.map((c: any) => + c.id === chapterId ? {...c, ...updates} : c, + ), + ); + }, + [editingChapters], + ); + + // --- In-flight help chapter list handlers --- + const addInFlightChapter = () => { + if (!editingInFlightChapters) { + return; + } + const newChapter = emptyChapter(`if-${Date.now()}`, "New Help Chapter"); + (newChapter as any).cardComponent = stationCards[0]?.component || ""; + setEditingInFlightChapters([...editingInFlightChapters, newChapter]); + setExpandedChapter(newChapter.id); + }; + + const removeInFlightChapter = (chapterId: string) => { + if (!editingInFlightChapters) { + return; + } + setEditingInFlightChapters( + editingInFlightChapters.filter((c: any) => c.id !== chapterId), + ); + if (expandedChapter === chapterId) { + setExpandedChapter(null); + } + }; + + const updateInFlightChapter = (chapterId: string, updates: any) => { + if (!editingInFlightChapters) { + return; + } + setEditingInFlightChapters( + editingInFlightChapters.map((c: any) => + c.id === chapterId ? {...c, ...updates} : c, + ), + ); + }; + + // Apply an updater to a chapter's subChapters within whichever collection the + // chapter lives in. Centralizes the login/completion/inflight/regular routing + // shared by the add/remove/update sub-chapter handlers below. + const mutateSubChapters = ( + chapterId: string, + isSpecial: SpecialKind | undefined, + updateSubs: (subs: any[]) => any[], + ) => { + if (isSpecial === "login") { + setEditingLoginChapter((prev: any) => ({ + ...prev, + subChapters: updateSubs(prev?.subChapters || []), + })); + return; + } + if (isSpecial === "completion") { + setEditingCompletionChapter((prev: any) => ({ + ...prev, + subChapters: updateSubs(prev?.subChapters || []), + })); + return; + } + if (isSpecial === "inflight") { + setEditingInFlightChapters(prev => + prev + ? prev.map((c: any) => + c.id === chapterId + ? {...c, subChapters: updateSubs(c.subChapters || [])} + : c, + ) + : prev, + ); + return; + } + setEditingChapters(prev => + prev + ? prev.map((c: any) => + c.id === chapterId + ? {...c, subChapters: updateSubs(c.subChapters || [])} + : c, + ) + : prev, + ); + }; + + const addSubChapter = (chapterId: string, isSpecial?: SpecialKind) => { + const newSub = { + id: `sc-${Date.now()}`, + name: "New Sub-Chapter", + requiredActions: [], + }; + mutateSubChapters(chapterId, isSpecial, subs => [...subs, newSub]); + }; + + const removeSubChapter = ( + chapterId: string, + subChapterId: string, + isSpecial?: SpecialKind, + ) => { + mutateSubChapters(chapterId, isSpecial, subs => + subs.filter((s: any) => s.id !== subChapterId), + ); + }; + + const updateSubChapter = ( + chapterId: string, + subChapterId: string, + updates: any, + isSpecial?: SpecialKind, + ) => { + mutateSubChapters(chapterId, isSpecial, subs => + subs.map((s: any) => (s.id === subChapterId ? {...s, ...updates} : s)), + ); + }; + + // --- Record mode helpers --- + const startRecording = (chapterId: string, subChapterId: string) => { + setRecordingSubChapter(`${chapterId}:${subChapterId}`); + }; + + const cancelRecording = () => { + setRecordingSubChapter(null); + }; + + const saveRecording = (actions: any[]) => { + if (!recordingSubChapter) { + return; + } + const [chapterId, subChapterId] = recordingSubChapter.split(":"); + const isInFlight = editingInFlightChapters?.some( + (c: any) => c.id === chapterId, + ); + updateSubChapter( + chapterId, + subChapterId, + {requiredActions: actions}, + isInFlight ? "inflight" : undefined, + ); + setRecordingSubChapter(null); + }; + + const recordingChapterId = recordingSubChapter?.split(":")[0]; + const recordingSubChapterId = recordingSubChapter?.split(":")[1]; + const recordingChapter = + editingChapters?.find((c: any) => c.id === recordingChapterId) || + editingInFlightChapters?.find((c: any) => c.id === recordingChapterId); + const recordingSubChapterData = recordingChapter?.subChapters?.find( + (s: any) => s.id === recordingSubChapterId, + ); + + return { + // State + isEditing, + displayChapters, + editingInFlightChapters, + editingSequential, + setEditingSequential, + editingStripPosition, + setEditingStripPosition, + editingLogin, + setEditingLoginChapter, + editingCompletion, + setEditingCompletionChapter, + expandedChapter, + toggleExpand, + setExpandedChapter, + mediaPickerChapter, + setMediaPickerChapter, + // Lifecycle + startEditing, + cancelEditing, + saveEditing, + // Chapter handlers + addChapter, + removeChapter, + updateChapter, + addInFlightChapter, + removeInFlightChapter, + updateInFlightChapter, + addSubChapter, + removeSubChapter, + updateSubChapter, + // Recording + recordingSubChapter, + recordingChapter, + recordingSubChapterData, + startRecording, + cancelRecording, + saveRecording, + }; +} diff --git a/src/containers/FlightDirector/SimulatorConfig/config/Stations/useSandboxFlight.ts b/src/containers/FlightDirector/SimulatorConfig/config/Stations/useSandboxFlight.ts new file mode 100644 index 000000000..bf15cc55b --- /dev/null +++ b/src/containers/FlightDirector/SimulatorConfig/config/Stations/useSandboxFlight.ts @@ -0,0 +1,132 @@ +import {useState, useEffect, useRef, useCallback} from "react"; +import {useMutation} from "react-apollo"; +import {useApolloClient} from "@apollo/client"; +import {useSimulatorUpdateSubscription} from "generated/graphql"; +import { + START_SANDBOX_FLIGHT, + DELETE_SANDBOX_FLIGHT, + SANDBOX_FLIGHT_SIMULATORS, +} from "components/training/queries"; + +interface UseSandboxFlightParams { + isOpen: boolean; + simulatorId: string; + stationSetId: string; +} + +// Manages the throwaway "sandbox" flight that backs the Record Actions card +// preview: starts a flight when the modal opens, resolves its simulator id, +// subscribes to that simulator's data, and tears the flight down again on +// close/unmount. Returns everything the modal needs to render the live preview. +export function useSandboxFlight({ + isOpen, + simulatorId, + stationSetId, +}: UseSandboxFlightParams) { + const client = useApolloClient(); + const [sandboxFlightId, setSandboxFlightId] = useState(null); + const [sandboxSimulatorId, setSandboxSimulatorId] = useState( + null, + ); + const [sandboxReady, setSandboxReady] = useState(false); + const sandboxFlightIdRef = useRef(null); + + const [startFlightMutation] = useMutation(START_SANDBOX_FLIGHT); + const [deleteFlightMutation] = useMutation(DELETE_SANDBOX_FLIGHT); + + // Subscribe to the sandbox simulator's data for the card preview + const {data: simData} = useSimulatorUpdateSubscription({ + variables: {simulatorId: sandboxSimulatorId || ""}, + skip: !sandboxSimulatorId, + }); + const simulator = simData?.simulatorsUpdate?.[0]; + + // Create sandbox flight when the modal opens + useEffect(() => { + if (!isOpen) { + return; + } + + setSandboxReady(false); + + let cancelled = false; + + startFlightMutation({ + variables: { + name: `__sandbox_${Date.now()}`, + simulators: [{simulatorId, stationSet: stationSetId}], + }, + }) + .then(({data}: any) => { + if (cancelled) { + // Modal closed before flight was created — clean up + if (data?.startFlight) { + deleteFlightMutation({variables: {flightId: data.startFlight}}); + } + return; + } + const flightId = data?.startFlight; + if (!flightId) { + return; + } + setSandboxFlightId(flightId); + sandboxFlightIdRef.current = flightId; + + // Query for the sandbox simulator ID + return client.query({ + query: SANDBOX_FLIGHT_SIMULATORS, + variables: {flightId}, + fetchPolicy: "network-only", + }); + }) + .then((result: any) => { + if (cancelled || !result) { + return; + } + const simId = result.data?.flights?.[0]?.simulators?.[0]?.id; + if (simId) { + setSandboxSimulatorId(simId); + setSandboxReady(true); + } + }) + .catch((err: any) => { + console.error("Failed to create sandbox flight:", err); + }); + + return () => { + cancelled = true; + }; + }, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps + + // Cleanup sandbox on unmount (handles unexpected navigation) + useEffect(() => { + return () => { + const flightId = sandboxFlightIdRef.current; + if (flightId) { + deleteFlightMutation({variables: {flightId}}).catch(() => {}); + sandboxFlightIdRef.current = null; + } + }; + }, [deleteFlightMutation]); + + const cleanupSandbox = useCallback(() => { + const flightId = sandboxFlightIdRef.current; + if (flightId) { + deleteFlightMutation({variables: {flightId}}).catch(err => + console.error("Failed to delete sandbox flight:", err), + ); + sandboxFlightIdRef.current = null; + setSandboxFlightId(null); + setSandboxSimulatorId(null); + setSandboxReady(false); + } + }, [deleteFlightMutation]); + + return { + sandboxFlightId, + sandboxSimulatorId, + sandboxReady, + simulator, + cleanupSandbox, + }; +} diff --git a/src/containers/FlightDirector/SimulatorConfig/config/Systems.jsx b/src/containers/FlightDirector/SimulatorConfig/config/Systems.jsx index b06be1a15..3d0dda101 100644 --- a/src/containers/FlightDirector/SimulatorConfig/config/Systems.jsx +++ b/src/containers/FlightDirector/SimulatorConfig/config/Systems.jsx @@ -16,6 +16,7 @@ import * as Configs from "./systemsConfig"; const systems = [ "AdvancedNavigationAndAstrometrics", + "Aegis", "ComputerCore", "Coolant", "Countermeasures", diff --git a/src/containers/FlightDirector/SimulatorConfig/index.tsx b/src/containers/FlightDirector/SimulatorConfig/index.tsx index 115fbd0d1..3dcf0665a 100644 --- a/src/containers/FlightDirector/SimulatorConfig/index.tsx +++ b/src/containers/FlightDirector/SimulatorConfig/index.tsx @@ -3,6 +3,7 @@ import {Col, Row, Container, Button} from "helpers/reactstrap"; import SimulatorProperties from "./SimulatorProperties"; import * as Config from "./config"; +import AdvancedTrainingConfig from "./config/Stations/AdvancedTrainingConfig"; import { useRemoveSimulatorMutation, useSimulatorsConfigSubscription, @@ -88,6 +89,10 @@ const SimulatorConfigRoutes = () => { } /> } /> + } + /> } /> } /> , "onChange"> { + /** Controlled value sourced from the server. Only synced in when the + * input is not focused, so an inbound subscription echo never clobbers + * actively typed text. */ + value: string | number | undefined | null; + /** Called with the latest value after the user pauses typing (~400 ms). */ + onCommit: (value: string) => void; +} + +/** + * A debounced, focus-aware controlled input for the Task Flow config editor. + * + * The task-flow subscription re-pushes the entire taskFlows array on every + * mutation, which previously caused `defaultValue`-based inputs to reset their + * text on every keystroke round-trip. This component fixes that by: + * 1. Holding local state for smooth typing — server value only seeded on mount + * or when the field is not focused. + * 2. Firing `onCommit` (the GraphQL mutation) debounced at 400 ms so the + * subscription does not echo back mid-keystroke. + */ +const DebouncedInput: React.FC = ({ + value, + onCommit, + ...rest +}) => { + const [localValue, setLocalValue] = useState( + value !== undefined && value !== null ? String(value) : "", + ); + const isFocused = useRef(false); + + // Sync from server only when not actively typing. + useEffect(() => { + if (!isFocused.current) { + setLocalValue(value !== undefined && value !== null ? String(value) : ""); + } + }, [value]); + + const debouncedCommit = useRef( + debounce((v: string) => onCommit(v), 400), + ); + + // Rebuild the debounced callback if onCommit identity changes (rare but safe). + useEffect(() => { + debouncedCommit.current = debounce((v: string) => onCommit(v), 400); + }, [onCommit]); + + return ( + ) => { + const v = e.target.value; + setLocalValue(v); + debouncedCommit.current(v); + }} + onFocus={() => { + isFocused.current = true; + }} + onBlur={() => { + isFocused.current = false; + }} + /> + ); +}; + +export default DebouncedInput; diff --git a/src/containers/FlightDirector/TaskTemplates/flowConfig.tsx b/src/containers/FlightDirector/TaskTemplates/flowConfig.tsx index e5a7ad718..8e1dc7795 100644 --- a/src/containers/FlightDirector/TaskTemplates/flowConfig.tsx +++ b/src/containers/FlightDirector/TaskTemplates/flowConfig.tsx @@ -9,7 +9,8 @@ import { useTaskFlowAddStepMutation, useTaskFlowRemoveStepMutation, } from "generated/graphql"; -import {Col, Label, Input, Button} from "reactstrap"; +import {Col, Label, Button} from "reactstrap"; +import DebouncedInput from "./DebouncedInput"; import SortableList from "helpers/SortableList"; const FlowConfig = () => { @@ -39,22 +40,22 @@ const FlowConfig = () => {

Flow Config