import type { AppScreen } from "../frontend/types"; import type { FrontendSnapshot } from "../frontend/store"; import { useFrontendStore } from "../frontend/store"; import { emitControllerToken, setExternalControllerMovement, type ControllerMovement, type ControllerTokenEvent } from "../input/controller"; import { type GameState, useGameStore } from "../game/store"; import type { AbilityId, BottomTab, MemberId, RunBuffId } from "../game/types"; import type { BossId, HealerClassId } from "../game/types"; import type { GameModeId, GameSettings, SaveSlotId } from "../frontend/types"; import type { GearOwnerId, GearSlotId } from "../game/progression/gear"; import type { DifficultySlug } from "../game/progression/loot"; const CHANNEL_NAME = "i-want-to-heal:thor-dual-screen:v1"; export type GameCommand = | { name: "startEncounter" } | { name: "restart" } | { name: "castAbility"; abilityId: AbilityId } | { name: "selectMember"; memberId: MemberId } | { name: "cycleMember"; direction: 1 | -1 } | { name: "setActiveTab"; tab: BottomTab } | { name: "selectItem"; itemId: string } | { name: "setPaused"; paused: boolean } | { name: "setPauseSelection"; selection: "resume" | "exit" } | { name: "setSelectedRunBuff"; buffId: RunBuffId } | { name: "chooseRunBuff"; buffId: RunBuffId } | { name: "continueRoguelikeRound" } | { name: "startRogueTrialsEndless" } | { name: "setEndlessChoiceSelection"; selection: "continue" | "quit" }; export type FrontendCommand = | { name: "signIn"; username: string; password: string } | { name: "createAccount"; username: string; password: string } | { name: "continueOffline" } | { name: "signOut" } | { name: "navigate"; screen: AppScreen } | { name: "selectSlot"; slotId: SaveSlotId } | { name: "createSlot"; slotId: SaveSlotId; hunterName: string } | { name: "playSlot"; slotId: SaveSlotId } | { name: "deleteSlot"; slotId: SaveSlotId } | { name: "copySlot"; sourceId: SaveSlotId; targetId: SaveSlotId } | { name: "uploadSlot"; slotId: SaveSlotId } | { name: "downloadSlot"; slotId: SaveSlotId } | { name: "selectMode"; mode: GameModeId } | { name: "selectBoss"; bossId: BossId } | { name: "selectDifficulty"; difficultySlug: DifficultySlug } | { name: "selectGearOwner"; ownerId: GearOwnerId } | { name: "selectGearSlot"; slotId: GearSlotId } | { name: "selectGearWorkshopMode"; mode: "upgrade" | "infusion" } | { name: "selectInfusion"; infusionId: string } | { name: "selectPassiveAbility"; abilityId: AbilityId } | { name: "selectPassiveInfusion"; passiveId: RunBuffId } | { name: "upgradeSelectedGear" } | { name: "equipSelectedInfusion" } | { name: "equipPassiveInfusion"; passiveId: RunBuffId } | { name: "selectHealerClass"; classId: HealerClassId } | { name: "updateSetting"; key: keyof GameSettings; value: GameSettings[keyof GameSettings] } | { name: "exitGame" } | { name: "launchGame"; bossIds: readonly BossId[]; difficultySlug?: DifficultySlug }; export const DUAL_SCREEN_LAUNCH_EVENT = "iwt:dual-screen-launch-game"; export const DUAL_SCREEN_EXIT_EVENT = "iwt:dual-screen-exit-game"; export type DualScreenMessage = | { type: "app-state"; screen: AppScreen; hunterName: string | null; notice: string; frontend?: FrontendSnapshot; game?: Partial } | { type: "controller-token"; id: string; event: ControllerTokenEvent } | { type: "controller-echo"; id: string; event: ControllerTokenEvent } | { type: "controller-motion"; movement: ControllerMovement } | { type: "game-command"; command: GameCommand } | { type: "frontend-command"; command: FrontendCommand } | { type: "authoritative-ready" } | { type: "companion-ready" } | { type: "companion-closing" }; export function createDualScreenChannel() { return typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(CHANNEL_NAME); } export function executeGameCommand(command: GameCommand) { const game = useGameStore.getState(); switch (command.name) { case "startEncounter": game.startEncounter(); break; case "restart": game.restart(); break; case "castAbility": game.castAbility(command.abilityId); break; case "selectMember": game.selectMember(command.memberId); break; case "cycleMember": game.cycleMember(command.direction); break; case "setActiveTab": game.setActiveTab(command.tab); break; case "selectItem": game.selectItem(command.itemId); break; case "setPaused": game.setPaused(command.paused); break; case "setPauseSelection": game.setPauseSelection(command.selection); break; case "setSelectedRunBuff": game.setSelectedRunBuff(command.buffId); break; case "chooseRunBuff": game.chooseRunBuff(command.buffId); break; case "continueRoguelikeRound": game.continueRoguelikeRound(); break; case "startRogueTrialsEndless": game.startRogueTrialsEndless(); break; case "setEndlessChoiceSelection": game.setEndlessChoiceSelection(command.selection); break; } } export function executeFrontendCommand(command: FrontendCommand) { const frontend = useFrontendStore.getState(); switch (command.name) { case "signIn": void frontend.signIn(command.username, command.password); break; case "createAccount": void frontend.createAccount(command.username, command.password); break; case "continueOffline": frontend.continueOffline(); break; case "signOut": frontend.signOut(); break; case "navigate": frontend.navigate(command.screen); break; case "selectSlot": frontend.selectSlot(command.slotId); break; case "createSlot": frontend.createSlot(command.slotId, command.hunterName); break; case "playSlot": frontend.playSlot(command.slotId); break; case "deleteSlot": frontend.deleteSlot(command.slotId); break; case "copySlot": frontend.copySlot(command.sourceId, command.targetId); break; case "uploadSlot": frontend.uploadSlot(command.slotId); break; case "downloadSlot": frontend.downloadSlot(command.slotId); break; case "selectMode": frontend.selectMode(command.mode); break; case "selectBoss": frontend.selectBoss(command.bossId); break; case "selectDifficulty": frontend.selectDifficulty(command.difficultySlug); break; case "selectGearOwner": frontend.selectGearOwner(command.ownerId); break; case "selectGearSlot": frontend.selectGearSlot(command.slotId); break; case "selectGearWorkshopMode": frontend.selectGearWorkshopMode(command.mode); break; case "selectInfusion": frontend.selectInfusion(command.infusionId); break; case "selectPassiveAbility": frontend.selectPassiveAbility(command.abilityId); break; case "selectPassiveInfusion": frontend.selectPassiveInfusion(command.passiveId); break; case "upgradeSelectedGear": frontend.upgradeSelectedGear(); break; case "equipSelectedInfusion": frontend.equipSelectedInfusion(); break; case "equipPassiveInfusion": frontend.equipPassiveInfusion(command.passiveId); break; case "selectHealerClass": frontend.selectHealerClass(command.classId); break; case "updateSetting": frontend.updateSetting(command.key, command.value); break; case "exitGame": window.dispatchEvent(new Event(DUAL_SCREEN_EXIT_EVENT)); break; case "launchGame": window.dispatchEvent(new CustomEvent(DUAL_SCREEN_LAUNCH_EVENT, { detail: { bossIds: command.bossIds, difficultySlug: command.difficultySlug } })); break; } } export function receiveAuthoritativeMessage(message: DualScreenMessage) { if (message.type === "controller-token") emitControllerToken(message.event); if (message.type === "controller-motion") setExternalControllerMovement(message.movement); if (message.type === "game-command") executeGameCommand(message.command); if (message.type === "frontend-command") executeFrontendCommand(message.command); } /** State the lower display actually renders. Renderer-only and progression data stay local to the authoritative screen. */ export type BottomGameSnapshot = Pick; const BOTTOM_GAME_SNAPSHOT_KEYS: readonly (keyof BottomGameSnapshot)[] = [ "bossId", "bossInstanceId", "paused", "healerClassId", "phase", "round", "endlessMode", "endlessBossKills", "endlessChoiceSelection", "runModifiers", "time", "party", "boss", "additionalBosses", "partyPositions", "bossMotion", "partyCombat", "mana", "maxMana", "selectedMemberId", "cooldowns", "globalCooldownUntil", "activeTab", "selectedItemId", "inventory", "playerPosition", "activeCast", "barrier", ]; function structurallyEqual(left: unknown, right: unknown): boolean { if (Object.is(left, right)) return true; if (!left || !right || typeof left !== "object" || typeof right !== "object") return false; if (Array.isArray(left) || Array.isArray(right)) { if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false; for (let index = 0; index < left.length; index += 1) { if (!structurallyEqual(left[index], right[index])) return false; } return true; } const leftRecord = left as Record; const rightRecord = right as Record; const keys = Object.keys(leftRecord); if (keys.length !== Object.keys(rightRecord).length) return false; return keys.every((key) => Object.prototype.hasOwnProperty.call(rightRecord, key) && structurallyEqual(leftRecord[key], rightRecord[key])); } export function currentBottomGameSnapshot(): BottomGameSnapshot { const state = useGameStore.getState(); return { bossId: state.bossId, bossInstanceId: state.bossInstanceId, paused: state.paused, healerClassId: state.healerClassId, phase: state.phase, round: state.round, endlessMode: state.endlessMode, endlessBossKills: state.endlessBossKills, endlessChoiceSelection: state.endlessChoiceSelection, runModifiers: state.runModifiers, time: state.time, party: state.party, boss: state.boss, additionalBosses: state.additionalBosses, partyPositions: state.partyPositions, bossMotion: state.bossMotion, partyCombat: state.partyCombat, mana: state.mana, maxMana: state.maxMana, selectedMemberId: state.selectedMemberId, cooldowns: state.cooldowns, globalCooldownUntil: state.globalCooldownUntil, activeTab: state.activeTab, selectedItemId: state.selectedItemId, inventory: state.inventory, playerPosition: state.playerPosition, activeCast: state.activeCast, barrier: state.barrier, }; } export function diffBottomGameSnapshot( previous: BottomGameSnapshot | undefined, next: BottomGameSnapshot, ): Partial { if (!previous) return next; const changes: Partial = {}; for (const key of BOTTOM_GAME_SNAPSHOT_KEYS) { if (!structurallyEqual(previous[key], next[key])) Object.assign(changes, { [key]: next[key] }); } return changes; }