import { classById, type GenderId, type RaceId } from "../app/characterCatalog"; import { useGameStore } from "../game/store"; import type { GameMode, GameOverlay } from "../game/store"; import { 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 type { AbilityTarget } from "../game/abilityCatalog"; import type { DungeonId } from "../game/dungeonCatalog"; import { useManastormStore } from "../game/manastormStore"; import { resolveStagePresentation } from "../game/manastormStagePresentation"; 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 { 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 { 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" }; 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 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 abilities = ACTION_CONTROLS.flatMap((control): CompanionAbilitySnapshot[] => { const abilityId = combat.actionBindings[actionBindingId(game.actionLayer, control)]; const ability = abilityId ? combat.abilities.find((candidate) => candidate.id === abilityId) ?? null : null; if (!ability) return []; 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: ability.cost.amount, resourceLabel: combat.resourceName, 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 || ability.cost.amount > combat.resource, target: ability.target, }]; }); return { phase: shell.phase, session: shell.session, characters: shell.characters, selectedCharacterId: shell.selectedCharacterId, activeCharacter: shell.activeCharacter, draft: shell.draft, notice: shell.notice, game: { 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 "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; } }