import { useEffect, useRef, useState, useCallback, useMemo, type CSSProperties } from 'react'; import { GameEngine } from './game/engine'; import { GamePhase, BuffType, type GameState, type ActiveBuff, type NPCData, type AttackPayload, type EnemyRegenPayload, type NearbyHeroData, } from './game/types'; import type { NPCEncounterEvent } from './game/types'; import { GameWebSocket } from './network/websocket'; import { wireWSHandler, sendActivateBuff, sendUsePotion, sendRevive, sendNPCAlmsAccept, sendNPCAlmsDecline, sendTownTourNPCDialogClosed, sendTownTourNPCInteractionOpened, sendTownTourNPCInteractionClosed, sendRequestNearbyHeroes, buildLootFromCombatEnd, buildMerchantLootDrop, } from './game/ws-handler'; import { initHero, ackChangelog, getAdventureLog, getTowns, getTownNPCs, getTownBuildings, getHeroQuests, getHeroEquipment, claimQuest, abandonQuest, getAchievements, requestRevive, defaultNpcShopBundle, npcShopCostsFromInit, offlineReportHasActivity, } from './network/api'; import type { HeroResponse, Achievement, ChangelogPayload } from './network/api'; import type { AdventureLogEntry, Town, HeroQuest, NPC, TownData, EquipmentItem, BuildingData, TownTourPhasePayload, } from './game/types'; import type { OfflineReport as OfflineReportData } from './network/api'; import { BUFF_COOLDOWN_MS, BUFF_DURATION_MS, debuffDurationsFromCatalog, mapHeroBuffsFromServer, mapHeroDebuffsFromServer, } from './network/buffMap'; import { hapticImpact, hapticNotification, onThemeChanged, getTelegramUserId } from './shared/telegram'; import { Rarity } from './game/types'; import type { HeroState, BuffChargeState } from './game/types'; import { useUiClock } from './hooks/useUiClock'; import { adventureEntriesFromServerLog, appendAdventureLogRawRow, buildAdventureLogEntriesFromRaw, type AdventureLogRawRow, } from './game/adventureLogMap'; import { isAdventureLogCombatCode, isAdventureLogEncounterCode, parseAdventureLogLine, shouldSuppressThoughtBubblePayload, } from './game/adventureLogMarkers'; import { formatAdventureLogPayload, formatClientLogLine } from './game/adventureLogFormat'; import { townLabel, npcLabel, dialogueText } from './i18n/contentLabels'; import type { AdventureLogLinePayload } from './game/types'; import { HUD } from './ui/HUD'; import { DeathScreen } from './ui/DeathScreen'; import { CombatOverlay, type CombatOverlayEvent } from './ui/CombatOverlay'; import { CombatLogPanel } from './ui/CombatLogPanel'; import { GameToast } from './ui/GameToast'; import { OfflineReport } from './ui/OfflineReport'; import { HeroSheetModal, type HeroSheetTab } from './ui/HeroSheetModal'; import { NPCDialog } from './ui/NPCDialog'; import { NameEntryScreen } from './ui/NameEntryScreen'; import { ChangelogModal } from './ui/ChangelogModal'; import { AchievementsPanel } from './ui/AchievementsPanel'; import { Minimap } from './ui/Minimap'; import { NPCInteraction } from './ui/NPCInteraction'; import { WanderingNPCPopup } from './ui/WanderingNPCPopup'; import { HeroMeetPanel } from './ui/HeroMeetPanel'; import { I18nContext, t, detectLocale, getTranslations, type Locale } from './i18n'; import { adventureLogTemplate, randomRoadsideThoughtLine } from './i18n/loadLocales'; const appStyle: CSSProperties = { width: '100%', height: '100%', position: 'relative', overflow: 'hidden', }; const canvasContainerStyle: CSSProperties = { width: '100%', height: '100%', position: 'absolute', top: 0, left: 0, }; const connectionBanner: CSSProperties = { position: 'absolute', top: 0, left: 0, right: 0, textAlign: 'center', padding: '4px 0', fontSize: 12, fontWeight: 600, color: '#fff', backgroundColor: 'rgba(204, 51, 51, 0.85)', zIndex: 200, }; function buffCdStorageKey(heroId: number): string { return `autohero-buffCd-v1-${heroId}`; } function pruneCooldownEnds( raw: Partial>, now: number, ): Partial> { const out: Partial> = {}; for (const [k, v] of Object.entries(raw)) { if (typeof v === 'number' && v > now && (Object.values(BuffType) as string[]).includes(k)) { out[k as BuffType] = v; } } return out; } function loadCooldownsFromStorage(heroId: number): Partial> { try { const raw = sessionStorage.getItem(buffCdStorageKey(heroId)); if (!raw) return {}; const parsed = JSON.parse(raw) as Partial>; return pruneCooldownEnds(parsed, Date.now()); } catch { return {}; } } function saveCooldownsToStorage(heroId: number, ends: Partial>): void { try { const pruned = pruneCooldownEnds(ends, Date.now()); sessionStorage.setItem(buffCdStorageKey(heroId), JSON.stringify(pruned)); } catch { /* ignore quota / private mode */ } } function xpToNextLevel(level: number): number { if (level < 1) level = 1; if (level <= 9) return Math.round(180 * Math.pow(1.28, level - 1)); if (level <= 29) return Math.round(1450 * Math.pow(1.15, level - 10)); return Math.round(23000 * Math.pow(1.10, level - 30)); } function normalizeHeroModelVariant(v: unknown): number { return typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= 2 ? v : 0; } /** Map backend buffCharges (keyed by string) into typed Partial>. */ function mapBuffCharges( raw: Record | undefined, ): Partial> { if (!raw) return {}; const out: Partial> = {}; const validTypes = new Set(Object.values(BuffType) as string[]); for (const [key, val] of Object.entries(raw)) { if (validTypes.has(key)) { out[key as BuffType] = { remaining: val.remaining, periodEnd: val.periodEnd }; } } return out; } /** Map backend equipment record to typed EquipmentItem map. */ function mapEquipment( raw: HeroResponse['equipment'], res: HeroResponse, ): Record { const out: Record = {}; // REST uses `equipment`, WS hero_state from Go uses `gear`. Treat JSON null like missing. const merged = raw ?? res.gear; const rawSlots = merged != null && typeof merged === 'object' ? merged : undefined; if (rawSlots) { for (const [slot, item] of Object.entries(rawSlots)) { out[slot] = { id: item.id, slot: item.slot ?? slot, formId: item.formId ?? '', name: item.name, rarity: (item.rarity?.toLowerCase() ?? 'common') as EquipmentItem['rarity'], ilvl: item.ilvl ?? 1, primaryStat: item.primaryStat ?? 0, statType: item.statType ?? 'mixed', subtype: item.subtype, }; } } // Fallback: populate main_hand from legacy weapon if not already set if (!out['main_hand'] && res.weapon) { out['main_hand'] = { id: res.weapon.id ?? 0, slot: 'main_hand', formId: '', name: res.weapon.name ?? 'Unknown', rarity: (res.weapon.rarity?.toLowerCase() ?? 'common') as EquipmentItem['rarity'], ilvl: res.weapon.ilvl ?? 1, primaryStat: res.weapon.damage ?? 0, statType: 'attack', }; } if (!out['chest'] && res.armor) { out['chest'] = { id: res.armor.id ?? 0, slot: 'chest', formId: '', name: res.armor.name ?? 'Unknown', rarity: (res.armor.rarity?.toLowerCase() ?? 'common') as EquipmentItem['rarity'], ilvl: res.armor.ilvl ?? 1, primaryStat: res.armor.defense ?? 0, statType: 'defense', }; } return out; } function townTourPayloadToNPCData(p: TownTourPhasePayload, loc: Locale, town: Town | null): NPCData { const tw = town ?? undefined; const displayName = p.npcNameKey ? npcLabel(loc, p.npcNameKey, p.npcName ?? '') : (p.npcName ?? ''); return { id: p.npcId ?? 0, name: displayName, nameKey: p.npcNameKey, type: (p.npcType ?? 'merchant') as NPCData['type'], worldX: p.worldX ?? 0, worldY: p.worldY ?? 0, townId: p.townId, townLevelMin: tw?.levelMin ?? 1, townLevelMax: tw?.levelMax ?? 1, }; } /** Convert Town (from /towns API) to engine-facing TownData, optionally with NPCs and buildings */ function townToTownData( town: Town, npcs?: NPC[], buildings?: BuildingData[], locale: Locale = 'en', ): TownData { const npcData: NPCData[] | undefined = npcs?.map((n) => ({ id: n.id, name: n.nameKey ? npcLabel(locale, n.nameKey, n.name) : n.name, nameKey: n.nameKey, type: n.type, worldX: town.worldX + n.offsetX, worldY: town.worldY + n.offsetY, buildingId: n.buildingId, townId: town.id, townLevelMin: town.levelMin, townLevelMax: town.levelMax, })); return { id: town.id, name: town.nameKey ? townLabel(locale, town.nameKey, town.name) : town.name, nameKey: town.nameKey, centerX: town.worldX, centerY: town.worldY, radius: town.radius, biome: town.biome, levelMin: town.levelMin, size: town.radius > 40 ? 'XL' : town.radius > 25 ? 'M' : town.radius > 15 ? 'S' : 'XS', npcs: npcData, buildings: buildings, }; } function heroResponseToState(res: HeroResponse): HeroState { const now = Date.now(); return { id: res.id, modelVariant: normalizeHeroModelVariant(res.modelVariant), hp: res.hp, maxHp: res.maxHp, position: { x: res.positionX ?? 0, y: res.positionY ?? 0 }, serverActivityState: res.state, restKind: res.restKind, excursionPhase: res.excursionPhase, excursionKind: res.excursionKind === 'roadside' || res.excursionKind === 'adventure' || res.excursionKind === 'town' || res.excursionKind === 'hero_meet' ? (res.excursionKind as HeroState['excursionKind']) : undefined, townTourPhase: res.townTourPhase, townTourNpcId: res.townTourNpcId, townTourExitPending: res.townTourExitPending, attackSpeed: res.attackSpeed ?? res.speed, damage: res.attackPower ?? res.attack, defense: res.defensePower ?? res.defense, weaponType: (res.gear?.main_hand?.subtype ?? res.weapon?.type ?? 'sword') as HeroState['weaponType'], weaponName: res.gear?.main_hand?.name ?? res.weapon?.name ?? '', weaponRarity: (res.gear?.main_hand?.rarity ?? res.weapon?.rarity ?? 'common') as Rarity, weaponIlvl: res.gear?.main_hand?.ilvl ?? res.weapon?.ilvl, armorType: (res.gear?.chest?.subtype ?? res.armor?.type ?? 'medium') as HeroState['armorType'], armorName: res.gear?.chest?.name ?? res.armor?.name ?? '', armorRarity: (res.gear?.chest?.rarity ?? res.armor?.rarity ?? 'common') as Rarity, armorIlvl: res.gear?.chest?.ilvl ?? res.armor?.ilvl, activeBuffs: mapHeroBuffsFromServer(res.buffs, now), debuffs: mapHeroDebuffsFromServer(res.debuffs, now), level: res.level, xp: res.xp, xpToNext: res.xpToNext ?? xpToNextLevel(res.level || 1), gold: res.gold, strength: res.strength, constitution: res.constitution, agility: res.agility, luck: res.luck, reviveCount: res.reviveCount, subscriptionActive: res.subscriptionActive, buffFreeChargesRemaining: res.buffFreeChargesRemaining, buffQuotaPeriodEnd: res.buffQuotaPeriodEnd, buffCharges: mapBuffCharges(res.buffCharges), potions: res.potions ?? 0, moveSpeed: res.moveSpeed, equipment: mapEquipment(res.equipment, res), inventory: mapInventoryFromResponse(res.inventory), debuffCatalogDurations: debuffDurationsFromCatalog(res.debuffCatalog), }; } function mapInventoryFromResponse(raw: HeroResponse['inventory']): EquipmentItem[] | undefined { if (!raw?.length) return undefined; return raw.map((it) => ({ id: it.id, slot: it.slot, formId: it.formId ?? '', name: it.name, rarity: it.rarity as Rarity, ilvl: it.ilvl ?? 1, primaryStat: it.primaryStat ?? 0, statType: it.statType ?? 'mixed', })); } export function App() { const [locale, setLocale] = useState(() => detectLocale()); const translations = useMemo(() => getTranslations(locale), [locale]); const handleSetLocale = useCallback((l: Locale) => { setLocale(l); try { localStorage.setItem('autohero_locale', l); } catch { /* ignore */ } }, []); const tr = translations; const canvasRef = useRef(null); const engineRef = useRef(null); const wsRef = useRef(null); const [gameState, setGameState] = useState({ phase: GamePhase.Walking, hero: null, enemy: null, loot: null, lastVictoryLoot: null, tick: 0, serverTimeMs: 0, routeWaypoints: null, }); const [combatEvents, setCombatEvents] = useState([]); const [wsConnected, setWsConnected] = useState(false); const [wsEverConnected, setWsEverConnected] = useState(false); const [buffCooldownEndsAt, setBuffCooldownEndsAt] = useState< Partial> >({}); const [connectionError, setConnectionError] = useState(null); const [toast, setToast] = useState<{ message: string; color: string } | null>(null); const [logEntries, setLogEntries] = useState([]); /** Live combat narration (mirrors prefixed adventure log lines). */ const [combatLogLines, setCombatLogLines] = useState([]); const [offlineReport, setOfflineReport] = useState(null); const [needsName, setNeedsName] = useState(false); type ChangelogOpen = { payload: ChangelogPayload; serverVersion?: string }; const pendingChangelogRef = useRef(null); const [changelogOpen, setChangelogOpen] = useState(null); const logIdCounter = useRef(0); const logRawRef = useRef([]); const i18nForLogRef = useRef({ locale, tr: translations }); i18nForLogRef.current = { locale, tr: translations }; const nearbyIntervalRef = useRef | null>(null); useEffect(() => { engineRef.current?.refreshRandomRestThoughtForLocale(); }, [locale]); // Quest system state const [towns, setTowns] = useState([]); const townsRef = useRef([]); const [heroQuests, setHeroQuests] = useState([]); const [currentTown, setCurrentTown] = useState(null); const [selectedNPC, setSelectedNPC] = useState(null); const [heroSheetOpen, setHeroSheetOpen] = useState(false); const [heroSheetInitialTab, setHeroSheetInitialTab] = useState('stats'); // NPC interaction (legacy / non-tour); town tour uses `townTourLastPayload` (NPCInteraction first; NPCDialog only after tap). const [nearestNPC, setNearestNPC] = useState(null); const [npcInteractionDismissed, setNpcInteractionDismissed] = useState(null); const [townTourLastPayload, setTownTourLastPayload] = useState(null); const townTourDialogWs = useMemo( () => ({ onInteractionOpened: () => { const w = wsRef.current; if (w) sendTownTourNPCInteractionOpened(w); }, onDialogAndInteractionClosed: () => { const w = wsRef.current; if (!w) return; sendTownTourNPCDialogClosed(w); sendTownTourNPCInteractionClosed(w); }, }), [], ); // Wandering NPC encounter state const [wanderingNPC, setWanderingNPC] = useState(null); const [heroMeetPanel, setHeroMeetPanel] = useState<{ partnerName: string; anySideOnline: boolean; maxChars: number; } | null>(null); const [npcShopCosts, setNpcShopCosts] = useState(defaultNpcShopBundle); // Achievements const [achievements, setAchievements] = useState([]); const prevAchievementsRef = useRef([]); const sheetNowMs = useUiClock(100); const appendLogPayload = useCallback((p: AdventureLogLinePayload) => { const { locale: loc, tr: bundle } = i18nForLogRef.current; logIdCounter.current += 1; const id = logIdCounter.current; logRawRef.current = appendAdventureLogRawRow( logRawRef.current, { id, message: p.message ?? '', timestamp: Date.now(), event: p.event }, () => 0, ); setLogEntries(buildAdventureLogEntriesFromRaw([...logRawRef.current], loc, bundle)); const thoughtText = formatAdventureLogPayload(loc, bundle, p); const eng = engineRef.current; if (thoughtText && eng && !shouldSuppressThoughtBubblePayload(p)) { eng.applyAdventureLogLine(thoughtText); } if (isAdventureLogEncounterCode(p.event?.code) && thoughtText) { setCombatLogLines([thoughtText]); } else { const parsed = parseAdventureLogLine(p.message ?? ''); if (parsed.type === 'encounter') { setCombatLogLines([parsed.title]); } else if (parsed.type === 'battle') { setCombatLogLines((prev) => [...prev, parsed.text].slice(-5)); } else if (isAdventureLogCombatCode(p.event?.code) && thoughtText) { setCombatLogLines((prev) => [...prev, thoughtText].slice(-5)); } } }, []); const appendLogClientMessage = useCallback((rawMessage: string) => { const { locale: loc, tr: bundle } = i18nForLogRef.current; logIdCounter.current += 1; const id = logIdCounter.current; logRawRef.current = appendAdventureLogRawRow( logRawRef.current, { id, message: rawMessage, timestamp: Date.now() }, () => 0, ); setLogEntries(buildAdventureLogEntriesFromRaw([...logRawRef.current], loc, bundle)); const parsed = parseAdventureLogLine(rawMessage); if (parsed.type === 'encounter') { setCombatLogLines([parsed.title]); } else if (parsed.type === 'battle') { setCombatLogLines((prev) => [...prev, parsed.text].slice(-5)); } }, []); useEffect(() => { const { locale: loc, tr: bundle } = i18nForLogRef.current; setLogEntries(buildAdventureLogEntriesFromRaw([...logRawRef.current], loc, bundle)); }, [locale, translations]); useEffect(() => { const eng = engineRef.current; if (!eng || towns.length === 0) return; let cancelled = false; (async () => { const townNPCMap = new Map(); const townBuildingMap = new Map(); try { const [npcResults, buildingResults] = await Promise.all([ Promise.allSettled(towns.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs })))), Promise.allSettled(towns.map((town) => getTownBuildings(town.id).then((b) => ({ townId: town.id, buildings: b })))), ]); for (const result of npcResults) { if (result.status === 'fulfilled') townNPCMap.set(result.value.townId, result.value.npcs); } for (const result of buildingResults) { if (result.status === 'fulfilled') townBuildingMap.set(result.value.townId, result.value.buildings); } } catch { /* ignore */ } if (cancelled) return; const loc = locale; const townDataList = towns.map((town) => townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id), loc), ); eng.setTowns(townDataList); const allNPCs: NPCData[] = []; for (const td of townDataList) { if (td.npcs) allNPCs.push(...td.npcs); } eng.setNPCs(allNPCs); })(); return () => { cancelled = true; }; }, [locale, towns]); const appendCombatEvent = useCallback((evt: CombatOverlayEvent) => { setCombatEvents((prev) => [...prev, evt]); }, []); const clearCombatEvents = useCallback(() => { setCombatEvents([]); }, []); const expireCombatEvent = useCallback((id: number) => { setCombatEvents((prev) => prev.filter((evt) => evt.id !== id)); }, []); const handleCombatAttack = useCallback((p: AttackPayload) => { if (p.source === 'potion') { return; } const defender: CombatOverlayEvent['target'] = p.source === 'enemy' ? 'hero' : 'enemy'; const isBlocked = p.outcome === 'block'; const isEvaded = p.outcome === 'dodge'; const isStunned = p.outcome === 'stun'; const isCrit = Boolean(p.isCrit); if (isStunned) { appendCombatEvent({ id: Date.now() + Math.random(), kind: 'stunned', target: 'hero', value: 0, createdAt: performance.now(), }); } else if (isBlocked || isEvaded) { appendCombatEvent({ id: Date.now() + Math.random(), kind: isBlocked ? 'blocked' : 'evaded', target: defender, value: 0, createdAt: performance.now(), }); } else { appendCombatEvent({ id: Date.now() + Math.random(), kind: 'damage', target: defender, value: p.damage, isCrit, createdAt: performance.now(), }); } const engine = engineRef.current; if (!engine) return; const isDotLike = p.source === 'dot' || p.source === 'summon'; if (isDotLike) return; if (isStunned) { hapticImpact('light'); engine.camera.shake(3, 120); return; } if (isBlocked || isEvaded) { hapticImpact('light'); engine.camera.shake(3, 120); return; } if (isCrit) { hapticImpact('heavy'); engine.camera.shake(8, 250); } else { hapticImpact('light'); engine.camera.shake(4, 150); } }, [appendCombatEvent]); const handleEnemyRegen = useCallback((p: EnemyRegenPayload) => { if (p.amount <= 0) return; appendCombatEvent({ id: Date.now() + Math.random(), kind: 'regen', target: 'enemy', value: p.amount, createdAt: performance.now(), }); }, [appendCombatEvent]); const refreshEquipment = useCallback(() => { const telegramId = getTelegramUserId() ?? 1; getHeroEquipment(telegramId) .then((eqMap) => { const merged: Record = {}; for (const [slot, item] of Object.entries(eqMap)) { merged[slot] = { id: item.id, slot: item.slot ?? slot, formId: item.formId ?? '', name: item.name, rarity: (item.rarity?.toLowerCase() ?? 'common') as EquipmentItem['rarity'], ilvl: item.ilvl ?? 1, primaryStat: item.primaryStat ?? 0, statType: item.statType ?? 'mixed', }; } const engine = engineRef.current; if (engine?.gameState.hero) { const prevEquip = engine.gameState.hero.equipment ?? {}; const combined = { ...prevEquip, ...merged }; engine.gameState.hero.equipment = combined; } setGameState((prev) => { if (!prev.hero) return prev; const prevEquip = prev.hero.equipment ?? {}; return { ...prev, hero: { ...prev.hero, equipment: { ...prevEquip, ...merged } } }; }); }) .catch(() => console.warn('[App] Could not fetch equipment')); }, []); const refreshHeroQuests = useCallback(() => { const telegramId = getTelegramUserId() ?? 1; getHeroQuests(telegramId) .then((q) => setHeroQuests(q)) .catch(() => console.warn('[App] Could not refresh hero quests')); }, []); // Initialize engine and WebSocket useEffect(() => { const container = canvasRef.current; if (!container) return; // ---- Game Engine ---- const engine = new GameEngine(); engineRef.current = engine; engine.setRestThoughtPicker(() => randomRoadsideThoughtLine(i18nForLogRef.current.locale), ); engine.onStateChange((state) => { setGameState(state); }); engine.init(container).then(async () => { let shouldOpenWS = false; try { const telegramId = getTelegramUserId() ?? 1; const initRes = await initHero(telegramId); setNpcShopCosts(npcShopCostsFromInit(initRes)); if (initRes.showChangelog && initRes.changelog) { const bundle: ChangelogOpen = { payload: initRes.changelog, serverVersion: initRes.serverVersion, }; if (initRes.needsName) { pendingChangelogRef.current = bundle; } else { setChangelogOpen(bundle); } } // Gate game start behind name entry — no hero row until POST /hero/name if (initRes.needsName) { setNeedsName(true); if (initRes.hero) { const heroState = heroResponseToState(initRes.hero); engine.initFromServer(heroState, initRes.hero.state); } getTowns() .then(async (t) => { setTowns(t); townsRef.current = t; const townNPCMap = new Map(); const townBuildingMap = new Map(); try { const [npcResults, buildingResults] = await Promise.all([ Promise.allSettled(t.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs })))), Promise.allSettled(t.map((town) => getTownBuildings(town.id).then((b) => ({ townId: town.id, buildings: b })))), ]); for (const result of npcResults) { if (result.status === 'fulfilled') townNPCMap.set(result.value.townId, result.value.npcs); } for (const result of buildingResults) { if (result.status === 'fulfilled') townBuildingMap.set(result.value.townId, result.value.buildings); } } catch { /* ignore */ } const loc = i18nForLogRef.current.locale; const townDataList = t.map((town) => townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id), loc), ); engine.setTowns(townDataList); const allNPCs: NPCData[] = []; for (const td of townDataList) { if (td.npcs) allNPCs.push(...td.npcs); } engine.setNPCs(allNPCs); }) .catch(() => console.warn('[App] Could not fetch towns (name gate)')); console.info('[App] Hero needs name, showing name entry screen'); return; } if (!initRes.hero) { console.error('[App] init: missing hero without needsName'); setConnectionError('Invalid server response.'); return; } shouldOpenWS = true; const heroState = heroResponseToState(initRes.hero); engine.initFromServer(heroState, initRes.hero.state); engine.setHeroName(initRes.hero.name); console.info('[App] Loaded hero from server, id=', initRes.hero.id); if (initRes.offlineReport && offlineReportHasActivity(initRes.offlineReport)) { const r = initRes.offlineReport; console.info( `[Offline] ${r.message ?? ''} Killed ${r.monstersKilled} monsters, +${r.xpGained} XP, +${r.goldGained} gold, +${r.levelsGained} levels, deaths ${r.deaths ?? 0}, revives ${r.revives ?? 0}`, ); setOfflineReport(r); } // Fetch towns, then their NPCs, and pass everything to the engine getTowns() .then(async (t) => { setTowns(t); townsRef.current = t; const townNPCMap = new Map(); const townBuildingMap = new Map(); try { const [npcResults, buildingResults] = await Promise.all([ Promise.allSettled(t.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs })))), Promise.allSettled(t.map((town) => getTownBuildings(town.id).then((b) => ({ townId: town.id, buildings: b })))), ]); for (const result of npcResults) { if (result.status === 'fulfilled') townNPCMap.set(result.value.townId, result.value.npcs); } for (const result of buildingResults) { if (result.status === 'fulfilled') townBuildingMap.set(result.value.townId, result.value.buildings); } } catch { console.warn('[App] Error fetching town NPCs/buildings'); } const loc = i18nForLogRef.current.locale; const townDataList = t.map((town) => townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id), loc), ); engine.setTowns(townDataList); const allNPCs: NPCData[] = []; for (const td of townDataList) { if (td.npcs) allNPCs.push(...td.npcs); } engine.setNPCs(allNPCs); }) .catch(() => console.warn('[App] Could not fetch towns')); getHeroQuests(telegramId) .then((q) => setHeroQuests(q)) .catch(() => console.warn('[App] Could not fetch hero quests')); refreshEquipment(); getAchievements(telegramId) .then((a) => { prevAchievementsRef.current = a; setAchievements(a); }) .catch(() => console.warn('[App] Could not fetch achievements')); // Fetch adventure log (same source as server DB; response shape { log: [...] }) try { const serverLog = await getAdventureLog(telegramId, 50); const { locale: loc, tr: bundle } = i18nForLogRef.current; const { entries, rawRows, maxId } = adventureEntriesFromServerLog(serverLog, loc, bundle); logRawRef.current = rawRows; logIdCounter.current = Math.max(logIdCounter.current, maxId); setLogEntries(entries); } catch { console.warn('[App] Could not fetch adventure log'); } } catch (err) { console.error('[App] Backend not available. Game requires a server connection:', err); setConnectionError('Cannot reach game server. Please try again later.'); return; } engine.start(); if (shouldOpenWS) { ws.connect(); } }).catch((err) => { console.error('[App] Failed to initialize game engine:', err); }); // ---- WebSocket ---- const ws = new GameWebSocket(); wsRef.current = ws; // Pass telegram ID so the WS connection identifies the correct hero. const wsTelegramId = getTelegramUserId() ?? 1; ws.setTelegramId(wsTelegramId); ws.onConnectionState((connState) => { const connected = connState === 'connected'; setWsConnected(connected); if (connected) setWsEverConnected(true); }); // Wire WS handler -- routes server messages to engine + UI callbacks wireWSHandler(ws, engine, { onCombatStart: () => { setCombatLogLines([]); clearCombatEvents(); }, onCombatAttack: handleCombatAttack, onEnemyRegen: handleEnemyRegen, onHeroStateReceived: (payload) => { // Convert raw payload to HeroResponse shape and apply const res = payload as unknown as HeroResponse; const heroState = heroResponseToState(res); engine.applyHeroState(heroState); }, onCombatEnd: (p) => { const bundle = i18nForLogRef.current.tr; setCombatLogLines([]); clearCombatEvents(); const loot = buildLootFromCombatEnd(p); engine.applyLoot(loot); hapticNotification('success'); const parts: string[] = []; if (p.xpGained > 0) parts.push(t(bundle.toastGainedXp, { xp: p.xpGained })); if (p.goldGained > 0) parts.push(t(bundle.toastGainedGold, { gold: p.goldGained })); const equipDrop = (p.loot ?? []).find( (l) => l.itemType !== 'gold' && l.itemType !== 'potion', ); if (equipDrop?.name) parts.push(t(bundle.toastFoundItem, { name: equipDrop.name })); // Victory line comes from server adventure log (Defeated …) + WS adventure_log_line if (p.leveledUp && p.newLevel) { setToast({ message: t(bundle.levelUp, { level: p.newLevel }), color: '#ffd700' }); hapticNotification('success'); } // Refresh quests, equipment, achievements after combat const tid = getTelegramUserId() ?? 1; getHeroQuests(tid).then((q) => setHeroQuests(q)).catch(() => {}); refreshEquipment(); getAchievements(tid).then((a) => { const prevIds = new Set(prevAchievementsRef.current.filter((x) => x.unlocked).map((x) => x.id)); const newlyUnlocked = a.filter((x) => x.unlocked && !prevIds.has(x.id)); for (const ach of newlyUnlocked) { setToast({ message: t(bundle.achievementUnlockedToast, { title: ach.title }), color: '#ffd700' }); hapticNotification('success'); } prevAchievementsRef.current = a; setAchievements(a); }).catch(() => {}); }, onHeroDied: (_p) => { hapticNotification('error'); // Death line comes from server log + WS }, onHeroRevived: () => { setCombatLogLines([]); clearCombatEvents(); setToast({ message: i18nForLogRef.current.tr.heroRevived, color: '#44cc44' }); // "Hero revived" comes from server log + WS }, onBuffApplied: (_p) => { // Buff activation comes from server log + WS }, onTownEnter: (p) => { const { locale: loc, tr: bundle } = i18nForLogRef.current; const town = townsRef.current.find((t) => t.id === p.townId) ?? null; setCurrentTown(town); const townDisp = townLabel(loc, p.townNameKey, p.townName); setToast({ message: t(bundle.entering, { townName: townDisp }), color: '#daa520' }); appendLogClientMessage(formatClientLogLine(bundle, 'enteredTown', { town: townDisp })); setNearestNPC(null); setSelectedNPC(null); setNpcInteractionDismissed(null); setTownTourLastPayload(null); }, onAdventureLogLine: (p) => { appendLogPayload(p); }, onTownNPCVisit: () => { // Town NPC UI is driven by `town_tour_phase` (engine still gets lines via ws-handler). }, onTownTourPhase: (p) => { setTownTourLastPayload(p); const ph = p.phase; if (ph === 'npc_welcome' || ph === 'npc_service') { setNpcInteractionDismissed(null); } if (ph === 'wander' || ph === 'rest' || ph === 'npc_approach') { setSelectedNPC(null); } }, onTownTourServiceEnd: () => { setTownTourLastPayload(null); setSelectedNPC(null); }, onTownExit: () => { setCurrentTown(null); setNearestNPC(null); setTownTourLastPayload(null); }, onNPCEncounter: (p) => { const loc = i18nForLogRef.current.locale; const name = p.npcNameKey ? npcLabel(loc, p.npcNameKey, p.npcName) : p.npcName; const msg = p.dialogueKey ? dialogueText(loc, p.dialogueKey, p.dialogue ?? `${name} approaches!`) : (p.dialogue ?? `${name} approaches!`); const npcEvent: NPCEncounterEvent = { type: 'npc_event', npcName: name, npcNameKey: p.npcNameKey, dialogueKey: p.dialogueKey, message: msg, cost: p.cost, }; setWanderingNPC(npcEvent); }, onNPCEncounterEnd: (p) => { if (p.reason === 'timeout') { appendLogClientMessage(formatClientLogLine(i18nForLogRef.current.tr, 'merchantMovedOn')); } setWanderingNPC(null); }, onLevelUp: (p) => { setToast({ message: t(i18nForLogRef.current.tr.levelUp, { level: p.newLevel }), color: '#ffd700' }); hapticNotification('success'); // Level-up lines come from server log + WS }, onEquipmentChange: (p) => { const bundle = i18nForLogRef.current.tr; setToast({ message: t(bundle.newEquipment, { slot: p.slot, itemName: p.item.name }), color: '#cc88ff' }); // Equipment line comes from server log + WS refreshEquipment(); }, onPotionCollected: (p) => { const bundle = i18nForLogRef.current.tr; setToast({ message: t(bundle.potionsCollected, { count: p.count }), color: '#44cc44' }); }, onQuestProgress: (p) => { const bundle = i18nForLogRef.current.tr; setHeroQuests((prev) => prev.map((hq) => hq.questId === p.questId ? { ...hq, progress: p.current } : hq, ), ); if (p.title) { setToast({ message: t(bundle.questProgress, { title: p.title, current: p.current, target: p.target }), color: '#44aaff', }); } }, onQuestComplete: (p) => { const bundle = i18nForLogRef.current.tr; setHeroQuests((prev) => prev.map((hq) => hq.questId === p.questId ? { ...hq, status: 'completed' as const } : hq, ), ); setToast({ message: t(bundle.questCompleted, { title: p.title }), color: '#ffd700' }); hapticNotification('success'); }, onError: (p) => { setToast({ message: p.message, color: '#ff4444' }); }, onMerchantLoot: (p) => { const loot = buildMerchantLootDrop(p); const eng = engineRef.current; if (loot && eng) { eng.applyLoot(loot); } hapticNotification('success'); refreshEquipment(); }, onHeroMeetStart: (p) => { const partner: NearbyHeroData = { id: p.partner.id, name: p.partner.name, level: p.partner.level, modelVariant: normalizeHeroModelVariant(p.partner.modelVariant), positionX: p.partner.positionX, positionY: p.partner.positionY, }; engine.setHeroMeetOverlay(partner); const phase = p.meetPhase; const showChat = phase === 'meet' || phase === undefined; if (showChat) { setHeroMeetPanel({ partnerName: p.partner.name, anySideOnline: p.anySideOnline, maxChars: 140, }); } else { setHeroMeetPanel(null); } }, onHeroMeetLine: (p) => { const loc = i18nForLogRef.current.locale; let text = (p.text ?? '').trim(); if (p.kind === 'scripted' && p.lineKey) { text = (adventureLogTemplate(loc, p.lineKey) ?? p.lineKey).trim(); } if (!text) return; engine.applyHeroMeetChatLine(p.fromHeroId, p.kind, text); }, onHeroMeetEnd: (p) => { setHeroMeetPanel(null); const linger = p.partnerLingerMs != null && p.partnerLingerMs > 0 ? p.partnerLingerMs : 20000; engine.endHeroMeetOverlayLinger(linger); }, }); // ---- Telegram Theme Listener ---- const unsubTheme = onThemeChanged(); // ---- Cleanup ---- return () => { engine.destroy(); ws.disconnect(); unsubTheme(); if (nearbyIntervalRef.current) { clearInterval(nearbyIntervalRef.current); nearbyIntervalRef.current = null; } engineRef.current = null; wsRef.current = null; }; }, []); // WS-driven nearby heroes: request list + subscribe to updates. useEffect(() => { const ws = wsRef.current; if (!wsConnected || !ws) { if (nearbyIntervalRef.current) { clearInterval(nearbyIntervalRef.current); nearbyIntervalRef.current = null; } return; } const request = (): void => { sendRequestNearbyHeroes(ws); }; request(); const interval = setInterval(request, 5000); nearbyIntervalRef.current = interval; return () => { clearInterval(interval); if (nearbyIntervalRef.current === interval) { nearbyIntervalRef.current = null; } }; }, [wsConnected]); // Restore per-hero buff button cooldowns useEffect(() => { const id = gameState.hero?.id; if (id == null) return; const loaded = loadCooldownsFromStorage(id); if (Object.keys(loaded).length === 0) return; setBuffCooldownEndsAt((prev) => ({ ...loaded, ...prev })); }, [gameState.hero?.id]); // ---- Handlers ---- const handleBuffActivate = useCallback((type: BuffType) => { const engine = engineRef.current; const ws = wsRef.current; const hero = engine?.gameState.hero; const now = Date.now(); if (engine?.gameState.phase === GamePhase.Dead) { return; } // Check per-buff charge quota if (hero) { const charge = hero.buffCharges?.[type]; if (charge != null && charge.remaining <= 0) { const label = type.charAt(0).toUpperCase() + type.slice(1).replace('_', ' '); setToast({ message: t(tr.noChargesLeft, { label }), color: '#ff8844' }); return; } } // Optimistic update if (engine && hero) { const durationMs = BUFF_DURATION_MS[type]; const optimisticBuff: ActiveBuff = { type, remainingMs: durationMs, durationMs, cooldownMs: BUFF_COOLDOWN_MS[type], cooldownRemainingMs: 0, expiresAtMs: now + durationMs, }; const alreadyActive = hero.activeBuffs.some( (b) => b.type === type && (b.expiresAtMs ?? 0) > now, ); const updatedBuffs = alreadyActive ? hero.activeBuffs.map((b) => (b.type === type ? optimisticBuff : b)) : [...hero.activeBuffs, optimisticBuff]; let { damage, defense, attackSpeed, hp, maxHp } = hero; if (!alreadyActive) { switch (type) { case BuffType.Rage: damage = Math.round((damage * 5) / 3); break; case BuffType.PowerPotion: damage = Math.round(damage * 2); break; case BuffType.WarCry: attackSpeed = Math.round(((attackSpeed * 5) / 3) * 100) / 100; break; case BuffType.Heal: hp = Math.min(maxHp, hp + Math.round(maxHp / 3)); break; } } engine.patchHeroCombat({ damage, defense, attackSpeed, activeBuffs: updatedBuffs, }); if (type === BuffType.Heal && hp !== hero.hp) { engine.patchHeroHp(hp); } setBuffCooldownEndsAt((prev) => { const next = { ...prev, [type]: now + BUFF_COOLDOWN_MS[type] }; if (hero.id != null) saveCooldownsToStorage(hero.id, next); return next; }); // Optimistic decrement of per-buff charge (server always consumes via ConsumeBuffCharge) const currentCharge = hero.buffCharges?.[type]; if (currentCharge != null && currentCharge.remaining > 0) { const updatedCharges: Partial> = { ...hero.buffCharges, [type]: { ...currentCharge, remaining: currentCharge.remaining - 1 }, }; engine.patchHeroBuffQuota({ buffFreeChargesRemaining: hero.buffFreeChargesRemaining, buffQuotaPeriodEnd: hero.buffQuotaPeriodEnd, buffCharges: updatedCharges, }); } hapticImpact('medium'); // Server logs buff activation + sends adventure_log_line } // Send command to server via WebSocket if (ws) { sendActivateBuff(ws, type); } }, []); const handleRevive = useCallback(() => { const ws = wsRef.current; if (ws && ws.getState() === 'connected') { sendRevive(ws); } else { // Fallback to REST if WS not connected const telegramId = getTelegramUserId() ?? 1; requestRevive(telegramId) .then((hero) => { const engine = engineRef.current; if (engine) { const state = heroResponseToState(hero); engine.applyHeroRevived(state.hp); engine.applyHeroState(state); } }) .catch((err) => { console.warn('[App] Revive failed:', err); }); } }, []); const handleQuestClaim = useCallback( (heroQuestId: number) => { const telegramId = getTelegramUserId() ?? 1; claimQuest(heroQuestId, telegramId) .then((hero) => { const merged = heroResponseToState(hero); const engine = engineRef.current; if (engine) { const pos = engine.gameState.hero?.position; if (pos) merged.position = pos; engine.applyHeroState(merged); } setToast({ message: tr.questRewardsClaimed, color: '#ffd700' }); hapticNotification('success'); refreshHeroQuests(); }) .catch((err) => { console.warn('[App] Failed to claim quest:', err); setToast({ message: tr.failedToClaimRewards, color: '#ff4444' }); }); }, [refreshHeroQuests], ); const handleQuestAbandon = useCallback( (heroQuestId: number) => { const telegramId = getTelegramUserId() ?? 1; abandonQuest(heroQuestId, telegramId) .then(() => { setToast({ message: tr.questAbandoned, color: '#ff8844' }); refreshHeroQuests(); }) .catch((err) => { console.warn('[App] Failed to abandon quest:', err); setToast({ message: tr.failedToAbandonQuest, color: '#ff4444' }); }); }, [refreshHeroQuests], ); const handleNPCHeroUpdated = useCallback((hero: HeroResponse) => { const merged = heroResponseToState(hero); const engine = engineRef.current; if (engine) { const pos = engine.gameState.hero?.position; if (pos) merged.position = pos; engine.applyHeroState(merged); } }, []); const handleNameSet = useCallback((hero: HeroResponse) => { setNeedsName(false); const engine = engineRef.current; if (engine) { const heroState = heroResponseToState(hero); engine.initFromServer(heroState, hero.state); engine.setHeroName(hero.name); engine.start(); wsRef.current?.connect(); const telegramId = getTelegramUserId() ?? 1; getTowns() .then(async (t) => { setTowns(t); const townNPCMap = new Map(); const townBuildingMap = new Map(); try { const [npcResults, buildingResults] = await Promise.all([ Promise.allSettled(t.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs })))), Promise.allSettled(t.map((town) => getTownBuildings(town.id).then((b) => ({ townId: town.id, buildings: b })))), ]); for (const result of npcResults) { if (result.status === 'fulfilled') townNPCMap.set(result.value.townId, result.value.npcs); } for (const result of buildingResults) { if (result.status === 'fulfilled') townBuildingMap.set(result.value.townId, result.value.buildings); } } catch { /* ignore */ } const loc = i18nForLogRef.current.locale; const townDataList = t.map((town) => townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id), loc), ); engine.setTowns(townDataList); const allNPCs: NPCData[] = []; for (const td of townDataList) { if (td.npcs) allNPCs.push(...td.npcs); } engine.setNPCs(allNPCs); }) .catch(() => console.warn('[App] Could not fetch towns')); getHeroQuests(telegramId) .then((q) => setHeroQuests(q)) .catch(() => console.warn('[App] Could not fetch hero quests')); getAdventureLog(telegramId, 50) .then((serverLog) => { const { locale: loc, tr: bundle } = i18nForLogRef.current; const { entries, rawRows, maxId } = adventureEntriesFromServerLog(serverLog, loc, bundle); logRawRef.current = rawRows; logIdCounter.current = Math.max(logIdCounter.current, maxId); setLogEntries(entries); }) .catch(() => console.warn('[App] Could not fetch adventure log')); getAchievements(telegramId) .then((a) => { prevAchievementsRef.current = a; setAchievements(a); }) .catch(() => {}); if (pendingChangelogRef.current) { setChangelogOpen(pendingChangelogRef.current); pendingChangelogRef.current = null; } } }, []); const handleDismissChangelog = useCallback(() => { setChangelogOpen(null); const telegramId = getTelegramUserId() ?? 1; ackChangelog(telegramId).catch(() => console.warn('[App] changelog ack failed')); }, []); const handleUsePotion = useCallback(() => { const ws = wsRef.current; const hero = engineRef.current?.gameState.hero; if (!hero || (hero.potions ?? 0) <= 0) return; // Send via WS if (ws) { sendUsePotion(ws); } hero.potions-- hapticImpact('medium'); // Server logs potion use + sends adventure_log_line }, []); // ---- NPC Interaction Handlers ---- const handleNPCViewQuests = useCallback((npc: NPCData) => { const matchedNPC: NPC = { id: npc.id, townId: npc.townId, name: npc.name, nameKey: npc.nameKey, type: npc.type, offsetX: 0, offsetY: 0, townLevelMin: npc.townLevelMin, townLevelMax: npc.townLevelMax, }; setSelectedNPC(matchedNPC); setNpcInteractionDismissed(npc.id); }, []); const handleTownTourInteractionDismiss = useCallback(() => { const w = wsRef.current; if (!w) return; const ph = townTourLastPayload?.phase; if (ph === 'npc_welcome') { sendTownTourNPCDialogClosed(w); } else if (ph === 'npc_service') { sendTownTourNPCInteractionClosed(w); } }, [townTourLastPayload?.phase]); // ---- Wandering NPC Encounter Handlers (via WS) ---- const handleWanderingAccept = useCallback(() => { const ws = wsRef.current; if (ws) { sendNPCAlmsAccept(ws); } setWanderingNPC(null); // Alms outcome is logged on server + WS when trade completes }, []); const handleWanderingDecline = useCallback(() => { const ws = wsRef.current; if (ws) { sendNPCAlmsDecline(ws); } setWanderingNPC(null); appendLogClientMessage(formatClientLogLine(i18nForLogRef.current.tr, 'declinedWanderingMerchant')); }, [appendLogClientMessage]); const heroOnTownTour = gameState.hero?.excursionKind === 'town'; const townTourChipActive = (townTourLastPayload?.phase === 'npc_welcome' || townTourLastPayload?.phase === 'npc_service') && (townTourLastPayload.npcId ?? 0) > 0; const townTourInteractionNPC = townTourChipActive && townTourLastPayload ? townTourPayloadToNPCData(townTourLastPayload, locale, currentTown) : null; const legacyProximityNPC = !heroOnTownTour ? nearestNPC : null; const interactionNpc = townTourInteractionNPC ?? legacyProximityNPC; const showNPCInteraction = interactionNpc != null && npcInteractionDismissed !== interactionNpc.id && (gameState.phase === GamePhase.Walking || gameState.phase === GamePhase.InTown) && !selectedNPC; const dialogNpc = selectedNPC; const handleNPCInteractionDismiss = useCallback(() => { if (interactionNpc) setNpcInteractionDismissed(interactionNpc.id); }, [interactionNpc]); const completedQuestCount = useMemo( () => heroQuests.filter((q) => q.status === 'completed').length, [heroQuests], ); const dismissToast = useCallback(() => setToast(null), []); const questClaimDisabled = gameState.phase === GamePhase.Dead || (gameState.hero?.hp ?? 0) <= 0; return (
{/* PixiJS Canvas */}
{/* React UI Overlay */} { setHeroSheetInitialTab('stats'); setHeroSheetOpen(true); }} /> {heroMeetPanel && wsRef.current ? ( ) : null} {gameState.hero && ( setHeroSheetOpen(false)} initialTab={heroSheetInitialTab} hero={gameState.hero} nowMs={sheetNowMs} equipment={gameState.hero.equipment ?? {}} logEntries={logEntries} quests={heroQuests} onQuestClaim={handleQuestClaim} onQuestAbandon={handleQuestAbandon} questClaimDisabled={questClaimDisabled} /> )} {/* Combat overlay */} {/* Name Entry Screen */} {needsName && } {changelogOpen && ( )} {/* Death Screen */} {/* Toast Notification */} {toast && ( )} {/* Town Name Indicator */} {currentTown && (
{currentTown.name}
)} {/* Town tour service chip or legacy proximity NPC */} {showNPCInteraction && interactionNpc && ( )} {/* NPC Dialog: opened from interaction / sheet only (not auto on town tour approach). */} {dialogNpc && ( engineRef.current?.getHeroDisplayWorldPosition() ?? { x: 0, y: 0 }} onClose={() => { setNpcInteractionDismissed(dialogNpc.id); setSelectedNPC(null); }} onQuestsChanged={refreshHeroQuests} onHeroUpdated={handleNPCHeroUpdated} onToast={(message, color) => setToast({ message, color })} questClaimDisabled={questClaimDisabled} townTourWs={heroOnTownTour && selectedNPC ? townTourDialogWs : undefined} /> )} {/* Wandering NPC Encounter Popup */} {wanderingNPC && ( )} {/* Minimap (top-right) */} {gameState.hero && ( )} {/* Achievements Panel */} {gameState.hero && } {/* Offline Report Overlay */} {offlineReport && ( setOfflineReport(null)} /> )} {/* Connection Status Banner */} {wsEverConnected && !wsConnected && (
Reconnecting...
)} {/* Fatal connection error */} {connectionError && (
Connection Failed
{connectionError}
)}
); }