client: auth context and API layer
Add JWT-based AuthContext (login/logout, token persistence), typed HTTP client with auth header injection, REST API functions for all game endpoints, and shared TypeScript types. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { request } from './http';
|
||||
import type {
|
||||
ActiveMission,
|
||||
AuthResponse,
|
||||
City,
|
||||
ClaimMissionResponse,
|
||||
InventoryResponse,
|
||||
LeaderboardResponse,
|
||||
MarketResponse,
|
||||
Mission,
|
||||
PlayerMeResponse,
|
||||
StartMissionResponse,
|
||||
TradeResponse,
|
||||
TravelResponse,
|
||||
WorldEvent,
|
||||
} from './types';
|
||||
|
||||
export const api = {
|
||||
// Auth
|
||||
register: (email: string, password: string, displayName: string) =>
|
||||
request<AuthResponse>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: { email, password, displayName },
|
||||
}),
|
||||
login: (email: string, password: string) =>
|
||||
request<AuthResponse>('/auth/login', { method: 'POST', body: { email, password } }),
|
||||
|
||||
// Player
|
||||
playerMe: () => request<PlayerMeResponse>('/player/me'),
|
||||
travel: (cityId: string) =>
|
||||
request<TravelResponse>('/player/travel', { method: 'POST', body: { cityId } }),
|
||||
|
||||
// Cities
|
||||
cities: () => request<{ cities: City[] }>('/cities'),
|
||||
|
||||
// Market
|
||||
market: () => request<MarketResponse>('/market/current'),
|
||||
buy: (itemId: string, quantity: number) =>
|
||||
request<TradeResponse>('/market/buy', { method: 'POST', body: { itemId, quantity } }),
|
||||
sell: (itemId: string, quantity: number) =>
|
||||
request<TradeResponse>('/market/sell', { method: 'POST', body: { itemId, quantity } }),
|
||||
|
||||
// Inventory
|
||||
inventory: () => request<InventoryResponse>('/inventory'),
|
||||
|
||||
// Missions
|
||||
missionsAvailable: () => request<{ missions: Mission[] }>('/missions/available'),
|
||||
missionsActive: () => request<{ missions: ActiveMission[] }>('/missions/active'),
|
||||
startMission: (missionId: string) =>
|
||||
request<StartMissionResponse>(`/missions/${missionId}/start`, { method: 'POST' }),
|
||||
claimMission: (playerMissionId: string) =>
|
||||
request<ClaimMissionResponse>(`/missions/${playerMissionId}/claim`, { method: 'POST' }),
|
||||
|
||||
// Events
|
||||
events: () => request<{ events: WorldEvent[] }>('/events/current'),
|
||||
|
||||
// Leaderboard
|
||||
leaderboard: (metric: 'money' | 'reputation', limit = 20) =>
|
||||
request<LeaderboardResponse>(`/leaderboard/${metric}?limit=${limit}`),
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { API_URL } from '../config';
|
||||
|
||||
/** Errore restituito dal server nella forma { error: { code, message } }. */
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
let accessToken: string | null = null;
|
||||
|
||||
export function setAccessToken(token: string | null): void {
|
||||
accessToken = token;
|
||||
}
|
||||
|
||||
type RequestOptions = {
|
||||
method?: 'GET' | 'POST';
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (options.body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_URL}/api/v1${path}`, {
|
||||
method: options.method ?? 'GET',
|
||||
headers,
|
||||
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(0, 'NETWORK_ERROR', 'Server non raggiungibile');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let code = 'REQUEST_ERROR';
|
||||
let message = `Errore ${response.status}`;
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: { code?: string; message?: string } };
|
||||
code = payload.error?.code ?? code;
|
||||
message = payload.error?.message ?? message;
|
||||
} catch {
|
||||
// risposta non JSON: si tengono i default
|
||||
}
|
||||
throw new ApiError(response.status, code, message);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/** DTO speculari alle risposte del server (server/src/modules/*). */
|
||||
|
||||
export type Player = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
money: number;
|
||||
reputation: number;
|
||||
level: number;
|
||||
experience: number;
|
||||
currentCityId: string;
|
||||
};
|
||||
|
||||
export type AuthResponse = {
|
||||
accessToken: string;
|
||||
player: Player;
|
||||
};
|
||||
|
||||
export type PlayerMeResponse = {
|
||||
player: Player;
|
||||
currentCity: { id: string; name: string; riskLevel: number };
|
||||
xpForNextLevel: number;
|
||||
};
|
||||
|
||||
export type City = {
|
||||
id: string;
|
||||
name: string;
|
||||
riskLevel: number;
|
||||
policePressure: number;
|
||||
economyModifier: number;
|
||||
travelCost: number;
|
||||
};
|
||||
|
||||
export type TravelResponse = {
|
||||
player: Player;
|
||||
cityName: string;
|
||||
cost: number;
|
||||
};
|
||||
|
||||
export type WorldEvent = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: 'MARKET_BOOM' | 'POLICE_RAID' | 'SHORTAGE' | 'BLACKOUT';
|
||||
cityId?: string | null;
|
||||
city?: { id: string; name: string } | null;
|
||||
priceModifier: number;
|
||||
policeModifier: number;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
};
|
||||
|
||||
export type MarketEntry = {
|
||||
itemId: string;
|
||||
name: string;
|
||||
buyPrice: number;
|
||||
sellPrice: number;
|
||||
demand: number;
|
||||
supply: number;
|
||||
rarity: number;
|
||||
illegalLevel: number;
|
||||
weight: number;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type MarketResponse = {
|
||||
city: { id: string; name: string; riskLevel: number; economyModifier: number };
|
||||
prices: MarketEntry[];
|
||||
activeEvents: WorldEvent[];
|
||||
};
|
||||
|
||||
export type TradeResponse = {
|
||||
itemId: string;
|
||||
itemName: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
total: number;
|
||||
playerMoney: number;
|
||||
};
|
||||
|
||||
export type InventoryEntry = {
|
||||
itemId: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
weight: number;
|
||||
basePrice: number;
|
||||
rarity: number;
|
||||
illegalLevel: number;
|
||||
};
|
||||
|
||||
export type InventoryResponse = {
|
||||
items: InventoryEntry[];
|
||||
totalWeight: number;
|
||||
};
|
||||
|
||||
export type Mission = {
|
||||
id: string;
|
||||
cityId: string;
|
||||
title: string;
|
||||
type: 'DELIVERY' | 'THEFT' | 'SMUGGLING' | 'INTEL';
|
||||
difficulty: number;
|
||||
durationSeconds: number;
|
||||
rewardMoney: number;
|
||||
rewardXp: number;
|
||||
risk: number;
|
||||
requiredItemId: string | null;
|
||||
requiredItemName: string | null;
|
||||
requiredItemQuantity: number | null;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export type ActiveMission = {
|
||||
playerMissionId: string;
|
||||
status: 'STARTED' | 'COMPLETED' | 'FAILED';
|
||||
startedAt: string;
|
||||
completesAt: string;
|
||||
mission: Mission;
|
||||
};
|
||||
|
||||
export type StartMissionResponse = {
|
||||
playerMissionId: string;
|
||||
status: string;
|
||||
startedAt: string;
|
||||
completesAt: string;
|
||||
};
|
||||
|
||||
export type ClaimMissionResponse = {
|
||||
playerMissionId: string;
|
||||
missionTitle: string;
|
||||
success: boolean;
|
||||
successChance: number;
|
||||
rewardMoney: number;
|
||||
rewardXp: number;
|
||||
reputationChange: number;
|
||||
levelsGained: number;
|
||||
player: Player;
|
||||
};
|
||||
|
||||
export type LeaderboardEntry = {
|
||||
rank: number;
|
||||
playerId: string;
|
||||
displayName: string;
|
||||
level: number;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type LeaderboardResponse = {
|
||||
metric: 'money' | 'reputation';
|
||||
entries: LeaderboardEntry[];
|
||||
};
|
||||
Reference in New Issue
Block a user