import { API_BASE } from '../shared/constants'; import { getTelegramInitData } from '../shared/telegram'; import type { ServerActiveBuffRow, ServerActiveDebuffRow } from './buffMap'; /** * REST API client for /api/v1/* endpoints. * * All requests include Telegram initData for authentication. * Responses are typed via generics. */ export class ApiError extends Error { constructor( public status: number, public statusText: string, public body: string, ) { super(`API Error ${status}: ${statusText}`); this.name = 'ApiError'; } } /** Build headers with Telegram auth */ function buildHeaders(extra?: Record): Record { const headers: Record = { 'Content-Type': 'application/json', ...extra, }; const initData = getTelegramInitData(); if (initData) { headers['X-Telegram-Init-Data'] = initData; } return headers; } /** Parse response or throw ApiError */ async function handleResponse(res: Response): Promise { if (!res.ok) { const body = await res.text().catch(() => ''); throw new ApiError(res.status, res.statusText, body); } // 204 No Content if (res.status === 204) { return undefined as T; } return res.json() as Promise; } /** GET request */ export async function apiGet(path: string): Promise { const res = await fetch(`${API_BASE}${path}`, { method: 'GET', headers: buildHeaders(), }); return handleResponse(res); } /** POST request */ export async function apiPost(path: string, body?: unknown): Promise { const res = await fetch(`${API_BASE}${path}`, { method: 'POST', headers: buildHeaders(), body: body !== undefined ? JSON.stringify(body) : undefined, }); return handleResponse(res); } /** PUT request */ export async function apiPut(path: string, body?: unknown): Promise { const res = await fetch(`${API_BASE}${path}`, { method: 'PUT', headers: buildHeaders(), body: body !== undefined ? JSON.stringify(body) : undefined, }); return handleResponse(res); } /** DELETE request */ export async function apiDelete(path: string): Promise { const res = await fetch(`${API_BASE}${path}`, { method: 'DELETE', headers: buildHeaders(), }); return handleResponse(res); } // ---- Typed API Methods ---- export interface HeroResponse { id: number; telegramId: number; name: string; hp: number; maxHp: number; attack: number; defense: number; speed: number; attackSpeed?: number; attackPower?: number; defensePower?: number; strength: number; constitution: number; agility: number; luck: number; state: string; restKind?: string; excursionPhase?: string; weaponId: number; armorId: number; weapon: WeaponResponse | null; armor: ArmorResponse | null; gold: number; xp: number; xpToNext?: number; level: number; reviveCount?: number; subscriptionActive?: boolean; buffFreeChargesRemaining?: number; buffQuotaPeriodEnd?: string; buffCharges?: Record; potions?: number; positionX?: number; positionY?: number; moveSpeed?: number; buffs?: ServerActiveBuffRow[]; debuffs?: ServerActiveDebuffRow[]; /** Extended equipment slots (ยง6.3) keyed by slot name */ equipment?: Record; /** Same slot data as `equipment`; WebSocket `hero_state` from Go uses `gear` */ gear?: HeroResponse['equipment']; /** Unequipped gear (backpack), max 40 โ€” mirrors Go `Hero.Inventory` */ inventory?: Array<{ id: number; slot: string; formId?: string; name: string; rarity: string; ilvl?: number; primaryStat?: number; statType?: string; }>; } export interface AuthResponse { token: string; /** 0 if the player has not created a hero yet. */ heroId: number; } /** Authenticate via Telegram initData, returns session info */ export async function authenticate(): Promise { return apiPost('/auth/telegram'); } /** Get current hero state */ export async function getHero(telegramId?: number): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; return apiGet(`/hero${query}`); } export interface OfflineReport { offlineSeconds: number; monstersKilled: number; xpGained: number; goldGained: number; levelsGained: number; message: string; } export interface InitHeroResponse { /** Null until the player submits a valid name (no DB row until then). */ hero: HeroResponse | null; offlineReport: OfflineReport | null; mapRef: MapRefResponse; needsName?: boolean; } /** Initialize or retrieve a hero from the backend (creates on first call) */ export async function initHero(telegramId?: number): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; return apiGet(`/hero/init${query}`); } /** Set the hero's display name (first time only). Returns updated hero on success. */ export async function setHeroName(name: string, telegramId?: number): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; return apiPost(`/hero/name${query}`, { name }); } export interface MapRefResponse { mapId: string; mapVersion: string; etag: string; biome: string; recommendedLevelMin: number; recommendedLevelMax: number; } export interface MapTileResponse { x: number; y: number; terrain: string; } export interface MapObjectResponse { id: string; type: string; x: number; y: number; } export interface SpawnPointResponse { id: string; kind: string; x: number; y: number; } export interface ServerMapResponse { mapId: string; mapVersion: string; biome: string; recommendedLevelMin: number; recommendedLevelMax: number; width: number; height: number; tiles: MapTileResponse[]; objects: MapObjectResponse[]; spawnPoints: SpawnPointResponse[]; } export interface GetMapResult { map: ServerMapResponse | null; etag: string; notModified: boolean; } /** Get map payload by map ID with optional ETag conditional fetch. */ export async function getMap(mapId: string, etag?: string): Promise { const headers = buildHeaders(); if (etag) { headers['If-None-Match'] = etag; } const res = await fetch(`${API_BASE}/maps/${encodeURIComponent(mapId)}`, { method: 'GET', headers, }); const responseEtag = res.headers.get('ETag') ?? etag ?? ''; if (res.status === 304) { return { map: null, etag: responseEtag, notModified: true, }; } if (!res.ok) { const body = await res.text().catch(() => ''); throw new ApiError(res.status, res.statusText, body); } return { map: await res.json() as ServerMapResponse, etag: responseEtag, notModified: false, }; } export interface ActivateBuffResponse { buff: ServerActiveBuffRow; heroBuffs: ServerActiveBuffRow[]; hero?: HeroResponse; } /** Activate a buff by type */ export async function activateBuff( buffType: string, telegramId?: number, ): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; return apiPost(`/hero/buff/${buffType}${query}`); } /** Request revive after death โ€” returns authoritative hero snapshot */ export async function requestRevive(telegramId?: number): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; return apiPost(`/hero/revive${query}`); } export interface VictoryDropResponse { itemType: string; itemId?: number; itemName?: string; rarity: string; goldAmount: number; } export interface ReportVictoryResponse { hero: HeroResponse; drops: VictoryDropResponse[]; } /** Apply server-side kill rewards (XP, gold, loot rolls, HP rules) after client-resolved combat */ export async function reportVictory( body: { enemyType: string; heroHp: number; positionX?: number; positionY?: number }, telegramId?: number, ): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; return apiPost(`/hero/victory${query}`, body); } // ---- Loot & Equipment ---- export interface LootResponse { itemType: 'weapon' | 'armor' | 'gold'; itemName?: string; rarity: string; goldAmount: number; } export interface WeaponResponse { id: number; name: string; type: string; rarity: string; damage: number; speedMultiplier: number; ilvl?: number; } export interface ArmorResponse { id: number; name: string; type: string; rarity: string; defense: number; speedMultiplier: number; ilvl?: number; } export interface EncounterEnemyResponse { id: number; name: string; hp: number; maxHp: number; attack: number; defense: number; speed: number; enemyType: string; } /** Fetch extended equipment slots for the hero */ export async function getHeroEquipment(telegramId?: number): Promise> { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; return apiGet>(`/hero/equipment${query}`); } export interface EquipmentItemResponse { id: number; slot: string; formId: string; name: string; rarity: string; ilvl: number; basePrimary: number; primaryStat: number; statType: string; } /** Ask backend to generate the next encounter enemy for this hero. */ export async function requestEncounter(telegramId?: number, posX?: number, posY?: number): Promise { const params = new URLSearchParams(); if (telegramId != null) params.set('telegramId', String(telegramId)); if (posX != null) params.set('posX', posX.toFixed(2)); if (posY != null) params.set('posY', posY.toFixed(2)); const query = params.toString() ? `?${params.toString()}` : ''; return apiPost(`/hero/encounter${query}`); } /** Get recent loot drops for the hero */ export async function getLoot(): Promise { return apiGet('/hero/loot'); } /** Get available weapons catalog */ export async function getWeapons(): Promise { return apiGet('/weapons'); } /** Get available armor catalog */ export async function getArmor(): Promise { return apiGet('/armor'); } // ---- Adventure Log ---- export interface LogEntry { id: number; message: string; createdAt: string; } /** Fetch recent adventure log entries (offline events, etc.) */ export async function getAdventureLog(telegramId?: number, limit?: number): Promise { const params = new URLSearchParams(); if (telegramId != null) params.set('telegramId', String(telegramId)); if (limit != null) params.set('limit', String(limit)); const query = params.toString() ? `?${params.toString()}` : ''; const raw = await apiGet<{ log?: LogEntry[] } | LogEntry[]>(`/hero/log${query}`); if (Array.isArray(raw)) { return raw; } return raw.log ?? []; } // ---- Potions ---- /** Use a healing potion; returns updated hero state */ export async function usePotion(telegramId?: number): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; return apiPost(`/hero/use-potion${query}`); } // ---- Towns ---- import type { Town, HeroQuest, NPC, Quest, BuildingData } from '../game/types'; /** Fetch all towns */ export async function getTowns(): Promise { return apiGet('/towns'); } /** Fetch NPCs for a town */ export async function getTownNPCs(townId: number): Promise { return apiGet(`/towns/${townId}/npcs`); } /** Fetch buildings for a town */ export async function getTownBuildings(townId: number): Promise { return apiGet(`/towns/${townId}/buildings`); } /** Fetch available quests from an NPC */ export async function getNPCQuests(npcId: number, telegramId?: number): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; return apiGet(`/npcs/${npcId}/quests${query}`); } // ---- Hero Quests ---- /** Raw shape from backend โ€” quest details are nested under "quest" */ interface HeroQuestRaw { id: number; heroId: number; questId: number; status: string; progress: number; quest?: { id: number; npcId: number; title: string; description: string; type: string; targetCount: number; targetEnemyType?: string; targetTownId?: number; dropChance: number; minLevel: number; maxLevel: number; rewardXp: number; rewardGold: number; rewardPotions: number; }; // Also accept flat fields in case backend changes later title?: string; description?: string; type?: string; targetCount?: number; rewardXp?: number; rewardGold?: number; rewardPotions?: number; npcName?: string; townName?: string; } function flattenHeroQuest(raw: HeroQuestRaw): HeroQuest { const q = raw.quest; return { id: raw.id, questId: raw.questId, title: raw.title ?? q?.title ?? '', description: raw.description ?? q?.description ?? '', type: raw.type ?? q?.type ?? '', targetCount: raw.targetCount ?? q?.targetCount ?? 0, progress: raw.progress, status: (raw.status as HeroQuest['status']) ?? 'accepted', rewardXp: raw.rewardXp ?? q?.rewardXp ?? 0, rewardGold: raw.rewardGold ?? q?.rewardGold ?? 0, rewardPotions: raw.rewardPotions ?? q?.rewardPotions ?? 0, npcName: raw.npcName ?? '', townName: raw.townName ?? '', }; } /** Fetch hero's active/completed quests */ export async function getHeroQuests(telegramId?: number): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; const raw = await apiGet(`/hero/quests${query}`); return raw.map(flattenHeroQuest); } /** Accept a quest */ export async function acceptQuest(questId: number, telegramId?: number): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; return apiPost(`/hero/quests/${questId}/accept${query}`); } /** Claim a completed quest's rewards */ export async function claimQuest(heroQuestId: number, telegramId?: number): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; return apiPost(`/hero/quests/${heroQuestId}/claim${query}`); } /** Abandon a quest */ export async function abandonQuest(heroQuestId: number, telegramId?: number): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; return apiDelete(`/hero/quests/${heroQuestId}${query}`); } // ---- NPC Services ---- /** Buy a potion from a merchant NPC (matches backend POST /api/v1/hero/npc-buy-potion) */ export async function buyPotion(telegramId?: number): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; return apiPost(`/hero/npc-buy-potion${query}`); } /** Heal to full at a healer NPC (matches backend POST /api/v1/hero/npc-heal; body must be JSON with npcId) */ export async function healAtNPC(telegramId?: number, npcId?: number): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; return apiPost(`/hero/npc-heal${query}`, { npcId: npcId ?? 0 }); } // ---- NPC Proximity & Interaction ---- export interface NearbyNPCResponse { id: number; name: string; type: string; worldX: number; worldY: number; } export interface NPCInteractResponse { npcName: string; npcType: string; actions: Array<{ type: string; label: string; cost?: number }>; quests?: Quest[]; } /** Fetch NPCs near the hero's current position */ export async function getNearbyNPCs(telegramId?: number, posX?: number, posY?: number): Promise { const params = new URLSearchParams(); if (telegramId != null) params.set('telegramId', String(telegramId)); if (posX != null) params.set('posX', String(posX)); if (posY != null) params.set('posY', String(posY)); const query = params.toString() ? `?${params.toString()}` : ''; return apiGet(`/hero/nearby-npcs${query}`); } /** Interact with an NPC to get available actions */ export async function interactWithNPC(npcId: number, telegramId?: number): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; return apiPost(`/npcs/${npcId}/interact${query}`); } /** Buy an item from a merchant NPC */ export async function buyFromMerchant(npcId: number, item: string, telegramId?: number): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; return apiPost(`/npcs/${npcId}/buy${query}`, { item }); } /** Heal at a healer NPC */ export async function healAtHealerNPC(npcId: number, telegramId?: number): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; return apiPost(`/npcs/${npcId}/heal${query}`); } // ---- Wandering NPC alms ---- /** Matches backend model.AlmsResponse (POST /hero/npc-alms) */ export interface NPCAlmsResponse { accepted: boolean; message: string; goldSpent?: number; itemDrop?: { itemType: string; itemId?: number; itemName?: string; rarity: string; goldAmount?: number; }; hero?: HeroResponse; } /** Give gold to a wandering NPC for a mysterious item */ export async function giveNPCAlms(telegramId?: number): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; return apiPost(`/hero/npc-alms${query}`, { accept: true }); } /** Purchase a buff charge refill (placeholder for Telegram Payments integration) */ export async function purchaseBuffRefill( buffType: string, telegramId?: number, ): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; return apiPost(`/hero/purchase-buff-refill${query}`, { buffType }); } // ---- Achievements (spec 10.3) ---- export interface Achievement { id: string; title: string; description: string; conditionType: string; conditionValue: number; rewardType: string; rewardAmount: number; unlocked: boolean; unlockedAt?: string; } /** Fetch all achievements with unlock status */ export async function getAchievements(telegramId?: number): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; return apiGet(`/hero/achievements${query}`); } // ---- Nearby Heroes (spec 2.3 shared world) ---- export interface NearbyHero { id: number; name: string; level: number; positionX: number; positionY: number; } /** Fetch heroes near the player's current position */ export async function getNearbyHeroes(telegramId?: number): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; return apiGet(`/hero/nearby${query}`); } // ---- Daily Tasks (backend-driven, spec 10.1) ---- export interface DailyTaskResponse { taskId: string; title: string; description: string; objectiveType: string; objectiveCount: number; progress: number; completed: boolean; claimed: boolean; rewardType: string; rewardAmount: number; period: string; } /** Fetch daily tasks with progress */ export async function getDailyTasks(telegramId?: number): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; return apiGet(`/hero/daily-tasks${query}`); } /** Claim a completed daily task reward */ export async function claimDailyTask(taskId: string, telegramId?: number): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; return apiPost(`/hero/daily-tasks/${encodeURIComponent(taskId)}/claim${query}`); }