import type { GameWebSocket, ServerMessage } from '../network/websocket'; import type { GameEngine } from './engine'; import type { HeroMovePayload, PositionSyncPayload, RouteAssignedPayload, CombatStartPayload, AttackPayload, EnemyRegenPayload, CombatEndPayload, HeroDiedPayload, HeroRevivedPayload, BuffAppliedPayload, TownEnterPayload, TownNPCVisitPayload, TownTourPhasePayload, TownTourServiceEndPayload, AdventureLogLinePayload, NPCEncounterPayload, NPCEncounterEndPayload, LevelUpPayload, EquipmentChangePayload, PotionCollectedPayload, QuestProgressPayload, QuestCompletePayload, QuestAvailablePayload, ServerErrorPayload, EnemyState, LootDrop, LootBonusItemSlot, MerchantLootPayload, DebuffAppliedPayload, HeroMeetStartPayload, HeroMeetLinePayload, HeroMeetEndPayload, NearbyHeroesPayload, NearbyHeroMovePayload, } from './types'; import { DebuffType, Rarity } from './types'; // ---- Callback types for UI layer (App.tsx) ---- export interface WSHandlerCallbacks { /** Fires after combat_start is applied (clear transient combat UI). */ onCombatStart?: () => void; onCombatAttack?: (payload: AttackPayload) => void; onEnemyRegen?: (payload: EnemyRegenPayload) => void; onCombatEnd?: (payload: CombatEndPayload) => void; onHeroDied?: (payload: HeroDiedPayload) => void; onHeroRevived?: (payload: HeroRevivedPayload) => void; onBuffApplied?: (payload: BuffAppliedPayload) => void; onTownEnter?: (payload: TownEnterPayload) => void; onTownNPCVisit?: (payload: TownNPCVisitPayload) => void; onTownTourPhase?: (payload: TownTourPhasePayload) => void; onTownTourServiceEnd?: (payload: TownTourServiceEndPayload) => void; onAdventureLogLine?: (payload: AdventureLogLinePayload) => void; onTownExit?: () => void; onNPCEncounter?: (payload: NPCEncounterPayload) => void; onNPCEncounterEnd?: (payload: NPCEncounterEndPayload) => void; onLevelUp?: (payload: LevelUpPayload) => void; onEquipmentChange?: (payload: EquipmentChangePayload) => void; onPotionCollected?: (payload: PotionCollectedPayload) => void; onQuestProgress?: (payload: QuestProgressPayload) => void; onQuestComplete?: (payload: QuestCompletePayload) => void; onQuestAvailable?: (payload: QuestAvailablePayload) => void; onError?: (payload: ServerErrorPayload) => void; onHeroStateReceived?: (hero: Record) => void; onMerchantLoot?: (payload: MerchantLootPayload) => void; onHeroMeetStart?: (payload: HeroMeetStartPayload) => void; onHeroMeetLine?: (payload: HeroMeetLinePayload) => void; onHeroMeetEnd?: (payload: HeroMeetEndPayload) => void; } /** * Bridges WebSocket messages to engine state updates and UI callbacks. * * This is a stateless dispatcher: it parses typed envelopes from the * server and calls the appropriate engine method or UI callback. * No game logic lives here -- just routing. */ export function wireWSHandler( ws: GameWebSocket, engine: GameEngine, callbacks: WSHandlerCallbacks, ): void { const isDebuffType = (value: string): value is DebuffType => ( Object.values(DebuffType).includes(value as DebuffType) ); // ---- Server -> Client: Movement ---- ws.on('hero_move', (msg: ServerMessage) => { const p = msg.payload as HeroMovePayload; engine.applyHeroMove(p.x, p.y, p.targetX, p.targetY, p.speed); }); ws.on('position_sync', (msg: ServerMessage) => { const p = msg.payload as PositionSyncPayload; engine.applyPositionSync(p.x, p.y, p.state); }); ws.on('route_assigned', (msg: ServerMessage) => { const p = msg.payload as RouteAssignedPayload; engine.applyRouteAssigned(p.waypoints, p.speed); }); // ---- Server -> Client: Hero state snapshot ---- ws.on('hero_state', (msg: ServerMessage) => { const payload = msg.payload as Record; callbacks.onHeroStateReceived?.(payload); }); // ---- Server -> Client: Combat ---- ws.on('combat_start', (msg: ServerMessage) => { const p = msg.payload as CombatStartPayload; const slug = typeof p.enemy.type === 'string' && p.enemy.type !== '' ? p.enemy.type : 'unknown'; const enemy: EnemyState = { id: Date.now(), name: p.enemy.name, level: p.enemy.level, hp: p.enemy.hp, maxHp: p.enemy.maxHp, position: { x: 0, y: 0 }, // engine will position relative to hero attackSpeed: p.enemy.speed, damage: p.enemy.attack, defense: p.enemy.defense, enemySlug: slug, enemyArchetype: p.enemy.archetype, enemyBiome: p.enemy.biome, }; engine.applyCombatStart(enemy); callbacks.onCombatStart?.(); }); ws.on('attack', (msg: ServerMessage) => { const p = msg.payload as AttackPayload; engine.applyAttack( p.source, p.damage, Boolean(p.isCrit), p.heroHp, p.enemyHp, p.outcome, ); callbacks.onCombatAttack?.(p); }); ws.on('enemy_regen', (msg: ServerMessage) => { const p = msg.payload as EnemyRegenPayload; engine.applyEnemyRegen(p.amount, p.enemyHp); callbacks.onEnemyRegen?.(p); }); ws.on('debuff_applied', (msg: ServerMessage) => { const p = msg.payload as DebuffAppliedPayload; if (!p?.debuffType || !isDebuffType(p.debuffType)) return; const nowMs = Date.now(); const fallbackMs = engine.getDebuffDurationMs(p.debuffType as DebuffType) ?? 0; const durationMs = Number.isFinite(p.durationMs) ? Math.max(0, p.durationMs as number) : fallbackMs; const expiresAtMs = p.expiresAt ? Date.parse(p.expiresAt) : nowMs + durationMs; engine.applyDebuffApplied(p.debuffType, durationMs, expiresAtMs); }); ws.on('combat_end', (msg: ServerMessage) => { const p = msg.payload as CombatEndPayload; engine.applyCombatEnd(); callbacks.onCombatEnd?.(p); }); ws.on('merchant_loot', (msg: ServerMessage) => { const p = msg.payload as MerchantLootPayload; callbacks.onMerchantLoot?.(p); }); // ---- Server -> Client: Death / Revive ---- ws.on('hero_died', (msg: ServerMessage) => { const p = msg.payload as HeroDiedPayload; engine.applyHeroDied(); callbacks.onHeroDied?.(p); }); ws.on('hero_revived', (msg: ServerMessage) => { const p = msg.payload as HeroRevivedPayload; engine.applyHeroRevived(p.hp); callbacks.onHeroRevived?.(p); }); // ---- Server -> Client: Buffs ---- ws.on('buff_applied', (msg: ServerMessage) => { const p = msg.payload as BuffAppliedPayload; callbacks.onBuffApplied?.(p); }); ws.on('nearby_heroes', (msg: ServerMessage) => { const p = msg.payload as NearbyHeroesPayload; const heroes = Array.isArray(p?.heroes) ? p.heroes : []; const sanitized = heroes.map((h) => ({ ...h, modelVariant: Number.isFinite(h.modelVariant) ? h.modelVariant : 0, positionX: Number.isFinite(h.positionX) ? h.positionX : 0, positionY: Number.isFinite(h.positionY) ? h.positionY : 0, })); engine.setNearbyHeroes(sanitized); }); ws.on('nearby_hero_move', (msg: ServerMessage) => { const p = msg.payload as NearbyHeroMovePayload; if (!p || !Number.isFinite(p.heroId)) return; engine.applyNearbyHeroMove(p.heroId, p.x, p.y); }); // ---- Server -> Client: Town ---- ws.on('town_enter', (msg: ServerMessage) => { const p = msg.payload as TownEnterPayload; engine.applyTownEnter(p.townId, p.buildings as any); callbacks.onTownEnter?.(p); }); ws.on('town_exit', () => { engine.applyTownExit(); callbacks.onTownExit?.(); }); ws.on('town_npc_visit', (msg: ServerMessage) => { const p = msg.payload as TownNPCVisitPayload; engine.applyTownNPCVisit(p.name, p.type); callbacks.onTownNPCVisit?.(p); }); ws.on('town_tour_phase', (msg: ServerMessage) => { const p = msg.payload as TownTourPhasePayload; callbacks.onTownTourPhase?.(p); }); ws.on('town_tour_service_end', (msg: ServerMessage) => { const p = msg.payload as TownTourServiceEndPayload; callbacks.onTownTourServiceEnd?.(p); }); ws.on('adventure_log_line', (msg: ServerMessage) => { const p = msg.payload as AdventureLogLinePayload; callbacks.onAdventureLogLine?.(p); }); // ---- Server -> Client: NPC Encounter ---- ws.on('npc_encounter', (msg: ServerMessage) => { const p = msg.payload as NPCEncounterPayload; callbacks.onNPCEncounter?.(p); }); ws.on('npc_encounter_end', (msg: ServerMessage) => { const p = msg.payload as NPCEncounterEndPayload; callbacks.onNPCEncounterEnd?.(p); }); // ---- Server -> Client: Progression ---- ws.on('level_up', (msg: ServerMessage) => { const p = msg.payload as LevelUpPayload; callbacks.onLevelUp?.(p); }); ws.on('equipment_change', (msg: ServerMessage) => { const p = msg.payload as EquipmentChangePayload; callbacks.onEquipmentChange?.(p); }); ws.on('potion_collected', (msg: ServerMessage) => { const p = msg.payload as PotionCollectedPayload; callbacks.onPotionCollected?.(p); }); // ---- Server -> Client: Quests ---- ws.on('quest_progress', (msg: ServerMessage) => { const p = msg.payload as QuestProgressPayload; callbacks.onQuestProgress?.(p); }); ws.on('quest_complete', (msg: ServerMessage) => { const p = msg.payload as QuestCompletePayload; callbacks.onQuestComplete?.(p); }); ws.on('quest_available', (msg: ServerMessage) => { const p = msg.payload as QuestAvailablePayload; callbacks.onQuestAvailable?.(p); }); // ---- Server -> Client: Error ---- ws.on('error', (msg: ServerMessage) => { const p = msg.payload as ServerErrorPayload; console.warn('[WS] Server error:', p.code, p.message); callbacks.onError?.(p); }); // ---- Server -> Client: Hero meet ---- ws.on('hero_meet_start', (msg: ServerMessage) => { const p = msg.payload as HeroMeetStartPayload; callbacks.onHeroMeetStart?.(p); }); ws.on('hero_meet_line', (msg: ServerMessage) => { const p = msg.payload as HeroMeetLinePayload; callbacks.onHeroMeetLine?.(p); }); ws.on('hero_meet_end', (msg: ServerMessage) => { const p = msg.payload as HeroMeetEndPayload; callbacks.onHeroMeetEnd?.(p); }); } // ---- Client -> Server command helpers ---- export function sendActivateBuff(ws: GameWebSocket, buffType: string): void { ws.send('activate_buff', { buffType }); } export function sendUsePotion(ws: GameWebSocket): void { ws.send('use_potion', {}); } export function sendRevive(ws: GameWebSocket): void { ws.send('revive', {}); } export function sendAcceptQuest(ws: GameWebSocket, questId: number): void { ws.send('accept_quest', { questId }); } export function sendClaimQuest(ws: GameWebSocket, questId: number): void { ws.send('claim_quest', { questId }); } export function sendNPCInteract(ws: GameWebSocket, npcId: number): void { ws.send('npc_interact', { npcId }); } export function sendNPCAlmsAccept(ws: GameWebSocket): void { ws.send('npc_alms_accept', {}); } export function sendNPCAlmsDecline(ws: GameWebSocket): void { ws.send('npc_alms_decline', {}); } export function sendTownTourNPCDialogClosed(ws: GameWebSocket): void { ws.send('town_tour_npc_dialog_closed', {}); } export function sendTownTourNPCInteractionOpened(ws: GameWebSocket): void { ws.send('town_tour_npc_interaction_opened', {}); } export function sendTownTourNPCInteractionClosed(ws: GameWebSocket): void { ws.send('town_tour_npc_interaction_closed', {}); } export function sendHeroMeetMessage(ws: GameWebSocket, text: string): void { ws.send('hero_meet_send_message', { text }); } export function sendHeroMeetEndConversation(ws: GameWebSocket): void { ws.send('hero_meet_end_conversation', {}); } export function sendRequestNearbyHeroes(ws: GameWebSocket, radius?: number): void { if (radius && Number.isFinite(radius)) { ws.send('request_nearby_heroes', { radius }); return; } ws.send('request_nearby_heroes', {}); } /** * Build a LootDrop from combat_end payload for the loot popup UI. */ function isEquipmentLootItemType(t: string): boolean { if (t === 'gold' || t === 'potion') return false; // Server uses equipment slot ids (main_hand, chest, …), not legacy weapon/armor. return true; } export function buildLootFromCombatEnd(p: CombatEndPayload): LootDrop | null { const loot = p.loot ?? []; if (p.goldGained <= 0 && loot.length === 0) return null; const equip = loot.find((l) => isEquipmentLootItemType(l.itemType)); return { itemType: 'gold', rarity: equip ? ((equip.rarity?.toLowerCase() ?? 'common') as Rarity) : Rarity.Common, goldAmount: Math.max(0, p.goldGained), itemName: equip?.name, bonusItem: equip ? { itemType: equip.itemType as LootBonusItemSlot, rarity: (equip.rarity?.toLowerCase() ?? 'common') as Rarity, itemName: equip.name, } : undefined, }; } /** * Build a LootDrop for the popup after a wandering merchant trade (gear only; equip or auto-sell). */ export function buildMerchantLootDrop(p: MerchantLootPayload): LootDrop | null { const rarity = (p.rarity?.toLowerCase() ?? 'common') as Rarity; if (p.goldAmount != null && p.goldAmount > 0) { return { itemType: 'gold', rarity, goldAmount: p.goldAmount, itemName: p.itemName ? `Sold: ${p.itemName}` : undefined, }; } if (p.itemName) { return { itemType: 'gold', rarity, goldAmount: 0, itemName: p.itemName, }; } return null; }