Compare commits

26 Commits

Author SHA1 Message Date
josh ea3951aa0c Cleanup: extract constants, fix typecheck, add ESLint, organize store types
Balance Check / balance-simulation (push) Successful in 37s
Balance Check / multi-run-balance (push) Successful in 13m39s
CI / build-and-push (push) Failing after 19s
- Remove unused initCount state from useAuthGate hook
- Replace magic number with MAX_SAVES_PER_USER constant in saves route
- Extract duplicated EMAIL_REGEX and MIN_PASSWORD_LENGTH in auth routes
- Fix game-simulation typecheck failure by adding DOM lib to tsconfig
- Extract store UI types (ActivePage, InfraNav, etc.) to store/types.ts
- Fix let→const for non-reassigned arrays in servingPipeline
- Fix useless initial assignments in reputationSystem
- Fix ambiguous multiline array access in sanityChecks
- Add minimal ESLint config with typescript-eslint
- Add .planning/ and *.log to .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 22:45:32 -04:00
josh 6ea136083a Overhaul cloud save system: fix destructive bugs, add save management UI
CI / build-and-push (push) Successful in 57s
Stop 401 responses from wiping local saves and force-reloading. Fix logout
race condition with final cloud save before token invalidation. Replace
hard 3-failure cap with exponential backoff (2min to 30min). Switch cloud
save interval from tick-based (30s) to wall-clock (5min). Add cloud save
status indicator and Force Save button in TopBar. Show save conflict
dialog on login when both local and cloud saves exist. Add cloud save
list, download, and delete in Settings. Server now keeps 10 save snapshots
per user instead of overwriting a single save.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 21:43:00 -04:00
josh 8ef1226755 Fix logout resetting progress: auto-load cloud save after re-login
CI / build-and-push (push) Successful in 39s
After logout and re-login, the cloud save was never fetched because
useAuthGate.init() had already run with an anonymous token. Now
handleSetRegistered fetches and restores the cloud save when the user
becomes registered, so they return directly to their game.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 21:17:54 -04:00
josh f3e6a2e692 Fix background music: bright lo-fi pad instead of horror ambient
CI / build-and-push (push) Successful in 46s
Moved chords up an octave (C4-E5 range), switched to triangle waves,
faster LFO rates, all major voicings, and higher filter cutoff. The
previous version with sub-bass sine drones and ultra-slow modulation
was genuinely terrifying.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 20:20:43 -04:00
josh d609934b73 Add working sound effects and background music via Web Audio API
CI / build-and-push (push) Successful in 1m17s
Synthesized audio system with 9 distinct SFX (click, success, warning,
danger, purchase, achievement, era transition, info) mapped to all game
notifications, plus generative ambient background music with chord
progressions. Adds SFX volume slider to settings alongside existing
music volume control. No audio files or npm dependencies needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 20:12:05 -04:00
josh 1e3d50719e Merge pull request 'Revitalize backend: working cloud saves, logout, and account UX' (#13) from feature/auth-invites into main
CI / build-and-push (push) Successful in 59s
Reviewed-on: #13
2026-04-28 19:34:37 -04:00
josh 2a6629af79 Revitalize backend: working cloud saves, logout, and account UX
Cloud saves were fully built but never wired up — useCloudSave() hook was
never called, no load-from-cloud flow existed, and there was no way to
continue a saved game. Logout was completely missing (no endpoint, no UI).
Accounts felt like a gate behind the invite wall rather than real accounts.

Backend: add tokenVersion to users for server-side token invalidation,
POST /auth/logout bumps it to revoke all JWTs, GET /auth/me returns
profile, GET /saves/latest returns most recent save with full gameData.
All createToken calls now include tokenVersion. Auth middleware rejects
tokens with stale tokenVersion.

Frontend: wire up useCloudSave() in App (auto-saves every 60 ticks with
error handling), fetch cloud save on startup for registered users, show
"Continue Your Game" card on NewGameScreen, add Log Out button with
confirmation in Settings, show username in sidebar, 401 interceptor
clears auth and reloads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 19:32:03 -04:00
josh 5d30d1f4a1 Make dev menu (Ctrl+D) always available without localStorage gate
CI / build-and-push (push) Successful in 36s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 12:29:46 -04:00
josh ce2cab3404 Merge pull request 'Fix admin seed, open username/email changes, invite refresh & revocation' (#11) from feature/auth-invites into main
CI / build-and-push (push) Successful in 51s
Reviewed-on: #11
2026-04-27 22:28:23 -04:00
josh 2912d760cb Fix admin seed, open username/email changes, invite refresh & revocation
- Seed checks for any admin role instead of username='admin'
- Username change open to all registered users (was admin-only)
- New change-email endpoint requiring password confirmation
- Settings page: inline editing for username and email
- Invitations: await refresh after generate so list updates visibly
- Invitations: revoke button to delete unused invites (admin only)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 22:22:42 -04:00
josh 035d4f0385 Merge pull request 'Rename AI Tycoon to Token Empire across entire codebase' (#10) from feature/auth-invites into main
Balance Check / balance-simulation (push) Successful in 41s
Balance Check / multi-run-balance (push) Successful in 14m22s
CI / build-and-push (push) Successful in 1m23s
Reviewed-on: #10
2026-04-27 21:08:36 -04:00
josh c1cc70eeb9 Rename AI Tycoon to Token Empire across entire codebase
Balance Check / balance-simulation (pull_request) Successful in 38s
Balance Check / multi-run-balance (pull_request) Successful in 13m44s
Full rebrand: UI display text, package scope (@ai-tycoon/* -> @token-empire/*),
localStorage keys, Docker/CI image paths, database names, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 21:04:07 -04:00
josh 5e4007160c Merge pull request 'Fix empty VITE_API_URL falling back to localhost in production' (#9) from feature/auth-invites into main
CI / build-and-push (push) Successful in 36s
Reviewed-on: #9
2026-04-27 20:51:19 -04:00
josh be93e57853 Fix empty VITE_API_URL falling back to localhost in production
Use ?? instead of || so empty string (same-origin) is preserved
while undefined still falls back to localhost:3001 for dev.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 20:50:34 -04:00
josh eca244f9d4 Merge pull request 'Fix double /api prefix causing invite gate bypass and broken API calls' (#8) from feature/auth-invites into main
CI / build-and-push (push) Successful in 37s
Reviewed-on: #8
2026-04-27 20:47:40 -04:00
josh fbedcec4f2 Fix double /api prefix causing invite gate bypass and broken API calls
VITE_API_URL was /api but all API paths already included /api/,
resulting in /api/api/config etc. Config call failed silently and
defaulted to requireInvite:false. Set VITE_API_URL to empty string
so paths like /api/auth/anonymous go through nginx as-is.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 20:45:13 -04:00
josh 95b43dceec Merge pull request 'Fix health check 404: add /api/health route, remove nginx /health proxy' (#7) from feature/auth-invites into main
CI / build-and-push (push) Successful in 1m13s
Reviewed-on: #7
2026-04-27 20:29:51 -04:00
josh 65afa886af Fix health check 404: add /api/health route, remove nginx /health proxy
Frontend API_BASE is /api in production, so health check was hitting
/api/health which didn't exist. Added /api/health on the server and
removed the now-unnecessary separate nginx /health proxy rule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 20:22:00 -04:00
josh 01d9703aec Merge pull request 'Improve API error messages: show HTTP status, catch network errors' (#6) from feature/auth-invites into main
CI / build-and-push (push) Successful in 37s
Reviewed-on: #6
2026-04-27 20:14:13 -04:00
josh 7348b35475 Improve API error messages: show HTTP status, catch network errors
"Unknown error" was hiding the actual HTTP status (likely 502 from
nginx). Now shows "HTTP 502 Bad Gateway" etc. Network TypeErrors
(connection refused) also get a clear message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 20:03:34 -04:00
josh 8e5dca471e Merge pull request 'Add nginx reverse proxy for /api and /health to backend server' (#5) from feature/auth-invites into main
CI / build-and-push (push) Successful in 1m4s
Reviewed-on: #5
2026-04-27 20:00:16 -04:00
josh 6cf5bf76b3 Add nginx reverse proxy for /api and /health to backend server
The frontend builds with VITE_API_URL=/api so all API calls target
the same origin. Without a proxy rule, nginx was serving index.html
for API paths, causing JSON parse errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 19:59:32 -04:00
josh cc27c00991 Merge pull request 'Add backend health check, fetch timeouts, stale token cleanup, and error screen' (#4) from feature/auth-invites into main
CI / build-and-push (push) Successful in 39s
Reviewed-on: #4
2026-04-27 19:56:53 -04:00
josh 2ab097ec8a Add backend health check, fetch timeouts, stale token cleanup, and error screen
Frontend now checks /health before starting auth flow. Shows a clear
"Cannot Connect to Server" screen with retry button when backend is
unreachable. Stale non-JWT tokens in localStorage are detected and
cleared automatically. All API calls have a 10s timeout via AbortController.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 19:55:50 -04:00
josh c0965cb7d7 Merge pull request 'Add auto-migration on server startup' (#3) from feature/auth-invites into main
CI / build-and-push (push) Successful in 45s
Reviewed-on: #3
2026-04-27 19:43:38 -04:00
josh 066c3310ff Add auto-migration on server startup
Run Drizzle migrations before seeding admin user so tables exist
on fresh database. Migration files generated from current schema.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 19:41:10 -04:00
152 changed files with 3943 additions and 424 deletions
+3 -3
View File
@@ -35,7 +35,7 @@ jobs:
run: pnpm test run: pnpm test
- name: Run greedy simulation - name: Run greedy simulation
run: pnpm --filter @ai-tycoon/game-simulation simulate:ci run: pnpm --filter @token-empire/game-simulation simulate:ci
multi-run-balance: multi-run-balance:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -54,8 +54,8 @@ jobs:
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Run multi-simulation (100 runs) - name: Run multi-simulation (100 runs)
run: pnpm --filter @ai-tycoon/game-simulation multirun -- --runs 100 --parallel 10 --strategy persona --ticks 28800 --no-timeseries run: pnpm --filter @token-empire/game-simulation multirun -- --runs 100 --parallel 10 --strategy persona --ticks 28800 --no-timeseries
- name: Interpret results - name: Interpret results
if: always() if: always()
run: pnpm --filter @ai-tycoon/game-simulation interpret -- --summary multirun-summary.csv run: pnpm --filter @token-empire/game-simulation interpret -- --summary multirun-summary.csv
+1 -1
View File
@@ -6,7 +6,7 @@ on:
env: env:
REGISTRY: gitea.thewrightserver.net REGISTRY: gitea.thewrightserver.net
IMAGE_PREFIX: gitea.thewrightserver.net/josh/aihostingtycoon IMAGE_PREFIX: gitea.thewrightserver.net/josh/tokenempire
jobs: jobs:
build-and-push: build-and-push:
+2
View File
@@ -10,3 +10,5 @@ balance-report*.json
balance-metrics*.csv balance-metrics*.csv
multirun-summary.csv multirun-summary.csv
multirun-timeseries.csv multirun-timeseries.csv
.planning/
*.log
+3 -3
View File
@@ -1,4 +1,4 @@
# AI Tycoon # Token Empire
A browser-based incremental/idle game where you manage an AI company from a garage startup to building AGI. Navigate the real tensions of the AI industry: scaling compute, training frontier models, balancing safety vs capability, hiring talent, and competing with rival labs. A browser-based incremental/idle game where you manage an AI company from a garage startup to building AGI. Navigate the real tensions of the AI industry: scaling compute, training frontier models, balancing safety vs capability, hiring talent, and competing with rival labs.
@@ -29,7 +29,7 @@ The web app starts at `http://localhost:5173` (or the next available port). The
## Project Structure ## Project Structure
``` ```
ai-tycoon/ token-empire/
├── apps/ ├── apps/
│ ├── web/ # React frontend (Vite) │ ├── web/ # React frontend (Vite)
│ └── server/ # Hono API backend │ └── server/ # Hono API backend
@@ -81,7 +81,7 @@ pnpm clean # Clean build artifacts
The backend requires PostgreSQL for cloud saves and leaderboards. Set the connection string in `apps/server/.env`: The backend requires PostgreSQL for cloud saves and leaderboards. Set the connection string in `apps/server/.env`:
``` ```
DATABASE_URL=postgresql://user:password@localhost:5432/ai_tycoon DATABASE_URL=postgresql://user:password@localhost:5432/token_empire
``` ```
Run migrations: Run migrations:
+2 -2
View File
@@ -14,8 +14,8 @@ COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=deps /app/apps/server/node_modules ./apps/server/node_modules COPY --from=deps /app/apps/server/node_modules ./apps/server/node_modules
COPY . . COPY . .
RUN pnpm --filter @ai-tycoon/shared build && \ RUN pnpm --filter @token-empire/shared build && \
pnpm --filter @ai-tycoon/server typecheck pnpm --filter @token-empire/server typecheck
FROM base AS production FROM base AS production
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
+1 -1
View File
@@ -5,6 +5,6 @@ export default defineConfig({
schema: './src/db/schema.ts', schema: './src/db/schema.ts',
out: './drizzle', out: './drizzle',
dbCredentials: { dbCredentials: {
url: process.env.DATABASE_URL ?? 'postgresql://localhost:5432/ai_tycoon', url: process.env.DATABASE_URL ?? 'postgresql://localhost:5432/token_empire',
}, },
}); });
@@ -0,0 +1,63 @@
CREATE TABLE "achievements" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"achievement_id" text NOT NULL,
"unlocked_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "invitations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"code" text NOT NULL,
"created_by" uuid NOT NULL,
"used_by" uuid,
"created_at" timestamp DEFAULT now() NOT NULL,
"expires_at" timestamp,
CONSTRAINT "invitations_code_unique" UNIQUE("code")
);
--> statement-breakpoint
CREATE TABLE "leaderboard" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"company_name" text NOT NULL,
"category" text NOT NULL,
"score" integer NOT NULL,
"era" text NOT NULL,
"tick_count" integer NOT NULL,
"submitted_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "saves" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"company_name" text NOT NULL,
"save_version" integer NOT NULL,
"game_data" jsonb NOT NULL,
"tick_count" integer DEFAULT 0 NOT NULL,
"era" text DEFAULT 'startup' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"anon_token" uuid DEFAULT gen_random_uuid() NOT NULL,
"username" text,
"email" text,
"password_hash" text,
"role" text DEFAULT 'user' NOT NULL,
"must_reset_password" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"last_seen_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "users_anon_token_unique" UNIQUE("anon_token"),
CONSTRAINT "users_username_unique" UNIQUE("username"),
CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> statement-breakpoint
ALTER TABLE "achievements" ADD CONSTRAINT "achievements_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_used_by_users_id_fk" FOREIGN KEY ("used_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "leaderboard" ADD CONSTRAINT "leaderboard_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "saves" ADD CONSTRAINT "saves_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "achievements_user_id_idx" ON "achievements" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "leaderboard_category_score_idx" ON "leaderboard" USING btree ("category","score");--> statement-breakpoint
CREATE INDEX "saves_user_id_idx" ON "saves" USING btree ("user_id");
@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "token_version" integer DEFAULT 0 NOT NULL;
+470
View File
@@ -0,0 +1,470 @@
{
"id": "8cfe4136-b228-464d-bf2c-e4f2e8c73ce1",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.achievements": {
"name": "achievements",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"achievement_id": {
"name": "achievement_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"unlocked_at": {
"name": "unlocked_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"achievements_user_id_idx": {
"name": "achievements_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"achievements_user_id_users_id_fk": {
"name": "achievements_user_id_users_id_fk",
"tableFrom": "achievements",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.invitations": {
"name": "invitations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"code": {
"name": "code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_by": {
"name": "created_by",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"used_by": {
"name": "used_by",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"invitations_created_by_users_id_fk": {
"name": "invitations_created_by_users_id_fk",
"tableFrom": "invitations",
"tableTo": "users",
"columnsFrom": [
"created_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"invitations_used_by_users_id_fk": {
"name": "invitations_used_by_users_id_fk",
"tableFrom": "invitations",
"tableTo": "users",
"columnsFrom": [
"used_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"invitations_code_unique": {
"name": "invitations_code_unique",
"nullsNotDistinct": false,
"columns": [
"code"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.leaderboard": {
"name": "leaderboard",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"company_name": {
"name": "company_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true
},
"score": {
"name": "score",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"era": {
"name": "era",
"type": "text",
"primaryKey": false,
"notNull": true
},
"tick_count": {
"name": "tick_count",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"submitted_at": {
"name": "submitted_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"leaderboard_category_score_idx": {
"name": "leaderboard_category_score_idx",
"columns": [
{
"expression": "category",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "score",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"leaderboard_user_id_users_id_fk": {
"name": "leaderboard_user_id_users_id_fk",
"tableFrom": "leaderboard",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.saves": {
"name": "saves",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"company_name": {
"name": "company_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"save_version": {
"name": "save_version",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"game_data": {
"name": "game_data",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"tick_count": {
"name": "tick_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"era": {
"name": "era",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'startup'"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"saves_user_id_idx": {
"name": "saves_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"saves_user_id_users_id_fk": {
"name": "saves_user_id_users_id_fk",
"tableFrom": "saves",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"anon_token": {
"name": "anon_token",
"type": "uuid",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'user'"
},
"must_reset_password": {
"name": "must_reset_password",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"last_seen_at": {
"name": "last_seen_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_anon_token_unique": {
"name": "users_anon_token_unique",
"nullsNotDistinct": false,
"columns": [
"anon_token"
]
},
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
},
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+477
View File
@@ -0,0 +1,477 @@
{
"id": "9324fe22-280a-4276-ace3-820f55654ec7",
"prevId": "8cfe4136-b228-464d-bf2c-e4f2e8c73ce1",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.achievements": {
"name": "achievements",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"achievement_id": {
"name": "achievement_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"unlocked_at": {
"name": "unlocked_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"achievements_user_id_idx": {
"name": "achievements_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"achievements_user_id_users_id_fk": {
"name": "achievements_user_id_users_id_fk",
"tableFrom": "achievements",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.invitations": {
"name": "invitations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"code": {
"name": "code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_by": {
"name": "created_by",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"used_by": {
"name": "used_by",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"invitations_created_by_users_id_fk": {
"name": "invitations_created_by_users_id_fk",
"tableFrom": "invitations",
"tableTo": "users",
"columnsFrom": [
"created_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"invitations_used_by_users_id_fk": {
"name": "invitations_used_by_users_id_fk",
"tableFrom": "invitations",
"tableTo": "users",
"columnsFrom": [
"used_by"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"invitations_code_unique": {
"name": "invitations_code_unique",
"nullsNotDistinct": false,
"columns": [
"code"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.leaderboard": {
"name": "leaderboard",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"company_name": {
"name": "company_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true
},
"score": {
"name": "score",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"era": {
"name": "era",
"type": "text",
"primaryKey": false,
"notNull": true
},
"tick_count": {
"name": "tick_count",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"submitted_at": {
"name": "submitted_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"leaderboard_category_score_idx": {
"name": "leaderboard_category_score_idx",
"columns": [
{
"expression": "category",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "score",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"leaderboard_user_id_users_id_fk": {
"name": "leaderboard_user_id_users_id_fk",
"tableFrom": "leaderboard",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.saves": {
"name": "saves",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"company_name": {
"name": "company_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"save_version": {
"name": "save_version",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"game_data": {
"name": "game_data",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"tick_count": {
"name": "tick_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"era": {
"name": "era",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'startup'"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"saves_user_id_idx": {
"name": "saves_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"saves_user_id_users_id_fk": {
"name": "saves_user_id_users_id_fk",
"tableFrom": "saves",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"anon_token": {
"name": "anon_token",
"type": "uuid",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'user'"
},
"must_reset_password": {
"name": "must_reset_password",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"token_version": {
"name": "token_version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"last_seen_at": {
"name": "last_seen_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_anon_token_unique": {
"name": "users_anon_token_unique",
"nullsNotDistinct": false,
"columns": [
"anon_token"
]
},
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
},
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1777333216602,
"tag": "0000_tearful_hedge_knight",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1777417629552,
"tag": "0001_certain_aaron_stack",
"breakpoints": true
}
]
}
+3 -3
View File
@@ -1,5 +1,5 @@
{ {
"name": "@ai-tycoon/server", "name": "@token-empire/server",
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -13,7 +13,7 @@
"db:push": "drizzle-kit push" "db:push": "drizzle-kit push"
}, },
"dependencies": { "dependencies": {
"@ai-tycoon/shared": "workspace:*", "@token-empire/shared": "workspace:*",
"@hono/node-server": "^1.13.8", "@hono/node-server": "^1.13.8",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"drizzle-orm": "^0.44.2", "drizzle-orm": "^0.44.2",
@@ -23,7 +23,7 @@
"uuid": "^11.1.0" "uuid": "^11.1.0"
}, },
"devDependencies": { "devDependencies": {
"@ai-tycoon/tsconfig": "workspace:*", "@token-empire/tsconfig": "workspace:*",
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"drizzle-kit": "^0.31.1", "drizzle-kit": "^0.31.1",
"typescript": "^5.8.3" "typescript": "^5.8.3"
+14 -1
View File
@@ -1,10 +1,23 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { drizzle } from 'drizzle-orm/postgres-js'; import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import postgres from 'postgres'; import postgres from 'postgres';
import * as schema from './schema'; import * as schema from './schema';
const connectionString = process.env.DATABASE_URL ?? 'postgresql://localhost:5432/ai_tycoon'; const connectionString = process.env.DATABASE_URL ?? 'postgresql://localhost:5432/token_empire';
const client = postgres(connectionString); const client = postgres(connectionString);
export const db = drizzle(client, { schema }); export const db = drizzle(client, { schema });
export type Database = typeof db; export type Database = typeof db;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export async function runMigrations() {
const migrationClient = postgres(connectionString, { max: 1 });
const migrationDb = drizzle(migrationClient);
await migrate(migrationDb, { migrationsFolder: path.resolve(__dirname, '../../drizzle') });
await migrationClient.end();
console.log('Database migrations complete');
}
+1
View File
@@ -8,6 +8,7 @@ export const users = pgTable('users', {
passwordHash: text('password_hash'), passwordHash: text('password_hash'),
role: text('role').notNull().default('user'), role: text('role').notNull().default('user'),
mustResetPassword: boolean('must_reset_password').notNull().default(false), mustResetPassword: boolean('must_reset_password').notNull().default(false),
tokenVersion: integer('token_version').notNull().default(0),
createdAt: timestamp('created_at').defaultNow().notNull(), createdAt: timestamp('created_at').defaultNow().notNull(),
lastSeenAt: timestamp('last_seen_at').defaultNow().notNull(), lastSeenAt: timestamp('last_seen_at').defaultNow().notNull(),
}); });
+1 -1
View File
@@ -7,7 +7,7 @@ export async function seedAdmin() {
const [existing] = await db const [existing] = await db
.select() .select()
.from(users) .from(users)
.where(eq(users.username, 'admin')) .where(eq(users.role, 'admin'))
.limit(1); .limit(1);
if (existing) { if (existing) {
+4 -1
View File
@@ -6,6 +6,7 @@ import { auth } from './routes/auth';
import { savesRouter } from './routes/saves'; import { savesRouter } from './routes/saves';
import { leaderboardRouter } from './routes/leaderboard'; import { leaderboardRouter } from './routes/leaderboard';
import { invitesRouter } from './routes/invites'; import { invitesRouter } from './routes/invites';
import { runMigrations } from './db';
import { seedAdmin } from './db/seed'; import { seedAdmin } from './db/seed';
if (!process.env.JWT_SECRET) { if (!process.env.JWT_SECRET) {
@@ -25,6 +26,7 @@ app.use('*', cors({
})); }));
app.get('/health', (c) => c.json({ status: 'ok', version: '0.1.0' })); app.get('/health', (c) => c.json({ status: 'ok', version: '0.1.0' }));
app.get('/api/health', (c) => c.json({ status: 'ok', version: '0.1.0' }));
app.get('/api/config', (c) => c.json({ app.get('/api/config', (c) => c.json({
requireInvite: process.env.REQUIRE_INVITE !== 'false', requireInvite: process.env.REQUIRE_INVITE !== 'false',
@@ -38,8 +40,9 @@ app.route('/api/invites', invitesRouter);
const port = Number(process.env.PORT) || 3001; const port = Number(process.env.PORT) || 3001;
console.log(`AI Tycoon API server starting on port ${port}...`); console.log(`Token Empire API server starting on port ${port}...`);
await runMigrations();
await seedAdmin(); await seedAdmin();
serve({ fetch: app.fetch, port }); serve({ fetch: app.fetch, port });
+4 -1
View File
@@ -14,10 +14,11 @@ export async function createToken(
role: string, role: string,
username: string | null, username: string | null,
mustResetPassword: boolean, mustResetPassword: boolean,
tokenVersion: number = 0,
): Promise<string> { ): Promise<string> {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
return sign( return sign(
{ sub: userId, email, role, username, mustResetPassword, iat: now, exp: now + JWT_EXPIRY_SECONDS }, { sub: userId, email, role, username, mustResetPassword, tokenVersion, iat: now, exp: now + JWT_EXPIRY_SECONDS },
getJwtSecret(), getJwtSecret(),
); );
} }
@@ -28,6 +29,7 @@ export async function verifyToken(token: string): Promise<{
role: string; role: string;
username: string | null; username: string | null;
mustResetPassword: boolean; mustResetPassword: boolean;
tokenVersion: number;
}> { }> {
const payload = await verify(token, getJwtSecret(), 'HS256'); const payload = await verify(token, getJwtSecret(), 'HS256');
return { return {
@@ -36,5 +38,6 @@ export async function verifyToken(token: string): Promise<{
role: (payload.role as string) ?? 'user', role: (payload.role as string) ?? 'user',
username: (payload.username as string) ?? null, username: (payload.username as string) ?? null,
mustResetPassword: (payload.mustResetPassword as boolean) ?? false, mustResetPassword: (payload.mustResetPassword as boolean) ?? false,
tokenVersion: (payload.tokenVersion as number) ?? 0,
}; };
} }
+5
View File
@@ -27,6 +27,10 @@ export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
return c.json({ error: 'Invalid token' }, 401); return c.json({ error: 'Invalid token' }, 401);
} }
if (payload.tokenVersion !== user.tokenVersion) {
return c.json({ error: 'Token has been revoked' }, 401);
}
await db await db
.update(users) .update(users)
.set({ lastSeenAt: new Date() }) .set({ lastSeenAt: new Date() })
@@ -40,6 +44,7 @@ export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
email: user.email, email: user.email,
role: user.role, role: user.role,
mustResetPassword: user.mustResetPassword, mustResetPassword: user.mustResetPassword,
tokenVersion: user.tokenVersion,
}); });
await next(); await next();
} catch { } catch {
+95 -16
View File
@@ -1,5 +1,5 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { eq, or } from 'drizzle-orm'; import { eq, or, sql } from 'drizzle-orm';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { db } from '../db'; import { db } from '../db';
import { users } from '../db/schema'; import { users } from '../db/schema';
@@ -7,6 +7,9 @@ import { createToken } from '../lib/jwt';
import { authMiddleware } from '../middleware/auth'; import { authMiddleware } from '../middleware/auth';
import type { AppEnv } from '../types'; import type { AppEnv } from '../types';
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const MIN_PASSWORD_LENGTH = 8;
const auth = new Hono<AppEnv>(); const auth = new Hono<AppEnv>();
auth.post('/anonymous', async (c) => { auth.post('/anonymous', async (c) => {
@@ -15,7 +18,7 @@ auth.post('/anonymous', async (c) => {
.values({}) .values({})
.returning(); .returning();
const token = await createToken(user.id, null, 'user', null, false); const token = await createToken(user.id, null, 'user', null, false, 0);
return c.json({ userId: user.id, token }); return c.json({ userId: user.id, token });
}); });
@@ -27,11 +30,11 @@ auth.post('/register', authMiddleware, async (c) => {
inviteCode: string; inviteCode: string;
}>(); }>();
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { if (!email || !EMAIL_REGEX.test(email)) {
return c.json({ error: 'Valid email required' }, 400); return c.json({ error: 'Valid email required' }, 400);
} }
if (!password || password.length < 8) { if (!password || password.length < MIN_PASSWORD_LENGTH) {
return c.json({ error: 'Password must be at least 8 characters' }, 400); return c.json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` }, 400);
} }
if (process.env.REQUIRE_INVITE !== 'false') { if (process.env.REQUIRE_INVITE !== 'false') {
@@ -80,7 +83,7 @@ auth.post('/register', authMiddleware, async (c) => {
.where(eq(users.id, userId)) .where(eq(users.id, userId))
.returning(); .returning();
const token = await createToken(updated.id, updated.email, updated.role, updated.username, false); const token = await createToken(updated.id, updated.email, updated.role, updated.username, false, updated.tokenVersion);
return c.json({ userId: updated.id, token }); return c.json({ userId: updated.id, token });
}); });
@@ -106,7 +109,7 @@ auth.post('/login', async (c) => {
return c.json({ error: 'Invalid credentials' }, 401); return c.json({ error: 'Invalid credentials' }, 401);
} }
const token = await createToken(user.id, user.email, user.role, user.username, user.mustResetPassword); const token = await createToken(user.id, user.email, user.role, user.username, user.mustResetPassword, user.tokenVersion);
return c.json({ userId: user.id, token }); return c.json({ userId: user.id, token });
}); });
@@ -117,8 +120,8 @@ auth.post('/change-password', authMiddleware, async (c) => {
newPassword: string; newPassword: string;
}>(); }>();
if (!newPassword || newPassword.length < 8) { if (!newPassword || newPassword.length < MIN_PASSWORD_LENGTH) {
return c.json({ error: 'New password must be at least 8 characters' }, 400); return c.json({ error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters` }, 400);
} }
if (!user.mustResetPassword) { if (!user.mustResetPassword) {
@@ -141,19 +144,20 @@ auth.post('/change-password', authMiddleware, async (c) => {
} }
const passwordHash = await bcrypt.hash(newPassword, 10); const passwordHash = await bcrypt.hash(newPassword, 10);
await db const [updated] = await db
.update(users) .update(users)
.set({ passwordHash, mustResetPassword: false }) .set({ passwordHash, mustResetPassword: false, tokenVersion: sql`${users.tokenVersion} + 1` })
.where(eq(users.id, user.id)); .where(eq(users.id, user.id))
.returning({ tokenVersion: users.tokenVersion });
const token = await createToken(user.id, user.email, user.role, user.username, false); const token = await createToken(user.id, user.email, user.role, user.username, false, updated.tokenVersion);
return c.json({ success: true, token }); return c.json({ success: true, token });
}); });
auth.post('/change-username', authMiddleware, async (c) => { auth.post('/change-username', authMiddleware, async (c) => {
const user = c.get('user'); const user = c.get('user');
if (user.role !== 'admin') { if (!user.email && user.role !== 'admin') {
return c.json({ error: 'Forbidden' }, 403); return c.json({ error: 'Must be registered to change username' }, 403);
} }
const { username } = await c.req.json<{ username: string }>(); const { username } = await c.req.json<{ username: string }>();
@@ -176,8 +180,83 @@ auth.post('/change-username', authMiddleware, async (c) => {
.set({ username }) .set({ username })
.where(eq(users.id, user.id)); .where(eq(users.id, user.id));
const token = await createToken(user.id, user.email, user.role, username, user.mustResetPassword); const token = await createToken(user.id, user.email, user.role, username, user.mustResetPassword, user.tokenVersion);
return c.json({ success: true, token }); return c.json({ success: true, token });
}); });
auth.post('/change-email', authMiddleware, async (c) => {
const user = c.get('user');
if (!user.email && user.role !== 'admin') {
return c.json({ error: 'Must be registered to change email' }, 403);
}
const { email, currentPassword } = await c.req.json<{
email: string;
currentPassword: string;
}>();
if (!email || !EMAIL_REGEX.test(email)) {
return c.json({ error: 'Valid email required' }, 400);
}
if (!currentPassword) {
return c.json({ error: 'Current password required' }, 400);
}
const [dbUser] = await db
.select({ passwordHash: users.passwordHash })
.from(users)
.where(eq(users.id, user.id))
.limit(1);
if (!dbUser?.passwordHash) {
return c.json({ error: 'No password set' }, 400);
}
const valid = await bcrypt.compare(currentPassword, dbUser.passwordHash);
if (!valid) {
return c.json({ error: 'Current password is incorrect' }, 401);
}
const existing = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (existing.length > 0 && existing[0].id !== user.id) {
return c.json({ error: 'Email already in use' }, 409);
}
await db
.update(users)
.set({ email })
.where(eq(users.id, user.id));
const token = await createToken(user.id, email, user.role, user.username, user.mustResetPassword, user.tokenVersion);
return c.json({ success: true, token });
});
auth.post('/logout', authMiddleware, async (c) => {
const user = c.get('user');
await db
.update(users)
.set({ tokenVersion: sql`${users.tokenVersion} + 1` })
.where(eq(users.id, user.id));
return c.json({ success: true });
});
auth.get('/me', authMiddleware, async (c) => {
const user = c.get('user');
return c.json({
id: user.id,
username: user.username,
email: user.email,
role: user.role,
});
});
export { auth }; export { auth };
+22
View File
@@ -141,4 +141,26 @@ invitesRouter.get('/', authMiddleware, requireAdmin, async (c) => {
return c.json({ invitations: enriched }); return c.json({ invitations: enriched });
}); });
invitesRouter.delete('/:id', authMiddleware, requireAdmin, async (c) => {
const inviteId = c.req.param('id');
const [invite] = await db
.select({ id: invitations.id, usedBy: invitations.usedBy })
.from(invitations)
.where(eq(invitations.id, inviteId))
.limit(1);
if (!invite) {
return c.json({ error: 'Invitation not found' }, 404);
}
if (invite.usedBy) {
return c.json({ error: 'Cannot revoke a used invitation' }, 400);
}
await db.delete(invitations).where(eq(invitations.id, inviteId));
return c.json({ deleted: true });
});
export { invitesRouter }; export { invitesRouter };
+31 -25
View File
@@ -1,10 +1,12 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { eq, and, desc } from 'drizzle-orm'; import { eq, and, desc, notInArray } from 'drizzle-orm';
import { db } from '../db'; import { db } from '../db';
import { saves } from '../db/schema'; import { saves } from '../db/schema';
import { authMiddleware } from '../middleware/auth'; import { authMiddleware } from '../middleware/auth';
import type { AppEnv } from '../types'; import type { AppEnv } from '../types';
const MAX_SAVES_PER_USER = 10;
const savesRouter = new Hono<AppEnv>(); const savesRouter = new Hono<AppEnv>();
savesRouter.use('*', authMiddleware); savesRouter.use('*', authMiddleware);
@@ -23,11 +25,24 @@ savesRouter.get('/', async (c) => {
.from(saves) .from(saves)
.where(eq(saves.userId, userId)) .where(eq(saves.userId, userId))
.orderBy(desc(saves.updatedAt)) .orderBy(desc(saves.updatedAt))
.limit(10); .limit(MAX_SAVES_PER_USER);
return c.json({ saves: userSaves }); return c.json({ saves: userSaves });
}); });
savesRouter.get('/latest', async (c) => {
const userId = c.get('userId') as string;
const [save] = await db
.select()
.from(saves)
.where(eq(saves.userId, userId))
.orderBy(desc(saves.updatedAt))
.limit(1);
return c.json({ save: save ?? null });
});
savesRouter.get('/:id', async (c) => { savesRouter.get('/:id', async (c) => {
const userId = c.get('userId') as string; const userId = c.get('userId') as string;
const saveId = c.req.param('id'); const saveId = c.req.param('id');
@@ -55,29 +70,6 @@ savesRouter.put('/', async (c) => {
era: string; era: string;
}>(); }>();
const existing = await db
.select({ id: saves.id })
.from(saves)
.where(eq(saves.userId, userId))
.orderBy(desc(saves.updatedAt))
.limit(1);
if (existing.length > 0) {
await db
.update(saves)
.set({
companyName: body.companyName,
saveVersion: body.saveVersion,
gameData: body.gameData,
tickCount: body.tickCount,
era: body.era,
updatedAt: new Date(),
})
.where(eq(saves.id, existing[0].id));
return c.json({ id: existing[0].id, updated: true });
}
const [newSave] = await db const [newSave] = await db
.insert(saves) .insert(saves)
.values({ .values({
@@ -90,6 +82,20 @@ savesRouter.put('/', async (c) => {
}) })
.returning({ id: saves.id }); .returning({ id: saves.id });
const keepIds = await db
.select({ id: saves.id })
.from(saves)
.where(eq(saves.userId, userId))
.orderBy(desc(saves.updatedAt))
.limit(MAX_SAVES_PER_USER);
const keepSet = keepIds.map((r) => r.id);
if (keepSet.length === MAX_SAVES_PER_USER) {
await db
.delete(saves)
.where(and(eq(saves.userId, userId), notInArray(saves.id, keepSet)));
}
return c.json({ id: newSave.id, created: true }); return c.json({ id: newSave.id, created: true });
}); });
+1
View File
@@ -8,6 +8,7 @@ export type AppEnv = {
email: string | null; email: string | null;
role: string; role: string;
mustResetPassword: boolean; mustResetPassword: boolean;
tokenVersion: number;
}; };
}; };
}; };
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"extends": "@ai-tycoon/tsconfig/node.json", "extends": "@token-empire/tsconfig/node.json",
"compilerOptions": { "compilerOptions": {
"outDir": "dist", "outDir": "dist",
"rootDir": "src" "rootDir": "src"
+4 -4
View File
@@ -16,11 +16,11 @@ COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_module
COPY --from=deps /app/packages/game-engine/node_modules ./packages/game-engine/node_modules COPY --from=deps /app/packages/game-engine/node_modules ./packages/game-engine/node_modules
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
COPY . . COPY . .
ARG VITE_API_URL=/api ARG VITE_API_URL=
ENV VITE_API_URL=$VITE_API_URL ENV VITE_API_URL=$VITE_API_URL
RUN pnpm --filter @ai-tycoon/shared build && \ RUN pnpm --filter @token-empire/shared build && \
pnpm --filter @ai-tycoon/game-engine build && \ pnpm --filter @token-empire/game-engine build && \
pnpm --filter @ai-tycoon/web build pnpm --filter @token-empire/web build
FROM nginx:alpine FROM nginx:alpine
COPY --from=build /app/apps/web/dist /usr/share/nginx/html COPY --from=build /app/apps/web/dist /usr/share/nginx/html
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Tycoon</title> <title>Token Empire</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
+7
View File
@@ -3,6 +3,13 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
location /api/ {
proxy_pass http://server:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
+4 -4
View File
@@ -1,5 +1,5 @@
{ {
"name": "@ai-tycoon/web", "name": "@token-empire/web",
"private": true, "private": true,
"version": "0.0.1", "version": "0.0.1",
"type": "module", "type": "module",
@@ -10,8 +10,8 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@ai-tycoon/shared": "workspace:*", "@token-empire/shared": "workspace:*",
"@ai-tycoon/game-engine": "workspace:*", "@token-empire/game-engine": "workspace:*",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"recharts": "^2.15.0", "recharts": "^2.15.0",
@@ -19,7 +19,7 @@
"lucide-react": "^0.475.0" "lucide-react": "^0.475.0"
}, },
"devDependencies": { "devDependencies": {
"@ai-tycoon/tsconfig": "workspace:*", "@token-empire/tsconfig": "workspace:*",
"@types/react": "^19.1.0", "@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0", "@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.4.0", "@vitejs/plugin-react": "^4.4.0",
+54 -6
View File
@@ -4,10 +4,12 @@ import { MainLayout } from '@/components/layout/MainLayout';
import { NewGameScreen } from '@/components/game/NewGameScreen'; import { NewGameScreen } from '@/components/game/NewGameScreen';
import { OfflineCatchUp } from '@/components/game/OfflineCatchUp'; import { OfflineCatchUp } from '@/components/game/OfflineCatchUp';
import { InviteGateScreen } from '@/components/game/InviteGateScreen'; import { InviteGateScreen } from '@/components/game/InviteGateScreen';
import { SaveConflictDialog } from '@/components/game/SaveConflictDialog';
import { useGameLoop } from '@/hooks/useGameLoop'; import { useGameLoop } from '@/hooks/useGameLoop';
import { useAuthGate } from '@/hooks/useAuthGate'; import { useAuthGate } from '@/hooks/useAuthGate';
import { TICK_INTERVAL_MS } from '@ai-tycoon/shared'; import { useCloudSave } from '@/hooks/useCloudSave';
import { Sparkles } from 'lucide-react'; import { TICK_INTERVAL_MS } from '@token-empire/shared';
import { Sparkles, RefreshCw, WifiOff } from 'lucide-react';
function LoadingScreen() { function LoadingScreen() {
return ( return (
@@ -16,7 +18,7 @@ function LoadingScreen() {
<div className="inline-flex items-center gap-2 mb-4"> <div className="inline-flex items-center gap-2 mb-4">
<Sparkles className="text-accent-light animate-pulse" size={32} /> <Sparkles className="text-accent-light animate-pulse" size={32} />
<h1 className="text-4xl font-bold bg-gradient-to-r from-accent-light to-accent bg-clip-text text-transparent"> <h1 className="text-4xl font-bold bg-gradient-to-r from-accent-light to-accent bg-clip-text text-transparent">
AI Tycoon Token Empire
</h1> </h1>
</div> </div>
<p className="text-surface-500 text-sm">Loading...</p> <p className="text-surface-500 text-sm">Loading...</p>
@@ -25,9 +27,38 @@ function LoadingScreen() {
); );
} }
function BackendErrorScreen({ error, onRetry }: { error: string; onRetry: () => void }) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-surface-950 to-surface-900">
<div className="max-w-md w-full mx-4 text-center">
<div className="inline-flex items-center gap-2 mb-6">
<Sparkles className="text-accent-light" size={32} />
<h1 className="text-4xl font-bold bg-gradient-to-r from-accent-light to-accent bg-clip-text text-transparent">
Token Empire
</h1>
</div>
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 space-y-4">
<WifiOff className="mx-auto text-danger" size={40} />
<h2 className="text-lg font-semibold text-surface-100">Cannot Connect to Server</h2>
<p className="text-sm text-surface-400">{error}</p>
<button
onClick={onRetry}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-accent hover:bg-accent-dark text-white font-medium text-sm transition-colors"
>
<RefreshCw size={16} /> Retry
</button>
</div>
</div>
</div>
);
}
export function App() { export function App() {
const { loading: authLoading, needsInvite, needsPasswordReset, setRegistered, setNeedsPasswordReset } = useAuthGate(); const { loading: authLoading, backendError, needsInvite, needsPasswordReset, cloudSave, hasConflict, loadCloudSave, resolveConflict, setRegistered, setNeedsPasswordReset, retry } = useAuthGate();
const companyName = useGameStore((s) => s.meta.companyName); const companyName = useGameStore((s) => s.meta.companyName);
const currentEra = useGameStore((s) => s.meta.currentEra);
const tickCount = useGameStore((s) => s.meta.tickCount);
const lastTickTimestamp = useGameStore((s) => s.meta.lastTickTimestamp); const lastTickTimestamp = useGameStore((s) => s.meta.lastTickTimestamp);
const [catchUpTicks, setCatchUpTicks] = useState<number | null>(null); const [catchUpTicks, setCatchUpTicks] = useState<number | null>(null);
const [catchUpDone, setCatchUpDone] = useState(false); const [catchUpDone, setCatchUpDone] = useState(false);
@@ -43,12 +74,17 @@ export function App() {
} }
}, [companyName, lastTickTimestamp, catchUpDone]); }, [companyName, lastTickTimestamp, catchUpDone]);
useGameLoop(!catchUpDone || authLoading || needsInvite || needsPasswordReset); useGameLoop(!catchUpDone || authLoading || !!backendError || needsInvite || needsPasswordReset || hasConflict);
useCloudSave();
if (authLoading) { if (authLoading) {
return <LoadingScreen />; return <LoadingScreen />;
} }
if (backendError) {
return <BackendErrorScreen error={backendError} onRetry={retry} />;
}
if (needsInvite || needsPasswordReset) { if (needsInvite || needsPasswordReset) {
return ( return (
<InviteGateScreen <InviteGateScreen
@@ -60,8 +96,20 @@ export function App() {
); );
} }
if (hasConflict && cloudSave && companyName) {
return (
<SaveConflictDialog
localSave={{ companyName, era: currentEra, tickCount, lastTickTimestamp }}
cloudSave={cloudSave}
onChooseLocal={() => resolveConflict('local')}
onChooseCloud={() => resolveConflict('cloud')}
onNewGame={() => resolveConflict('new')}
/>
);
}
if (!companyName) { if (!companyName) {
return <NewGameScreen />; return <NewGameScreen cloudSave={cloudSave} onContinue={loadCloudSave} />;
} }
if (catchUpTicks !== null && !catchUpDone) { if (catchUpTicks !== null && !catchUpDone) {
+126
View File
@@ -0,0 +1,126 @@
import type { SoundId } from './synthesizer';
import { playSound } from './synthesizer';
import { MusicEngine } from './musicEngine';
export class AudioManager {
private static instance: AudioManager | null = null;
private ctx: AudioContext | null = null;
private masterGain: GainNode | null = null;
private sfxGain: GainNode | null = null;
private musicGain: GainNode | null = null;
private music: MusicEngine | null = null;
private musicPlaying = false;
private soundEnabled = true;
private sfxVol = 0.5;
private musicVol = 0.5;
private lastPlayed = new Map<SoundId, number>();
static getInstance(): AudioManager {
if (!AudioManager.instance) {
AudioManager.instance = new AudioManager();
}
return AudioManager.instance;
}
private ensureContext(): AudioContext {
if (!this.ctx) {
this.ctx = new AudioContext();
this.masterGain = this.ctx.createGain();
this.masterGain.connect(this.ctx.destination);
this.sfxGain = this.ctx.createGain();
this.sfxGain.connect(this.masterGain);
this.sfxGain.gain.value = this.sfxVol;
this.musicGain = this.ctx.createGain();
this.musicGain.connect(this.masterGain);
this.musicGain.gain.value = this.musicVol;
this.masterGain.gain.value = this.soundEnabled ? 1 : 0;
}
if (this.ctx.state === 'suspended') {
this.ctx.resume();
}
return this.ctx;
}
setSoundEnabled(enabled: boolean): void {
this.soundEnabled = enabled;
if (this.masterGain && this.ctx) {
const now = this.ctx.currentTime;
this.masterGain.gain.cancelScheduledValues(now);
this.masterGain.gain.setValueAtTime(this.masterGain.gain.value, now);
this.masterGain.gain.linearRampToValueAtTime(enabled ? 1 : 0, now + 0.05);
}
}
setSfxVolume(v: number): void {
this.sfxVol = v;
if (this.sfxGain && this.ctx) {
const now = this.ctx.currentTime;
this.sfxGain.gain.cancelScheduledValues(now);
this.sfxGain.gain.setValueAtTime(this.sfxGain.gain.value, now);
this.sfxGain.gain.linearRampToValueAtTime(v, now + 0.05);
}
}
setMusicVolume(v: number): void {
this.musicVol = v;
if (this.musicGain && this.ctx) {
const now = this.ctx.currentTime;
this.musicGain.gain.cancelScheduledValues(now);
this.musicGain.gain.setValueAtTime(this.musicGain.gain.value, now);
this.musicGain.gain.linearRampToValueAtTime(v, now + 0.2);
}
}
playSfx(soundId: SoundId): void {
if (!this.soundEnabled || this.sfxVol === 0) return;
const now = performance.now();
const last = this.lastPlayed.get(soundId) ?? 0;
if (now - last < 100) return;
this.lastPlayed.set(soundId, now);
const ctx = this.ensureContext();
playSound(ctx, this.sfxGain!, soundId);
}
startMusic(): void {
if (this.musicPlaying) return;
const ctx = this.ensureContext();
this.music = new MusicEngine(ctx, this.musicGain!);
this.music.start();
this.musicPlaying = true;
}
stopMusic(): void {
if (!this.musicPlaying || !this.music) return;
this.music.stop();
this.music = null;
this.musicPlaying = false;
}
get isMusicPlaying(): boolean {
return this.musicPlaying;
}
get isContextActive(): boolean {
return this.ctx !== null && this.ctx.state === 'running';
}
dispose(): void {
this.stopMusic();
if (this.ctx) {
this.ctx.close();
this.ctx = null;
this.masterGain = null;
this.sfxGain = null;
this.musicGain = null;
}
this.lastPlayed.clear();
AudioManager.instance = null;
}
}
+69
View File
@@ -0,0 +1,69 @@
import { AudioManager } from './AudioManager';
import type { GameSettings } from '@token-empire/shared';
import { useGameStore } from '@/store';
export { AudioManager } from './AudioManager';
export { triggerNotificationSound, playUISound } from './sounds';
export type { SoundId } from './synthesizer';
function applyVolumes(audio: AudioManager, settings: GameSettings): void {
audio.setSoundEnabled(settings.soundEnabled);
audio.setMusicVolume(settings.musicVolume);
audio.setSfxVolume(settings.sfxVolume ?? 0.5);
}
function shouldPlayMusic(settings: GameSettings): boolean {
return settings.soundEnabled && settings.musicVolume > 0;
}
export function initAudioSystem(): () => void {
const audio = AudioManager.getInstance();
let gestureListenerActive = false;
const settings = useGameStore.getState().meta.settings;
applyVolumes(audio, settings);
// Music requires a user gesture to start (browser autoplay policy).
// We register a one-shot listener that starts music on first click/key.
const startOnGesture = () => {
const s = useGameStore.getState().meta.settings;
if (shouldPlayMusic(s)) {
audio.startMusic();
}
document.removeEventListener('click', startOnGesture);
document.removeEventListener('keydown', startOnGesture);
gestureListenerActive = false;
};
if (shouldPlayMusic(settings)) {
document.addEventListener('click', startOnGesture);
document.addEventListener('keydown', startOnGesture);
gestureListenerActive = true;
}
const unsub = useGameStore.subscribe((state) => {
const next = state.meta.settings;
applyVolumes(audio, next);
if (shouldPlayMusic(next)) {
if (!audio.isMusicPlaying) {
if (audio.isContextActive) {
audio.startMusic();
} else if (!gestureListenerActive) {
document.addEventListener('click', startOnGesture);
document.addEventListener('keydown', startOnGesture);
gestureListenerActive = true;
}
}
} else {
audio.stopMusic();
}
});
return () => {
unsub();
document.removeEventListener('click', startOnGesture);
document.removeEventListener('keydown', startOnGesture);
audio.dispose();
};
}
+157
View File
@@ -0,0 +1,157 @@
// Bright, lo-fi ambient pad in C major — pleasant background for a tech management game.
// Higher register, all major/bright voicings, gentle triangle waves, moderate LFO rates.
const CHORDS: number[][] = [
[261.63, 329.63, 392.00, 493.88], // Cmaj7: C4, E4, G4, B4
[349.23, 440.00, 523.25, 659.25], // Fmaj7: F4, A4, C5, E5
[293.66, 369.99, 440.00, 523.25], // Dm7: D4, F#4, A4, C5
[196.00, 246.94, 293.66, 392.00], // Gadd9: G3, B3, D4, G4
];
const CHORD_DURATION = 24;
export class MusicEngine {
private ctx: AudioContext;
private dest: AudioNode;
private oscs: OscillatorNode[] = [];
private lfos: OscillatorNode[] = [];
private gains: GainNode[] = [];
private shimmerOsc: OscillatorNode | null = null;
private shimmerGain: GainNode | null = null;
private shimmerLfo: OscillatorNode | null = null;
private filter: BiquadFilterNode | null = null;
private filterLfo: OscillatorNode | null = null;
private filterLfoGain: GainNode | null = null;
private chordIndex = 0;
private timer: ReturnType<typeof setInterval> | null = null;
private running = false;
constructor(ctx: AudioContext, dest: AudioNode) {
this.ctx = ctx;
this.dest = dest;
}
start(): void {
if (this.running) return;
this.running = true;
// Warm lowpass keeps it soft
this.filter = this.ctx.createBiquadFilter();
this.filter.type = 'lowpass';
this.filter.frequency.value = 2200;
this.filter.Q.value = 0.3;
this.filter.connect(this.dest);
// Gentle filter movement
this.filterLfo = this.ctx.createOscillator();
this.filterLfoGain = this.ctx.createGain();
this.filterLfo.type = 'sine';
this.filterLfo.frequency.value = 0.04;
this.filterLfoGain.gain.value = 400;
this.filterLfo.connect(this.filterLfoGain).connect(this.filter.frequency);
this.filterLfo.start();
// Chord pad — triangle waves for warmth without the drone menace
const chord = CHORDS[0];
for (let i = 0; i < chord.length; i++) {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
const lfo = this.ctx.createOscillator();
const lfoGain = this.ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = chord[i];
gain.gain.value = 0.03;
// Moderate breathing — fast enough to feel alive, not ominous
lfo.type = 'sine';
lfo.frequency.value = 0.12 + i * 0.04;
lfoGain.gain.value = 0.015;
lfo.connect(lfoGain).connect(gain.gain);
osc.connect(gain).connect(this.filter!);
osc.start();
lfo.start();
this.oscs.push(osc);
this.lfos.push(lfo);
this.gains.push(gain);
}
// High sparkle layer
this.shimmerOsc = this.ctx.createOscillator();
this.shimmerGain = this.ctx.createGain();
this.shimmerLfo = this.ctx.createOscillator();
const shimmerLfoGain = this.ctx.createGain();
this.shimmerOsc.type = 'sine';
this.shimmerOsc.frequency.value = 1046.50; // C6
this.shimmerGain.gain.value = 0.008;
this.shimmerLfo.type = 'sine';
this.shimmerLfo.frequency.value = 0.06;
shimmerLfoGain.gain.value = 0.007;
this.shimmerLfo.connect(shimmerLfoGain).connect(this.shimmerGain.gain);
this.shimmerOsc.connect(this.shimmerGain).connect(this.filter!);
this.shimmerOsc.start();
this.shimmerLfo.start();
this.lfos.push(this.shimmerLfo);
this.chordIndex = 0;
this.timer = setInterval(() => this.nextChord(), CHORD_DURATION * 1000);
}
private nextChord(): void {
this.chordIndex = (this.chordIndex + 1) % CHORDS.length;
const chord = CHORDS[this.chordIndex];
const now = this.ctx.currentTime;
for (let i = 0; i < chord.length; i++) {
const osc = this.oscs[i];
osc.frequency.cancelScheduledValues(now);
osc.frequency.setValueAtTime(osc.frequency.value, now);
osc.frequency.linearRampToValueAtTime(chord[i], now + 3);
}
if (this.shimmerOsc) {
const shimmerFreq = chord[2] * 2; // Fifth of chord, up an octave
this.shimmerOsc.frequency.cancelScheduledValues(now);
this.shimmerOsc.frequency.setValueAtTime(this.shimmerOsc.frequency.value, now);
this.shimmerOsc.frequency.linearRampToValueAtTime(shimmerFreq, now + 3);
}
}
stop(): void {
if (!this.running) return;
this.running = false;
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
for (const osc of this.oscs) {
try { osc.stop(); } catch { /* already stopped */ }
}
for (const lfo of this.lfos) {
try { lfo.stop(); } catch { /* already stopped */ }
}
if (this.shimmerOsc) {
try { this.shimmerOsc.stop(); } catch { /* already stopped */ }
}
if (this.filterLfo) {
try { this.filterLfo.stop(); } catch { /* already stopped */ }
}
this.oscs = [];
this.lfos = [];
this.gains = [];
this.shimmerOsc = null;
this.shimmerGain = null;
this.shimmerLfo = null;
this.filter = null;
this.filterLfo = null;
this.filterLfoGain = null;
}
}
+54
View File
@@ -0,0 +1,54 @@
import { AudioManager } from './AudioManager';
import type { SoundId } from './synthesizer';
export type { SoundId } from './synthesizer';
const NOTIFICATION_SOUND_MAP: Record<string, SoundId> = {
'Training Complete': 'success-major',
'Research Complete': 'success',
'Model Deployed': 'success-major',
'Variant Deployed': 'success',
'Cluster Online': 'success',
'Campus Ready': 'success',
'Data Center Online': 'success',
'Retrofit Complete': 'success',
'Campus Retrofit Complete': 'success',
'Breakthrough!': 'success-major',
'Variant Created': 'success',
'Model Open Sourced': 'purchase',
'Loss Spike': 'warning',
'Training Instability': 'warning',
'Hardware Failure': 'warning',
'Network Switch Failure': 'warning',
'Safety Incident!': 'danger',
'Core Network Failure': 'danger',
'Data Contamination': 'danger',
'Training Started': 'info',
'Quantization Started': 'info',
'Pre-training Complete': 'info',
'SFT Complete': 'info',
'Achievement Unlocked!': 'achievement',
'Era Transition!': 'era',
};
const TYPE_FALLBACK: Record<string, SoundId> = {
success: 'success',
warning: 'warning',
danger: 'danger',
info: 'info',
};
export function triggerNotificationSound(n: { title: string; type: string }): void {
const soundId = NOTIFICATION_SOUND_MAP[n.title] ?? TYPE_FALLBACK[n.type];
if (soundId) {
AudioManager.getInstance().playSfx(soundId);
}
}
export function playUISound(soundId: SoundId = 'click'): void {
AudioManager.getInstance().playSfx(soundId);
}
+267
View File
@@ -0,0 +1,267 @@
export type SoundId =
| 'click'
| 'success'
| 'success-major'
| 'warning'
| 'danger'
| 'purchase'
| 'achievement'
| 'era'
| 'info';
export function playSound(ctx: AudioContext, dest: AudioNode, id: SoundId): void {
const fn = SOUNDS[id];
if (fn) fn(ctx, dest);
}
type SynthFn = (ctx: AudioContext, dest: AudioNode) => void;
const SOUNDS: Record<SoundId, SynthFn> = {
click: playClick,
success: playSuccess,
'success-major': playSuccessMajor,
warning: playWarning,
danger: playDanger,
purchase: playPurchase,
achievement: playAchievement,
era: playEra,
info: playInfo,
};
function playClick(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = 800;
gain.gain.setValueAtTime(0.3, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
osc.connect(gain).connect(dest);
osc.start(now);
osc.stop(now + 0.06);
}
function playSuccess(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
const notes = [523.25, 659.25]; // C5, E5
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = freq;
const start = now + i * 0.1;
gain.gain.setValueAtTime(0, start);
gain.gain.linearRampToValueAtTime(0.25, start + 0.02);
gain.gain.exponentialRampToValueAtTime(0.001, start + 0.15);
osc.connect(gain).connect(dest);
osc.start(start);
osc.stop(start + 0.16);
});
}
function playSuccessMajor(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
const notes = [523.25, 659.25, 783.99]; // C5, E5, G5
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 2000;
osc.type = 'sawtooth';
osc.frequency.value = freq;
const start = now + i * 0.09;
gain.gain.setValueAtTime(0, start);
gain.gain.linearRampToValueAtTime(0.2, start + 0.02);
gain.gain.exponentialRampToValueAtTime(0.001, start + 0.2);
osc.connect(filter).connect(gain).connect(dest);
osc.start(start);
osc.stop(start + 0.22);
});
}
function playWarning(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
const osc1 = ctx.createOscillator();
const osc2 = ctx.createOscillator();
const gain = ctx.createGain();
const lfo = ctx.createOscillator();
const lfoGain = ctx.createGain();
osc1.type = 'square';
osc1.frequency.value = 300;
osc2.type = 'square';
osc2.frequency.value = 305;
lfo.type = 'sine';
lfo.frequency.value = 8;
lfoGain.gain.value = 0.15;
lfo.connect(lfoGain).connect(gain.gain);
gain.gain.setValueAtTime(0.15, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.25);
osc1.connect(gain);
osc2.connect(gain);
gain.connect(dest);
osc1.start(now);
osc2.start(now);
lfo.start(now);
osc1.stop(now + 0.26);
osc2.stop(now + 0.26);
lfo.stop(now + 0.26);
}
function playDanger(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
// Sub-bass hit
const sub = ctx.createOscillator();
const subGain = ctx.createGain();
sub.type = 'sine';
sub.frequency.value = 150;
subGain.gain.setValueAtTime(0.3, now);
subGain.gain.exponentialRampToValueAtTime(0.001, now + 0.35);
sub.connect(subGain).connect(dest);
sub.start(now);
sub.stop(now + 0.36);
// Noise burst via oscillator with rapid detuning
const noise = ctx.createOscillator();
const noiseGain = ctx.createGain();
const noiseFilter = ctx.createBiquadFilter();
noise.type = 'sawtooth';
noise.frequency.value = 80;
noise.detune.value = 1200;
noiseFilter.type = 'lowpass';
noiseFilter.frequency.value = 400;
noiseGain.gain.setValueAtTime(0.2, now);
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
noise.connect(noiseFilter).connect(noiseGain).connect(dest);
noise.start(now);
noise.stop(now + 0.16);
}
function playPurchase(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
// Pitch slide
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(1200, now);
osc.frequency.exponentialRampToValueAtTime(800, now + 0.1);
gain.gain.setValueAtTime(0.2, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
osc.connect(gain).connect(dest);
osc.start(now);
osc.stop(now + 0.13);
// Short noise tick
const tick = ctx.createOscillator();
const tickGain = ctx.createGain();
tick.type = 'square';
tick.frequency.value = 3000;
tickGain.gain.setValueAtTime(0.1, now);
tickGain.gain.exponentialRampToValueAtTime(0.001, now + 0.02);
tick.connect(tickGain).connect(dest);
tick.start(now);
tick.stop(now + 0.03);
}
function playAchievement(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
const notes = [523.25, 659.25, 783.99, 1046.5]; // C5, E5, G5, C6
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = freq;
const start = now + i * 0.1;
gain.gain.setValueAtTime(0, start);
gain.gain.linearRampToValueAtTime(0.22, start + 0.02);
gain.gain.setValueAtTime(0.22, start + 0.08);
gain.gain.exponentialRampToValueAtTime(0.001, start + 0.25);
osc.connect(gain).connect(dest);
osc.start(start);
osc.stop(start + 0.26);
});
// Echo layer
notes.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
const start = now + i * 0.1 + 0.15;
gain.gain.setValueAtTime(0, start);
gain.gain.linearRampToValueAtTime(0.07, start + 0.02);
gain.gain.exponentialRampToValueAtTime(0.001, start + 0.3);
osc.connect(gain).connect(dest);
osc.start(start);
osc.stop(start + 0.31);
});
}
function playEra(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
// Low sine sweep
const sweep = ctx.createOscillator();
const sweepGain = ctx.createGain();
sweep.type = 'sine';
sweep.frequency.setValueAtTime(100, now);
sweep.frequency.exponentialRampToValueAtTime(400, now + 0.6);
sweepGain.gain.setValueAtTime(0, now);
sweepGain.gain.linearRampToValueAtTime(0.25, now + 0.1);
sweepGain.gain.setValueAtTime(0.25, now + 0.5);
sweepGain.gain.exponentialRampToValueAtTime(0.001, now + 0.8);
sweep.connect(sweepGain).connect(dest);
sweep.start(now);
sweep.stop(now + 0.81);
// High shimmer
const shimmer = ctx.createOscillator();
const shimmerGain = ctx.createGain();
const shimmerFilter = ctx.createBiquadFilter();
shimmer.type = 'sawtooth';
shimmer.frequency.value = 2000;
shimmerFilter.type = 'highpass';
shimmerFilter.frequency.value = 4000;
shimmerGain.gain.setValueAtTime(0, now + 0.2);
shimmerGain.gain.linearRampToValueAtTime(0.08, now + 0.35);
shimmerGain.gain.exponentialRampToValueAtTime(0.001, now + 0.7);
shimmer.connect(shimmerFilter).connect(shimmerGain).connect(dest);
shimmer.start(now + 0.2);
shimmer.stop(now + 0.71);
// Impact
const impact = ctx.createOscillator();
const impactGain = ctx.createGain();
impact.type = 'sine';
impact.frequency.value = 200;
impactGain.gain.setValueAtTime(0.3, now + 0.6);
impactGain.gain.exponentialRampToValueAtTime(0.001, now + 1.0);
impact.connect(impactGain).connect(dest);
impact.start(now + 0.6);
impact.stop(now + 1.01);
}
function playInfo(ctx: AudioContext, dest: AudioNode): void {
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = 880;
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(0.18, now + 0.01);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.08);
osc.connect(gain).connect(dest);
osc.start(now);
osc.stop(now + 0.09);
}
@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { X, CheckCircle, AlertTriangle, AlertCircle, Info, Bell, Trash2 } from 'lucide-react'; import { X, CheckCircle, AlertTriangle, AlertCircle, Info, Bell, Trash2 } from 'lucide-react';
import { useGameStore, type GameNotification } from '@/store'; import { useGameStore, type GameNotification } from '@/store';
import { formatDuration } from '@ai-tycoon/shared'; import { formatDuration } from '@token-empire/shared';
const ICON_MAP = { const ICON_MAP = {
success: { icon: CheckCircle, color: 'text-success' }, success: { icon: CheckCircle, color: 'text-success' },
+2 -5
View File
@@ -18,16 +18,13 @@ export function DevMenu() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [activeTab, setActiveTab] = useState<Tab>('resources'); const [activeTab, setActiveTab] = useState<Tab>('resources');
const isEnabled = import.meta.env.DEV || localStorage.getItem('ai-tycoon-dev-menu') === 'true';
useEffect(() => { useEffect(() => {
if (!isEnabled) return;
const handler = () => setIsOpen((o) => !o); const handler = () => setIsOpen((o) => !o);
window.addEventListener('toggle-dev-menu', handler); window.addEventListener('toggle-dev-menu', handler);
return () => window.removeEventListener('toggle-dev-menu', handler); return () => window.removeEventListener('toggle-dev-menu', handler);
}, [isEnabled]); }, []);
if (!isEnabled || !isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed bottom-4 right-4 z-50 w-[440px] max-h-[520px] flex flex-col bg-surface-900 border border-surface-700 rounded-lg shadow-2xl"> <div className="fixed bottom-4 right-4 z-50 w-[440px] max-h-[520px] flex flex-col bg-surface-900 border border-surface-700 rounded-lg shadow-2xl">
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import type { FundingRoundType } from '@ai-tycoon/shared'; import type { FundingRoundType } from '@token-empire/shared';
function DevButton({ onClick, children, variant = 'default' }: { function DevButton({ onClick, children, variant = 'default' }: {
onClick: () => void; onClick: () => void;
+1 -1
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { formatMoney } from '@ai-tycoon/shared'; import { formatMoney } from '@token-empire/shared';
function DevButton({ onClick, children, variant = 'default' }: { function DevButton({ onClick, children, variant = 'default' }: {
onClick: () => void; onClick: () => void;
@@ -1,5 +1,5 @@
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { formatMoney, formatNumber, formatFlops, formatPercent } from '@ai-tycoon/shared'; import { formatMoney, formatNumber, formatFlops, formatPercent } from '@token-empire/shared';
function Section({ title, children }: { title: string; children: React.ReactNode }) { function Section({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { processTick, setAchievementDefinitions, ACHIEVEMENT_DEFINITIONS, TECH_TREE } from '@ai-tycoon/game-engine'; import { processTick, setAchievementDefinitions, ACHIEVEMENT_DEFINITIONS, TECH_TREE } from '@token-empire/game-engine';
import type { GameState, Era } from '@ai-tycoon/shared'; import type { GameState, Era } from '@token-empire/shared';
function DevButton({ onClick, children, variant = 'default' }: { function DevButton({ onClick, children, variant = 'default' }: {
onClick: () => void; onClick: () => void;
@@ -0,0 +1,41 @@
import { Cloud, CloudOff, Check, AlertTriangle, Loader2 } from 'lucide-react';
import { useCloudSaveStore, type CloudSaveStatus } from '@/hooks/useCloudSave';
import { Tooltip } from '@/components/common/Tooltip';
function formatTimeAgo(ms: number): string {
const seconds = Math.floor((Date.now() - ms) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
return `${hours}h ago`;
}
const STATUS_CONFIG: Record<CloudSaveStatus, { icon: typeof Cloud; color: string; label: string }> = {
idle: { icon: Cloud, color: 'text-surface-500', label: 'Cloud save idle' },
saving: { icon: Loader2, color: 'text-accent', label: 'Saving to cloud...' },
success: { icon: Check, color: 'text-success', label: 'Saved to cloud' },
error: { icon: AlertTriangle, color: 'text-warning', label: 'Cloud save failed' },
offline: { icon: CloudOff, color: 'text-surface-500', label: 'Cloud save offline' },
};
export function CloudSaveIndicator({ onForceSave }: { onForceSave: () => void }) {
const status = useCloudSaveStore((s) => s.status);
const lastSaveTime = useCloudSaveStore((s) => s.lastSaveTime);
const config = STATUS_CONFIG[status];
const Icon = config.icon;
const timeLabel = lastSaveTime ? `Last saved: ${formatTimeAgo(lastSaveTime)}` : 'Not yet saved';
return (
<Tooltip content={<div className="space-y-1"><div>{config.label}</div><div className="text-surface-400">{timeLabel}</div><div className="text-surface-500 text-xs">Click to save now</div></div>}>
<button
onClick={onForceSave}
className={`p-2 rounded hover:bg-surface-800 transition-colors ${config.color}`}
aria-label="Cloud save"
>
<Icon size={18} className={status === 'saving' ? 'animate-spin' : ''} />
</button>
</Tooltip>
);
}
@@ -0,0 +1,195 @@
import { useState, useEffect } from 'react';
import { Download, Trash2, Upload, RefreshCw, Cloud } from 'lucide-react';
import { api } from '@/lib/api';
import { useGameStore } from '@/store';
import { formatDuration } from '@token-empire/shared';
import { ConfirmModal } from '@/components/common/ConfirmModal';
interface SaveEntry {
id: string;
companyName: string;
era: string;
tickCount: number;
updatedAt: string;
}
function timeAgo(dateStr: string): string {
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
export function CloudSaveList() {
const [saves, setSaves] = useState<SaveEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [loadConfirm, setLoadConfirm] = useState<SaveEntry | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<SaveEntry | null>(null);
const addNotification = useGameStore((s) => s.addNotification);
async function fetchSaves() {
setLoading(true);
setError(null);
try {
const { saves: list } = await api.saves.list();
setSaves(list);
} catch {
setError('Failed to load cloud saves');
} finally {
setLoading(false);
}
}
useEffect(() => { fetchSaves(); }, []);
async function handleLoad(save: SaveEntry) {
try {
const { save: full } = await api.saves.get(save.id);
if (full?.gameData) {
useGameStore.setState(full.gameData as Record<string, unknown>);
addNotification({
title: 'Cloud Save Loaded',
message: `Loaded "${save.companyName}" from cloud.`,
type: 'success',
tick: useGameStore.getState().meta.tickCount,
});
}
} catch {
addNotification({
title: 'Load Failed',
message: 'Could not load cloud save.',
type: 'danger',
tick: useGameStore.getState().meta.tickCount,
});
}
setLoadConfirm(null);
}
async function handleDownload(save: SaveEntry) {
try {
const { save: full } = await api.saves.get(save.id);
if (full?.gameData) {
const blob = new Blob([JSON.stringify(full.gameData)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `token-empire-cloud-${save.companyName.replace(/\s+/g, '-').toLowerCase()}.json`;
a.click();
URL.revokeObjectURL(url);
}
} catch {
addNotification({
title: 'Download Failed',
message: 'Could not download cloud save.',
type: 'danger',
tick: useGameStore.getState().meta.tickCount,
});
}
}
async function handleDelete(save: SaveEntry) {
try {
await api.saves.delete(save.id);
setSaves((prev) => prev.filter((s) => s.id !== save.id));
} catch {
addNotification({
title: 'Delete Failed',
message: 'Could not delete cloud save.',
type: 'danger',
tick: useGameStore.getState().meta.tickCount,
});
}
setDeleteConfirm(null);
}
if (loading) {
return (
<div className="flex items-center gap-2 text-sm text-surface-400 py-2">
<RefreshCw size={14} className="animate-spin" /> Loading cloud saves...
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-between text-sm text-surface-400 py-2">
<span>{error}</span>
<button onClick={fetchSaves} className="text-accent hover:text-accent-light text-xs">Retry</button>
</div>
);
}
if (saves.length === 0) {
return (
<div className="flex items-center gap-2 text-sm text-surface-400 py-2">
<Cloud size={14} /> No cloud saves yet.
</div>
);
}
return (
<>
<div className="space-y-2">
{saves.map((save) => (
<div key={save.id} className="flex items-center justify-between bg-surface-800 rounded-lg px-3 py-2 border border-surface-700">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{save.companyName}</div>
<div className="text-xs text-surface-400">
{save.era} &middot; {formatDuration(save.tickCount)} &middot; {timeAgo(save.updatedAt)}
</div>
</div>
<div className="flex items-center gap-1 ml-3 shrink-0">
<button
onClick={() => setLoadConfirm(save)}
className="p-1.5 rounded hover:bg-surface-700 text-surface-400 hover:text-accent transition-colors"
title="Load this save"
>
<Upload size={14} />
</button>
<button
onClick={() => handleDownload(save)}
className="p-1.5 rounded hover:bg-surface-700 text-surface-400 hover:text-surface-200 transition-colors"
title="Download as JSON"
>
<Download size={14} />
</button>
<button
onClick={() => setDeleteConfirm(save)}
className="p-1.5 rounded hover:bg-surface-700 text-surface-400 hover:text-danger transition-colors"
title="Delete this save"
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
{loadConfirm && (
<ConfirmModal
title="Load Cloud Save"
message={`Load "${loadConfirm.companyName}"? This will replace your current local game.`}
confirmLabel="Load"
onConfirm={() => handleLoad(loadConfirm)}
onCancel={() => setLoadConfirm(null)}
/>
)}
{deleteConfirm && (
<ConfirmModal
title="Delete Cloud Save"
message={`Delete "${deleteConfirm.companyName}" from the cloud? This cannot be undone.`}
confirmLabel="Delete"
danger
onConfirm={() => handleDelete(deleteConfirm)}
onCancel={() => setDeleteConfirm(null)}
/>
)}
</>
);
}
@@ -1,8 +1,8 @@
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { formatMoney, formatNumber, formatPercent } from '@ai-tycoon/shared'; import { formatMoney, formatNumber, formatPercent } from '@token-empire/shared';
import { Share2, Copy, Check } from 'lucide-react'; import { Share2, Copy, Check } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { ACHIEVEMENT_DEFINITIONS } from '@ai-tycoon/game-engine'; import { ACHIEVEMENT_DEFINITIONS } from '@token-empire/game-engine';
export function CompanyStatsCard({ onClose }: { onClose: () => void }) { export function CompanyStatsCard({ onClose }: { onClose: () => void }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -25,7 +25,7 @@ export function CompanyStatsCard({ onClose }: { onClose: () => void }) {
const minutes = Math.floor((totalPlayTime % 3600) / 60); const minutes = Math.floor((totalPlayTime % 3600) / 60);
const statsText = [ const statsText = [
`${companyName}AI Tycoon`, `${companyName}Token Empire`,
`Era: ${eraLabel} | Playtime: ${hours}h ${minutes}m`, `Era: ${eraLabel} | Playtime: ${hours}h ${minutes}m`,
`Cash: ${formatMoney(money)} | Revenue: ${formatMoney(totalRevenue)}`, `Cash: ${formatMoney(money)} | Revenue: ${formatMoney(totalRevenue)}`,
`Valuation: ${formatMoney(valuation)}`, `Valuation: ${formatMoney(valuation)}`,
@@ -111,7 +111,7 @@ export function InviteGateScreen({ onRegistered }: { onRegistered: () => void })
<div className="inline-flex items-center gap-2 mb-4"> <div className="inline-flex items-center gap-2 mb-4">
<Sparkles className="text-accent-light" size={32} /> <Sparkles className="text-accent-light" size={32} />
<h1 className="text-4xl font-bold bg-gradient-to-r from-accent-light to-accent bg-clip-text text-transparent"> <h1 className="text-4xl font-bold bg-gradient-to-r from-accent-light to-accent bg-clip-text text-transparent">
AI Tycoon Token Empire
</h1> </h1>
</div> </div>
<p className="text-surface-400 text-sm"> <p className="text-surface-400 text-sm">
+69 -4
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Sparkles } from 'lucide-react'; import { Sparkles, Cloud, Play } from 'lucide-react';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import type { CloudSaveInfo } from '@/hooks/useAuthGate';
const SUGGESTED_NAMES = [ const SUGGESTED_NAMES = [
'Nexus AI', 'Cortex Labs', 'Synapse Technologies', 'Nexus AI', 'Cortex Labs', 'Synapse Technologies',
@@ -8,8 +9,32 @@ const SUGGESTED_NAMES = [
'Neural Forge', 'DeepMind+', 'Cerebral Systems', 'Neural Forge', 'DeepMind+', 'Cerebral Systems',
]; ];
export function NewGameScreen() { const ERA_LABELS: Record<string, string> = {
startup: 'Startup',
scaleup: 'Scale-Up',
bigtech: 'Big Tech',
agi: 'AGI',
};
function formatTimeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60_000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
interface Props {
cloudSave?: CloudSaveInfo | null;
onContinue?: () => Promise<void>;
}
export function NewGameScreen({ cloudSave, onContinue }: Props) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [loading, setLoading] = useState(false);
const startNewGame = useGameStore((s) => s.startNewGame); const startNewGame = useGameStore((s) => s.startNewGame);
const handleStart = () => { const handleStart = () => {
@@ -17,6 +42,16 @@ export function NewGameScreen() {
startNewGame(companyName); startNewGame(companyName);
}; };
const handleContinue = async () => {
if (!onContinue) return;
setLoading(true);
try {
await onContinue();
} finally {
setLoading(false);
}
};
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-surface-950 to-surface-900"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-surface-950 to-surface-900">
<div className="max-w-md w-full mx-4"> <div className="max-w-md w-full mx-4">
@@ -24,7 +59,7 @@ export function NewGameScreen() {
<div className="inline-flex items-center gap-2 mb-4"> <div className="inline-flex items-center gap-2 mb-4">
<Sparkles className="text-accent-light" size={32} /> <Sparkles className="text-accent-light" size={32} />
<h1 className="text-4xl font-bold bg-gradient-to-r from-accent-light to-accent bg-clip-text text-transparent"> <h1 className="text-4xl font-bold bg-gradient-to-r from-accent-light to-accent bg-clip-text text-transparent">
AI Tycoon Token Empire
</h1> </h1>
</div> </div>
<p className="text-surface-400 text-sm"> <p className="text-surface-400 text-sm">
@@ -32,7 +67,37 @@ export function NewGameScreen() {
</p> </p>
</div> </div>
{cloudSave && onContinue && (
<div className="bg-surface-900 border border-accent/30 rounded-xl p-6 mb-4 space-y-4">
<div className="flex items-center gap-2 text-accent-light">
<Cloud size={18} />
<h3 className="font-semibold text-sm">Continue Your Game</h3>
</div>
<div className="space-y-1">
<div className="text-lg font-semibold text-surface-100">{cloudSave.companyName}</div>
<div className="flex items-center gap-3 text-xs text-surface-400">
<span className="px-2 py-0.5 rounded-full bg-surface-800 border border-surface-700">
{ERA_LABELS[cloudSave.era] ?? cloudSave.era}
</span>
<span>Tick {cloudSave.tickCount.toLocaleString()}</span>
<span>Saved {formatTimeAgo(cloudSave.updatedAt)}</span>
</div>
</div>
<button
onClick={handleContinue}
disabled={loading}
className="w-full inline-flex items-center justify-center gap-2 bg-accent hover:bg-accent-dark text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
>
<Play size={16} />
{loading ? 'Loading...' : 'Continue'}
</button>
</div>
)}
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 space-y-6"> <div className="bg-surface-900 border border-surface-700 rounded-xl p-6 space-y-6">
{cloudSave && onContinue && (
<div className="text-xs text-surface-500 uppercase tracking-wider font-medium">Or start fresh</div>
)}
<div> <div>
<label className="block text-sm font-medium text-surface-300 mb-2"> <label className="block text-sm font-medium text-surface-300 mb-2">
Name your AI company Name your AI company
@@ -44,7 +109,7 @@ export function NewGameScreen() {
onKeyDown={(e) => e.key === 'Enter' && handleStart()} onKeyDown={(e) => e.key === 'Enter' && handleStart()}
placeholder={SUGGESTED_NAMES[0]} placeholder={SUGGESTED_NAMES[0]}
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-3 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent" className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-3 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
autoFocus autoFocus={!cloudSave}
maxLength={30} maxLength={30}
/> />
</div> </div>
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { formatMoney, formatDuration, formatNumber, MAX_OFFLINE_TICKS, TICK_INTERVAL_MS } from '@ai-tycoon/shared'; import { formatMoney, formatDuration, formatNumber, MAX_OFFLINE_TICKS, TICK_INTERVAL_MS } from '@token-empire/shared';
import { GameEngine } from '@ai-tycoon/game-engine'; import { GameEngine } from '@token-empire/game-engine';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
interface OfflineResult { interface OfflineResult {
@@ -0,0 +1,103 @@
import { useEffect } from 'react';
import { Cloud, HardDrive, Plus } from 'lucide-react';
import { formatDuration } from '@token-empire/shared';
import type { CloudSaveInfo } from '@/hooks/useAuthGate';
interface LocalSaveInfo {
companyName: string;
era: string;
tickCount: number;
lastTickTimestamp: number;
}
function SaveCard({ icon: Icon, label, companyName, era, tickCount, timestamp, selected, onClick }: {
icon: typeof Cloud;
label: string;
companyName: string;
era: string;
tickCount: number;
timestamp: string;
selected?: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`flex-1 p-4 rounded-lg border text-left transition-colors ${
selected
? 'border-accent bg-accent/10'
: 'border-surface-600 bg-surface-800 hover:border-surface-500'
}`}
>
<div className="flex items-center gap-2 mb-3">
<Icon size={16} className={selected ? 'text-accent' : 'text-surface-400'} />
<span className="text-sm font-medium">{label}</span>
</div>
<div className="space-y-1.5">
<div className="text-base font-semibold">{companyName}</div>
<div className="text-xs text-surface-400">Era: {era}</div>
<div className="text-xs text-surface-400">Time: {formatDuration(tickCount)}</div>
<div className="text-xs text-surface-500">{timestamp}</div>
</div>
</button>
);
}
export function SaveConflictDialog({ localSave, cloudSave, onChooseLocal, onChooseCloud, onNewGame }: {
localSave: LocalSaveInfo;
cloudSave: CloudSaveInfo;
onChooseLocal: () => void;
onChooseCloud: () => void;
onNewGame: () => void;
}) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onChooseLocal();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onChooseLocal]);
const localDate = new Date(localSave.lastTickTimestamp).toLocaleString();
const cloudDate = new Date(cloudSave.updatedAt).toLocaleString();
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl">
<h3 className="text-lg font-bold mb-2">Save Conflict</h3>
<p className="text-sm text-surface-400 mb-5">
Both a local save and a cloud save exist. Which would you like to continue with?
</p>
<div className="flex gap-3 mb-5">
<SaveCard
icon={HardDrive}
label="Local Save"
companyName={localSave.companyName}
era={localSave.era}
tickCount={localSave.tickCount}
timestamp={localDate}
onClick={onChooseLocal}
/>
<SaveCard
icon={Cloud}
label="Cloud Save"
companyName={cloudSave.companyName}
era={cloudSave.era}
tickCount={cloudSave.tickCount}
timestamp={cloudDate}
onClick={onChooseCloud}
/>
</div>
<button
onClick={onNewGame}
className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded text-sm text-surface-400 hover:text-surface-200 hover:bg-surface-800 transition-colors"
>
<Plus size={14} />
Start New Game
</button>
</div>
</div>
);
}
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { X, Lightbulb } from 'lucide-react'; import { X, Lightbulb } from 'lucide-react';
const DISMISSED_KEY = 'ai-tycoon-dismissed-hints'; const DISMISSED_KEY = 'token-empire-dismissed-hints';
function getDismissed(): Set<string> { function getDismissed(): Set<string> {
try { try {
@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { TopBar } from './TopBar'; import { TopBar } from './TopBar';
import { ToastContainer } from '@/components/common/ToastContainer'; import { ToastContainer } from '@/components/common/ToastContainer';
@@ -5,6 +6,7 @@ import { DevMenu } from '@/components/dev/DevMenu';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { useHashRouter } from '@/hooks/useHashRouter'; import { useHashRouter } from '@/hooks/useHashRouter';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'; import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
import { initAudioSystem } from '@/audio';
import { DashboardPage } from '@/pages/DashboardPage'; import { DashboardPage } from '@/pages/DashboardPage';
import { InfrastructurePage } from '@/pages/InfrastructurePage'; import { InfrastructurePage } from '@/pages/InfrastructurePage';
import { ResearchPage } from '@/pages/ResearchPage'; import { ResearchPage } from '@/pages/ResearchPage';
@@ -23,6 +25,7 @@ import { InvitationsPage } from '@/pages/InvitationsPage';
export function MainLayout() { export function MainLayout() {
const { subPath, setSubPath } = useHashRouter(); const { subPath, setSubPath } = useHashRouter();
useKeyboardShortcuts(); useKeyboardShortcuts();
useEffect(() => initAudioSystem(), []);
const activePage = useGameStore((s) => s.activePage); const activePage = useGameStore((s) => s.activePage);
return ( return (
+9 -4
View File
@@ -5,7 +5,7 @@ import {
PanelLeftClose, PanelLeftOpen, Mail, UserPlus, Copy, Check, PanelLeftClose, PanelLeftOpen, Mail, UserPlus, Copy, Check,
} from 'lucide-react'; } from 'lucide-react';
import { useGameStore, type ActivePage } from '@/store'; import { useGameStore, type ActivePage } from '@/store';
import { isAdmin as checkIsAdmin, isRegistered as checkIsRegistered, api } from '@/lib/api'; import { isAdmin as checkIsAdmin, isRegistered as checkIsRegistered, getTokenPayload, api } from '@/lib/api';
const NAV_ITEMS: { page: ActivePage; label: string; icon: typeof LayoutDashboard; era?: string; adminOnly?: boolean }[] = [ const NAV_ITEMS: { page: ActivePage; label: string; icon: typeof LayoutDashboard; era?: string; adminOnly?: boolean }[] = [
{ page: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }, { page: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
@@ -26,7 +26,7 @@ const NAV_ITEMS: { page: ActivePage; label: string; icon: typeof LayoutDashboard
function getInitialCollapsed(): boolean { function getInitialCollapsed(): boolean {
try { try {
const stored = localStorage.getItem('ai-tycoon-sidebar-collapsed'); const stored = localStorage.getItem('token-empire-sidebar-collapsed');
if (stored !== null) return stored === 'true'; if (stored !== null) return stored === 'true';
return window.innerWidth < 1280; return window.innerWidth < 1280;
} catch { return false; } } catch { return false; }
@@ -82,7 +82,7 @@ export function Sidebar() {
const toggleCollapse = () => { const toggleCollapse = () => {
setCollapsed(prev => { setCollapsed(prev => {
const next = !prev; const next = !prev;
localStorage.setItem('ai-tycoon-sidebar-collapsed', String(next)); localStorage.setItem('token-empire-sidebar-collapsed', String(next));
return next; return next;
}); });
}; };
@@ -166,7 +166,12 @@ export function Sidebar() {
)} )}
<div className={`${collapsed ? 'px-2 py-3 text-center' : 'p-4'} border-t border-surface-700 text-xs text-surface-500`}> <div className={`${collapsed ? 'px-2 py-3 text-center' : 'p-4'} border-t border-surface-700 text-xs text-surface-500`}>
{collapsed ? 'v0.1' : 'AI Tycoon v0.1'} {!collapsed && (() => {
const payload = getTokenPayload();
const displayName = payload?.username || payload?.email || 'Guest';
return <div className="truncate mb-1 text-surface-400">{displayName}</div>;
})()}
{collapsed ? 'v0.1' : 'Token Empire v0.1'}
</div> </div>
</aside> </aside>
); );
+9 -2
View File
@@ -1,10 +1,13 @@
import { type ReactNode, useState } from 'react'; import { type ReactNode, useState } from 'react';
import { Pause, Play, Bell, Share2 } from 'lucide-react'; import { Pause, Play, Bell, Share2 } from 'lucide-react';
import { CompanyStatsCard } from '@/components/game/CompanyStatsCard'; import { CompanyStatsCard } from '@/components/game/CompanyStatsCard';
import { CloudSaveIndicator } from '@/components/game/CloudSaveIndicator';
import { NotificationPanel } from '@/components/common/NotificationPanel'; import { NotificationPanel } from '@/components/common/NotificationPanel';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { formatMoney, formatNumber, formatDuration, formatPercent } from '@ai-tycoon/shared'; import { isRegistered } from '@/lib/api';
import type { GameSpeed } from '@ai-tycoon/shared'; import { performCloudSave } from '@/hooks/useCloudSave';
import { formatMoney, formatNumber, formatDuration, formatPercent } from '@token-empire/shared';
import type { GameSpeed } from '@token-empire/shared';
import { Tooltip } from '@/components/common/Tooltip'; import { Tooltip } from '@/components/common/Tooltip';
const SPEEDS: GameSpeed[] = [1, 2, 5]; const SPEEDS: GameSpeed[] = [1, 2, 5];
@@ -97,6 +100,10 @@ export function TopBar() {
))} ))}
</div> </div>
{isRegistered() && (
<CloudSaveIndicator onForceSave={performCloudSave} />
)}
<button <button
onClick={() => setShowStats(true)} onClick={() => setShowStats(true)}
className="p-2 rounded hover:bg-surface-800 transition-colors" className="p-2 rounded hover:bg-surface-800 transition-colors"
+124 -20
View File
@@ -1,63 +1,161 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { api, getTokenPayload, isRegistered as checkRegistered, needsPasswordReset as checkNeedsReset, setAuthToken } from '@/lib/api'; import { api, getTokenPayload, isRegistered as checkRegistered, needsPasswordReset as checkNeedsReset, validateStoredToken } from '@/lib/api';
import { useGameStore } from '@/store';
import { ensureAuth } from './useCloudSave'; import { ensureAuth } from './useCloudSave';
export interface CloudSaveInfo {
id: string;
companyName: string;
era: string;
tickCount: number;
updatedAt: string;
}
interface AuthGateState { interface AuthGateState {
loading: boolean; loading: boolean;
backendError: string | null;
needsInvite: boolean; needsInvite: boolean;
needsPasswordReset: boolean; needsPasswordReset: boolean;
registered: boolean; registered: boolean;
isAdmin: boolean; isAdmin: boolean;
config: { requireInvite: boolean; userInvitations: number } | null; config: { requireInvite: boolean; userInvitations: number } | null;
cloudSave: CloudSaveInfo | null;
hasConflict: boolean;
loadCloudSave: () => Promise<void>;
resolveConflict: (choice: 'local' | 'cloud' | 'new') => Promise<void>;
setRegistered: (value: boolean) => void; setRegistered: (value: boolean) => void;
setNeedsPasswordReset: (value: boolean) => void; setNeedsPasswordReset: (value: boolean) => void;
retry: () => void;
} }
export function useAuthGate(): AuthGateState { export function useAuthGate(): AuthGateState {
const [config, setConfig] = useState<{ requireInvite: boolean; userInvitations: number } | null>(null); const [config, setConfig] = useState<{ requireInvite: boolean; userInvitations: number } | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [backendError, setBackendError] = useState<string | null>(null);
const [registered, setRegistered] = useState(false); const [registered, setRegistered] = useState(false);
const [passwordReset, setPasswordReset] = useState(false); const [passwordReset, setPasswordReset] = useState(false);
const [admin, setAdmin] = useState(false); const [admin, setAdmin] = useState(false);
const [cloudSave, setCloudSave] = useState<CloudSaveInfo | null>(null);
const [hasConflict, setHasConflict] = useState(false);
useEffect(() => { const init = useCallback(async () => {
let cancelled = false; setLoading(true);
setBackendError(null);
validateStoredToken();
async function init() {
try { try {
const [cfg] = await Promise.all([ await api.health();
api.config.get(), } catch (e) {
ensureAuth(), setBackendError(e instanceof Error ? e.message : 'Cannot connect to server');
]); setLoading(false);
return;
if (cancelled) return; }
try {
const cfg = await api.config.get();
setConfig(cfg); setConfig(cfg);
} catch {
setConfig({ requireInvite: false, userInvitations: 0 });
}
try {
await ensureAuth();
} catch {
// auth failed — will show as unregistered
}
const payload = getTokenPayload(); const payload = getTokenPayload();
const reg = checkRegistered(); const isReg = checkRegistered();
setRegistered(reg); setRegistered(isReg);
setPasswordReset(checkNeedsReset()); setPasswordReset(checkNeedsReset());
setAdmin(payload?.role === 'admin'); setAdmin(payload?.role === 'admin');
if (isReg) {
try {
const { save } = await api.saves.latest();
if (save && save.tickCount > 0) {
setCloudSave({
id: save.id,
companyName: save.companyName,
era: save.era,
tickCount: save.tickCount,
updatedAt: save.updatedAt,
});
} else {
setCloudSave(null);
}
} catch { } catch {
// Config fetch failed — allow game to load (fail open) setCloudSave(null);
setConfig({ requireInvite: false, userInvitations: 0 });
} finally {
if (!cancelled) setLoading(false);
} }
} }
init(); setLoading(false);
return () => { cancelled = true; };
}, []); }, []);
const handleSetRegistered = useCallback((value: boolean) => { // Run init on mount and on retry
useState(() => { init(); });
const retry = useCallback(() => {
init();
}, [init]);
const loadCloudSave = useCallback(async () => {
try {
const { save } = await api.saves.latest();
if (save?.gameData) {
const gameData = save.gameData as Record<string, unknown>;
useGameStore.setState(gameData);
}
} catch {
// Fall through to new game if cloud load fails
}
}, []);
const resolveConflict = useCallback(async (choice: 'local' | 'cloud' | 'new') => {
if (choice === 'cloud') {
await loadCloudSave();
} else if (choice === 'new') {
localStorage.removeItem('token-empire-save');
window.location.reload();
return;
}
setHasConflict(false);
}, [loadCloudSave]);
const handleSetRegistered = useCallback(async (value: boolean) => {
setRegistered(value); setRegistered(value);
const payload = getTokenPayload(); const payload = getTokenPayload();
if (payload) { if (payload) {
setAdmin(payload.role === 'admin'); setAdmin(payload.role === 'admin');
setPasswordReset(payload.mustResetPassword); setPasswordReset(payload.mustResetPassword);
} }
if (value) {
setLoading(true);
try {
const { save } = await api.saves.latest();
if (save && save.tickCount > 0) {
setCloudSave({
id: save.id,
companyName: save.companyName,
era: save.era,
tickCount: save.tickCount,
updatedAt: save.updatedAt,
});
const localCompany = useGameStore.getState().meta.companyName;
if (localCompany) {
setHasConflict(true);
} else {
useGameStore.setState(save.gameData as Record<string, unknown>);
}
}
} catch {
// No cloud save — user sees new game screen
} finally {
setLoading(false);
}
}
}, []); }, []);
const handleSetPasswordReset = useCallback((value: boolean) => { const handleSetPasswordReset = useCallback((value: boolean) => {
@@ -68,12 +166,18 @@ export function useAuthGate(): AuthGateState {
return { return {
loading, loading,
backendError,
needsInvite, needsInvite,
needsPasswordReset: passwordReset, needsPasswordReset: passwordReset,
registered, registered,
isAdmin: admin, isAdmin: admin,
config, config,
cloudSave,
hasConflict,
loadCloudSave,
resolveConflict,
setRegistered: handleSetRegistered, setRegistered: handleSetRegistered,
setNeedsPasswordReset: handleSetPasswordReset, setNeedsPasswordReset: handleSetPasswordReset,
retry,
}; };
} }
+107 -19
View File
@@ -1,38 +1,126 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useCallback } from 'react';
import { create } from 'zustand';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { api, getAuthToken, setAuthToken } from '@/lib/api'; import { api, getAuthToken, setAuthToken, clearAuthToken, decodeTokenPayload, isRegistered } from '@/lib/api';
import { AUTO_SAVE_INTERVAL_TICKS } from '@ai-tycoon/shared';
export function useCloudSave() { const CLOUD_SAVE_INTERVAL_MS = 5 * 60 * 1000;
const tickCount = useGameStore((s) => s.meta.tickCount); const BASE_BACKOFF_MS = 2 * 60 * 1000;
const companyName = useGameStore((s) => s.meta.companyName); const MAX_BACKOFF_MS = 30 * 60 * 1000;
const lastSaveTick = useRef(0);
useEffect(() => { export type CloudSaveStatus = 'idle' | 'saving' | 'success' | 'error' | 'offline';
if (!companyName) return;
if (tickCount - lastSaveTick.current < AUTO_SAVE_INTERVAL_TICKS * 5) return;
const token = getAuthToken(); interface CloudSaveState {
if (!token) return; status: CloudSaveStatus;
lastSaveTime: number | null;
failureCount: number;
setStatus: (status: CloudSaveStatus) => void;
setLastSaveTime: (time: number) => void;
setFailureCount: (count: number) => void;
}
lastSaveTick.current = tickCount; export const useCloudSaveStore = create<CloudSaveState>((set) => ({
status: 'idle',
lastSaveTime: null,
failureCount: 0,
setStatus: (status) => set({ status }),
setLastSaveTime: (time) => set({ lastSaveTime: time }),
setFailureCount: (count) => set({ failureCount: count }),
}));
function buildSavePayload() {
const state = useGameStore.getState(); const state = useGameStore.getState();
const { activePage, notifications, ...gameState } = state; const { activePage, notifications, infraNav, modelsTab, ...gameState } = state;
return {
api.saves.put({
companyName: state.meta.companyName, companyName: state.meta.companyName,
saveVersion: state.meta.saveVersion, saveVersion: state.meta.saveVersion,
gameData: gameState, gameData: gameState,
tickCount: state.meta.tickCount, tickCount: state.meta.tickCount,
era: state.meta.currentEra, era: state.meta.currentEra,
}).catch(() => {}); };
}
export async function performCloudSave(): Promise<boolean> {
const token = getAuthToken();
if (!token || !isRegistered()) return false;
const store = useCloudSaveStore.getState();
store.setStatus('saving');
try {
await api.saves.put(buildSavePayload());
store.setStatus('success');
store.setLastSaveTime(Date.now());
if (store.failureCount > 0) {
useGameStore.getState().addNotification({
title: 'Cloud Save Reconnected',
message: 'Your game is syncing to the cloud again.',
type: 'success',
tick: useGameStore.getState().meta.tickCount,
});
}
store.setFailureCount(0);
return true;
} catch {
const newCount = store.failureCount + 1;
store.setFailureCount(newCount);
store.setStatus('error');
if (newCount === 1) {
useGameStore.getState().addNotification({
title: 'Cloud Save Failed',
message: 'Progress is saved locally. Retrying automatically.',
type: 'warning',
tick: useGameStore.getState().meta.tickCount,
});
} else if (newCount === 5) {
useGameStore.getState().addNotification({
title: 'Cloud Save Unavailable',
message: 'Use Settings → Export Save to back up manually.',
type: 'danger',
tick: useGameStore.getState().meta.tickCount,
});
}
return false;
}
}
export function useCloudSave() {
const tickCount = useGameStore((s) => s.meta.tickCount);
const companyName = useGameStore((s) => s.meta.companyName);
const lastAttemptTime = useRef(Date.now());
useEffect(() => {
if (!companyName) return;
const token = getAuthToken();
if (!token || !isRegistered()) return;
const now = Date.now();
const { failureCount, lastSaveTime } = useCloudSaveStore.getState();
const backoffMs = failureCount > 0
? Math.min(BASE_BACKOFF_MS * Math.pow(2, failureCount - 1), MAX_BACKOFF_MS)
: CLOUD_SAVE_INTERVAL_MS;
const timeSinceLastAttempt = now - lastAttemptTime.current;
if (timeSinceLastAttempt < backoffMs) return;
lastAttemptTime.current = now;
performCloudSave();
}, [tickCount, companyName]); }, [tickCount, companyName]);
const forceSave = useCallback(() => performCloudSave(), []);
return { forceSave };
} }
export async function ensureAuth(): Promise<string | null> { export async function ensureAuth(): Promise<string | null> {
let token = getAuthToken(); const token = getAuthToken();
if (token) return token; if (token) {
if (decodeTokenPayload(token)) return token;
clearAuthToken();
}
try { try {
const result = await api.auth.anonymous(); const result = await api.auth.anonymous();
+2 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { GameEngine, setAchievementDefinitions, ACHIEVEMENT_DEFINITIONS } from '@ai-tycoon/game-engine'; import { GameEngine, setAchievementDefinitions, ACHIEVEMENT_DEFINITIONS } from '@token-empire/game-engine';
import type { TickNotification } from '@ai-tycoon/game-engine'; import type { TickNotification } from '@token-empire/game-engine';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
export function useGameLoop(skip = false) { export function useGameLoop(skip = false) {
+1 -1
View File
@@ -1,6 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useGameStore, type ActivePage } from '@/store'; import { useGameStore, type ActivePage } from '@/store';
import type { GameSpeed } from '@ai-tycoon/shared'; import type { GameSpeed } from '@token-empire/shared';
const PAGE_SHORTCUTS: Record<string, ActivePage> = { const PAGE_SHORTCUTS: Record<string, ActivePage> = {
d: 'dashboard', d: 'dashboard',
+51 -9
View File
@@ -1,10 +1,10 @@
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001'; const API_BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3001';
let authToken: string | null = localStorage.getItem('ai-tycoon-auth-token'); let authToken: string | null = localStorage.getItem('token-empire-auth-token');
export function setAuthToken(token: string) { export function setAuthToken(token: string) {
authToken = token; authToken = token;
localStorage.setItem('ai-tycoon-auth-token', token); localStorage.setItem('token-empire-auth-token', token);
} }
export function getAuthToken() { export function getAuthToken() {
@@ -13,7 +13,7 @@ export function getAuthToken() {
export function clearAuthToken() { export function clearAuthToken() {
authToken = null; authToken = null;
localStorage.removeItem('ai-tycoon-auth-token'); localStorage.removeItem('token-empire-auth-token');
} }
export interface TokenPayload { export interface TokenPayload {
@@ -63,30 +63,61 @@ export function needsPasswordReset(): boolean {
return payload?.mustResetPassword === true; return payload?.mustResetPassword === true;
} }
async function request<T>(path: string, options: RequestInit = {}): Promise<T> { const AUTH_PATHS = ['/api/auth/anonymous', '/api/auth/login', '/api/auth/logout', '/api/health'];
async function request<T>(path: string, options: RequestInit & { timeoutMs?: number } = {}): Promise<T> {
const { timeoutMs = 10_000, ...fetchOptions } = options;
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(options.headers as Record<string, string>), ...(fetchOptions.headers as Record<string, string>),
}; };
if (authToken) { if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`; headers['Authorization'] = `Bearer ${authToken}`;
} }
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(`${API_BASE}${path}`, { const res = await fetch(`${API_BASE}${path}`, {
...options, ...fetchOptions,
headers, headers,
signal: controller.signal,
}); });
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => ({ error: 'Unknown error' })); if (res.status === 401 && authToken && !AUTH_PATHS.includes(path)) {
throw new Error(body.error || `HTTP ${res.status}`); clearAuthToken();
}
const body = await res.json().catch(() => null);
throw new Error(body?.error || `HTTP ${res.status} ${res.statusText}`);
} }
return res.json(); return res.json();
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
throw new Error('Request timed out — server may be unreachable');
}
if (e instanceof TypeError) {
throw new Error('Network error — server may be unreachable');
}
throw e;
} finally {
clearTimeout(timeout);
}
}
export function validateStoredToken(): void {
const token = getAuthToken();
if (token && !decodeTokenPayload(token)) {
clearAuthToken();
}
} }
export const api = { export const api = {
health: () => request<{ status: string }>('/api/health', { timeoutMs: 5_000 }),
auth: { auth: {
anonymous: () => request<{ userId: string; token: string }>('/api/auth/anonymous', { method: 'POST' }), anonymous: () => request<{ userId: string; token: string }>('/api/auth/anonymous', { method: 'POST' }),
login: (login: string, password: string) => login: (login: string, password: string) =>
@@ -109,6 +140,15 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ username }), body: JSON.stringify({ username }),
}), }),
changeEmail: (email: string, currentPassword: string) =>
request<{ success: boolean; token: string }>('/api/auth/change-email', {
method: 'POST',
body: JSON.stringify({ email, currentPassword }),
}),
logout: () =>
request<{ success: boolean }>('/api/auth/logout', { method: 'POST' }),
me: () =>
request<{ id: string; username: string | null; email: string | null; role: string }>('/api/auth/me'),
}, },
config: { config: {
get: () => request<{ requireInvite: boolean; userInvitations: number }>('/api/config'), get: () => request<{ requireInvite: boolean; userInvitations: number }>('/api/config'),
@@ -128,10 +168,12 @@ export const api = {
}>; }>;
}>('/api/invites'), }>('/api/invites'),
remaining: () => request<{ remaining: number }>('/api/invites/remaining'), remaining: () => request<{ remaining: number }>('/api/invites/remaining'),
revoke: (id: string) => request<{ deleted: boolean }>(`/api/invites/${id}`, { method: 'DELETE' }),
}, },
saves: { saves: {
list: () => request<{ saves: Array<{ id: string; companyName: string; era: string; tickCount: number; updatedAt: string }> }>('/api/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}`), get: (id: string) => request<{ save: { id: string; gameData: unknown } }>(`/api/saves/${id}`),
latest: () => request<{ save: { id: string; companyName: string; era: string; tickCount: number; updatedAt: string; gameData: unknown } | null }>('/api/saves/latest'),
put: (data: { companyName: string; saveVersion: number; gameData: unknown; tickCount: number; era: string }) => put: (data: { companyName: string; saveVersion: number; gameData: unknown; tickCount: number; era: string }) =>
request<{ id: string }>('/api/saves', { method: 'PUT', body: JSON.stringify(data) }), request<{ id: string }>('/api/saves', { method: 'PUT', body: JSON.stringify(data) }),
delete: (id: string) => request<{ deleted: boolean }>(`/api/saves/${id}`, { method: 'DELETE' }), delete: (id: string) => request<{ deleted: boolean }>(`/api/saves/${id}`, { method: 'DELETE' }),
+3 -3
View File
@@ -1,12 +1,12 @@
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { ACHIEVEMENT_DEFINITIONS } from '@ai-tycoon/game-engine'; import { ACHIEVEMENT_DEFINITIONS } from '@token-empire/game-engine';
import { formatNumber } from '@ai-tycoon/shared'; import { formatNumber } from '@token-empire/shared';
import { import {
Trophy, Lock, Server, Brain, Rocket, DollarSign, Sprout, Users, Trophy, Lock, Server, Brain, Rocket, DollarSign, Sprout, Users,
Globe, Sparkles, TrendingUp, Building2, Atom, Cpu, FlaskConical, Globe, Sparkles, TrendingUp, Building2, Atom, Cpu, FlaskConical,
GitBranch, Zap, GitBranch, Zap,
} from 'lucide-react'; } from 'lucide-react';
import type { AchievementCondition } from '@ai-tycoon/shared'; import type { AchievementCondition } from '@token-empire/shared';
const ICON_MAP: Record<string, React.ComponentType<{ size?: number; className?: string }>> = { const ICON_MAP: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
Trophy, Server, Brain, Rocket, DollarSign, Sprout, Users, Trophy, Server, Brain, Rocket, DollarSign, Sprout, Users,
+2 -2
View File
@@ -3,8 +3,8 @@ import { Swords, TrendingUp, Shield, Users, Brain, ShoppingCart } from 'lucide-r
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { ConfirmModal } from '@/components/common/ConfirmModal'; import { ConfirmModal } from '@/components/common/ConfirmModal';
import { Tooltip } from '@/components/common/Tooltip'; import { Tooltip } from '@/components/common/Tooltip';
import { formatMoney, formatNumber } from '@ai-tycoon/shared'; import { formatMoney, formatNumber } from '@token-empire/shared';
import type { Era } from '@ai-tycoon/shared'; import type { Era } from '@token-empire/shared';
const ARCHETYPE_LABELS: Record<string, string> = { const ARCHETYPE_LABELS: Record<string, string> = {
'safety-first': 'Safety-First Lab', 'safety-first': 'Safety-First Lab',
+4 -4
View File
@@ -1,8 +1,8 @@
import type React from 'react'; import type React from 'react';
import { useGameStore, type ActivePage } from '@/store'; import { useGameStore, type ActivePage } from '@/store';
import { formatMoney, formatNumber, formatPercent, formatDuration } from '@ai-tycoon/shared'; import { formatMoney, formatNumber, formatPercent, formatDuration } from '@token-empire/shared';
import type { Era } from '@ai-tycoon/shared'; import type { Era } from '@token-empire/shared';
import { TECH_TREE } from '@ai-tycoon/game-engine'; import { TECH_TREE } from '@token-empire/game-engine';
import { import {
DollarSign, TrendingUp, TrendingDown, Minus, Cpu, Brain, Users, DollarSign, TrendingUp, TrendingDown, Minus, Cpu, Brain, Users,
Shield, ChevronRight, Zap, Wifi, Sparkles, FlaskConical, Building2, Shield, ChevronRight, Zap, Wifi, Sparkles, FlaskConical, Building2,
@@ -96,7 +96,7 @@ export function DashboardPage() {
{totalDCs === 0 && ( {totalDCs === 0 && (
<TutorialHint id="welcome"> <TutorialHint id="welcome">
Welcome to AI Tycoon! Start by building a cluster in the Infrastructure tab, then add a campus and data center to deploy racks and train your first AI model. Welcome to Token Empire! Start by building a cluster in the Infrastructure tab, then add a campus and data center to deploy racks and train your first AI model.
</TutorialHint> </TutorialHint>
)} )}
+2 -2
View File
@@ -1,8 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { Database, ShoppingCart, Zap } from 'lucide-react'; import { Database, ShoppingCart, Zap } from 'lucide-react';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { formatNumber, formatMoney, uuid } from '@ai-tycoon/shared'; import { formatNumber, formatMoney, uuid } from '@token-empire/shared';
import type { OwnedDataset, DataDomain } from '@ai-tycoon/shared'; import type { OwnedDataset, DataDomain } from '@token-empire/shared';
interface MarketplaceDataset { interface MarketplaceDataset {
name: string; name: string;
+4 -4
View File
@@ -1,10 +1,10 @@
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { formatMoney, formatPercent, formatNumber, FUNDING_ROUNDS } from '@ai-tycoon/shared'; import { formatMoney, formatPercent, formatNumber, FUNDING_ROUNDS } from '@token-empire/shared';
import type { FundingRoundType } from '@ai-tycoon/shared'; import type { FundingRoundType } from '@token-empire/shared';
import { TrendingUp, DollarSign, PiggyBank, BarChart3, Rocket, Check, X as XIcon } from 'lucide-react'; import { TrendingUp, DollarSign, PiggyBank, BarChart3, Rocket, Check, X as XIcon } from 'lucide-react';
import { AreaChart, Area, XAxis, YAxis, ResponsiveContainer, LineChart, Line, Tooltip } from 'recharts'; import { AreaChart, Area, XAxis, YAxis, ResponsiveContainer, LineChart, Line, Tooltip } from 'recharts';
import { canRaiseFunding } from '@ai-tycoon/game-engine'; import { canRaiseFunding } from '@token-empire/game-engine';
import type { GameState } from '@ai-tycoon/shared'; import type { GameState } from '@token-empire/shared';
export function FinancePage() { export function FinancePage() {
const money = useGameStore((s) => s.economy.money); const money = useGameStore((s) => s.economy.money);
+2 -2
View File
@@ -18,11 +18,11 @@ import {
SWITCH_TIER_CONFIGS, SWITCH_TIER_CONFIGS,
DC_UPGRADE_COST_FRACTION, DC_UPGRADE_INCREMENT, DC_UPGRADE_COST_FRACTION, DC_UPGRADE_INCREMENT,
skuTotalFlops, skuTotalFlops,
} from '@ai-tycoon/shared'; } from '@token-empire/shared';
import type { import type {
DCTier, RackSkuId, LocationId, PipelineStage, Era, DCTier, RackSkuId, LocationId, PipelineStage, Era,
Cluster, Campus, DataCenter, DeploymentCohort, Cluster, Campus, DataCenter, DeploymentCohort,
} from '@ai-tycoon/shared'; } from '@token-empire/shared';
const ERA_ORDER: Era[] = ['startup', 'scaleup', 'bigtech', 'agi']; const ERA_ORDER: Era[] = ['startup', 'scaleup', 'bigtech', 'agi'];
+25 -2
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Copy, Check, Plus, RefreshCw } from 'lucide-react'; import { Copy, Check, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
interface Invitation { interface Invitation {
@@ -31,6 +31,7 @@ export function InvitationsPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [copiedCode, setCopiedCode] = useState<string | null>(null); const [copiedCode, setCopiedCode] = useState<string | null>(null);
const [revoking, setRevoking] = useState<string | null>(null);
const fetchInvitations = useCallback(async () => { const fetchInvitations = useCallback(async () => {
try { try {
@@ -53,7 +54,7 @@ export function InvitationsPage() {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
setCopiedCode(result.code); setCopiedCode(result.code);
setTimeout(() => setCopiedCode(null), 2000); setTimeout(() => setCopiedCode(null), 2000);
fetchInvitations(); await fetchInvitations();
} catch { } catch {
// silent // silent
} finally { } finally {
@@ -61,6 +62,18 @@ export function InvitationsPage() {
} }
} }
async function handleRevoke(id: string) {
setRevoking(id);
try {
await api.invites.revoke(id);
await fetchInvitations();
} catch {
// silent
} finally {
setRevoking(null);
}
}
async function handleCopyCode(code: string) { async function handleCopyCode(code: string) {
const url = `${window.location.origin}?invite=${code}`; const url = `${window.location.origin}?invite=${code}`;
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
@@ -132,6 +145,7 @@ export function InvitationsPage() {
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{!inv.used && ( {!inv.used && (
<div className="flex items-center gap-2">
<button <button
onClick={() => handleCopyCode(inv.code)} onClick={() => handleCopyCode(inv.code)}
className="text-surface-400 hover:text-surface-200 transition-colors" className="text-surface-400 hover:text-surface-200 transition-colors"
@@ -139,6 +153,15 @@ export function InvitationsPage() {
> >
{copiedCode === inv.code ? <Check size={14} className="text-accent" /> : <Copy size={14} />} {copiedCode === inv.code ? <Check size={14} className="text-accent" /> : <Copy size={14} />}
</button> </button>
<button
onClick={() => handleRevoke(inv.id)}
disabled={revoking === inv.id}
className="text-surface-400 hover:text-danger transition-colors disabled:opacity-50"
title="Revoke invitation"
>
<Trash2 size={14} />
</button>
</div>
)} )}
</td> </td>
</tr> </tr>
+1 -1
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Trophy, Medal, Clock, TrendingUp } from 'lucide-react'; import { Trophy, Medal, Clock, TrendingUp } from 'lucide-react';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { formatMoney, formatNumber } from '@ai-tycoon/shared'; import { formatMoney, formatNumber } from '@token-empire/shared';
import { api, getAuthToken } from '@/lib/api'; import { api, getAuthToken } from '@/lib/api';
interface LeaderboardEntry { interface LeaderboardEntry {
+2 -2
View File
@@ -13,12 +13,12 @@ import {
SIZE_TIER_LABELS, SIZE_TIER_LABELS,
SFT_SPECIALIZATION_BONUSES, SFT_SPECIALIZATION_BONUSES,
PRETRAINING_BASE_TICKS, PRETRAINING_BASE_TICKS,
} from '@ai-tycoon/shared'; } from '@token-empire/shared';
import type { import type {
ModelArchitecture, DataMixAllocation, SFTSpecialization, AlignmentMethod, ModelArchitecture, DataMixAllocation, SFTSpecialization, AlignmentMethod,
DataDomain, QuantizationLevel, BaseModel, ModelVariant, DataDomain, QuantizationLevel, BaseModel, ModelVariant,
SizeTier, ModelFamily, SizeTier, ModelFamily,
} from '@ai-tycoon/shared'; } from '@token-empire/shared';
const DATA_MIX_PRESETS: Record<string, { label: string; mix: DataMixAllocation }> = { const DATA_MIX_PRESETS: Record<string, { label: string; mix: DataMixAllocation }> = {
balanced: { label: 'Balanced', mix: DEFAULT_DATA_MIX }, balanced: { label: 'Balanced', mix: DEFAULT_DATA_MIX },
+3 -3
View File
@@ -1,9 +1,9 @@
import { FlaskConical, Lock, Check, Play, ListOrdered, X } from 'lucide-react'; import { FlaskConical, Lock, Check, Play, ListOrdered, X } from 'lucide-react';
import { TutorialHint } from '@/components/game/TutorialHint'; import { TutorialHint } from '@/components/game/TutorialHint';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { formatDuration, formatPercent, formatNumber, formatMoney } from '@ai-tycoon/shared'; import { formatDuration, formatPercent, formatNumber, formatMoney } from '@token-empire/shared';
import { TECH_TREE, getAvailableResearch } from '@ai-tycoon/game-engine'; import { TECH_TREE, getAvailableResearch } from '@token-empire/game-engine';
import type { ResearchNode } from '@ai-tycoon/shared'; import type { ResearchNode } from '@token-empire/shared';
const CATEGORY_COLORS: Record<string, string> = { const CATEGORY_COLORS: Record<string, string> = {
generation: 'border-purple-500/50 bg-purple-500/10', generation: 'border-purple-500/50 bg-purple-500/10',
+1 -1
View File
@@ -3,7 +3,7 @@ import {
formatNumber, formatPercent, formatNumber, formatPercent,
type TrafficPriority, type OverflowBehavior, type RoutingStrategy, type TrafficPriority, type OverflowBehavior, type RoutingStrategy,
TRAFFIC_PRIORITIES, TRAFFIC_PRIORITIES,
} from '@ai-tycoon/shared'; } from '@token-empire/shared';
import { import {
Activity, Shield, Clock, CheckCircle, XCircle, Layers, Activity, Shield, Clock, CheckCircle, XCircle, Layers,
AlertTriangle, Zap, Server, ArrowRight, AlertTriangle, Zap, Server, ArrowRight,
+229 -13
View File
@@ -1,7 +1,10 @@
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { Pencil, Check, X, LogOut, Cloud, Loader2 } from 'lucide-react';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { ConfirmModal } from '@/components/common/ConfirmModal'; import { ConfirmModal } from '@/components/common/ConfirmModal';
import { getTokenPayload, isRegistered, isAdmin } from '@/lib/api'; import { CloudSaveList } from '@/components/game/CloudSaveList';
import { api, setAuthToken, getTokenPayload, isRegistered, isAdmin, clearAuthToken } from '@/lib/api';
import { performCloudSave, useCloudSaveStore } from '@/hooks/useCloudSave';
export function SettingsPage() { export function SettingsPage() {
const settings = useGameStore((s) => s.meta.settings); const settings = useGameStore((s) => s.meta.settings);
@@ -12,6 +15,60 @@ export function SettingsPage() {
const [showResetConfirm, setShowResetConfirm] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false);
const [importData, setImportData] = useState<{ data: unknown; name: string } | null>(null); const [importData, setImportData] = useState<{ data: unknown; name: string } | null>(null);
const [editingUsername, setEditingUsername] = useState(false);
const [usernameValue, setUsernameValue] = useState('');
const [usernameError, setUsernameError] = useState('');
const [usernameSaving, setUsernameSaving] = useState(false);
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
const [editingEmail, setEditingEmail] = useState(false);
const [emailValue, setEmailValue] = useState('');
const [emailPassword, setEmailPassword] = useState('');
const [emailError, setEmailError] = useState('');
const [emailSaving, setEmailSaving] = useState(false);
async function handleSaveUsername() {
setUsernameError('');
if (!usernameValue || usernameValue.length < 2) {
setUsernameError('Username must be at least 2 characters');
return;
}
setUsernameSaving(true);
try {
const result = await api.auth.changeUsername(usernameValue);
setAuthToken(result.token);
setEditingUsername(false);
} catch (e) {
setUsernameError(e instanceof Error ? e.message : 'Failed to change username');
} finally {
setUsernameSaving(false);
}
}
async function handleSaveEmail() {
setEmailError('');
if (!emailValue || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailValue)) {
setEmailError('Valid email required');
return;
}
if (!emailPassword) {
setEmailError('Current password required');
return;
}
setEmailSaving(true);
try {
const result = await api.auth.changeEmail(emailValue, emailPassword);
setAuthToken(result.token);
setEditingEmail(false);
setEmailPassword('');
} catch (e) {
setEmailError(e instanceof Error ? e.message : 'Failed to change email');
} finally {
setEmailSaving(false);
}
}
const toggleSound = () => { const toggleSound = () => {
updateState({ meta: { ...useGameStore.getState().meta, settings: { ...settings, soundEnabled: !settings.soundEnabled } } }); updateState({ meta: { ...useGameStore.getState().meta, settings: { ...settings, soundEnabled: !settings.soundEnabled } } });
}; };
@@ -20,8 +77,12 @@ export function SettingsPage() {
updateState({ meta: { ...useGameStore.getState().meta, settings: { ...settings, musicVolume: v } } }); updateState({ meta: { ...useGameStore.getState().meta, settings: { ...settings, musicVolume: v } } });
}; };
const setSfxVolume = (v: number) => {
updateState({ meta: { ...useGameStore.getState().meta, settings: { ...settings, sfxVolume: v } } });
};
const handleReset = () => { const handleReset = () => {
localStorage.removeItem('ai-tycoon-save'); localStorage.removeItem('token-empire-save');
window.location.reload(); window.location.reload();
}; };
@@ -32,7 +93,7 @@ export function SettingsPage() {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `ai-tycoon-${companyName.replace(/\s+/g, '-').toLowerCase()}.json`; a.download = `token-empire-${companyName.replace(/\s+/g, '-').toLowerCase()}.json`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
@@ -51,7 +112,7 @@ export function SettingsPage() {
} }
setImportData({ data, name: data.meta.companyName }); setImportData({ data, name: data.meta.companyName });
} catch { } catch {
addNotification({ title: 'Import Failed', message: 'Could not read save file. Make sure it is a valid AI Tycoon export.', type: 'danger', tick: useGameStore.getState().meta.tickCount }); addNotification({ title: 'Import Failed', message: 'Could not read save file. Make sure it is a valid Token Empire export.', type: 'danger', tick: useGameStore.getState().meta.tickCount });
} }
}; };
reader.readAsText(file); reader.readAsText(file);
@@ -60,7 +121,7 @@ export function SettingsPage() {
const confirmImport = () => { const confirmImport = () => {
if (!importData) return; if (!importData) return;
localStorage.setItem('ai-tycoon-save', JSON.stringify({ state: importData.data })); localStorage.setItem('token-empire-save', JSON.stringify({ state: importData.data }));
window.location.reload(); window.location.reload();
}; };
@@ -75,23 +136,76 @@ export function SettingsPage() {
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4"> <div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
<h3 className="font-semibold">Account</h3> <h3 className="font-semibold">Account</h3>
{registered ? ( {registered ? (
<div className="space-y-2"> <div className="space-y-3">
{payload?.email && ( {payload?.email != null && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div className="flex-1">
<div className="text-sm">Email</div> <div className="text-sm">Email</div>
{editingEmail ? (
<div className="mt-1 space-y-2">
<div className="flex items-center gap-2">
<input
type="email"
value={emailValue}
onChange={(e) => setEmailValue(e.target.value)}
className="bg-surface-800 border border-surface-600 rounded px-2 py-1 text-sm w-56"
placeholder="New email"
autoFocus
/>
<button onClick={handleSaveEmail} disabled={emailSaving}
className="text-accent hover:text-accent-light disabled:opacity-50"><Check size={16} /></button>
<button onClick={() => { setEditingEmail(false); setEmailError(''); setEmailPassword(''); }}
className="text-surface-400 hover:text-surface-200"><X size={16} /></button>
</div>
<input
type="password"
value={emailPassword}
onChange={(e) => setEmailPassword(e.target.value)}
className="bg-surface-800 border border-surface-600 rounded px-2 py-1 text-sm w-56"
placeholder="Current password"
/>
{emailError && <p className="text-xs text-danger">{emailError}</p>}
</div>
) : (
<div className="text-xs text-surface-400">{payload.email}</div> <div className="text-xs text-surface-400">{payload.email}</div>
)}
</div> </div>
{!editingEmail && (
<button onClick={() => { setEmailValue(payload.email ?? ''); setEditingEmail(true); }}
className="text-surface-400 hover:text-surface-200"><Pencil size={14} /></button>
)}
</div> </div>
)} )}
{payload?.username && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div className="flex-1">
<div className="text-sm">Username</div> <div className="text-sm">Username</div>
<div className="text-xs text-surface-400">{payload.username}</div> {editingUsername ? (
<div className="mt-1 space-y-1">
<div className="flex items-center gap-2">
<input
type="text"
value={usernameValue}
onChange={(e) => setUsernameValue(e.target.value)}
className="bg-surface-800 border border-surface-600 rounded px-2 py-1 text-sm w-48"
placeholder="Username"
autoFocus
/>
<button onClick={handleSaveUsername} disabled={usernameSaving}
className="text-accent hover:text-accent-light disabled:opacity-50"><Check size={16} /></button>
<button onClick={() => { setEditingUsername(false); setUsernameError(''); }}
className="text-surface-400 hover:text-surface-200"><X size={16} /></button>
</div> </div>
{usernameError && <p className="text-xs text-danger">{usernameError}</p>}
</div> </div>
) : (
<div className="text-xs text-surface-400">{payload?.username ?? 'Not set'}</div>
)} )}
</div>
{!editingUsername && (
<button onClick={() => { setUsernameValue(payload?.username ?? ''); setEditingUsername(true); }}
className="text-surface-400 hover:text-surface-200"><Pencil size={14} /></button>
)}
</div>
{admin && ( {admin && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs px-2 py-0.5 rounded-full bg-accent/20 text-accent font-medium">Admin</span> <span className="text-xs px-2 py-0.5 rounded-full bg-accent/20 text-accent font-medium">Admin</span>
@@ -101,6 +215,16 @@ export function SettingsPage() {
) : ( ) : (
<div className="text-sm text-surface-400">Playing as guest.</div> <div className="text-sm text-surface-400">Playing as guest.</div>
)} )}
<div className="pt-2 border-t border-surface-700">
<button
onClick={() => setShowLogoutConfirm(true)}
className="inline-flex items-center gap-2 px-4 py-2 rounded bg-surface-800 hover:bg-surface-700 border border-surface-600 text-sm text-surface-300 hover:text-surface-100 transition-colors"
>
<LogOut size={14} />
{registered ? 'Log Out' : 'Sign Out'}
</button>
</div>
</div> </div>
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4"> <div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
@@ -114,7 +238,26 @@ export function SettingsPage() {
<ToggleSwitch checked={settings.soundEnabled} onChange={toggleSound} /> <ToggleSwitch checked={settings.soundEnabled} onChange={toggleSound} />
</div> </div>
<div className="flex items-center justify-between"> <div className={`flex items-center justify-between ${!settings.soundEnabled ? 'opacity-40' : ''}`}>
<div>
<div className="text-sm">SFX Volume</div>
<div className="text-xs text-surface-400">Sound effects level</div>
</div>
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={100}
value={(settings.sfxVolume ?? 0.5) * 100}
onChange={(e) => setSfxVolume(Number(e.target.value) / 100)}
disabled={!settings.soundEnabled}
className="w-32 accent-accent"
/>
<span className="text-sm font-mono text-surface-400 w-8 text-right">{Math.round((settings.sfxVolume ?? 0.5) * 100)}%</span>
</div>
</div>
<div className={`flex items-center justify-between ${!settings.soundEnabled ? 'opacity-40' : ''}`}>
<div> <div>
<div className="text-sm">Music Volume</div> <div className="text-sm">Music Volume</div>
<div className="text-xs text-surface-400">Background music level</div> <div className="text-xs text-surface-400">Background music level</div>
@@ -126,6 +269,7 @@ export function SettingsPage() {
max={100} max={100}
value={settings.musicVolume * 100} value={settings.musicVolume * 100}
onChange={(e) => setMusicVolume(Number(e.target.value) / 100)} onChange={(e) => setMusicVolume(Number(e.target.value) / 100)}
disabled={!settings.soundEnabled}
className="w-32 accent-accent" className="w-32 accent-accent"
/> />
<span className="text-sm font-mono text-surface-400 w-8 text-right">{Math.round(settings.musicVolume * 100)}%</span> <span className="text-sm font-mono text-surface-400 w-8 text-right">{Math.round(settings.musicVolume * 100)}%</span>
@@ -135,7 +279,8 @@ export function SettingsPage() {
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4"> <div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
<h3 className="font-semibold">Save Data</h3> <h3 className="font-semibold">Save Data</h3>
<div className="flex gap-3"> <div className="flex flex-wrap gap-3">
{registered && <SaveToCloudButton />}
<button <button
onClick={handleExport} onClick={handleExport}
className="px-4 py-2 rounded bg-surface-800 hover:bg-surface-700 border border-surface-600 text-sm" className="px-4 py-2 rounded bg-surface-800 hover:bg-surface-700 border border-surface-600 text-sm"
@@ -164,6 +309,13 @@ export function SettingsPage() {
</div> </div>
</div> </div>
{registered && (
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
<h3 className="font-semibold">Cloud Saves</h3>
<CloudSaveList />
</div>
)}
{showResetConfirm && ( {showResetConfirm && (
<ConfirmModal <ConfirmModal
title="Reset All Progress" title="Reset All Progress"
@@ -184,6 +336,70 @@ export function SettingsPage() {
onCancel={() => setImportData(null)} onCancel={() => setImportData(null)}
/> />
)} )}
{showLogoutConfirm && (
<ConfirmModal
title={registered ? 'Log Out' : 'Sign Out'}
message={registered
? 'You will be logged out. Your game progress is saved to the cloud and will be available when you log back in.'
: 'You will be signed out. As a guest, your local progress will be lost. Consider registering first to save your progress.'}
confirmLabel={registered ? 'Log Out' : 'Sign Out'}
danger={!registered}
onConfirm={async () => {
if (registered) {
try {
const state = useGameStore.getState();
const { activePage, notifications, infraNav, modelsTab, ...gameState } = state;
await api.saves.put({
companyName: state.meta.companyName,
saveVersion: state.meta.saveVersion,
gameData: gameState,
tickCount: state.meta.tickCount,
era: state.meta.currentEra,
});
} catch {}
}
try { await api.auth.logout(); } catch {}
clearAuthToken();
if (!registered) {
localStorage.removeItem('token-empire-save');
}
window.location.reload();
}}
onCancel={() => setShowLogoutConfirm(false)}
/>
)}
</div>
);
}
function SaveToCloudButton() {
const status = useCloudSaveStore((s) => s.status);
const lastSaveTime = useCloudSaveStore((s) => s.lastSaveTime);
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
await performCloudSave();
setSaving(false);
};
const isBusy = saving || status === 'saving';
const timeLabel = lastSaveTime
? `Last saved ${Math.floor((Date.now() - lastSaveTime) / 60000)}m ago`
: null;
return (
<div className="flex items-center gap-2">
<button
onClick={handleSave}
disabled={isBusy}
className="inline-flex items-center gap-2 px-4 py-2 rounded bg-accent/20 hover:bg-accent/30 border border-accent/50 text-accent text-sm disabled:opacity-50 transition-colors"
>
{isBusy ? <Loader2 size={14} className="animate-spin" /> : <Cloud size={14} />}
Save to Cloud
</button>
{timeLabel && <span className="text-xs text-surface-500">{timeLabel}</span>}
</div> </div>
); );
} }
+3 -3
View File
@@ -1,9 +1,9 @@
import { useState } from 'react'; import { useState } from 'react';
import { Users, Plus, Star, Briefcase } from 'lucide-react'; import { Users, Plus, Star, Briefcase } from 'lucide-react';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { formatMoney } from '@ai-tycoon/shared'; import { formatMoney } from '@token-empire/shared';
import { KEY_HIRE_POOL } from '@ai-tycoon/game-engine'; import { KEY_HIRE_POOL } from '@token-empire/game-engine';
import type { DepartmentId } from '@ai-tycoon/shared'; import type { DepartmentId } from '@token-empire/shared';
const DEPT_LABELS: Record<string, string> = { const DEPT_LABELS: Record<string, string> = {
research: 'Research', research: 'Research',
+2 -2
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared'; import { formatNumber, formatMoney, formatPercent } from '@token-empire/shared';
import type { ApiTierId } from '@ai-tycoon/shared'; import type { ApiTierId } from '@token-empire/shared';
import { Code, Check } from 'lucide-react'; import { Code, Check } from 'lucide-react';
const TIER_ORDER: ApiTierId[] = ['free', 'payg', 'scale', 'enterprise-api']; const TIER_ORDER: ApiTierId[] = ['free', 'payg', 'scale', 'enterprise-api'];
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared'; import { formatNumber, formatMoney, formatPercent } from '@token-empire/shared';
import type { ConsumerTierId } from '@ai-tycoon/shared'; import type { ConsumerTierId } from '@token-empire/shared';
import { Users, Check } from 'lucide-react'; import { Users, Check } from 'lucide-react';
const TIER_ORDER: ConsumerTierId[] = ['free', 'plus', 'pro', 'team']; const TIER_ORDER: ConsumerTierId[] = ['free', 'plus', 'pro', 'team'];
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared'; import { formatNumber, formatMoney, formatPercent } from '@token-empire/shared';
import { Boxes, Check } from 'lucide-react'; import { Boxes, Check } from 'lucide-react';
function useAppliedFeedback() { function useAppliedFeedback() {
@@ -1,6 +1,6 @@
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared'; import { formatNumber, formatMoney, formatPercent } from '@token-empire/shared';
import type { EnterprisePipelineStage, EnterpriseSegment } from '@ai-tycoon/shared'; import type { EnterprisePipelineStage, EnterpriseSegment } from '@token-empire/shared';
import { Building2, AlertTriangle } from 'lucide-react'; import { Building2, AlertTriangle } from 'lucide-react';
const STAGE_ORDER: EnterprisePipelineStage[] = ['lead', 'qualification', 'poc', 'negotiation']; const STAGE_ORDER: EnterprisePipelineStage[] = ['lead', 'qualification', 'poc', 'negotiation'];
@@ -1,6 +1,6 @@
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { formatNumber, formatPercent } from '@ai-tycoon/shared'; import { formatNumber, formatPercent } from '@token-empire/shared';
import type { TAMSegmentId } from '@ai-tycoon/shared'; import type { TAMSegmentId } from '@token-empire/shared';
import { Globe, TrendingUp, Clock, Thermometer } from 'lucide-react'; import { Globe, TrendingUp, Clock, Thermometer } from 'lucide-react';
const SEGMENT_LABELS: Record<TAMSegmentId, string> = { const SEGMENT_LABELS: Record<TAMSegmentId, string> = {
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared'; import { formatNumber, formatMoney, formatPercent } from '@token-empire/shared';
import { Wrench, Bot, Check, Lock } from 'lucide-react'; import { Wrench, Bot, Check, Lock } from 'lucide-react';
function useAppliedFeedback() { function useAppliedFeedback() {
+13 -37
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { triggerNotificationSound } from '@/audio/sounds';
import type { import type {
GameState, Era, GameSpeed, GameSettings, GameState, Era, GameSpeed, GameSettings,
EconomyState, InfrastructureState, ComputeState, EconomyState, InfrastructureState, ComputeState,
@@ -16,7 +17,7 @@ import type {
ModelArchitecture, AlignmentMethod, SizeTier, ModelArchitecture, AlignmentMethod, SizeTier,
SFTSpecialization, QuantizationLevel, VariantCreationJob, SFTSpecialization, QuantizationLevel, VariantCreationJob,
ConsumerTierId, ApiTierId, ConsumerTierId, ApiTierId,
} from '@ai-tycoon/shared'; } from '@token-empire/shared';
import { import {
INITIAL_SETTINGS, SAVE_VERSION, INITIAL_SETTINGS, SAVE_VERSION,
INITIAL_ECONOMY, INITIAL_INFRASTRUCTURE, INITIAL_COMPUTE, INITIAL_ECONOMY, INITIAL_INFRASTRUCTURE, INITIAL_COMPUTE,
@@ -39,43 +40,15 @@ import {
SFT_TIME_FRACTION, ALIGNMENT_TIME_FRACTION, SFT_TIME_FRACTION, ALIGNMENT_TIME_FRACTION,
SIZE_TIER_MAP, SIZE_TIER_LABELS, SIZE_TIER_MAP, SIZE_TIER_LABELS,
POINT_RELEASE_TIME_FRACTION, POINT_RELEASE_MAX_VERSION, POINT_RELEASE_TIME_FRACTION, POINT_RELEASE_MAX_VERSION,
} from '@ai-tycoon/shared'; } from '@token-empire/shared';
import { import {
emptyDCNetworkSummary, emptyCampusNetworkSummary, emptyClusterNetworkSummary, emptyDCNetworkSummary, emptyCampusNetworkSummary, emptyClusterNetworkSummary,
TECH_TREE, onModelDeployed, TECH_TREE, onModelDeployed,
} from '@ai-tycoon/game-engine'; } from '@token-empire/game-engine';
import { INITIAL_RIVALS } from '@ai-tycoon/game-engine'; import { INITIAL_RIVALS } from '@token-empire/game-engine';
import type { ActivePage, InfraNav, ModelsTab, UIState, GameNotification } from './types';
export type ActivePage = 'dashboard' | 'infrastructure' | 'research' | 'models' export type { ActivePage, InfraNavLevel, InfraNav, ModelsTab, UIState, GameNotification } from './types';
| 'market' | 'serving' | 'talent' | 'data' | 'competitors' | 'finance' | 'achievements' | 'leaderboard' | 'invitations' | 'settings';
export type InfraNavLevel = 'clusters' | 'cluster' | 'campus' | 'datacenter';
export interface InfraNav {
level: InfraNavLevel;
clusterId?: string;
campusId?: string;
datacenterId?: string;
}
type ModelsTab = 'overview' | 'train' | 'models' | 'products';
interface UIState {
activePage: ActivePage;
notifications: GameNotification[];
infraNav: InfraNav;
modelsTab: ModelsTab;
}
export interface GameNotification {
id: string;
title: string;
message: string;
type: 'info' | 'success' | 'warning' | 'danger';
tick: number;
read: boolean;
action?: { label: string; page?: ActivePage; modelsTab?: ModelsTab };
}
function emptyDC(): Pick<DataCenter, 'networkSummary' | 'effectiveComputeRacks' | 'usedSlots' | 'usedPowerKW' | 'energyCostPerTick' | 'maintenanceCostPerTick' | 'currentUptime'> { function emptyDC(): Pick<DataCenter, 'networkSummary' | 'effectiveComputeRacks' | 'usedSlots' | 'usedPowerKW' | 'energyCostPerTick' | 'maintenanceCostPerTick' | 'currentUptime'> {
return { return {
@@ -313,12 +286,15 @@ export const useGameStore = create<Store>()(
setModelsTab: (tab) => set({ modelsTab: tab }), setModelsTab: (tab) => set({ modelsTab: tab }),
addNotification: (n) => set((s) => ({ addNotification: (n) => {
set((s) => ({
notifications: [ notifications: [
{ ...n, id: uuid(), read: false }, { ...n, id: uuid(), read: false },
...s.notifications.slice(0, 49), ...s.notifications.slice(0, 49),
], ],
})), }));
triggerNotificationSound(n);
},
dismissNotification: (id) => set((s) => ({ dismissNotification: (id) => set((s) => ({
notifications: s.notifications.map(n => notifications: s.notifications.map(n =>
@@ -1428,7 +1404,7 @@ export const useGameStore = create<Store>()(
}), }),
}), }),
{ {
name: 'ai-tycoon-save', name: 'token-empire-save',
version: SAVE_VERSION, version: SAVE_VERSION,
partialize: (state) => { partialize: (state) => {
const { activePage, notifications, infraNav, modelsTab, ...rest } = state; const { activePage, notifications, infraNav, modelsTab, ...rest } = state;
+30
View File
@@ -0,0 +1,30 @@
export type ActivePage = 'dashboard' | 'infrastructure' | 'research' | 'models'
| 'market' | 'serving' | 'talent' | 'data' | 'competitors' | 'finance' | 'achievements' | 'leaderboard' | 'invitations' | 'settings';
export type InfraNavLevel = 'clusters' | 'cluster' | 'campus' | 'datacenter';
export interface InfraNav {
level: InfraNavLevel;
clusterId?: string;
campusId?: string;
datacenterId?: string;
}
export type ModelsTab = 'overview' | 'train' | 'models' | 'products';
export interface UIState {
activePage: ActivePage;
notifications: GameNotification[];
infraNav: InfraNav;
modelsTab: ModelsTab;
}
export interface GameNotification {
id: string;
title: string;
message: string;
type: 'info' | 'success' | 'warning' | 'danger';
tick: number;
read: boolean;
action?: { label: string; page?: ActivePage; modelsTab?: ModelsTab };
}
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"extends": "@ai-tycoon/tsconfig/react.json", "extends": "@token-empire/tsconfig/react.json",
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
+7 -7
View File
@@ -1,6 +1,6 @@
services: services:
web: web:
image: gitea.thewrightserver.net/josh/aihostingtycoon/web:latest image: gitea.thewrightserver.net/josh/tokenempire/web:latest
ports: ports:
- "80:80" - "80:80"
depends_on: depends_on:
@@ -8,11 +8,11 @@ services:
restart: unless-stopped restart: unless-stopped
server: server:
image: gitea.thewrightserver.net/josh/aihostingtycoon/server:latest image: gitea.thewrightserver.net/josh/tokenempire/server:latest
ports: ports:
- "3001:3001" - "3001:3001"
environment: environment:
- DATABASE_URL=postgresql://aitycoon:aitycoon@db:5432/aitycoon - DATABASE_URL=postgresql://tokenempire:tokenempire@db:5432/tokenempire
- PORT=3001 - PORT=3001
- CORS_ORIGIN=* - CORS_ORIGIN=*
- JWT_SECRET=change-me-to-a-random-secret - JWT_SECRET=change-me-to-a-random-secret
@@ -26,13 +26,13 @@ services:
db: db:
image: postgres:17-alpine image: postgres:17-alpine
environment: environment:
- POSTGRES_USER=aitycoon - POSTGRES_USER=tokenempire
- POSTGRES_PASSWORD=aitycoon - POSTGRES_PASSWORD=tokenempire
- POSTGRES_DB=aitycoon - POSTGRES_DB=tokenempire
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U aitycoon"] test: ["CMD-SHELL", "pg_isready -U tokenempire"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
+2 -2
View File
@@ -17,7 +17,7 @@ The game engine and simulation core have no React dependency. They can run ident
## Monorepo Layout ## Monorepo Layout
``` ```
ai-tycoon/ token-empire/
├── turbo.json # Turborepo task config ├── turbo.json # Turborepo task config
├── pnpm-workspace.yaml # Workspace definition ├── pnpm-workspace.yaml # Workspace definition
@@ -122,7 +122,7 @@ The store uses a slice pattern with 14 slices, each owning a portion of the game
### Persistence ### Persistence
- **localStorage**: Auto-save every 60 ticks under key `ai-tycoon-save`. The Zustand `persist` middleware handles serialization. - **localStorage**: Auto-save every 60 ticks under key `token-empire-save`. The Zustand `persist` middleware handles serialization.
- **Cloud saves**: Optional. POST to `/api/saves` every 5 minutes when authenticated. Requires the Hono backend + PostgreSQL. - **Cloud saves**: Optional. POST to `/api/saves` every 5 minutes when authenticated. Requires the Hono backend + PostgreSQL.
- **Save format versioning**: A `version` field in meta enables migration functions for breaking state changes. - **Save format versioning**: A `version` field in meta enables migration functions for breaking state changes.
+18
View File
@@ -0,0 +1,18 @@
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
{
ignores: ['**/dist/**', '**/node_modules/**', '**/*.js', '**/*.mjs', '**/drizzle/**'],
},
{
rules: {
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
'no-empty': ['error', { allowEmptyCatch: true }],
'preserve-caught-error': 'off',
},
},
);
+7 -4
View File
@@ -1,20 +1,23 @@
{ {
"name": "ai-tycoon", "name": "token-empire",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "turbo dev", "dev": "turbo dev",
"build": "turbo build", "build": "turbo build",
"typecheck": "turbo typecheck", "typecheck": "turbo typecheck",
"lint": "turbo lint", "lint": "eslint .",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"clean": "turbo clean", "clean": "turbo clean",
"simulate": "turbo simulate --filter=@ai-tycoon/game-simulation", "simulate": "turbo simulate --filter=@token-empire/game-simulation",
"simulate:ci": "pnpm --filter @ai-tycoon/game-simulation simulate:ci" "simulate:ci": "pnpm --filter @token-empire/game-simulation simulate:ci"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"eslint": "^10.2.1",
"turbo": "^2.5.0", "turbo": "^2.5.0",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"typescript-eslint": "^8.59.1",
"vitest": "^4.1.5" "vitest": "^4.1.5"
}, },
"packageManager": "pnpm@10.33.0", "packageManager": "pnpm@10.33.0",
+3 -3
View File
@@ -1,5 +1,5 @@
{ {
"name": "@ai-tycoon/game-engine", "name": "@token-empire/game-engine",
"private": true, "private": true,
"version": "0.0.1", "version": "0.0.1",
"type": "module", "type": "module",
@@ -11,10 +11,10 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@ai-tycoon/shared": "workspace:*" "@token-empire/shared": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@ai-tycoon/tsconfig": "workspace:*", "@token-empire/tsconfig": "workspace:*",
"typescript": "^5.8.0" "typescript": "^5.8.0"
} }
} }
@@ -2,8 +2,8 @@ import type {
Cluster, Campus, DataCenter, DeploymentCohort, Cluster, Campus, DataCenter, DeploymentCohort,
DCNetworkSummary, CampusNetworkSummary, ClusterNetworkSummary, DCNetworkSummary, CampusNetworkSummary, ClusterNetworkSummary,
TrainingPipeline, BaseModel, ModelFamily, TrainingPipeline, BaseModel, ModelFamily,
} from '@ai-tycoon/shared'; } from '@token-empire/shared';
import { uuid } from '@ai-tycoon/shared'; import { uuid } from '@token-empire/shared';
import type { DeepPartial } from './createTestState'; import type { DeepPartial } from './createTestState';
function emptyDCNetwork(): DCNetworkSummary { function emptyDCNetwork(): DCNetworkSummary {
@@ -1,11 +1,11 @@
import type { GameState } from '@ai-tycoon/shared'; import type { GameState } from '@token-empire/shared';
import { import {
INITIAL_SETTINGS, SAVE_VERSION, INITIAL_SETTINGS, SAVE_VERSION,
INITIAL_ECONOMY, INITIAL_INFRASTRUCTURE, INITIAL_COMPUTE, INITIAL_ECONOMY, INITIAL_INFRASTRUCTURE, INITIAL_COMPUTE,
INITIAL_RESEARCH, INITIAL_MODELS, INITIAL_MARKET, INITIAL_RESEARCH, INITIAL_MODELS, INITIAL_MARKET,
INITIAL_COMPETITORS, INITIAL_TALENT, INITIAL_DATA, INITIAL_COMPETITORS, INITIAL_TALENT, INITIAL_DATA,
INITIAL_REPUTATION, INITIAL_ACHIEVEMENTS, INITIAL_REPUTATION, INITIAL_ACHIEVEMENTS,
} from '@ai-tycoon/shared'; } from '@token-empire/shared';
export type DeepPartial<T> = T extends object export type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> } ? { [K in keyof T]?: DeepPartial<T[K]> }
@@ -1,4 +1,4 @@
import type { AchievementDefinition } from '@ai-tycoon/shared'; import type { AchievementDefinition } from '@token-empire/shared';
export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [ export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
{ {
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Competitor } from '@ai-tycoon/shared'; import type { Competitor } from '@token-empire/shared';
export const INITIAL_RIVALS: Competitor[] = [ export const INITIAL_RIVALS: Competitor[] = [
{ {
@@ -1,4 +1,4 @@
import type { EnterpriseSegment } from '@ai-tycoon/shared'; import type { EnterpriseSegment } from '@token-empire/shared';
export const ENTERPRISE_NAMES: Record<EnterpriseSegment, string[]> = { export const ENTERPRISE_NAMES: Record<EnterpriseSegment, string[]> = {
startup: [ startup: [
+1 -1
View File
@@ -1,4 +1,4 @@
import type { DepartmentId } from '@ai-tycoon/shared'; import type { DepartmentId } from '@token-empire/shared';
/** /**
* A recruitable key hire as it appears in the available pool. * A recruitable key hire as it appears in the available pool.
+1 -1
View File
@@ -1,4 +1,4 @@
import type { ResearchNode } from '@ai-tycoon/shared'; import type { ResearchNode } from '@token-empire/shared';
export const TECH_TREE: ResearchNode[] = [ export const TECH_TREE: ResearchNode[] = [
// === COMPUTE / INFRASTRUCTURE === // === COMPUTE / INFRASTRUCTURE ===
+1 -1
View File
@@ -1,4 +1,4 @@
import type { GameState } from '@ai-tycoon/shared'; import type { GameState } from '@token-empire/shared';
import { processTick } from './tick'; import { processTick } from './tick';
export interface GameEngineCallbacks { export interface GameEngineCallbacks {
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { processAchievements } from './achievementSystem'; import { processAchievements } from './achievementSystem';
import { createTestState } from '../__test-utils__'; import { createTestState } from '../__test-utils__';
import type { AchievementDefinition } from '@ai-tycoon/shared'; import type { AchievementDefinition } from '@token-empire/shared';
function makeDef(overrides: Partial<AchievementDefinition> = {}): AchievementDefinition { function makeDef(overrides: Partial<AchievementDefinition> = {}): AchievementDefinition {
return { return {
@@ -1,4 +1,4 @@
import type { GameState, AchievementState, AchievementDefinition } from '@ai-tycoon/shared'; import type { GameState, AchievementState, AchievementDefinition } from '@token-empire/shared';
export interface AchievementTickResult { export interface AchievementTickResult {
achievements: AchievementState; achievements: AchievementState;
@@ -1,8 +1,8 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { processCompetitors } from './competitorSystem'; import { processCompetitors } from './competitorSystem';
import { createTestState, createSeededRNG } from '../__test-utils__'; import { createTestState, createSeededRNG } from '../__test-utils__';
import { FRESHNESS_DECAY_RATE } from '@ai-tycoon/shared'; import { FRESHNESS_DECAY_RATE } from '@token-empire/shared';
import type { Competitor } from '@ai-tycoon/shared'; import type { Competitor } from '@token-empire/shared';
const rng = createSeededRNG(42); const rng = createSeededRNG(42);
beforeEach(() => rng.install()); beforeEach(() => rng.install());
@@ -1,10 +1,10 @@
import type { GameState, CompetitorState, Competitor } from '@ai-tycoon/shared'; import type { GameState, CompetitorState, Competitor } from '@token-empire/shared';
import { import {
COMPETITOR_PRODUCT_THRESHOLDS, COMPETITOR_PRODUCT_THRESHOLDS,
COMPETITOR_CATCHUP_SHARE_THRESHOLD, COMPETITOR_CATCHUP_SHARE_THRESHOLD,
COMPETITOR_CATCHUP_PRICE_CUT, COMPETITOR_CATCHUP_PRICE_CUT,
FRESHNESS_DECAY_RATE, FRESHNESS_DECAY_RATE,
} from '@ai-tycoon/shared'; } from '@token-empire/shared';
function updateCompetitorProducts(rival: Competitor): Competitor['products'] { function updateCompetitorProducts(rival: Competitor): Competitor['products'] {
const cap = rival.estimatedCapability; const cap = rival.estimatedCapability;
@@ -1,8 +1,8 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { createTestState } from '../__test-utils__'; import { createTestState } from '../__test-utils__';
import { computeCapacity, finalizeCompute } from './computeSystem'; import { computeCapacity, finalizeCompute } from './computeSystem';
import type { InfrastructureState } from '@ai-tycoon/shared'; import type { InfrastructureState } from '@token-empire/shared';
import { INITIAL_INFRASTRUCTURE, FLOPS_TO_TOKENS_MULTIPLIER, COMPUTE_SNAPSHOT_INTERVAL, MAX_COMPUTE_HISTORY } from '@ai-tycoon/shared'; import { INITIAL_INFRASTRUCTURE, FLOPS_TO_TOKENS_MULTIPLIER, COMPUTE_SNAPSHOT_INTERVAL, MAX_COMPUTE_HISTORY } from '@token-empire/shared';
function createInfrastructure(overrides: Partial<InfrastructureState> = {}): InfrastructureState { function createInfrastructure(overrides: Partial<InfrastructureState> = {}): InfrastructureState {
return { ...INITIAL_INFRASTRUCTURE, ...overrides }; return { ...INITIAL_INFRASTRUCTURE, ...overrides };
@@ -1,5 +1,5 @@
import type { GameState, ComputeState, InfrastructureState } from '@ai-tycoon/shared'; import type { GameState, ComputeState, InfrastructureState } from '@token-empire/shared';
import { FLOPS_TO_TOKENS_MULTIPLIER, COMPUTE_SNAPSHOT_INTERVAL, MAX_COMPUTE_HISTORY } from '@ai-tycoon/shared'; import { FLOPS_TO_TOKENS_MULTIPLIER, COMPUTE_SNAPSHOT_INTERVAL, MAX_COMPUTE_HISTORY } from '@token-empire/shared';
import type { ResearchBonuses } from './researchBonuses'; import type { ResearchBonuses } from './researchBonuses';
export interface CapacityResult { export interface CapacityResult {
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { processData } from './dataSystem'; import { processData } from './dataSystem';
import { createTestState } from '../__test-utils__'; import { createTestState } from '../__test-utils__';
import type { DataPartnership } from '@ai-tycoon/shared'; import type { DataPartnership } from '@token-empire/shared';
function makePartnership(tokensPerTick: number): DataPartnership { function makePartnership(tokensPerTick: number): DataPartnership {
return { return {
@@ -1,4 +1,4 @@
import type { GameState, DataState } from '@ai-tycoon/shared'; import type { GameState, DataState } from '@token-empire/shared';
export function processData(state: GameState): DataState { export function processData(state: GameState): DataState {
const subscribers = state.market.consumerTiers.totalUsers; const subscribers = state.market.consumerTiers.totalUsers;
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import { processEconomy } from './economySystem'; import { processEconomy } from './economySystem';
import { createTestState, createTestCluster } from '../__test-utils__'; import { createTestState, createTestCluster } from '../__test-utils__';
import type { MarketTickResult } from './marketSystem'; import type { MarketTickResult } from './marketSystem';
import type { InfrastructureState } from '@ai-tycoon/shared'; import type { InfrastructureState } from '@token-empire/shared';
function createMarketResult( function createMarketResult(
overrides: Partial<MarketTickResult> = {}, overrides: Partial<MarketTickResult> = {},
@@ -1,5 +1,5 @@
import type { GameState, EconomyState, InfrastructureState } from '@ai-tycoon/shared'; import type { GameState, EconomyState, InfrastructureState } from '@token-empire/shared';
import { FINANCIAL_SNAPSHOT_INTERVAL, MAX_FINANCIAL_HISTORY, REGULATION_COMPLIANCE_PER_CAPABILITY } from '@ai-tycoon/shared'; import { FINANCIAL_SNAPSHOT_INTERVAL, MAX_FINANCIAL_HISTORY, REGULATION_COMPLIANCE_PER_CAPABILITY } from '@token-empire/shared';
import { TECH_TREE } from '../data/techTree'; import { TECH_TREE } from '../data/techTree';
import type { MarketTickResult } from './marketSystem'; import type { MarketTickResult } from './marketSystem';

Some files were not shown because too many files have changed in this diff Show More