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
+46
View File
@@ -0,0 +1,46 @@
import { pgTable, uuid, text, timestamp, jsonb, integer, boolean, index } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
anonToken: uuid('anon_token').defaultRandom().notNull().unique(),
email: text('email').unique(),
passwordHash: text('password_hash'),
createdAt: timestamp('created_at').defaultNow().notNull(),
lastSeenAt: timestamp('last_seen_at').defaultNow().notNull(),
});
export const saves = pgTable('saves', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').notNull().references(() => users.id),
companyName: text('company_name').notNull(),
saveVersion: integer('save_version').notNull(),
gameData: jsonb('game_data').notNull(),
tickCount: integer('tick_count').notNull().default(0),
era: text('era').notNull().default('startup'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => [
index('saves_user_id_idx').on(table.userId),
]);
export const leaderboard = pgTable('leaderboard', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').notNull().references(() => users.id),
companyName: text('company_name').notNull(),
category: text('category').notNull(),
score: integer('score').notNull(),
era: text('era').notNull(),
tickCount: integer('tick_count').notNull(),
submittedAt: timestamp('submitted_at').defaultNow().notNull(),
}, (table) => [
index('leaderboard_category_score_idx').on(table.category, table.score),
]);
export const achievements = pgTable('achievements', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').notNull().references(() => users.id),
achievementId: text('achievement_id').notNull(),
unlockedAt: timestamp('unlocked_at').defaultNow().notNull(),
}, (table) => [
index('achievements_user_id_idx').on(table.userId),
]);