Add Hono backend with PostgreSQL, auth, cloud saves, and leaderboard

Server app (apps/server) with Hono framework and Drizzle ORM:
- PostgreSQL schema: users, saves, leaderboard, achievements tables
- Anonymous auth with UUID tokens, optional email/password linking
- Cloud save API: list, get, upsert, delete with auto-save hook
- Leaderboard API: per-category rankings with score submission
- CORS configured for dev server ports
- Typed middleware with Hono env variables

Frontend cloud save integration:
- API client with auth token management in localStorage
- useCloudSave hook auto-saves every 300 ticks when authenticated
- Vite env type declarations for VITE_API_URL

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 17:35:18 -04:00
parent 8c9555bc08
commit 8ea6c771a1
16 changed files with 1289 additions and 9 deletions
+44
View File
@@ -0,0 +1,44 @@
import { useEffect, useRef } from 'react';
import { useGameStore } from '@/store';
import { api, getAuthToken, setAuthToken } from '@/lib/api';
import { AUTO_SAVE_INTERVAL_TICKS } from '@ai-tycoon/shared';
export function useCloudSave() {
const tickCount = useGameStore((s) => s.meta.tickCount);
const companyName = useGameStore((s) => s.meta.companyName);
const lastSaveTick = useRef(0);
useEffect(() => {
if (!companyName) return;
if (tickCount - lastSaveTick.current < AUTO_SAVE_INTERVAL_TICKS * 5) return;
const token = getAuthToken();
if (!token) return;
lastSaveTick.current = tickCount;
const state = useGameStore.getState();
const { activePage, notifications, ...gameState } = state;
api.saves.put({
companyName: state.meta.companyName,
saveVersion: state.meta.saveVersion,
gameData: gameState,
tickCount: state.meta.tickCount,
era: state.meta.currentEra,
}).catch(() => {});
}, [tickCount, companyName]);
}
export async function ensureAuth(): Promise<string | null> {
let token = getAuthToken();
if (token) return token;
try {
const result = await api.auth.anonymous();
setAuthToken(result.token);
return result.token;
} catch {
return null;
}
}
+68
View File
@@ -0,0 +1,68 @@
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
let authToken: string | null = localStorage.getItem('ai-tycoon-auth-token');
export function setAuthToken(token: string) {
authToken = token;
localStorage.setItem('ai-tycoon-auth-token', token);
}
export function getAuthToken() {
return authToken;
}
export function clearAuthToken() {
authToken = null;
localStorage.removeItem('ai-tycoon-auth-token');
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(body.error || `HTTP ${res.status}`);
}
return res.json();
}
export const api = {
auth: {
anonymous: () => request<{ userId: string; token: string }>('/api/auth/anonymous', { method: 'POST' }),
login: (email: string, password: string) =>
request<{ userId: string; token: string }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
}),
linkEmail: (email: string, password: string) =>
request<{ success: boolean }>('/api/auth/link-email', {
method: 'POST',
body: JSON.stringify({ email, password }),
}),
},
saves: {
list: () => request<{ saves: Array<{ id: string; companyName: string; era: string; tickCount: number; updatedAt: string }> }>('/api/saves'),
get: (id: string) => request<{ save: { id: string; gameData: unknown } }>(`/api/saves/${id}`),
put: (data: { companyName: string; saveVersion: number; gameData: unknown; tickCount: number; era: string }) =>
request<{ id: string }>('/api/saves', { method: 'PUT', body: JSON.stringify(data) }),
delete: (id: string) => request<{ deleted: boolean }>(`/api/saves/${id}`, { method: 'DELETE' }),
},
leaderboard: {
get: (category: string) => request<{ entries: Array<{ companyName: string; score: number; era: string; tickCount: number }> }>(`/api/leaderboard/${category}`),
submit: (data: { companyName: string; category: string; score: number; era: string; tickCount: number }) =>
request<{ entry: unknown }>('/api/leaderboard', { method: 'POST', body: JSON.stringify(data) }),
},
};
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />