You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1714 lines
58 KiB
TypeScript

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<Record<BuffType, number>>,
now: number,
): Partial<Record<BuffType, number>> {
const out: Partial<Record<BuffType, number>> = {};
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<Record<BuffType, number>> {
try {
const raw = sessionStorage.getItem(buffCdStorageKey(heroId));
if (!raw) return {};
const parsed = JSON.parse(raw) as Partial<Record<BuffType, number>>;
return pruneCooldownEnds(parsed, Date.now());
} catch {
return {};
}
}
function saveCooldownsToStorage(heroId: number, ends: Partial<Record<BuffType, number>>): 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<Record<BuffType, BuffChargeState>>. */
function mapBuffCharges(
raw: Record<string, { remaining: number; periodEnd: string | null }> | undefined,
): Partial<Record<BuffType, BuffChargeState>> {
if (!raw) return {};
const out: Partial<Record<BuffType, BuffChargeState>> = {};
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<string, EquipmentItem> {
const out: Record<string, EquipmentItem> = {};
// 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<Locale>(() => 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<HTMLDivElement>(null);
const engineRef = useRef<GameEngine | null>(null);
const wsRef = useRef<GameWebSocket | null>(null);
const [gameState, setGameState] = useState<GameState>({
phase: GamePhase.Walking,
hero: null,
enemy: null,
loot: null,
lastVictoryLoot: null,
tick: 0,
serverTimeMs: 0,
routeWaypoints: null,
});
const [combatEvents, setCombatEvents] = useState<CombatOverlayEvent[]>([]);
const [wsConnected, setWsConnected] = useState(false);
const [wsEverConnected, setWsEverConnected] = useState(false);
const [buffCooldownEndsAt, setBuffCooldownEndsAt] = useState<
Partial<Record<BuffType, number>>
>({});
const [connectionError, setConnectionError] = useState<string | null>(null);
const [toast, setToast] = useState<{ message: string; color: string } | null>(null);
const [logEntries, setLogEntries] = useState<AdventureLogEntry[]>([]);
/** Live combat narration (mirrors prefixed adventure log lines). */
const [combatLogLines, setCombatLogLines] = useState<string[]>([]);
const [offlineReport, setOfflineReport] = useState<OfflineReportData | null>(null);
const [needsName, setNeedsName] = useState(false);
type ChangelogOpen = { payload: ChangelogPayload; serverVersion?: string };
const pendingChangelogRef = useRef<ChangelogOpen | null>(null);
const [changelogOpen, setChangelogOpen] = useState<ChangelogOpen | null>(null);
const logIdCounter = useRef(0);
const logRawRef = useRef<AdventureLogRawRow[]>([]);
const i18nForLogRef = useRef({ locale, tr: translations });
i18nForLogRef.current = { locale, tr: translations };
const nearbyIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
engineRef.current?.refreshRandomRestThoughtForLocale();
}, [locale]);
// Quest system state
const [towns, setTowns] = useState<Town[]>([]);
const townsRef = useRef<Town[]>([]);
const [heroQuests, setHeroQuests] = useState<HeroQuest[]>([]);
const [currentTown, setCurrentTown] = useState<Town | null>(null);
const [selectedNPC, setSelectedNPC] = useState<NPC | null>(null);
const [heroSheetOpen, setHeroSheetOpen] = useState(false);
const [heroSheetInitialTab, setHeroSheetInitialTab] = useState<HeroSheetTab>('stats');
// NPC interaction (legacy / non-tour); town tour uses `townTourLastPayload` (NPCInteraction first; NPCDialog only after tap).
const [nearestNPC, setNearestNPC] = useState<NPCData | null>(null);
const [npcInteractionDismissed, setNpcInteractionDismissed] = useState<number | null>(null);
const [townTourLastPayload, setTownTourLastPayload] = useState<TownTourPhasePayload | null>(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<NPCEncounterEvent | null>(null);
const [heroMeetPanel, setHeroMeetPanel] = useState<{
partnerName: string;
anySideOnline: boolean;
maxChars: number;
} | null>(null);
const [npcShopCosts, setNpcShopCosts] = useState(defaultNpcShopBundle);
// Achievements
const [achievements, setAchievements] = useState<Achievement[]>([]);
const prevAchievementsRef = useRef<Achievement[]>([]);
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<number, NPC[]>();
const townBuildingMap = new Map<number, BuildingData[]>();
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<string, EquipmentItem> = {};
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<number, NPC[]>();
const townBuildingMap = new Map<number, BuildingData[]>();
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<number, NPC[]>();
const townBuildingMap = new Map<number, BuildingData[]>();
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<Record<BuffType, BuffChargeState>> = {
...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<number, NPC[]>();
const townBuildingMap = new Map<number, BuildingData[]>();
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 (
<I18nContext.Provider value={{ tr: translations, locale, setLocale: handleSetLocale }}>
<div style={appStyle}>
{/* PixiJS Canvas */}
<div ref={canvasRef} style={canvasContainerStyle} />
{/* React UI Overlay */}
<HUD
gameState={gameState}
onBuffActivate={handleBuffActivate}
buffCooldownEndsAt={buffCooldownEndsAt}
onUsePotion={handleUsePotion}
onHeroUpdated={handleNPCHeroUpdated}
completedQuestCount={completedQuestCount}
onOpenHeroSheet={() => {
setHeroSheetInitialTab('stats');
setHeroSheetOpen(true);
}}
/>
{heroMeetPanel && wsRef.current ? (
<HeroMeetPanel
partnerName={heroMeetPanel.partnerName}
anySideOnline={heroMeetPanel.anySideOnline}
ws={wsRef.current}
maxChars={heroMeetPanel.maxChars}
/>
) : null}
{gameState.hero && (
<HeroSheetModal
open={heroSheetOpen}
onClose={() => 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 */}
<CombatOverlay events={combatEvents} onExpire={expireCombatEvent} />
<CombatLogPanel
visible={
gameState.phase === GamePhase.Fighting || gameState.phase === GamePhase.Dead
}
lines={combatLogLines}
anchor={gameState.enemyOnScreenRight !== false ? 'left' : 'right'}
/>
{/* Name Entry Screen */}
{needsName && <NameEntryScreen onNameSet={handleNameSet} />}
{changelogOpen && (
<ChangelogModal
title={changelogOpen.payload.title}
items={changelogOpen.payload.items}
serverVersion={changelogOpen.serverVersion}
onDismiss={handleDismissChangelog}
/>
)}
{/* Death Screen */}
<DeathScreen
visible={gameState.phase === GamePhase.Dead}
onRevive={handleRevive}
subscriptionUnlimited={!!gameState.hero?.subscriptionActive}
revivesRemaining={Math.max(0, 2 - (gameState.hero?.reviveCount ?? 0))}
/>
{/* Toast Notification */}
{toast && (
<GameToast
message={toast.message}
color={toast.color}
onDone={dismissToast}
/>
)}
{/* Town Name Indicator */}
{currentTown && (
<div
style={{
position: 'absolute',
top: 48,
left: '50%',
transform: 'translateX(-50%)',
padding: '3px 14px',
borderRadius: 6,
backgroundColor: 'rgba(218, 165, 32, 0.15)',
border: '1px solid rgba(218, 165, 32, 0.3)',
color: '#daa520',
fontSize: 11,
fontWeight: 600,
zIndex: 30,
pointerEvents: 'none',
textShadow: '0 0 6px rgba(218, 165, 32, 0.3)',
}}
>
{currentTown.name}
</div>
)}
{/* Town tour service chip or legacy proximity NPC */}
{showNPCInteraction && interactionNpc && (
<NPCInteraction
npc={interactionNpc}
onViewQuests={handleNPCViewQuests}
onOpenServiceDialog={handleNPCViewQuests}
onDismiss={handleNPCInteractionDismiss}
onDismissTownTour={townTourInteractionNPC ? handleTownTourInteractionDismiss : undefined}
/>
)}
{/* NPC Dialog: opened from interaction / sheet only (not auto on town tour approach). */}
{dialogNpc && (
<NPCDialog
npc={dialogNpc}
heroQuests={heroQuests}
heroGold={gameState.hero?.gold ?? 0}
potionCost={npcShopCosts.potionCost}
healCost={npcShopCosts.healCost}
getHeroWorldPosition={() => 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 && (
<WanderingNPCPopup
npcName={wanderingNPC.npcName}
message={wanderingNPC.message}
cost={wanderingNPC.cost}
heroGold={gameState.hero?.gold ?? 0}
onAccept={handleWanderingAccept}
onDecline={handleWanderingDecline}
/>
)}
{/* Minimap (top-right) */}
{gameState.hero && (
<Minimap
heroX={gameState.hero.position.x}
heroY={gameState.hero.position.y}
towns={towns}
routeWaypoints={gameState.routeWaypoints}
/>
)}
{/* Achievements Panel */}
{gameState.hero && <AchievementsPanel achievements={achievements} />}
{/* Offline Report Overlay */}
{offlineReport && (
<OfflineReport
monstersKilled={offlineReport.monstersKilled}
xpGained={offlineReport.xpGained}
goldGained={offlineReport.goldGained}
levelsGained={offlineReport.levelsGained}
deaths={offlineReport.deaths}
revives={offlineReport.revives}
loot={offlineReport.loot}
message={offlineReport.message}
onDismiss={() => setOfflineReport(null)}
/>
)}
{/* Connection Status Banner */}
{wsEverConnected && !wsConnected && (
<div style={connectionBanner}>
Reconnecting...
</div>
)}
{/* Fatal connection error */}
{connectionError && (
<div style={{
position: 'absolute', inset: 0,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.85)', color: '#fff', zIndex: 1000,
fontFamily: 'system-ui, sans-serif', textAlign: 'center', padding: 24,
}}>
<div style={{ fontSize: 48, marginBottom: 16 }}>&#x26A0;</div>
<div style={{ fontSize: 18, fontWeight: 700, marginBottom: 8 }}>Connection Failed</div>
<div style={{ fontSize: 14, opacity: 0.8, marginBottom: 24 }}>{connectionError}</div>
<button
onClick={() => window.location.reload()}
style={{
padding: '10px 28px', fontSize: 14, fontWeight: 600,
border: 'none', borderRadius: 8, cursor: 'pointer',
backgroundColor: '#4a90d9', color: '#fff',
}}
>
Retry
</button>
</div>
)}
</div>
</I18nContext.Provider>
);
}