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.

739 lines
22 KiB
TypeScript

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<string, string>): Record<string, string> {
const headers: Record<string, string> = {
'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<T>(res: Response): Promise<T> {
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<T>;
}
/** GET request */
export async function apiGet<T>(path: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: 'GET',
headers: buildHeaders(),
});
return handleResponse<T>(res);
}
/** POST request */
export async function apiPost<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: 'POST',
headers: buildHeaders(),
body: body !== undefined ? JSON.stringify(body) : undefined,
});
return handleResponse<T>(res);
}
/** PUT request */
export async function apiPut<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: 'PUT',
headers: buildHeaders(),
body: body !== undefined ? JSON.stringify(body) : undefined,
});
return handleResponse<T>(res);
}
/** DELETE request */
export async function apiDelete<T>(path: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: 'DELETE',
headers: buildHeaders(),
});
return handleResponse<T>(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<string, { remaining: number; periodEnd: string | null }>;
potions?: number;
positionX?: number;
positionY?: number;
moveSpeed?: number;
buffs?: ServerActiveBuffRow[];
debuffs?: ServerActiveDebuffRow[];
/** Effective debuff definitions from server catalog (durations in ms); not stored client-side as source of truth. */
debuffCatalog?: Record<string, { name?: string; durationMs: number; magnitude?: number }>;
/** Extended equipment slots (§6.3) keyed by slot name */
equipment?: Record<string, {
id: number;
slot: string;
formId?: string;
name: string;
rarity: string;
ilvl?: number;
primaryStat?: number;
statType?: string;
}>;
/** 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<AuthResponse> {
return apiPost<AuthResponse>('/auth/telegram');
}
/** Get current hero state */
export async function getHero(telegramId?: number): Promise<HeroResponse> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiGet<HeroResponse>(`/hero${query}`);
}
export interface OfflineReport {
offlineSeconds: number;
monstersKilled: number;
xpGained: number;
goldGained: number;
levelsGained: number;
message: string;
}
/** Curated release notes for the current server version (see backend changelog.json). */
export interface ChangelogPayload {
title: string;
items: 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;
/** Runtime tuning: merchant potion price (from DB / runtime_config). */
npcCostPotion?: number;
/** Runtime tuning: healer full heal price (from DB / runtime_config). */
npcCostHeal?: number;
/** Server build id; bump on backend with changelog entry to show the modal. */
serverVersion?: string;
/** True when there is a changelog entry for serverVersion and the player has not ack'd yet. */
showChangelog?: boolean;
changelog?: ChangelogPayload | null;
}
/** Matches server defaults when init omits costs (must stay in sync with tuning.DefaultValues). */
export function defaultNpcShopCosts(): { potionCost: number; healCost: number } {
return { potionCost: 50, healCost: 100 };
}
export function npcShopCostsFromInit(res: InitHeroResponse): { potionCost: number; healCost: number } {
const d = defaultNpcShopCosts();
const p = res.npcCostPotion;
const h = res.npcCostHeal;
return {
potionCost: typeof p === 'number' && p > 0 ? p : d.potionCost,
healCost: typeof h === 'number' && h > 0 ? h : d.healCost,
};
}
/** Initialize or retrieve a hero from the backend (creates on first call) */
export async function initHero(telegramId?: number): Promise<InitHeroResponse> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiGet<InitHeroResponse>(`/hero/init${query}`);
}
/** Mark the current server changelog as read (call after the user dismisses the modal). */
export async function ackChangelog(telegramId?: number): Promise<void> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
await apiPost<{ ok?: boolean }>(`/hero/changelog/ack${query}`);
}
/** Set the hero's display name (first time only). Returns updated hero on success. */
export async function setHeroName(name: string, telegramId?: number): Promise<HeroResponse> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiPost<HeroResponse>(`/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<GetMapResult> {
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<ActivateBuffResponse> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiPost<ActivateBuffResponse>(`/hero/buff/${buffType}${query}`);
}
/** Request revive after death — returns authoritative hero snapshot */
export async function requestRevive(telegramId?: number): Promise<HeroResponse> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiPost<HeroResponse>(`/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<ReportVictoryResponse> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiPost<ReportVictoryResponse>(`/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<Record<string, EquipmentItemResponse>> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiGet<Record<string, EquipmentItemResponse>>(`/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<EncounterEnemyResponse> {
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<EncounterEnemyResponse>(`/hero/encounter${query}`);
}
/** Get recent loot drops for the hero */
export async function getLoot(): Promise<LootResponse[]> {
return apiGet<LootResponse[]>('/hero/loot');
}
/** Get available weapons catalog */
export async function getWeapons(): Promise<WeaponResponse[]> {
return apiGet<WeaponResponse[]>('/weapons');
}
/** Get available armor catalog */
export async function getArmor(): Promise<ArmorResponse[]> {
return apiGet<ArmorResponse[]>('/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<LogEntry[]> {
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<HeroResponse> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiPost<HeroResponse>(`/hero/use-potion${query}`);
}
// ---- Towns ----
import type { Town, HeroQuest, NPC, Quest, BuildingData } from '../game/types';
/** Fetch all towns */
export async function getTowns(): Promise<Town[]> {
return apiGet<Town[]>('/towns');
}
/** Fetch NPCs for a town */
export async function getTownNPCs(townId: number): Promise<NPC[]> {
return apiGet<NPC[]>(`/towns/${townId}/npcs`);
}
/** Fetch buildings for a town */
export async function getTownBuildings(townId: number): Promise<BuildingData[]> {
return apiGet<BuildingData[]>(`/towns/${townId}/buildings`);
}
/** Fetch available quests from an NPC */
export async function getNPCQuests(npcId: number, telegramId?: number): Promise<Quest[]> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
const data = await apiGet<Quest[] | null>(`/npcs/${npcId}/quests${query}`);
return Array.isArray(data) ? data : [];
}
// ---- 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;
targetTownName?: string;
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,
targetTownName: raw.quest?.targetTownName ?? q?.targetTownName,
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<HeroQuest[]> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
const raw = await apiGet<HeroQuestRaw[]>(`/hero/quests${query}`);
return raw.map(flattenHeroQuest);
}
/** Accept a quest */
export async function acceptQuest(questId: number, telegramId?: number): Promise<HeroQuest> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiPost<HeroQuest>(`/hero/quests/${questId}/accept${query}`);
}
/** Claim a completed quest's rewards */
export async function claimQuest(heroQuestId: number, telegramId?: number): Promise<HeroResponse> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiPost<HeroResponse>(`/hero/quests/${heroQuestId}/claim${query}`);
}
/** Abandon a quest */
export async function abandonQuest(heroQuestId: number, telegramId?: number): Promise<void> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiDelete<void>(`/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<HeroResponse> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiPost<HeroResponse>(`/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<HeroResponse> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiPost<HeroResponse>(`/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<NearbyNPCResponse[]> {
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<NearbyNPCResponse[]>(`/hero/nearby-npcs${query}`);
}
/** Interact with an NPC to get available actions */
export async function interactWithNPC(npcId: number, telegramId?: number): Promise<NPCInteractResponse> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiPost<NPCInteractResponse>(`/npcs/${npcId}/interact${query}`);
}
/** Buy an item from a merchant NPC */
export async function buyFromMerchant(npcId: number, item: string, telegramId?: number): Promise<HeroResponse> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiPost<HeroResponse>(`/npcs/${npcId}/buy${query}`, { item });
}
/** Heal at a healer NPC */
export async function healAtHealerNPC(npcId: number, telegramId?: number): Promise<HeroResponse> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiPost<HeroResponse>(`/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<NPCAlmsResponse> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiPost<NPCAlmsResponse>(`/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<HeroResponse> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiPost<HeroResponse>(`/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<Achievement[]> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiGet<Achievement[]>(`/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<NearbyHero[]> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiGet<NearbyHero[]>(`/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<DailyTaskResponse[]> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiGet<DailyTaskResponse[]>(`/hero/daily-tasks${query}`);
}
/** Claim a completed daily task reward */
export async function claimDailyTask(taskId: string, telegramId?: number): Promise<HeroResponse> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiPost<HeroResponse>(`/hero/daily-tasks/${encodeURIComponent(taskId)}/claim${query}`);
}