import { getInputPreferences } from "../input/keybindRuntime"; import type { InputPreferences } from "../input/keybinds"; import { classById, type GenderId, type RaceId } from "../app/characterCatalog"; import { useGameStore } from "../game/store"; import type { GameMode, GameOverlay } from "../game/store"; import { resolvedAbilityCosts, useCombatStore } from "../game/combatStore"; import { activePartyBossObjectives } from "../game/partyRuntime"; import { requireDungeonDefinition } from "../game/dungeonRegistry"; import { usePartyStore, type PartyCommand, type PartyMemberStatus, type PartyRole, } from "../game/partyStore"; import { useShellStore } from "../app/shellStore"; import type { ControllerMovement, ControllerTokenEvent } from "../input/controller"; import type { CharacterDraft, CharacterProfile, PlayerSession, ShellPhase } from "../app/types"; import { requestDisplaySurface } from "./displayRouting"; import { ACTION_CONTROLS, actionBindingId, type ActionBindingControl, type ActionBindingLayer, } from "../game/actionBindings"; import { abilityAtLevel, learnedAbilityRank, resourceProfileForClass, type AbilityTarget } from "../game/abilityCatalog"; import type { DungeonId } from "../game/dungeonCatalog"; import { useManastormStore } from "../game/manastormStore"; import { resolveStagePresentation } from "../game/manastormStagePresentation"; import { currentDpsMeterSnapshot } from "../game/dpsMeterStore"; import type { DpsMeterSnapshot } from "../game/dpsMeter"; import { activeUiLayout, useUiLayoutStore } from "../game/uiLayoutStore"; import { normalizeHudPosition } from "../game/hudLayout"; import { resolveDpsMeterDisplay, type HudElementLayout } from "../game/uiLayout"; import { currentDisplayLayout } from "./displayLayout"; import { currentHudSnapshot, type HudSnapshot } from "./hudSnapshot"; import { HUD_ELEMENTS, type HudElementId } from "../game/uiLayout"; import { interactWithActiveDungeonObject } from "../game/dungeonWorldInteraction"; import { resurrectDeadPartyMembers } from "../game/partyResurrection"; export { createDualScreenChannel } from "./dualScreenChannel"; export interface CompanionPlayerSnapshot { level: number; experience: number; experienceToNext: number; health: number; maxHealth: number; shield: number; resource: number; maxResource: number; resourceName: string; dead: boolean; } export interface CompanionTargetSnapshot { id: string; name: string; boss: boolean; level: number; health: number; maxHealth: number; dead: boolean; } export interface CompanionAbilitySnapshot { control: ActionBindingControl; id: string; name: string; shortLabel: string; description: string; icon: string; color: string; resourceCost: number; resourceLabel: string; cooldownRemaining: number; cooldownDuration: number; disabled: boolean; lockedUntilLevel?: number; target: AbilityTarget; } export interface CompanionPartyMemberSnapshot { id: string; name: string; classId: import("../app/characterCatalog").ClassId; raceId: RaceId; gender: GenderId; specialization: string; role: PartyRole; level: number; health: number; maxHealth: number; status: PartyMemberStatus; } export interface CompanionPartyObjectiveSnapshot { id: string; name: string; } export interface CompanionGameSnapshot { hud: HudSnapshot; dpsMeter: { values: DpsMeterSnapshot; layout: HudElementLayout; display: "main" | "companion"; editing: boolean; }; gameMode: GameMode; dungeonId: string; dungeonTitle: string; areaName: string; playerPosition: readonly [number, number, number]; distanceTravelled: number; cameraYaw: number; overlay: GameOverlay; paused: boolean; mapOpen: boolean; companionModal: boolean; inputMode: "keyboard" | "gamepad"; actionLayer: ActionBindingLayer; assetStatus: string; player: CompanionPlayerSnapshot; selectedTarget: CompanionTargetSnapshot | null; activeCast: { name: string; mode: "cast" | "channel"; remaining: number; duration: number; } | null; abilities: CompanionAbilitySnapshot[]; party: { members: CompanionPartyMemberSnapshot[]; selectedMemberId: string | null; command: PartyCommand; routeBlocked: boolean; objective: CompanionPartyObjectiveSnapshot | null; }; feedback: string | null; } export interface CompanionSnapshot { keybinds?: InputPreferences; phase: ShellPhase; session: PlayerSession | null; characters: CharacterProfile[]; selectedCharacterId: string | null; activeCharacter: CharacterProfile | null; draft: CharacterDraft; notice: string; game: CompanionGameSnapshot; } export type CompanionCommand = | { name: "selectCharacter"; characterId: string } | { name: "openCreator" } | { name: "cancelCreator" } | { name: "openMainMenu" } | { name: "openDungeons" } | { name: "openManastorms" } | { name: "returnToMainMenu" } | { name: "abandonManastormRun" } | { name: "enterDungeon"; dungeonId: DungeonId; playerRole?: PartyRole } | { name: "enterWorld" } | { name: "returnToCharacters" } | { name: "signOut" } | { name: "randomizeDraft" } | { name: "toggleMap" } | { name: "togglePause" } | { name: "openOverlay"; overlay: Exclude } | { name: "closeOverlay" } | { name: "castActionBinding"; layer: ActionBindingLayer; control: ActionBindingControl } | { name: "cycleTarget"; direction: 1 | -1 } | { name: "clearTarget" } | { name: "selectPartyMember"; memberId: string } | { name: "cyclePartyMember"; direction: 1 | -1 } | { name: "setPartyCommand"; command: PartyCommand } | { name: "recallParty" } | { name: "resetAtEntrance" } | { name: "editDpsMeter"; update: Partial } | { name: "editHudElement"; id: HudElementId; update: Partial } | { name: "selectFriendlyActor"; id: string | null } | { name: "selectPlayer" } | { name: "clearPartySelection" } | { name: "resurrectParty"; checkpointId: string } | { name: "interactDungeonObject" } | { name: "finishDungeon" } | { name: "moveThreatMeter"; position: { x: number; y: number } }; export type DualScreenMessage = | { type: "companion-ready" } | { type: "companion-closing" } | { type: "authoritative-ready" } | { type: "app-state"; snapshot: CompanionSnapshot } | { type: "command"; command: CompanionCommand } | { type: "controller-token"; event: ControllerTokenEvent } | { type: "controller-motion"; movement: ControllerMovement }; function currentClock(): number { return Date.now(); } export function currentCompanionSnapshot(): CompanionSnapshot { const shell = useShellStore.getState(); const game = useGameStore.getState(); const combat = useCombatStore.getState(); const party = usePartyStore.getState(); const manastorm = useManastormStore.getState(); const uiLayout = activeUiLayout(); const dungeon = requireDungeonDefinition(game.activeDungeonId); const stagePresentation = resolveStagePresentation(game.gameMode, manastorm.currentEncounter, { title: dungeon.title, mapId: dungeon.mapId, areaName: game.areaName, }); const partyObjective = party.objectiveBossId ? activePartyBossObjectives().find((objective) => objective.id === party.objectiveBossId) ?? null : null; const now = currentClock(); const selectedTarget = combat.selectedTargetId ? combat.mobs[combat.selectedTargetId] ?? null : null; const globalCooldownRemaining = Math.max(0, combat.globalCooldownEndsAt - now); const classColor = classById(combat.classId).color; const activeResourceType = resourceProfileForClass(combat.classId).type; const abilities = ACTION_CONTROLS.flatMap((control): CompanionAbilitySnapshot[] => { const abilityId = combat.actionBindings[actionBindingId(game.actionLayer, control)]; const catalogAbility = abilityId ? combat.abilities.find((candidate) => candidate.id === abilityId) ?? null : null; if (!catalogAbility) return []; const learnedTalentRank = learnedAbilityRank(catalogAbility, combat.talentRanks); const ability = abilityAtLevel(catalogAbility, combat.level, learnedTalentRank); const costs = resolvedAbilityCosts(ability, combat.talentModifiers, combat); const displayCost = costs[0] ?? { resource: ability.cost.resource, amount: 0 }; const cannotPay = costs.some((cost) => cost.resource === "health" ? combat.health <= cost.amount : (cost.resource === activeResourceType ? combat.resource : combat.resourcePools[cost.resource] ?? 0) < cost.amount); const personalCooldownRemaining = Math.max(0, (combat.cooldowns[ability.id] ?? 0) - now); const remainingMs = Math.max(personalCooldownRemaining, globalCooldownRemaining); const durationMs = personalCooldownRemaining >= globalCooldownRemaining ? ability.cooldownMs : ability.gcdMs; return [{ control, id: ability.id, name: ability.name, shortLabel: ability.sigil, description: ability.description, icon: ability.icon, color: classColor, resourceCost: displayCost.amount, resourceLabel: displayCost.resource === activeResourceType ? combat.resourceName : displayCost.resource.replace("-", " "), cooldownRemaining: remainingMs / 1_000, cooldownDuration: durationMs / 1_000, lockedUntilLevel: combat.level < ability.unlockLevel ? ability.unlockLevel : undefined, disabled: game.overlay !== null || game.companionOpen || combat.level < ability.unlockLevel || combat.health <= 0 || combat.activeCast !== null || remainingMs > 0 || cannotPay, target: ability.target, }]; }); return { keybinds: getInputPreferences(), phase: shell.phase, session: shell.session, characters: shell.characters, selectedCharacterId: shell.selectedCharacterId, activeCharacter: shell.activeCharacter, draft: shell.draft, notice: shell.notice, game: { hud: currentHudSnapshot(), dpsMeter: { values: currentDpsMeterSnapshot(), layout: uiLayout.elements.dps, display: resolveDpsMeterDisplay(uiLayout, typeof window !== "undefined" && currentDisplayLayout() !== "single"), editing: game.overlay === "ui-layout", }, gameMode: game.gameMode, dungeonId: dungeon.id, dungeonTitle: stagePresentation.title, areaName: stagePresentation.areaName, playerPosition: game.playerPosition, distanceTravelled: game.distanceTravelled, cameraYaw: game.cameraYaw, overlay: game.overlay, paused: game.paused, mapOpen: game.mapOpen, companionModal: game.companionOpen, inputMode: game.inputMode, actionLayer: game.actionLayer, assetStatus: game.assetStatus, player: { level: combat.level, experience: combat.xp, experienceToNext: combat.xpToNext, health: combat.health, maxHealth: combat.maxHealth, shield: combat.shield, resource: combat.resource, maxResource: combat.maxResource, resourceName: combat.resourceName, dead: combat.health <= 0, }, selectedTarget: selectedTarget ? { id: selectedTarget.id, name: selectedTarget.name, boss: selectedTarget.boss, level: selectedTarget.level, health: selectedTarget.health, maxHealth: selectedTarget.maxHealth, dead: selectedTarget.dead, } : null, activeCast: combat.activeCast ? { name: combat.abilities.find((ability) => ability.id === combat.activeCast!.abilityId)?.name ?? "Spell", mode: combat.activeCast.mode, remaining: Math.max(0, combat.activeCast.completesAt - now) / 1_000, duration: Math.max(0.001, combat.activeCast.completesAt - combat.activeCast.startedAt) / 1_000, } : null, abilities, party: { members: party.members.map((member) => ({ id: member.id, name: member.name, classId: member.classId, raceId: member.raceId, gender: member.gender, specialization: member.specialization, role: member.role, level: member.level, health: member.health, maxHealth: member.maxHealth, status: member.status, })), selectedMemberId: party.selectedMemberId, command: party.command, routeBlocked: party.routeBlocked, objective: partyObjective ? { id: partyObjective.id, name: partyObjective.name } : null, }, feedback: combat.feedback?.message ?? null, }, }; } export function executeCompanionCommand(command: CompanionCommand): void { const shell = useShellStore.getState(); const game = useGameStore.getState(); const combat = useCombatStore.getState(); switch (command.name) { case "editHudElement": { if (game.overlay !== "ui-layout" || !useUiLayoutStore.getState().draft || !(command.id in HUD_ELEMENTS)) break; const update: Partial = {}; if (command.update.position) update.position = normalizeHudPosition(command.update.position, { x: .5, y: .5 }); if (typeof command.update.width === "number" && Number.isFinite(command.update.width)) update.width = Math.max(20, Math.min(2000, command.update.width)); useUiLayoutStore.getState().updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, [command.id]: { ...layout.elements[command.id], ...update }, } })); useUiLayoutStore.setState({ selectedElement: command.id }); break; } case "selectFriendlyActor": combat.selectFriendlyActor(command.id); break; case "selectPlayer": combat.clearTarget(); usePartyStore.getState().selectSelf(); break; case "clearPartySelection": usePartyStore.getState().clearSelection(); break; case "resurrectParty": resurrectDeadPartyMembers(command.checkpointId); break; case "interactDungeonObject": if (!game.overlay) interactWithActiveDungeonObject(); break; case "finishDungeon": if (currentHudSnapshot().completion) { shell.saveActiveDungeonRunCheckpoint(null); shell.returnToMainMenu(); } break; case "moveThreatMeter": combat.updateSettings({ threatMeterPosition: normalizeHudPosition(command.position, { x: .8, y: .3 }) }); break; case "editDpsMeter": { if (game.overlay !== "ui-layout" || !useUiLayoutStore.getState().draft) break; const update: Partial = {}; if (command.update.position) update.position = normalizeHudPosition(command.update.position, { x: .5, y: .5 }); if (typeof command.update.width === "number" && Number.isFinite(command.update.width)) update.width = Math.round(Math.max(160, Math.min(600, command.update.width))); useUiLayoutStore.getState().updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, dps: { ...layout.elements.dps, ...update }, } })); useUiLayoutStore.setState({ selectedElement: "dps" }); break; } case "selectCharacter": shell.selectCharacter(command.characterId); break; case "openCreator": shell.openCreator(); break; case "cancelCreator": shell.cancelCreator(); break; case "openMainMenu": shell.openMainMenu(); break; case "openDungeons": shell.openDungeons(); break; case "openManastorms": shell.openManastorms(); break; case "returnToMainMenu": shell.returnToMainMenu(); break; case "abandonManastormRun": shell.abandonManastormRun(); break; case "enterDungeon": shell.enterDungeon(command.dungeonId, command.playerRole); break; case "enterWorld": shell.enterWorld(); break; case "returnToCharacters": shell.returnToCharacters(); requestDisplaySurface("top"); break; case "signOut": shell.signOut(); break; case "randomizeDraft": shell.randomizeDraft(); break; case "toggleMap": game.toggleMap(); break; case "togglePause": game.togglePause(); break; case "openOverlay": game.openOverlay(command.overlay); requestDisplaySurface("top"); break; case "closeOverlay": game.closeOverlay(); break; case "castActionBinding": if (game.overlay === null && !game.companionOpen) combat.castActionBinding(command.layer, command.control, game.playerPosition, currentClock()); break; case "cycleTarget": usePartyStore.getState().clearSelection(); combat.cycleTarget(command.direction, game.playerPosition); break; case "clearTarget": combat.clearTarget(); usePartyStore.getState().clearSelection(); break; case "selectPartyMember": combat.clearTarget(); usePartyStore.getState().selectMember(command.memberId); break; case "cyclePartyMember": combat.clearTarget(); usePartyStore.getState().cycleMember(command.direction); break; case "setPartyCommand": usePartyStore.getState().setCommand(command.command); break; case "recallParty": usePartyStore.getState().recallParty(); break; case "resetAtEntrance": if (game.gameMode === "dungeon") game.resetAtEntrance(); requestDisplaySurface("top"); break; } }