Compare commits

...

20 Commits

Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 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
josh a061337d6f Fix Docker production build: move tsx to dependencies, reinstall in production stage
The production Docker stage was copying pnpm symlinks between stages
which broke module resolution. Now does a fresh pnpm install --prod
in the production stage and runs from the server working directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 19:33:16 -04:00
josh 4881907c28 Add auth system with invite-only registration and admin roles
JWT-based auth (hono/jwt + bcrypt), anonymous-first flow preserved.
Registration requires invite code when REQUIRE_INVITE=true. Admin
user seeded on startup (admin/admin, forced password reset). Login
accepts email or username. Admin invitations management page in
sidebar. Regular users get invite-a-friend button when USER_INVITATIONS > 0.
Frontend gate screen blocks game access for unregistered users with
invite code entry, registration, login, and password reset flows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 19:25:16 -04:00
josh df01ac8e35 Move revenue after churn and raise price churn cap to prevent exploit
Balance Check / balance-simulation (push) Successful in 2m1s
CI / build-and-push (push) Successful in 2m6s
Balance Check / multi-run-balance (push) Successful in 13m10s
Churned subscribers no longer generate revenue the tick they leave,
and the price churn multiplier cap is raised from 10 to 1000 so
astronomical prices empty the subscriber pool in a single tick.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 22:17:30 -04:00
josh 63e56dc229 Fix consumer subscription pricing exploit with perceived-value-based elasticity
Balance Check / balance-simulation (push) Successful in 51s
Balance Check / multi-run-balance (push) Successful in 13m19s
CI / build-and-push (push) Successful in 45s
Players could set astronomical prices and still retain subscribers because
price elasticity floored at 10% for any price above $100, satisfaction
ignored pricing entirely, and churn had no price component.

Introduces perceived value per tier (model quality × reputation), replaces
the broken linear formula with sigmoid decay, adds price-aware satisfaction
blending, and applies per-tier price-based churn multipliers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 21:51:03 -04:00
josh 5aa9436368 Expand multirun reporting: health summary, era durations, serving diagnostics, cash-flow detail
Balance Check / balance-simulation (push) Successful in 46s
Balance Check / multi-run-balance (push) Successful in 14m6s
CI / build-and-push (push) Successful in 46s
Propagate per-era duration/bottleneck, serving utilization, cash-flow nadir/peak,
and late-game revenue growth through the worker→CSV→interpret pipeline. Add
simulation health archetype classification, per-era bottleneck frequency,
unused-feature frequency table, failed-run AGI gate analysis, and log-scale
variance for exponential metrics. All new CSV columns parse defensively for
backward compatibility with older summary files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 20:55:49 -04:00
josh 62998d6cb2 Remove duplicate per-run completion line from worker stderr
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 20:21:38 -04:00
josh a240ba2e44 Scale CI multi-simulation to 100 runs, remove per-run progress ticker
Balance Check / balance-simulation (push) Successful in 42s
Balance Check / multi-run-balance (push) Successful in 13m37s
CI / build-and-push (push) Successful in 43s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 20:11:49 -04:00
josh 19f652b43a Replace per-switch network simulation with aggregate per-DC statistical model
Balance Check / balance-simulation (push) Successful in 48s
Balance Check / multi-run-balance (push) Successful in 1m24s
CI / build-and-push (push) Successful in 43s
Eliminates the 22K-object switchRegistry that caused O(n×m) scans 4x per tick.
Network health is now tracked as aggregate counts per tier (totalByTier/healthyByTier)
with RepairBatch timers, cutting late-game tick cost from ~50ms to ~0.3ms.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 20:06:40 -04:00
josh 57a81be769 Cache serving pipeline fleet to eliminate per-tick rebuilds and reduce GC pressure
Fleet template is now rebuilt only when deploymentVersion changes (~68 times per
28,800-tick run instead of every tick). Reuses module-level Maps, arrays, and
utilization objects instead of allocating new ones each tick. Replaces 4x
Object.values().reduce() with single-pass aggregation and sorts fleet in-place.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 19:51:13 -04:00
josh bbb69a315c Remove benchmark evaluation system, use training capabilities directly
Model quality for market segments and product lines now derives from deployed
model capabilities (coding, reasoning, agents, etc.) instead of requiring a
separate manual benchmark evaluation step. This eliminates an unbounded
benchmarkResults[] array that was scanned 5x per tick and removes ~480 lines
of dead-weight UI, types, and engine code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 19:28:59 -04:00
149 changed files with 3943 additions and 1371 deletions
+4 -4
View File
@@ -35,7 +35,7 @@ jobs:
run: pnpm test
- 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:
runs-on: ubuntu-latest
@@ -53,9 +53,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run multi-simulation (5 runs)
run: pnpm --filter @ai-tycoon/game-simulation multirun -- --runs 5 --parallel 5 --strategy persona --ticks 28800 --no-timeseries
- name: Run multi-simulation (100 runs)
run: pnpm --filter @token-empire/game-simulation multirun -- --runs 100 --parallel 10 --strategy persona --ticks 28800 --no-timeseries
- name: Interpret results
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:
REGISTRY: gitea.thewrightserver.net
IMAGE_PREFIX: gitea.thewrightserver.net/josh/aihostingtycoon
IMAGE_PREFIX: gitea.thewrightserver.net/josh/tokenempire
jobs:
build-and-push:
+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.
@@ -29,7 +29,7 @@ The web app starts at `http://localhost:5173` (or the next available port). The
## Project Structure
```
ai-tycoon/
token-empire/
├── apps/
│ ├── web/ # React frontend (Vite)
│ └── 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`:
```
DATABASE_URL=postgresql://user:password@localhost:5432/ai_tycoon
DATABASE_URL=postgresql://user:password@localhost:5432/token_empire
```
Run migrations:
+6 -6
View File
@@ -14,16 +14,16 @@ COPY --from=deps /app/node_modules ./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 . .
RUN pnpm --filter @ai-tycoon/shared build && \
pnpm --filter @ai-tycoon/server typecheck
RUN pnpm --filter @token-empire/shared build && \
pnpm --filter @token-empire/server typecheck
FROM base AS production
COPY --from=deps /app/node_modules ./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 pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY packages/shared ./packages/shared
COPY packages/tsconfig ./packages/tsconfig
COPY apps/server ./apps/server
RUN pnpm install --frozen-lockfile --prod
ENV NODE_ENV=production
EXPOSE 3001
CMD ["node", "--import", "tsx", "apps/server/src/index.ts"]
WORKDIR /app/apps/server
CMD ["node", "--import", "tsx", "src/index.ts"]
+1 -1
View File
@@ -5,6 +5,6 @@ export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
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
}
]
}
+5 -4
View File
@@ -1,5 +1,5 @@
{
"name": "@ai-tycoon/server",
"name": "@token-empire/server",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -13,18 +13,19 @@
"db:push": "drizzle-kit push"
},
"dependencies": {
"@ai-tycoon/shared": "workspace:*",
"@token-empire/shared": "workspace:*",
"@hono/node-server": "^1.13.8",
"bcryptjs": "^3.0.3",
"drizzle-orm": "^0.44.2",
"hono": "^4.7.10",
"postgres": "^3.4.7",
"tsx": "^4.19.4",
"uuid": "^11.1.0"
},
"devDependencies": {
"@ai-tycoon/tsconfig": "workspace:*",
"@token-empire/tsconfig": "workspace:*",
"@types/node": "^25.6.0",
"drizzle-kit": "^0.31.1",
"tsx": "^4.19.4",
"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 { migrate } from 'drizzle-orm/postgres-js/migrator';
import postgres from 'postgres';
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);
export const db = drizzle(client, { schema });
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');
}
+13
View File
@@ -3,8 +3,12 @@ import { pgTable, uuid, text, timestamp, jsonb, integer, boolean, index } from '
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
anonToken: uuid('anon_token').defaultRandom().notNull().unique(),
username: text('username').unique(),
email: text('email').unique(),
passwordHash: text('password_hash'),
role: text('role').notNull().default('user'),
mustResetPassword: boolean('must_reset_password').notNull().default(false),
tokenVersion: integer('token_version').notNull().default(0),
createdAt: timestamp('created_at').defaultNow().notNull(),
lastSeenAt: timestamp('last_seen_at').defaultNow().notNull(),
});
@@ -44,3 +48,12 @@ export const achievements = pgTable('achievements', {
}, (table) => [
index('achievements_user_id_idx').on(table.userId),
]);
export const invitations = pgTable('invitations', {
id: uuid('id').defaultRandom().primaryKey(),
code: text('code').notNull().unique(),
createdBy: uuid('created_by').notNull().references(() => users.id),
usedBy: uuid('used_by').references(() => users.id),
createdAt: timestamp('created_at').defaultNow().notNull(),
expiresAt: timestamp('expires_at'),
});
+27
View File
@@ -0,0 +1,27 @@
import { eq } from 'drizzle-orm';
import bcrypt from 'bcryptjs';
import { db } from './index';
import { users } from './schema';
export async function seedAdmin() {
const [existing] = await db
.select()
.from(users)
.where(eq(users.role, 'admin'))
.limit(1);
if (existing) {
console.log('Admin user already exists');
return;
}
const passwordHash = await bcrypt.hash('admin', 10);
await db.insert(users).values({
username: 'admin',
passwordHash,
role: 'admin',
mustResetPassword: true,
});
console.log('Admin user seeded (admin/admin — password reset required)');
}
+19 -1
View File
@@ -5,6 +5,14 @@ import { serve } from '@hono/node-server';
import { auth } from './routes/auth';
import { savesRouter } from './routes/saves';
import { leaderboardRouter } from './routes/leaderboard';
import { invitesRouter } from './routes/invites';
import { runMigrations } from './db';
import { seedAdmin } from './db/seed';
if (!process.env.JWT_SECRET) {
console.error('FATAL: JWT_SECRET environment variable is required');
process.exit(1);
}
const app = new Hono();
@@ -18,13 +26,23 @@ app.use('*', cors({
}));
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({
requireInvite: process.env.REQUIRE_INVITE !== 'false',
userInvitations: parseInt(process.env.USER_INVITATIONS || '0', 10),
}));
app.route('/api/auth', auth);
app.route('/api/saves', savesRouter);
app.route('/api/leaderboard', leaderboardRouter);
app.route('/api/invites', invitesRouter);
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();
serve({ fetch: app.fetch, port });
+43
View File
@@ -0,0 +1,43 @@
import { sign, verify } from 'hono/jwt';
const JWT_EXPIRY_SECONDS = 30 * 24 * 60 * 60;
export function getJwtSecret(): string {
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error('JWT_SECRET env var is required');
return secret;
}
export async function createToken(
userId: string,
email: string | null,
role: string,
username: string | null,
mustResetPassword: boolean,
tokenVersion: number = 0,
): Promise<string> {
const now = Math.floor(Date.now() / 1000);
return sign(
{ sub: userId, email, role, username, mustResetPassword, tokenVersion, iat: now, exp: now + JWT_EXPIRY_SECONDS },
getJwtSecret(),
);
}
export async function verifyToken(token: string): Promise<{
sub: string;
email: string | null;
role: string;
username: string | null;
mustResetPassword: boolean;
tokenVersion: number;
}> {
const payload = await verify(token, getJwtSecret(), 'HS256');
return {
sub: payload.sub as string,
email: (payload.email as string) ?? null,
role: (payload.role as string) ?? 'user',
username: (payload.username as string) ?? null,
mustResetPassword: (payload.mustResetPassword as boolean) ?? false,
tokenVersion: (payload.tokenVersion as number) ?? 0,
};
}
+26 -3
View File
@@ -2,6 +2,7 @@ import { createMiddleware } from 'hono/factory';
import { eq } from 'drizzle-orm';
import { db } from '../db';
import { users } from '../db/schema';
import { verifyToken } from '../lib/jwt';
import type { AppEnv } from '../types';
export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
@@ -14,25 +15,47 @@ export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
const token = authHeader.slice(7);
try {
const payload = await verifyToken(token);
const [user] = await db
.select()
.from(users)
.where(eq(users.anonToken, token))
.where(eq(users.id, payload.sub))
.limit(1);
if (!user) {
return c.json({ error: 'Invalid token' }, 401);
}
if (payload.tokenVersion !== user.tokenVersion) {
return c.json({ error: 'Token has been revoked' }, 401);
}
await db
.update(users)
.set({ lastSeenAt: new Date() })
.where(eq(users.id, user.id));
c.set('userId', user.id);
c.set('user', user as AppEnv['Variables']['user']);
c.set('user', {
id: user.id,
anonToken: user.anonToken,
username: user.username,
email: user.email,
role: user.role,
mustResetPassword: user.mustResetPassword,
tokenVersion: user.tokenVersion,
});
await next();
} catch {
return c.json({ error: 'Authentication failed' }, 500);
return c.json({ error: 'Invalid or expired token' }, 401);
}
});
export const requireAdmin = createMiddleware<AppEnv>(async (c, next) => {
const user = c.get('user');
if (user.role !== 'admin') {
return c.json({ error: 'Forbidden' }, 403);
}
await next();
});
+209 -32
View File
@@ -1,7 +1,10 @@
import { Hono } from 'hono';
import { eq } from 'drizzle-orm';
import { eq, or, sql } from 'drizzle-orm';
import bcrypt from 'bcryptjs';
import { db } from '../db';
import { users } from '../db/schema';
import { createToken } from '../lib/jwt';
import { authMiddleware } from '../middleware/auth';
import type { AppEnv } from '../types';
const auth = new Hono<AppEnv>();
@@ -12,20 +15,51 @@ auth.post('/anonymous', async (c) => {
.values({})
.returning();
return c.json({
userId: user.id,
token: user.anonToken,
});
const token = await createToken(user.id, null, 'user', null, false, 0);
return c.json({ userId: user.id, token });
});
auth.post('/link-email', async (c) => {
const userId = c.get('userId') as string;
if (!userId) return c.json({ error: 'Not authenticated' }, 401);
auth.post('/register', authMiddleware, async (c) => {
const userId = c.get('userId');
const { email, password, inviteCode } = await c.req.json<{
email: string;
password: string;
inviteCode: string;
}>();
const { email, password } = await c.req.json<{ email: string; password: string }>();
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return c.json({ error: 'Valid email required' }, 400);
}
if (!password || password.length < 8) {
return c.json({ error: 'Password must be at least 8 characters' }, 400);
}
if (!email || !password) {
return c.json({ error: 'Email and password required' }, 400);
if (process.env.REQUIRE_INVITE !== 'false') {
if (!inviteCode) {
return c.json({ error: 'Invite code required' }, 400);
}
const { invitations } = await import('../db/schema');
const { isNull, and, sql } = await import('drizzle-orm');
const [consumed] = await db
.update(invitations)
.set({ usedBy: userId })
.where(
and(
eq(invitations.code, inviteCode),
isNull(invitations.usedBy),
or(
isNull(invitations.expiresAt),
sql`${invitations.expiresAt} > NOW()`,
),
),
)
.returning();
if (!consumed) {
return c.json({ error: 'Invalid or used invite code' }, 400);
}
}
const existing = await db
@@ -38,44 +72,187 @@ auth.post('/link-email', async (c) => {
return c.json({ error: 'Email already in use' }, 409);
}
const encoder = new TextEncoder();
const data = encoder.encode(password);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashHex = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
const passwordHash = await bcrypt.hash(password, 10);
await db
const [updated] = await db
.update(users)
.set({ email, passwordHash: hashHex })
.where(eq(users.id, userId));
.set({ email, passwordHash })
.where(eq(users.id, userId))
.returning();
return c.json({ success: true });
const token = await createToken(updated.id, updated.email, updated.role, updated.username, false, updated.tokenVersion);
return c.json({ userId: updated.id, token });
});
auth.post('/login', async (c) => {
const { email, password } = await c.req.json<{ email: string; password: string }>();
const { login, password } = await c.req.json<{ login: string; password: string }>();
const encoder = new TextEncoder();
const data = encoder.encode(password);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashHex = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
if (!login || !password) {
return c.json({ error: 'Login and password required' }, 400);
}
const [user] = await db
.select()
.from(users)
.where(or(eq(users.email, login), eq(users.username, login)))
.limit(1);
if (!user || !user.passwordHash) {
return c.json({ error: 'Invalid credentials' }, 401);
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return c.json({ error: 'Invalid credentials' }, 401);
}
const token = await createToken(user.id, user.email, user.role, user.username, user.mustResetPassword, user.tokenVersion);
return c.json({ userId: user.id, token });
});
auth.post('/change-password', authMiddleware, async (c) => {
const user = c.get('user');
const { currentPassword, newPassword } = await c.req.json<{
currentPassword?: string;
newPassword: string;
}>();
if (!newPassword || newPassword.length < 8) {
return c.json({ error: 'New password must be at least 8 characters' }, 400);
}
if (!user.mustResetPassword) {
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 passwordHash = await bcrypt.hash(newPassword, 10);
const [updated] = await db
.update(users)
.set({ passwordHash, mustResetPassword: false, tokenVersion: sql`${users.tokenVersion} + 1` })
.where(eq(users.id, user.id))
.returning({ tokenVersion: users.tokenVersion });
const token = await createToken(user.id, user.email, user.role, user.username, false, updated.tokenVersion);
return c.json({ success: true, token });
});
auth.post('/change-username', authMiddleware, async (c) => {
const user = c.get('user');
if (!user.email && user.role !== 'admin') {
return c.json({ error: 'Must be registered to change username' }, 403);
}
const { username } = await c.req.json<{ username: string }>();
if (!username || username.length < 2) {
return c.json({ error: 'Username must be at least 2 characters' }, 400);
}
const existing = await db
.select()
.from(users)
.where(eq(users.username, username))
.limit(1);
if (existing.length > 0 && existing[0].id !== user.id) {
return c.json({ error: 'Username already taken' }, 409);
}
await db
.update(users)
.set({ username })
.where(eq(users.id, user.id));
const token = await createToken(user.id, user.email, user.role, username, user.mustResetPassword, user.tokenVersion);
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 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.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 (!user || user.passwordHash !== hashHex) {
return c.json({ error: 'Invalid credentials' }, 401);
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({
userId: user.id,
token: user.anonToken,
id: user.id,
username: user.username,
email: user.email,
role: user.role,
});
});
+166
View File
@@ -0,0 +1,166 @@
import { Hono } from 'hono';
import { eq, and, isNull, or, sql, count, desc } from 'drizzle-orm';
import crypto from 'node:crypto';
import { db } from '../db';
import { invitations, users } from '../db/schema';
import { authMiddleware, requireAdmin } from '../middleware/auth';
import type { AppEnv } from '../types';
const invitesRouter = new Hono<AppEnv>();
function generateCode(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
const bytes = crypto.randomBytes(8);
let code = '';
for (let i = 0; i < 8; i++) {
code += chars[bytes[i] % chars.length];
}
return code;
}
invitesRouter.post('/', authMiddleware, async (c) => {
const user = c.get('user');
if (!user.email && user.role !== 'admin') {
return c.json({ error: 'Must be registered to create invites' }, 403);
}
if (user.role !== 'admin') {
const limit = parseInt(process.env.USER_INVITATIONS || '0', 10);
if (limit <= 0) {
return c.json({ error: 'You are not allowed to create invites' }, 403);
}
const [{ value: created }] = await db
.select({ value: count() })
.from(invitations)
.where(eq(invitations.createdBy, user.id));
if (created >= limit) {
return c.json({ error: 'Invite limit reached' }, 403);
}
}
for (let attempt = 0; attempt < 5; attempt++) {
const code = generateCode();
try {
await db.insert(invitations).values({ code, createdBy: user.id });
return c.json({ code });
} catch (e: unknown) {
const message = e instanceof Error ? e.message : '';
if (message.includes('unique') || message.includes('duplicate')) continue;
throw e;
}
}
return c.json({ error: 'Failed to generate unique code' }, 500);
});
invitesRouter.get('/validate/:code', async (c) => {
const code = c.req.param('code');
const [invite] = await db
.select()
.from(invitations)
.where(
and(
eq(invitations.code, code),
isNull(invitations.usedBy),
or(
isNull(invitations.expiresAt),
sql`${invitations.expiresAt} > NOW()`,
),
),
)
.limit(1);
return c.json({ valid: !!invite });
});
invitesRouter.get('/remaining', authMiddleware, async (c) => {
const user = c.get('user');
if (user.role === 'admin') {
return c.json({ remaining: -1 });
}
const limit = parseInt(process.env.USER_INVITATIONS || '0', 10);
if (limit <= 0) {
return c.json({ remaining: 0 });
}
const [{ value: created }] = await db
.select({ value: count() })
.from(invitations)
.where(eq(invitations.createdBy, user.id));
return c.json({ remaining: Math.max(0, limit - created) });
});
invitesRouter.get('/', authMiddleware, requireAdmin, async (c) => {
const allInvites = await db
.select({
id: invitations.id,
code: invitations.code,
createdBy: invitations.createdBy,
usedBy: invitations.usedBy,
createdAt: invitations.createdAt,
expiresAt: invitations.expiresAt,
})
.from(invitations)
.orderBy(desc(invitations.createdAt));
const userIds = new Set<string>();
for (const inv of allInvites) {
userIds.add(inv.createdBy);
if (inv.usedBy) userIds.add(inv.usedBy);
}
const userMap = new Map<string, { username: string | null; email: string | null }>();
if (userIds.size > 0) {
const userList = await db
.select({ id: users.id, username: users.username, email: users.email })
.from(users)
.where(or(...[...userIds].map(id => eq(users.id, id))));
for (const u of userList) {
userMap.set(u.id, { username: u.username, email: u.email });
}
}
const enriched = allInvites.map(inv => ({
id: inv.id,
code: inv.code,
createdBy: userMap.get(inv.createdBy) ?? { username: null, email: null },
usedBy: inv.usedBy ? (userMap.get(inv.usedBy) ?? { username: null, email: null }) : null,
createdAt: inv.createdAt,
expiresAt: inv.expiresAt,
used: !!inv.usedBy,
}));
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 };
+13
View File
@@ -28,6 +28,19 @@ savesRouter.get('/', async (c) => {
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) => {
const userId = c.get('userId') as string;
const saveId = c.req.param('id');
+4
View File
@@ -4,7 +4,11 @@ export type AppEnv = {
user: {
id: string;
anonToken: string;
username: string | null;
email: string | null;
role: string;
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": {
"outDir": "dist",
"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/apps/web/node_modules ./apps/web/node_modules
COPY . .
ARG VITE_API_URL=/api
ARG VITE_API_URL=
ENV VITE_API_URL=$VITE_API_URL
RUN pnpm --filter @ai-tycoon/shared build && \
pnpm --filter @ai-tycoon/game-engine build && \
pnpm --filter @ai-tycoon/web build
RUN pnpm --filter @token-empire/shared build && \
pnpm --filter @token-empire/game-engine build && \
pnpm --filter @token-empire/web build
FROM nginx:alpine
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<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.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">
+7
View File
@@ -3,6 +3,13 @@ server {
root /usr/share/nginx/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 / {
try_files $uri $uri/ /index.html;
}
+4 -4
View File
@@ -1,5 +1,5 @@
{
"name": "@ai-tycoon/web",
"name": "@token-empire/web",
"private": true,
"version": "0.0.1",
"type": "module",
@@ -10,8 +10,8 @@
"preview": "vite preview"
},
"dependencies": {
"@ai-tycoon/shared": "workspace:*",
"@ai-tycoon/game-engine": "workspace:*",
"@token-empire/shared": "workspace:*",
"@token-empire/game-engine": "workspace:*",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"recharts": "^2.15.0",
@@ -19,7 +19,7 @@
"lucide-react": "^0.475.0"
},
"devDependencies": {
"@ai-tycoon/tsconfig": "workspace:*",
"@token-empire/tsconfig": "workspace:*",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.4.0",
+71 -3
View File
@@ -3,10 +3,58 @@ import { useGameStore } from '@/store';
import { MainLayout } from '@/components/layout/MainLayout';
import { NewGameScreen } from '@/components/game/NewGameScreen';
import { OfflineCatchUp } from '@/components/game/OfflineCatchUp';
import { InviteGateScreen } from '@/components/game/InviteGateScreen';
import { useGameLoop } from '@/hooks/useGameLoop';
import { TICK_INTERVAL_MS } from '@ai-tycoon/shared';
import { useAuthGate } from '@/hooks/useAuthGate';
import { useCloudSave } from '@/hooks/useCloudSave';
import { TICK_INTERVAL_MS } from '@token-empire/shared';
import { Sparkles, RefreshCw, WifiOff } from 'lucide-react';
function LoadingScreen() {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-surface-950 to-surface-900">
<div className="text-center">
<div className="inline-flex items-center gap-2 mb-4">
<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">
Token Empire
</h1>
</div>
<p className="text-surface-500 text-sm">Loading...</p>
</div>
</div>
);
}
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() {
const { loading: authLoading, backendError, needsInvite, needsPasswordReset, cloudSave, loadCloudSave, setRegistered, setNeedsPasswordReset, retry } = useAuthGate();
const companyName = useGameStore((s) => s.meta.companyName);
const lastTickTimestamp = useGameStore((s) => s.meta.lastTickTimestamp);
const [catchUpTicks, setCatchUpTicks] = useState<number | null>(null);
@@ -23,10 +71,30 @@ export function App() {
}
}, [companyName, lastTickTimestamp, catchUpDone]);
useGameLoop(!catchUpDone);
useGameLoop(!catchUpDone || authLoading || !!backendError || needsInvite || needsPasswordReset);
useCloudSave();
if (authLoading) {
return <LoadingScreen />;
}
if (backendError) {
return <BackendErrorScreen error={backendError} onRetry={retry} />;
}
if (needsInvite || needsPasswordReset) {
return (
<InviteGateScreen
onRegistered={() => {
setRegistered(true);
setNeedsPasswordReset(false);
}}
/>
);
}
if (!companyName) {
return <NewGameScreen />;
return <NewGameScreen cloudSave={cloudSave} onContinue={loadCloudSave} />;
}
if (catchUpTicks !== null && !catchUpDone) {
@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react';
import { X, CheckCircle, AlertTriangle, AlertCircle, Info, Bell, Trash2 } from 'lucide-react';
import { useGameStore, type GameNotification } from '@/store';
import { formatDuration } from '@ai-tycoon/shared';
import { formatDuration } from '@token-empire/shared';
const ICON_MAP = {
success: { icon: CheckCircle, color: 'text-success' },
+1 -1
View File
@@ -18,7 +18,7 @@ export function DevMenu() {
const [isOpen, setIsOpen] = useState(false);
const [activeTab, setActiveTab] = useState<Tab>('resources');
const isEnabled = import.meta.env.DEV || localStorage.getItem('ai-tycoon-dev-menu') === 'true';
const isEnabled = import.meta.env.DEV || localStorage.getItem('token-empire-dev-menu') === 'true';
useEffect(() => {
if (!isEnabled) return;
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useGameStore } from '@/store';
import type { FundingRoundType } from '@ai-tycoon/shared';
import type { FundingRoundType } from '@token-empire/shared';
function DevButton({ onClick, children, variant = 'default' }: {
onClick: () => void;
+1 -1
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useGameStore } from '@/store';
import { formatMoney } from '@ai-tycoon/shared';
import { formatMoney } from '@token-empire/shared';
function DevButton({ onClick, children, variant = 'default' }: {
onClick: () => void;
@@ -1,5 +1,5 @@
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 }) {
return (
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useGameStore } from '@/store';
import { processTick, setAchievementDefinitions, ACHIEVEMENT_DEFINITIONS, TECH_TREE } from '@ai-tycoon/game-engine';
import type { GameState, Era } from '@ai-tycoon/shared';
import { processTick, setAchievementDefinitions, ACHIEVEMENT_DEFINITIONS, TECH_TREE } from '@token-empire/game-engine';
import type { GameState, Era } from '@token-empire/shared';
function DevButton({ onClick, children, variant = 'default' }: {
onClick: () => void;
@@ -1,8 +1,8 @@
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 { 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 }) {
const [copied, setCopied] = useState(false);
@@ -25,7 +25,7 @@ export function CompanyStatsCard({ onClose }: { onClose: () => void }) {
const minutes = Math.floor((totalPlayTime % 3600) / 60);
const statsText = [
`${companyName}AI Tycoon`,
`${companyName}Token Empire`,
`Era: ${eraLabel} | Playtime: ${hours}h ${minutes}m`,
`Cash: ${formatMoney(money)} | Revenue: ${formatMoney(totalRevenue)}`,
`Valuation: ${formatMoney(valuation)}`,
@@ -0,0 +1,318 @@
import { useState, useEffect } from 'react';
import { Sparkles, ArrowLeft } from 'lucide-react';
import { api, setAuthToken, needsPasswordReset } from '@/lib/api';
type Stage = 'invite' | 'register' | 'login' | 'reset-password';
export function InviteGateScreen({ onRegistered }: { onRegistered: () => void }) {
const [stage, setStage] = useState<Stage>('invite');
const [inviteCode, setInviteCode] = useState('');
const [email, setEmail] = useState('');
const [loginField, setLoginField] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmNewPassword, setConfirmNewPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const code = params.get('invite');
if (code) {
setInviteCode(code);
handleValidateCode(code);
}
}, []);
async function handleValidateCode(code?: string) {
const codeToValidate = code || inviteCode.trim();
if (!codeToValidate) {
setError('Please enter an invite code');
return;
}
setLoading(true);
setError('');
try {
const result = await api.invites.validate(codeToValidate);
if (result.valid) {
setInviteCode(codeToValidate);
setStage('register');
} else {
setError('Invalid or already used invite code');
}
} catch {
setError('Could not validate invite code');
} finally {
setLoading(false);
}
}
async function handleRegister() {
if (!email.trim()) { setError('Email is required'); return; }
if (password.length < 8) { setError('Password must be at least 8 characters'); return; }
if (password !== confirmPassword) { setError('Passwords do not match'); return; }
setLoading(true);
setError('');
try {
const result = await api.auth.register(email.trim(), password, inviteCode);
setAuthToken(result.token);
onRegistered();
} catch (e) {
setError(e instanceof Error ? e.message : 'Registration failed');
} finally {
setLoading(false);
}
}
async function handleLogin() {
if (!loginField.trim() || !password) { setError('Email/username and password required'); return; }
setLoading(true);
setError('');
try {
const result = await api.auth.login(loginField.trim(), password);
setAuthToken(result.token);
if (needsPasswordReset()) {
setStage('reset-password');
} else {
onRegistered();
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Login failed');
} finally {
setLoading(false);
}
}
async function handlePasswordReset() {
if (newPassword.length < 8) { setError('Password must be at least 8 characters'); return; }
if (newPassword !== confirmNewPassword) { setError('Passwords do not match'); return; }
setLoading(true);
setError('');
try {
const result = await api.auth.changePassword(newPassword);
setAuthToken(result.token);
onRegistered();
} catch (e) {
setError(e instanceof Error ? e.message : 'Password change failed');
} finally {
setLoading(false);
}
}
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">
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 mb-4">
<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>
<p className="text-surface-400 text-sm">
{stage === 'reset-password'
? 'Please set a new password to continue.'
: 'Access is invite-only during early access.'}
</p>
</div>
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 space-y-5">
{stage === 'invite' && (
<>
<div>
<label className="block text-sm font-medium text-surface-300 mb-2">
Invite Code
</label>
<input
type="text"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleValidateCode()}
placeholder="Enter your invite code"
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 font-mono tracking-wider text-center text-lg"
autoFocus
maxLength={8}
/>
</div>
{error && <p className="text-danger text-sm">{error}</p>}
<button
onClick={() => handleValidateCode()}
disabled={loading}
className="w-full bg-accent hover:bg-accent-dark text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
>
{loading ? 'Validating...' : 'Continue'}
</button>
<div className="text-center">
<button
onClick={() => { setStage('login'); setError(''); setPassword(''); }}
className="text-sm text-surface-400 hover:text-accent-light transition-colors"
>
Already have an account? Log in
</button>
</div>
</>
)}
{stage === 'register' && (
<>
<button
onClick={() => { setStage('invite'); setError(''); }}
className="flex items-center gap-1 text-sm text-surface-400 hover:text-surface-200 transition-colors"
>
<ArrowLeft size={14} /> Back
</button>
<div className="text-xs text-surface-500 bg-surface-800 rounded px-3 py-2 font-mono">
Invite: {inviteCode}
</div>
<div>
<label className="block text-sm font-medium text-surface-300 mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-surface-300 mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min 8 characters"
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
/>
</div>
<div>
<label className="block text-sm font-medium text-surface-300 mb-1">Confirm Password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleRegister()}
placeholder="Confirm your password"
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
/>
</div>
{error && <p className="text-danger text-sm">{error}</p>}
<button
onClick={handleRegister}
disabled={loading}
className="w-full bg-accent hover:bg-accent-dark text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
>
{loading ? 'Creating account...' : 'Create Account'}
</button>
</>
)}
{stage === 'login' && (
<>
<button
onClick={() => { setStage('invite'); setError(''); setPassword(''); }}
className="flex items-center gap-1 text-sm text-surface-400 hover:text-surface-200 transition-colors"
>
<ArrowLeft size={14} /> Back
</button>
<div>
<label className="block text-sm font-medium text-surface-300 mb-1">Email or Username</label>
<input
type="text"
value={loginField}
onChange={(e) => setLoginField(e.target.value)}
placeholder="admin"
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-surface-300 mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
placeholder="Your password"
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
/>
</div>
{error && <p className="text-danger text-sm">{error}</p>}
<button
onClick={handleLogin}
disabled={loading}
className="w-full bg-accent hover:bg-accent-dark text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
>
{loading ? 'Logging in...' : 'Log In'}
</button>
</>
)}
{stage === 'reset-password' && (
<>
<div className="text-sm text-warning bg-warning/10 border border-warning/20 rounded-lg px-3 py-2">
You must set a new password before continuing.
</div>
<div>
<label className="block text-sm font-medium text-surface-300 mb-1">New Password</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Min 8 characters"
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-surface-300 mb-1">Confirm New Password</label>
<input
type="password"
value={confirmNewPassword}
onChange={(e) => setConfirmNewPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handlePasswordReset()}
placeholder="Confirm your new password"
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
/>
</div>
{error && <p className="text-danger text-sm">{error}</p>}
<button
onClick={handlePasswordReset}
disabled={loading}
className="w-full bg-accent hover:bg-accent-dark text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
>
{loading ? 'Saving...' : 'Set Password & Continue'}
</button>
</>
)}
</div>
<p className="text-center text-xs text-surface-600 mt-6">
Manage data centers, train models, serve millions of users, and achieve AGI.
</p>
</div>
</div>
);
}
+69 -4
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Sparkles } from 'lucide-react';
import { Sparkles, Cloud, Play } from 'lucide-react';
import { useGameStore } from '@/store';
import type { CloudSaveInfo } from '@/hooks/useAuthGate';
const SUGGESTED_NAMES = [
'Nexus AI', 'Cortex Labs', 'Synapse Technologies',
@@ -8,8 +9,32 @@ const SUGGESTED_NAMES = [
'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 [loading, setLoading] = useState(false);
const startNewGame = useGameStore((s) => s.startNewGame);
const handleStart = () => {
@@ -17,6 +42,16 @@ export function NewGameScreen() {
startNewGame(companyName);
};
const handleContinue = async () => {
if (!onContinue) return;
setLoading(true);
try {
await onContinue();
} finally {
setLoading(false);
}
};
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">
@@ -24,7 +59,7 @@ export function NewGameScreen() {
<div className="inline-flex items-center gap-2 mb-4">
<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">
AI Tycoon
Token Empire
</h1>
</div>
<p className="text-surface-400 text-sm">
@@ -32,7 +67,37 @@ export function NewGameScreen() {
</p>
</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">
{cloudSave && onContinue && (
<div className="text-xs text-surface-500 uppercase tracking-wider font-medium">Or start fresh</div>
)}
<div>
<label className="block text-sm font-medium text-surface-300 mb-2">
Name your AI company
@@ -44,7 +109,7 @@ export function NewGameScreen() {
onKeyDown={(e) => e.key === 'Enter' && handleStart()}
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"
autoFocus
autoFocus={!cloudSave}
maxLength={30}
/>
</div>
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { formatMoney, formatDuration, formatNumber, MAX_OFFLINE_TICKS, TICK_INTERVAL_MS } from '@ai-tycoon/shared';
import { GameEngine } from '@ai-tycoon/game-engine';
import { formatMoney, formatDuration, formatNumber, MAX_OFFLINE_TICKS, TICK_INTERVAL_MS } from '@token-empire/shared';
import { GameEngine } from '@token-empire/game-engine';
import { useGameStore } from '@/store';
interface OfflineResult {
@@ -1,7 +1,7 @@
import { useState } from '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> {
try {
@@ -18,6 +18,7 @@ import { CompetitorsPage } from '@/pages/CompetitorsPage';
import { AchievementsPage } from '@/pages/AchievementsPage';
import { LeaderboardPage } from '@/pages/LeaderboardPage';
import { ServingPage } from '@/pages/ServingPage';
import { InvitationsPage } from '@/pages/InvitationsPage';
export function MainLayout() {
const { subPath, setSubPath } = useHashRouter();
@@ -53,6 +54,7 @@ function PageRouter({ page, subPath, setSubPath }: { page: string; subPath: stri
case 'competitors': return <CompetitorsPage />;
case 'achievements': return <AchievementsPage />;
case 'leaderboard': return <LeaderboardPage />;
case 'invitations': return <InvitationsPage />;
case 'settings': return <SettingsPage />;
default: return <PlaceholderPage name={page} />;
}
+54 -7
View File
@@ -2,11 +2,12 @@ import { useState, useEffect, useRef } from 'react';
import {
LayoutDashboard, Server, FlaskConical, Brain,
TrendingUp, Activity, Users, Database, Swords, DollarSign, Settings, Trophy, Medal,
PanelLeftClose, PanelLeftOpen,
PanelLeftClose, PanelLeftOpen, Mail, UserPlus, Copy, Check,
} from 'lucide-react';
import { useGameStore, type ActivePage } from '@/store';
import { isAdmin as checkIsAdmin, isRegistered as checkIsRegistered, getTokenPayload, api } from '@/lib/api';
const NAV_ITEMS: { page: ActivePage; label: string; icon: typeof LayoutDashboard; era?: string }[] = [
const NAV_ITEMS: { page: ActivePage; label: string; icon: typeof LayoutDashboard; era?: string; adminOnly?: boolean }[] = [
{ page: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ page: 'infrastructure', label: 'Infrastructure', icon: Server },
{ page: 'research', label: 'Research', icon: FlaskConical },
@@ -19,12 +20,13 @@ const NAV_ITEMS: { page: ActivePage; label: string; icon: typeof LayoutDashboard
{ page: 'competitors', label: 'Competitors', icon: Swords, era: 'scaleup' },
{ page: 'achievements', label: 'Achievements', icon: Trophy },
{ page: 'leaderboard', label: 'Leaderboard', icon: Medal },
{ page: 'invitations', label: 'Invitations', icon: Mail, adminOnly: true },
{ page: 'settings', label: 'Settings', icon: Settings },
];
function getInitialCollapsed(): boolean {
try {
const stored = localStorage.getItem('ai-tycoon-sidebar-collapsed');
const stored = localStorage.getItem('token-empire-sidebar-collapsed');
if (stored !== null) return stored === 'true';
return window.innerWidth < 1280;
} catch { return false; }
@@ -36,6 +38,11 @@ export function Sidebar() {
const companyName = useGameStore((s) => s.meta.companyName);
const era = useGameStore((s) => s.meta.currentEra);
const [collapsed, setCollapsed] = useState(getInitialCollapsed);
const [remainingInvites, setRemainingInvites] = useState(0);
const [inviteCopied, setInviteCopied] = useState(false);
const admin = checkIsAdmin();
const registered = checkIsRegistered();
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'];
const currentEraIdx = eraOrder.indexOf(era);
@@ -57,6 +64,12 @@ export function Sidebar() {
}
}, [era]);
useEffect(() => {
if (!admin && registered) {
api.invites.remaining().then(r => setRemainingInvites(r.remaining)).catch(() => {});
}
}, [admin, registered]);
const handleNavClick = (page: ActivePage) => {
setActivePage(page);
setNewPages(prev => {
@@ -69,11 +82,26 @@ export function Sidebar() {
const toggleCollapse = () => {
setCollapsed(prev => {
const next = !prev;
localStorage.setItem('ai-tycoon-sidebar-collapsed', String(next));
localStorage.setItem('token-empire-sidebar-collapsed', String(next));
return next;
});
};
async function handleInviteFriend() {
try {
const result = await api.invites.create();
const url = `${window.location.origin}?invite=${result.code}`;
await navigator.clipboard.writeText(url);
setInviteCopied(true);
setRemainingInvites(prev => Math.max(0, prev - 1));
setTimeout(() => setInviteCopied(false), 2000);
} catch {
// silent
}
}
const showInviteButton = !admin && registered && remainingInvites > 0;
return (
<aside className={`${collapsed ? 'w-16' : 'w-56'} bg-surface-900 border-r border-surface-700 flex flex-col h-screen transition-all duration-200`}>
<div className={`${collapsed ? 'px-2 py-3' : 'p-4'} border-b border-surface-700 flex items-center ${collapsed ? 'justify-center' : 'justify-between'}`}>
@@ -91,12 +119,13 @@ export function Sidebar() {
</div>
<nav className="flex-1 py-2 overflow-y-auto">
{NAV_ITEMS.map(({ page, label, icon: Icon, era: requiredEra }) => {
{NAV_ITEMS.map(({ page, label, icon: Icon, era: requiredEra, adminOnly }) => {
if (requiredEra && eraOrder.indexOf(requiredEra) > currentEraIdx) return null;
if (adminOnly && !admin) return null;
const isActive = activePage === page;
const isNew = newPages.has(page);
const showDivider = page === 'talent' || page === 'achievements';
const showDivider = page === 'talent' || page === 'achievements' || page === 'invitations';
return (
<div key={page}>
{showDivider && <div className={`border-t border-surface-700 my-1 ${collapsed ? 'mx-2' : 'mx-4'}`} />}
@@ -123,8 +152,26 @@ export function Sidebar() {
})}
</nav>
{showInviteButton && (
<div className={`${collapsed ? 'px-2' : 'px-3'} pb-2`}>
<button
onClick={handleInviteFriend}
className={`w-full flex items-center ${collapsed ? 'justify-center' : 'gap-2'} px-3 py-2 text-sm rounded-lg bg-accent/10 hover:bg-accent/20 text-accent-light transition-colors`}
title={collapsed ? 'Invite a Friend' : undefined}
>
{inviteCopied ? <Check size={16} /> : <UserPlus size={16} />}
{!collapsed && (inviteCopied ? 'Link Copied!' : 'Invite a Friend')}
</button>
</div>
)}
<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>
</aside>
);
+2 -2
View File
@@ -3,8 +3,8 @@ import { Pause, Play, Bell, Share2 } from 'lucide-react';
import { CompanyStatsCard } from '@/components/game/CompanyStatsCard';
import { NotificationPanel } from '@/components/common/NotificationPanel';
import { useGameStore } from '@/store';
import { formatMoney, formatNumber, formatDuration, formatPercent } from '@ai-tycoon/shared';
import type { GameSpeed } from '@ai-tycoon/shared';
import { formatMoney, formatNumber, formatDuration, formatPercent } from '@token-empire/shared';
import type { GameSpeed } from '@token-empire/shared';
import { Tooltip } from '@/components/common/Tooltip';
const SPEEDS: GameSpeed[] = [1, 2, 5];
+143
View File
@@ -0,0 +1,143 @@
import { useState, useCallback } from 'react';
import { api, getTokenPayload, isRegistered as checkRegistered, needsPasswordReset as checkNeedsReset, validateStoredToken } from '@/lib/api';
import { useGameStore } from '@/store';
import { ensureAuth } from './useCloudSave';
export interface CloudSaveInfo {
id: string;
companyName: string;
era: string;
tickCount: number;
updatedAt: string;
}
interface AuthGateState {
loading: boolean;
backendError: string | null;
needsInvite: boolean;
needsPasswordReset: boolean;
registered: boolean;
isAdmin: boolean;
config: { requireInvite: boolean; userInvitations: number } | null;
cloudSave: CloudSaveInfo | null;
loadCloudSave: () => Promise<void>;
setRegistered: (value: boolean) => void;
setNeedsPasswordReset: (value: boolean) => void;
retry: () => void;
}
export function useAuthGate(): AuthGateState {
const [config, setConfig] = useState<{ requireInvite: boolean; userInvitations: number } | null>(null);
const [loading, setLoading] = useState(true);
const [backendError, setBackendError] = useState<string | null>(null);
const [registered, setRegistered] = useState(false);
const [passwordReset, setPasswordReset] = useState(false);
const [admin, setAdmin] = useState(false);
const [cloudSave, setCloudSave] = useState<CloudSaveInfo | null>(null);
const [initCount, setInitCount] = useState(0);
const init = useCallback(async () => {
setLoading(true);
setBackendError(null);
validateStoredToken();
try {
await api.health();
} catch (e) {
setBackendError(e instanceof Error ? e.message : 'Cannot connect to server');
setLoading(false);
return;
}
try {
const cfg = await api.config.get();
setConfig(cfg);
} catch {
setConfig({ requireInvite: false, userInvitations: 0 });
}
try {
await ensureAuth();
} catch {
// auth failed — will show as unregistered
}
const payload = getTokenPayload();
const isReg = checkRegistered();
setRegistered(isReg);
setPasswordReset(checkNeedsReset());
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 {
setCloudSave(null);
}
}
setLoading(false);
}, []);
// Run init on mount and on retry
useState(() => { init(); });
const retry = useCallback(() => {
setInitCount(c => c + 1);
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 handleSetRegistered = useCallback((value: boolean) => {
setRegistered(value);
const payload = getTokenPayload();
if (payload) {
setAdmin(payload.role === 'admin');
setPasswordReset(payload.mustResetPassword);
}
}, []);
const handleSetPasswordReset = useCallback((value: boolean) => {
setPasswordReset(value);
}, []);
const needsInvite = !!(config?.requireInvite && !registered);
return {
loading,
backendError,
needsInvite,
needsPasswordReset: passwordReset,
registered,
isAdmin: admin,
config,
cloudSave,
loadCloudSave,
setRegistered: handleSetRegistered,
setNeedsPasswordReset: handleSetPasswordReset,
retry,
};
}
+27 -7
View File
@@ -1,24 +1,29 @@
import { useEffect, useRef } from 'react';
import { useGameStore } from '@/store';
import { api, getAuthToken, setAuthToken } from '@/lib/api';
import { AUTO_SAVE_INTERVAL_TICKS } from '@ai-tycoon/shared';
import { api, getAuthToken, setAuthToken, clearAuthToken, decodeTokenPayload } from '@/lib/api';
import { AUTO_SAVE_INTERVAL_TICKS } from '@token-empire/shared';
const MAX_CONSECUTIVE_FAILURES = 3;
export function useCloudSave() {
const tickCount = useGameStore((s) => s.meta.tickCount);
const companyName = useGameStore((s) => s.meta.companyName);
const lastSaveTick = useRef(0);
const failureCount = useRef(0);
useEffect(() => {
if (!companyName) return;
if (tickCount - lastSaveTick.current < AUTO_SAVE_INTERVAL_TICKS * 5) return;
if (tickCount - lastSaveTick.current < AUTO_SAVE_INTERVAL_TICKS) return;
const token = getAuthToken();
if (!token) return;
if (failureCount.current >= MAX_CONSECUTIVE_FAILURES) return;
lastSaveTick.current = tickCount;
const state = useGameStore.getState();
const { activePage, notifications, ...gameState } = state;
const { activePage, notifications, infraNav, modelsTab, ...gameState } = state;
api.saves.put({
companyName: state.meta.companyName,
@@ -26,13 +31,28 @@ export function useCloudSave() {
gameData: gameState,
tickCount: state.meta.tickCount,
era: state.meta.currentEra,
}).catch(() => {});
}).then(() => {
failureCount.current = 0;
}).catch(() => {
failureCount.current++;
if (failureCount.current === MAX_CONSECUTIVE_FAILURES) {
useGameStore.getState().addNotification({
title: 'Cloud Save Failed',
message: 'Unable to save to cloud. Your progress is still saved locally.',
type: 'danger',
tick: useGameStore.getState().meta.tickCount,
});
}
});
}, [tickCount, companyName]);
}
export async function ensureAuth(): Promise<string | null> {
let token = getAuthToken();
if (token) return token;
const token = getAuthToken();
if (token) {
if (decodeTokenPayload(token)) return token;
clearAuthToken();
}
try {
const result = await api.auth.anonymous();
+2 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
import { GameEngine, setAchievementDefinitions, ACHIEVEMENT_DEFINITIONS } from '@ai-tycoon/game-engine';
import type { TickNotification } from '@ai-tycoon/game-engine';
import { GameEngine, setAchievementDefinitions, ACHIEVEMENT_DEFINITIONS } from '@token-empire/game-engine';
import type { TickNotification } from '@token-empire/game-engine';
import { useGameStore } from '@/store';
export function useGameLoop(skip = false) {
+1 -1
View File
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
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> = {
d: 'dashboard',
+140 -19
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) {
authToken = token;
localStorage.setItem('ai-tycoon-auth-token', token);
localStorage.setItem('token-empire-auth-token', token);
}
export function getAuthToken() {
@@ -13,49 +13,170 @@ export function getAuthToken() {
export function clearAuthToken() {
authToken = null;
localStorage.removeItem('ai-tycoon-auth-token');
localStorage.removeItem('token-empire-auth-token');
localStorage.removeItem('token-empire-refresh-token');
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
export interface TokenPayload {
sub: string;
email: string | null;
role: string;
username: string | null;
mustResetPassword: boolean;
}
export function decodeTokenPayload(token: string): TokenPayload | null {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
return {
sub: payload.sub,
email: payload.email ?? null,
role: payload.role ?? 'user',
username: payload.username ?? null,
mustResetPassword: payload.mustResetPassword ?? false,
};
} catch {
return null;
}
}
export function getTokenPayload(): TokenPayload | null {
const token = getAuthToken();
if (!token) return null;
return decodeTokenPayload(token);
}
export function isRegistered(): boolean {
const payload = getTokenPayload();
if (!payload) return false;
return payload.email != null || payload.role === 'admin';
}
export function isAdmin(): boolean {
const payload = getTokenPayload();
return payload?.role === 'admin';
}
export function needsPasswordReset(): boolean {
const payload = getTokenPayload();
return payload?.mustResetPassword === true;
}
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> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
...(fetchOptions.headers as Record<string, string>),
};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
if (!res.ok) {
const body = await res.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(body.error || `HTTP ${res.status}`);
try {
const res = await fetch(`${API_BASE}${path}`, {
...fetchOptions,
headers,
signal: controller.signal,
});
if (!res.ok) {
if (res.status === 401 && authToken && !AUTH_PATHS.includes(path)) {
clearAuthToken();
localStorage.removeItem('token-empire-save');
window.location.reload();
}
const body = await res.json().catch(() => null);
throw new Error(body?.error || `HTTP ${res.status} ${res.statusText}`);
}
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);
}
}
return res.json();
export function validateStoredToken(): void {
const token = getAuthToken();
if (token && !decodeTokenPayload(token)) {
clearAuthToken();
}
}
export const api = {
health: () => request<{ status: string }>('/api/health', { timeoutMs: 5_000 }),
auth: {
anonymous: () => request<{ userId: string; token: string }>('/api/auth/anonymous', { method: 'POST' }),
login: (email: string, password: string) =>
login: (login: string, password: string) =>
request<{ userId: string; token: string }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
body: JSON.stringify({ login, password }),
}),
linkEmail: (email: string, password: string) =>
request<{ success: boolean }>('/api/auth/link-email', {
register: (email: string, password: string, inviteCode: string) =>
request<{ userId: string; token: string }>('/api/auth/register', {
method: 'POST',
body: JSON.stringify({ email, password }),
body: JSON.stringify({ email, password, inviteCode }),
}),
changePassword: (newPassword: string, currentPassword?: string) =>
request<{ success: boolean; token: string }>('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({ newPassword, currentPassword }),
}),
changeUsername: (username: string) =>
request<{ success: boolean; token: string }>('/api/auth/change-username', {
method: 'POST',
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: {
get: () => request<{ requireInvite: boolean; userInvitations: number }>('/api/config'),
},
invites: {
create: () => request<{ code: string }>('/api/invites', { method: 'POST' }),
validate: (code: string) => request<{ valid: boolean }>(`/api/invites/validate/${encodeURIComponent(code)}`),
list: () => request<{
invitations: Array<{
id: string;
code: string;
createdBy: { username: string | null; email: string | null };
usedBy: { username: string | null; email: string | null } | null;
createdAt: string;
expiresAt: string | null;
used: boolean;
}>;
}>('/api/invites'),
remaining: () => request<{ remaining: number }>('/api/invites/remaining'),
revoke: (id: string) => request<{ deleted: boolean }>(`/api/invites/${id}`, { method: 'DELETE' }),
},
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}`),
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 }) =>
request<{ id: string }>('/api/saves', { method: 'PUT', body: JSON.stringify(data) }),
delete: (id: string) => request<{ deleted: boolean }>(`/api/saves/${id}`, { method: 'DELETE' }),
+3 -3
View File
@@ -1,12 +1,12 @@
import { useGameStore } from '@/store';
import { ACHIEVEMENT_DEFINITIONS } from '@ai-tycoon/game-engine';
import { formatNumber } from '@ai-tycoon/shared';
import { ACHIEVEMENT_DEFINITIONS } from '@token-empire/game-engine';
import { formatNumber } from '@token-empire/shared';
import {
Trophy, Lock, Server, Brain, Rocket, DollarSign, Sprout, Users,
Globe, Sparkles, TrendingUp, Building2, Atom, Cpu, FlaskConical,
GitBranch, Zap,
} 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 }>> = {
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 { ConfirmModal } from '@/components/common/ConfirmModal';
import { Tooltip } from '@/components/common/Tooltip';
import { formatMoney, formatNumber } from '@ai-tycoon/shared';
import type { Era } from '@ai-tycoon/shared';
import { formatMoney, formatNumber } from '@token-empire/shared';
import type { Era } from '@token-empire/shared';
const ARCHETYPE_LABELS: Record<string, string> = {
'safety-first': 'Safety-First Lab',
+4 -4
View File
@@ -1,8 +1,8 @@
import type React from 'react';
import { useGameStore, type ActivePage } from '@/store';
import { formatMoney, formatNumber, formatPercent, formatDuration } from '@ai-tycoon/shared';
import type { Era } from '@ai-tycoon/shared';
import { TECH_TREE } from '@ai-tycoon/game-engine';
import { formatMoney, formatNumber, formatPercent, formatDuration } from '@token-empire/shared';
import type { Era } from '@token-empire/shared';
import { TECH_TREE } from '@token-empire/game-engine';
import {
DollarSign, TrendingUp, TrendingDown, Minus, Cpu, Brain, Users,
Shield, ChevronRight, Zap, Wifi, Sparkles, FlaskConical, Building2,
@@ -96,7 +96,7 @@ export function DashboardPage() {
{totalDCs === 0 && (
<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>
)}
+2 -2
View File
@@ -1,8 +1,8 @@
import { useState } from 'react';
import { Database, ShoppingCart, Zap } from 'lucide-react';
import { useGameStore } from '@/store';
import { formatNumber, formatMoney, uuid } from '@ai-tycoon/shared';
import type { OwnedDataset, DataDomain } from '@ai-tycoon/shared';
import { formatNumber, formatMoney, uuid } from '@token-empire/shared';
import type { OwnedDataset, DataDomain } from '@token-empire/shared';
interface MarketplaceDataset {
name: string;
+4 -4
View File
@@ -1,10 +1,10 @@
import { useGameStore } from '@/store';
import { formatMoney, formatPercent, formatNumber, FUNDING_ROUNDS } from '@ai-tycoon/shared';
import type { FundingRoundType } from '@ai-tycoon/shared';
import { formatMoney, formatPercent, formatNumber, FUNDING_ROUNDS } from '@token-empire/shared';
import type { FundingRoundType } from '@token-empire/shared';
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 { canRaiseFunding } from '@ai-tycoon/game-engine';
import type { GameState } from '@ai-tycoon/shared';
import { canRaiseFunding } from '@token-empire/game-engine';
import type { GameState } from '@token-empire/shared';
export function FinancePage() {
const money = useGameStore((s) => s.economy.money);
+4 -3
View File
@@ -18,11 +18,11 @@ import {
SWITCH_TIER_CONFIGS,
DC_UPGRADE_COST_FRACTION, DC_UPGRADE_INCREMENT,
skuTotalFlops,
} from '@ai-tycoon/shared';
} from '@token-empire/shared';
import type {
DCTier, RackSkuId, LocationId, PipelineStage, Era,
Cluster, Campus, DataCenter, DeploymentCohort,
} from '@ai-tycoon/shared';
} from '@token-empire/shared';
const ERA_ORDER: Era[] = ['startup', 'scaleup', 'bigtech', 'agi'];
@@ -162,7 +162,8 @@ function CohortStageBreakdown({ cohorts }: { cohorts: DeploymentCohort[] }) {
function NetworkHealthIndicator({ dc }: { dc: DataCenter }) {
const ns = dc.networkSummary;
if (ns.switchIds.length === 0) return null;
const torTotal = ns.totalByTier?.tor ?? 0;
if (torTotal === 0) return null;
const hasDisconnected = ns.racksDisconnected > 0;
const hasDegraded = ns.racksDegraded > 0;
+175
View File
@@ -0,0 +1,175 @@
import { useState, useEffect, useCallback } from 'react';
import { Copy, Check, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { api } from '@/lib/api';
interface Invitation {
id: string;
code: string;
createdBy: { username: string | null; email: string | null };
usedBy: { username: string | null; email: string | null } | null;
createdAt: string;
expiresAt: string | null;
used: boolean;
}
function displayUser(u: { username: string | null; email: string | null }): string {
return u.username || u.email || 'Unknown';
}
function StatusBadge({ used, expiresAt }: { used: boolean; expiresAt: string | null }) {
if (used) {
return <span className="text-xs px-2 py-0.5 rounded-full bg-surface-700 text-surface-400">Used</span>;
}
if (expiresAt && new Date(expiresAt) < new Date()) {
return <span className="text-xs px-2 py-0.5 rounded-full bg-danger/20 text-danger">Expired</span>;
}
return <span className="text-xs px-2 py-0.5 rounded-full bg-accent/20 text-accent">Active</span>;
}
export function InvitationsPage() {
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [copiedCode, setCopiedCode] = useState<string | null>(null);
const [revoking, setRevoking] = useState<string | null>(null);
const fetchInvitations = useCallback(async () => {
try {
const result = await api.invites.list();
setInvitations(result.invitations);
} catch {
// silent
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchInvitations(); }, [fetchInvitations]);
async function handleGenerate() {
setGenerating(true);
try {
const result = await api.invites.create();
const url = `${window.location.origin}?invite=${result.code}`;
await navigator.clipboard.writeText(url);
setCopiedCode(result.code);
setTimeout(() => setCopiedCode(null), 2000);
await fetchInvitations();
} catch {
// silent
} finally {
setGenerating(false);
}
}
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) {
const url = `${window.location.origin}?invite=${code}`;
await navigator.clipboard.writeText(url);
setCopiedCode(code);
setTimeout(() => setCopiedCode(null), 2000);
}
const activeCount = invitations.filter(i => !i.used && (!i.expiresAt || new Date(i.expiresAt) > new Date())).length;
const usedCount = invitations.filter(i => i.used).length;
return (
<div className="space-y-6 max-w-4xl">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold">Invitations</h2>
<p className="text-sm text-surface-400 mt-1">
{activeCount} active, {usedCount} used, {invitations.length} total
</p>
</div>
<div className="flex gap-2">
<button
onClick={fetchInvitations}
className="px-3 py-2 rounded bg-surface-800 hover:bg-surface-700 border border-surface-600 text-sm flex items-center gap-2"
>
<RefreshCw size={14} /> Refresh
</button>
<button
onClick={handleGenerate}
disabled={generating}
className="px-4 py-2 rounded bg-accent hover:bg-accent-dark text-white font-medium text-sm flex items-center gap-2 disabled:opacity-50"
>
<Plus size={14} /> {generating ? 'Generating...' : 'Generate Invite'}
</button>
</div>
</div>
<div className="bg-surface-900 border border-surface-700 rounded-xl overflow-hidden">
{loading ? (
<div className="p-8 text-center text-surface-500">Loading invitations...</div>
) : invitations.length === 0 ? (
<div className="p-8 text-center text-surface-500">
No invitations yet. Generate one to get started.
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-surface-700 text-left text-surface-400">
<th className="px-4 py-3 font-medium">Code</th>
<th className="px-4 py-3 font-medium">Created By</th>
<th className="px-4 py-3 font-medium">Used By</th>
<th className="px-4 py-3 font-medium">Created</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 font-medium w-10"></th>
</tr>
</thead>
<tbody>
{invitations.map((inv) => (
<tr key={inv.id} className="border-b border-surface-800 hover:bg-surface-800/50">
<td className="px-4 py-3 font-mono text-surface-200">{inv.code}</td>
<td className="px-4 py-3 text-surface-300">{displayUser(inv.createdBy)}</td>
<td className="px-4 py-3 text-surface-300">
{inv.usedBy ? displayUser(inv.usedBy) : <span className="text-surface-600"></span>}
</td>
<td className="px-4 py-3 text-surface-400">
{new Date(inv.createdAt).toLocaleDateString()}
</td>
<td className="px-4 py-3">
<StatusBadge used={inv.used} expiresAt={inv.expiresAt} />
</td>
<td className="px-4 py-3">
{!inv.used && (
<div className="flex items-center gap-2">
<button
onClick={() => handleCopyCode(inv.code)}
className="text-surface-400 hover:text-surface-200 transition-colors"
title="Copy invite link"
>
{copiedCode === inv.code ? <Check size={14} className="text-accent" /> : <Copy size={14} />}
</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>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { Trophy, Medal, Clock, TrendingUp } from 'lucide-react';
import { useGameStore } from '@/store';
import { formatMoney, formatNumber } from '@ai-tycoon/shared';
import { formatMoney, formatNumber } from '@token-empire/shared';
import { api, getAuthToken } from '@/lib/api';
interface LeaderboardEntry {
+8 -251
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Play, Rocket, Globe, ChevronDown, ChevronUp, Beaker, Shield, Zap, BarChart3 } from 'lucide-react';
import { Play, Rocket, Globe, ChevronDown, ChevronUp, Beaker, Shield, Zap } from 'lucide-react';
import { TutorialHint } from '@/components/game/TutorialHint';
import { ConfirmModal } from '@/components/common/ConfirmModal';
import { useGameStore } from '@/store';
@@ -13,13 +13,12 @@ import {
SIZE_TIER_LABELS,
SFT_SPECIALIZATION_BONUSES,
PRETRAINING_BASE_TICKS,
} from '@ai-tycoon/shared';
} from '@token-empire/shared';
import type {
ModelArchitecture, DataMixAllocation, SFTSpecialization, AlignmentMethod,
DataDomain, QuantizationLevel, BaseModel, ModelVariant, BenchmarkResult,
DataDomain, QuantizationLevel, BaseModel, ModelVariant,
SizeTier, ModelFamily,
} from '@ai-tycoon/shared';
import { BENCHMARKS } from '@ai-tycoon/game-engine';
} from '@token-empire/shared';
const DATA_MIX_PRESETS: Record<string, { label: string; mix: DataMixAllocation }> = {
balanced: { label: 'Balanced', mix: DEFAULT_DATA_MIX },
@@ -52,8 +51,6 @@ export function ModelsPage() {
const families = useGameStore((s) => s.models.families);
const pipelines = useGameStore((s) => s.models.activeTrainingPipelines);
const variantJobs = useGameStore((s) => s.models.variantJobs);
const evalJobs = useGameStore((s) => s.models.evalJobs);
const benchmarkResults = useGameStore((s) => s.models.benchmarkResults);
const productLines = useGameStore((s) => s.models.productLines);
const totalFlops = useGameStore((s) => s.compute.totalFlops);
const totalVramGB = useGameStore((s) => s.compute.totalVramGB);
@@ -64,7 +61,6 @@ export function ModelsPage() {
const deployModel = useGameStore((s) => s.deployModel);
const deployVariant = useGameStore((s) => s.deployVariant);
const createQuantization = useGameStore((s) => s.createQuantization);
const startEvaluation = useGameStore((s) => s.startEvaluation);
const setTrainingAllocation = useGameStore((s) => s.setTrainingAllocation);
const openSourceModel = useGameStore((s) => s.openSourceModel);
const openSourcedModels = useGameStore((s) => s.market.openSourcedModels);
@@ -96,15 +92,12 @@ export function ModelsPage() {
const activePipelines = pipelines.filter(p => p.status === 'active' || p.status === 'stalled');
const activeVariantJobs = variantJobs.filter(j => j.status === 'active');
const activeEvalJobs = evalJobs.filter(j => j.status === 'active');
const undeployedCount = baseModels.filter(m => !m.isDeployed).length;
const hasActiveJobs = activePipelines.length > 0 || activeVariantJobs.length > 0 || activeEvalJobs.length > 0;
const hasActiveJobs = activePipelines.length > 0 || activeVariantJobs.length > 0;
const noModelDeployed = baseModels.length > 0 && !baseModels.some(m => m.isDeployed);
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'] as const;
const currentEraIdx = eraOrder.indexOf(currentEra);
const availableBenchmarks = BENCHMARKS.filter(b => eraOrder.indexOf(b.unlockedAtEra) <= currentEraIdx);
const hasAlignmentResearch = completedResearch.some(r =>
r === 'alignment-research' || r === 'interpretability' || r === 'constitutional-ai',
);
@@ -186,7 +179,6 @@ export function ModelsPage() {
{ id: 'overview' as const, label: 'Overview' },
{ id: 'train' as const, label: 'Train New' },
{ id: 'models' as const, label: `Families${families.length > 0 ? ` (${families.length})` : ''}` },
{ id: 'benchmarks' as const, label: 'Benchmarks' },
{ id: 'products' as const, label: 'Products' },
]).map(tab => (
<button
@@ -347,28 +339,6 @@ export function ModelsPage() {
</div>
)}
{/* Active Eval Jobs */}
{modelsTab === 'overview' && activeEvalJobs.length > 0 && (
<div className="space-y-3">
<h3 className="font-semibold">Running Evaluations</h3>
{activeEvalJobs.map(job => {
const model = baseModels.find(m => m.id === job.modelId) ?? families.flatMap(f => f.variants).find(v => v.id === job.modelId);
const progress = job.progressTicks / job.totalTicks;
return (
<div key={job.id} className="bg-surface-900 border border-surface-700 rounded-xl p-3">
<div className="flex items-center justify-between mb-1">
<span className="text-sm">{model?.name ?? 'Unknown'} {job.benchmarkIds.length} benchmarks</span>
<span className="text-xs text-surface-400">{formatPercent(progress)}</span>
</div>
<div className="h-1.5 bg-surface-800 rounded-full overflow-hidden">
<div className="h-full bg-blue-500 rounded-full transition-all" style={{ width: `${progress * 100}%` }} />
</div>
</div>
);
})}
</div>
)}
{/* Train New Model */}
{modelsTab === 'train' && <div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
<h3 className="font-semibold">Train New Model</h3>
@@ -716,9 +686,8 @@ export function ModelsPage() {
{familyModels.map(model => (
<div key={model.id} className="space-y-3">
<h5 className="text-sm font-medium text-surface-300">{model.name}</h5>
<ModelDetails model={model} benchmarkResults={benchmarkResults} />
<ModelDetails model={model} />
<QuantizationCreator model={model} completedResearch={completedResearch} onQuantize={createQuantization} />
<BenchmarkEvaluator modelId={model.id} modelName={model.name} availableBenchmarks={availableBenchmarks} benchmarkResults={benchmarkResults} evalJobs={evalJobs} onStartEval={startEvaluation} />
</div>
))}
@@ -730,11 +699,7 @@ export function ModelsPage() {
key={variant.id}
variant={variant}
familyId={family.id}
benchmarkResults={benchmarkResults}
availableBenchmarks={availableBenchmarks}
evalJobs={evalJobs}
onDeploy={() => deployVariant(family.id, variant.id)}
onStartEval={startEvaluation}
/>
))}
</div>
@@ -747,21 +712,6 @@ export function ModelsPage() {
</div>
)}
{/* Benchmark Leaderboard */}
{modelsTab === 'benchmarks' && benchmarkResults.length > 0 && (
<BenchmarkLeaderboard
benchmarkResults={benchmarkResults}
baseModels={baseModels}
families={families}
availableBenchmarks={availableBenchmarks}
/>
)}
{modelsTab === 'benchmarks' && benchmarkResults.length === 0 && (
<div className="bg-surface-900 border border-surface-700 rounded-xl p-8 text-center text-surface-500 text-sm">
No benchmark results yet. Run evaluations from the Models tab.
</div>
)}
{/* Product Lines */}
{modelsTab === 'products' && <div className="space-y-3">
<h3 className="font-semibold">Product Lines</h3>
@@ -865,9 +815,7 @@ function ModelActions({ model, isOpenSourced, onDeploy, onOpenSource }: {
);
}
function ModelDetails({ model, benchmarkResults }: { model: BaseModel; benchmarkResults: BenchmarkResult[] }) {
const modelResults = benchmarkResults.filter(r => r.modelId === model.id);
function ModelDetails({ model }: { model: BaseModel }) {
return (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3 text-xs">
@@ -907,22 +855,6 @@ function ModelDetails({ model, benchmarkResults }: { model: BaseModel; benchmark
</div>
</div>
{modelResults.length > 0 && (
<div>
<span className="text-xs font-medium text-surface-300">Benchmark Scores</span>
<div className="grid grid-cols-3 gap-2 mt-1">
{modelResults.map(r => {
const bench = BENCHMARKS.find(b => b.id === r.benchmarkId);
return (
<div key={r.benchmarkId} className="bg-surface-800 rounded-lg p-2 text-xs">
<span className="text-surface-400">{bench?.name ?? r.benchmarkId}</span>
<div className="font-mono mt-0.5 text-accent-light">{r.score.toFixed(1)}</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}
@@ -981,91 +913,12 @@ function QuantizationCreator({ model, completedResearch, onQuantize }: {
);
}
function BenchmarkEvaluator({ modelId, modelName, availableBenchmarks, benchmarkResults, evalJobs, onStartEval }: {
modelId: string;
modelName: string;
availableBenchmarks: typeof BENCHMARKS;
benchmarkResults: BenchmarkResult[];
evalJobs: { id: string; modelId: string; status: string }[];
onStartEval: (modelId: string, benchmarkIds: string[]) => void;
}) {
const [showEval, setShowEval] = useState(false);
const [selectedBenchmarks, setSelectedBenchmarks] = useState<string[]>([]);
const existingResults = benchmarkResults.filter(r => r.modelId === modelId);
const evaluatedIds = new Set(existingResults.map(r => r.benchmarkId));
const isEvaluating = evalJobs.some(j => j.modelId === modelId && j.status === 'active');
const unevaluated = availableBenchmarks.filter(b => !evaluatedIds.has(b.id));
if (unevaluated.length === 0 && !showEval) {
return null;
}
if (!showEval) {
return (
<button onClick={() => { setShowEval(true); setSelectedBenchmarks(unevaluated.map(b => b.id)); }}
disabled={isEvaluating}
className="flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300 disabled:opacity-50">
<BarChart3 size={12} /> Run Benchmarks ({unevaluated.length} available)
</button>
);
}
return (
<div className="bg-surface-800/50 rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-surface-300">Run Evaluation</span>
<button onClick={() => setShowEval(false)} className="text-xs text-surface-500 hover:text-surface-300">Close</button>
</div>
<div className="flex flex-wrap gap-1">
{availableBenchmarks.map(bench => {
const alreadyDone = evaluatedIds.has(bench.id);
const selected = selectedBenchmarks.includes(bench.id);
return (
<button key={bench.id}
disabled={alreadyDone}
onClick={() => setSelectedBenchmarks(prev =>
prev.includes(bench.id) ? prev.filter(id => id !== bench.id) : [...prev, bench.id]
)}
className={`px-2 py-0.5 rounded text-[10px] border ${
alreadyDone ? 'bg-success/10 border-success/30 text-success cursor-default' :
selected ? 'bg-blue-500/20 border-blue-500 text-blue-300' :
'bg-surface-800 border-surface-600 text-surface-400'
}`}
title={bench.description}
>
{bench.name} {alreadyDone ? `(${existingResults.find(r => r.benchmarkId === bench.id)?.score.toFixed(0)})` : ''}
</button>
);
})}
</div>
{selectedBenchmarks.length > 0 && (
<div className="flex items-center justify-between">
<span className="text-[10px] text-surface-500">
{selectedBenchmarks.length} benchmark{selectedBenchmarks.length > 1 ? 's' : ''} · ~{availableBenchmarks.filter(b => selectedBenchmarks.includes(b.id)).reduce((s, b) => s + b.ticksToRun, 0)} ticks
</span>
<button onClick={() => { onStartEval(modelId, selectedBenchmarks); setShowEval(false); }}
disabled={isEvaluating}
className="bg-blue-600 hover:bg-blue-700 text-white rounded px-3 py-1 text-xs disabled:opacity-50">
Evaluate
</button>
</div>
)}
</div>
);
}
function VariantCard({ variant, familyId, benchmarkResults, availableBenchmarks, evalJobs, onDeploy, onStartEval }: {
function VariantCard({ variant, familyId, onDeploy }: {
variant: ModelVariant;
familyId: string;
benchmarkResults: BenchmarkResult[];
availableBenchmarks: typeof BENCHMARKS;
evalJobs: { id: string; modelId: string; status: string }[];
onDeploy: () => void;
onStartEval: (modelId: string, benchmarkIds: string[]) => void;
}) {
const [isExpanded, setIsExpanded] = useState(false);
const variantResults = benchmarkResults.filter(r => r.modelId === variant.id);
return (
<div className="bg-surface-800/50 rounded-lg p-3 ml-4 border-l-2 border-surface-600">
@@ -1106,108 +959,12 @@ function VariantCard({ variant, familyId, benchmarkResults, availableBenchmarks,
</div>
))}
</div>
{variantResults.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{variantResults.map(r => {
const bench = BENCHMARKS.find(b => b.id === r.benchmarkId);
return (
<div key={r.benchmarkId} className="bg-surface-800 rounded p-1.5 text-xs">
<span className="text-surface-400 text-[10px]">{bench?.name ?? r.benchmarkId}</span>
<div className="font-mono text-accent-light text-[11px]">{r.score.toFixed(1)}</div>
</div>
);
})}
</div>
)}
<BenchmarkEvaluator
modelId={variant.id}
modelName={variant.name}
availableBenchmarks={availableBenchmarks}
benchmarkResults={benchmarkResults}
evalJobs={evalJobs}
onStartEval={onStartEval}
/>
</div>
)}
</div>
);
}
function BenchmarkLeaderboard({ benchmarkResults, baseModels, families, availableBenchmarks }: {
benchmarkResults: BenchmarkResult[];
baseModels: BaseModel[];
families: { id: string; name: string; variants: ModelVariant[] }[];
availableBenchmarks: typeof BENCHMARKS;
}) {
const allModels: (BaseModel | ModelVariant)[] = [
...baseModels,
...families.flatMap(f => f.variants),
];
const modelNames = new Map(allModels.map(m => [m.id, m.name]));
const benchmarksWithResults = availableBenchmarks.filter(b =>
benchmarkResults.some(r => r.benchmarkId === b.id),
);
if (benchmarksWithResults.length === 0) return null;
const modelIds = [...new Set(benchmarkResults.map(r => r.modelId))];
return (
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<h3 className="font-semibold mb-3 flex items-center gap-2">
<BarChart3 size={16} /> Benchmark Leaderboard
</h3>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-surface-700">
<th className="text-left py-1.5 pr-3 text-surface-400 font-medium">Model</th>
{benchmarksWithResults.map(b => (
<th key={b.id} className="text-center py-1.5 px-2 text-surface-400 font-medium">{b.name}</th>
))}
<th className="text-center py-1.5 px-2 text-surface-400 font-medium">Avg</th>
</tr>
</thead>
<tbody>
{modelIds.map(modelId => {
const results = benchmarkResults.filter(r => r.modelId === modelId);
const scores = benchmarksWithResults.map(b => {
const r = results.find(r => r.benchmarkId === b.id);
return r?.score ?? null;
});
const validScores = scores.filter((s): s is number => s !== null);
const avg = validScores.length > 0 ? validScores.reduce((a, b) => a + b, 0) / validScores.length : 0;
return (
<tr key={modelId} className="border-b border-surface-800">
<td className="py-1.5 pr-3 font-medium">{modelNames.get(modelId) ?? 'Unknown'}</td>
{scores.map((score, i) => (
<td key={i} className="text-center py-1.5 px-2 font-mono">
{score !== null ? (
<span className={score >= 80 ? 'text-success' : score >= 50 ? 'text-accent-light' : 'text-surface-400'}>
{score.toFixed(1)}
</span>
) : (
<span className="text-surface-600"></span>
)}
</td>
))}
<td className="text-center py-1.5 px-2 font-mono font-medium text-accent-light">
{avg > 0 ? avg.toFixed(1) : '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
function StageBar({ label, active, complete, progress }: {
label: string; active: boolean; complete: boolean; progress: number;
}) {
+3 -3
View File
@@ -1,9 +1,9 @@
import { FlaskConical, Lock, Check, Play, ListOrdered, X } from 'lucide-react';
import { TutorialHint } from '@/components/game/TutorialHint';
import { useGameStore } from '@/store';
import { formatDuration, formatPercent, formatNumber, formatMoney } from '@ai-tycoon/shared';
import { TECH_TREE, getAvailableResearch } from '@ai-tycoon/game-engine';
import type { ResearchNode } from '@ai-tycoon/shared';
import { formatDuration, formatPercent, formatNumber, formatMoney } from '@token-empire/shared';
import { TECH_TREE, getAvailableResearch } from '@token-empire/game-engine';
import type { ResearchNode } from '@token-empire/shared';
const CATEGORY_COLORS: Record<string, string> = {
generation: 'border-purple-500/50 bg-purple-500/10',
+1 -1
View File
@@ -3,7 +3,7 @@ import {
formatNumber, formatPercent,
type TrafficPriority, type OverflowBehavior, type RoutingStrategy,
TRAFFIC_PRIORITIES,
} from '@ai-tycoon/shared';
} from '@token-empire/shared';
import {
Activity, Shield, Clock, CheckCircle, XCircle, Layers,
AlertTriangle, Zap, Server, ArrowRight,
+176 -4
View File
@@ -1,6 +1,8 @@
import { useRef, useState } from 'react';
import { Pencil, Check, X, LogOut } from 'lucide-react';
import { useGameStore } from '@/store';
import { ConfirmModal } from '@/components/common/ConfirmModal';
import { api, setAuthToken, getTokenPayload, isRegistered, isAdmin, clearAuthToken } from '@/lib/api';
export function SettingsPage() {
const settings = useGameStore((s) => s.meta.settings);
@@ -11,6 +13,60 @@ export function SettingsPage() {
const [showResetConfirm, setShowResetConfirm] = useState(false);
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 = () => {
updateState({ meta: { ...useGameStore.getState().meta, settings: { ...settings, soundEnabled: !settings.soundEnabled } } });
};
@@ -20,7 +76,7 @@ export function SettingsPage() {
};
const handleReset = () => {
localStorage.removeItem('ai-tycoon-save');
localStorage.removeItem('token-empire-save');
window.location.reload();
};
@@ -31,7 +87,7 @@ export function SettingsPage() {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
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();
URL.revokeObjectURL(url);
};
@@ -50,7 +106,7 @@ export function SettingsPage() {
}
setImportData({ data, name: data.meta.companyName });
} 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);
@@ -59,14 +115,112 @@ export function SettingsPage() {
const confirmImport = () => {
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();
};
const payload = getTokenPayload();
const registered = isRegistered();
const admin = isAdmin();
return (
<div className="space-y-6 max-w-2xl">
<h2 className="text-2xl font-bold">Settings</h2>
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
<h3 className="font-semibold">Account</h3>
{registered ? (
<div className="space-y-3">
{payload?.email != null && (
<div className="flex items-center justify-between">
<div className="flex-1">
<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>
{!editingEmail && (
<button onClick={() => { setEmailValue(payload.email ?? ''); setEditingEmail(true); }}
className="text-surface-400 hover:text-surface-200"><Pencil size={14} /></button>
)}
</div>
)}
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="text-sm">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>
{usernameError && <p className="text-xs text-danger">{usernameError}</p>}
</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 && (
<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>
</div>
)}
</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 className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
<h3 className="font-semibold">Game</h3>
@@ -148,6 +302,24 @@ export function SettingsPage() {
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 () => {
try { await api.auth.logout(); } catch {}
clearAuthToken();
localStorage.removeItem('token-empire-save');
window.location.reload();
}}
onCancel={() => setShowLogoutConfirm(false)}
/>
)}
</div>
);
}
+3 -3
View File
@@ -1,9 +1,9 @@
import { useState } from 'react';
import { Users, Plus, Star, Briefcase } from 'lucide-react';
import { useGameStore } from '@/store';
import { formatMoney } from '@ai-tycoon/shared';
import { KEY_HIRE_POOL } from '@ai-tycoon/game-engine';
import type { DepartmentId } from '@ai-tycoon/shared';
import { formatMoney } from '@token-empire/shared';
import { KEY_HIRE_POOL } from '@token-empire/game-engine';
import type { DepartmentId } from '@token-empire/shared';
const DEPT_LABELS: Record<string, string> = {
research: 'Research',
+2 -2
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useGameStore } from '@/store';
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared';
import type { ApiTierId } from '@ai-tycoon/shared';
import { formatNumber, formatMoney, formatPercent } from '@token-empire/shared';
import type { ApiTierId } from '@token-empire/shared';
import { Code, Check } from 'lucide-react';
const TIER_ORDER: ApiTierId[] = ['free', 'payg', 'scale', 'enterprise-api'];
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useGameStore } from '@/store';
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared';
import type { ConsumerTierId } from '@ai-tycoon/shared';
import { formatNumber, formatMoney, formatPercent } from '@token-empire/shared';
import type { ConsumerTierId } from '@token-empire/shared';
import { Users, Check } from 'lucide-react';
const TIER_ORDER: ConsumerTierId[] = ['free', 'plus', 'pro', 'team'];
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react';
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';
function useAppliedFeedback() {
@@ -1,6 +1,6 @@
import { useGameStore } from '@/store';
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared';
import type { EnterprisePipelineStage, EnterpriseSegment } from '@ai-tycoon/shared';
import { formatNumber, formatMoney, formatPercent } from '@token-empire/shared';
import type { EnterprisePipelineStage, EnterpriseSegment } from '@token-empire/shared';
import { Building2, AlertTriangle } from 'lucide-react';
const STAGE_ORDER: EnterprisePipelineStage[] = ['lead', 'qualification', 'poc', 'negotiation'];
@@ -1,6 +1,6 @@
import { useGameStore } from '@/store';
import { formatNumber, formatPercent } from '@ai-tycoon/shared';
import type { TAMSegmentId } from '@ai-tycoon/shared';
import { formatNumber, formatPercent } from '@token-empire/shared';
import type { TAMSegmentId } from '@token-empire/shared';
import { Globe, TrendingUp, Clock, Thermometer } from 'lucide-react';
const SEGMENT_LABELS: Record<TAMSegmentId, string> = {
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react';
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';
function useAppliedFeedback() {
+10 -36
View File
@@ -15,9 +15,8 @@ import type {
TrainingPipeline, ModelFamily, DataMixAllocation,
ModelArchitecture, AlignmentMethod, SizeTier,
SFTSpecialization, QuantizationLevel, VariantCreationJob,
EvalJob,
ConsumerTierId, ApiTierId,
} from '@ai-tycoon/shared';
} from '@token-empire/shared';
import {
INITIAL_SETTINGS, SAVE_VERSION,
INITIAL_ECONOMY, INITIAL_INFRASTRUCTURE, INITIAL_COMPUTE,
@@ -40,15 +39,15 @@ import {
SFT_TIME_FRACTION, ALIGNMENT_TIME_FRACTION,
SIZE_TIER_MAP, SIZE_TIER_LABELS,
POINT_RELEASE_TIME_FRACTION, POINT_RELEASE_MAX_VERSION,
} from '@ai-tycoon/shared';
} from '@token-empire/shared';
import {
emptyDCNetworkSummary, emptyCampusNetworkSummary, emptyClusterNetworkSummary,
BENCHMARKS, TECH_TREE, onModelDeployed,
} from '@ai-tycoon/game-engine';
import { INITIAL_RIVALS } from '@ai-tycoon/game-engine';
TECH_TREE, onModelDeployed,
} from '@token-empire/game-engine';
import { INITIAL_RIVALS } from '@token-empire/game-engine';
export type ActivePage = 'dashboard' | 'infrastructure' | 'research' | 'models'
| 'market' | 'serving' | 'talent' | 'data' | 'competitors' | 'finance' | 'achievements' | 'leaderboard' | 'settings';
| 'market' | 'serving' | 'talent' | 'data' | 'competitors' | 'finance' | 'achievements' | 'leaderboard' | 'invitations' | 'settings';
export type InfraNavLevel = 'clusters' | 'cluster' | 'campus' | 'datacenter';
@@ -59,7 +58,7 @@ export interface InfraNav {
datacenterId?: string;
}
type ModelsTab = 'overview' | 'train' | 'models' | 'benchmarks' | 'products';
type ModelsTab = 'overview' | 'train' | 'models' | 'products';
interface UIState {
activePage: ActivePage;
@@ -132,7 +131,6 @@ interface Actions {
}) => void;
startPointRelease: (baseModelId: string) => void;
createQuantization: (baseModelId: string, level: QuantizationLevel, variantName: string) => void;
startEvaluation: (modelId: string, benchmarkIds: string[]) => void;
deployModel: (modelId: string) => void;
deployVariant: (familyId: string, variantId: string) => void;
setProductPricing: (productLineId: string, field: string, value: number) => void;
@@ -1076,32 +1074,6 @@ export const useGameStore = create<Store>()(
}
},
startEvaluation: (modelId, benchmarkIds) => {
let created = false;
set((s) => {
const benchmarks = BENCHMARKS.filter(b => benchmarkIds.includes(b.id));
if (benchmarks.length === 0) return s;
created = true;
const totalTicks = benchmarks.reduce((sum, b) => sum + b.ticksToRun, 0);
const computeCost = benchmarks.reduce((sum, b) => sum + b.computeCost, 0);
const job: EvalJob = {
id: uuid(),
modelId,
benchmarkIds,
progressTicks: 0,
totalTicks,
computeAllocated: computeCost,
status: 'active',
results: [],
};
return { models: { ...s.models, evalJobs: [...s.models.evalJobs, job] } };
});
if (created) {
get().addNotification({ title: 'Evaluation Started', message: `${benchmarkIds.length} benchmark${benchmarkIds.length > 1 ? 's' : ''} queued.`, type: 'info', tick: get().meta.tickCount });
set({ modelsTab: 'overview' as ModelsTab });
}
},
deployModel: (modelId) => {
const modelName = get().models.baseModels.find(m => m.id === modelId)?.name ?? 'Model';
set((s) => ({
@@ -1113,6 +1085,7 @@ export const useGameStore = create<Store>()(
productLines: s.models.productLines.map(pl => ({
...pl, modelId, isActive: true,
})),
deploymentVersion: s.models.deploymentVersion + 1,
},
market: {
...s.market,
@@ -1132,6 +1105,7 @@ export const useGameStore = create<Store>()(
? { ...f, variants: f.variants.map(v => v.id === variantId ? { ...v, isDeployed: true } : v) }
: f,
),
deploymentVersion: s.models.deploymentVersion + 1,
},
}));
get().addNotification({ title: 'Variant Deployed', message: 'Variant is now live.', type: 'success', tick: get().meta.tickCount });
@@ -1454,7 +1428,7 @@ export const useGameStore = create<Store>()(
}),
}),
{
name: 'ai-tycoon-save',
name: 'token-empire-save',
version: SAVE_VERSION,
partialize: (state) => {
const { activePage, notifications, infraNav, modelsTab, ...rest } = state;
+1 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "@ai-tycoon/tsconfig/react.json",
"extends": "@token-empire/tsconfig/react.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
+10 -7
View File
@@ -1,6 +1,6 @@
services:
web:
image: gitea.thewrightserver.net/josh/aihostingtycoon/web:latest
image: gitea.thewrightserver.net/josh/tokenempire/web:latest
ports:
- "80:80"
depends_on:
@@ -8,13 +8,16 @@ services:
restart: unless-stopped
server:
image: gitea.thewrightserver.net/josh/aihostingtycoon/server:latest
image: gitea.thewrightserver.net/josh/tokenempire/server:latest
ports:
- "3001:3001"
environment:
- DATABASE_URL=postgresql://aitycoon:aitycoon@db:5432/aitycoon
- DATABASE_URL=postgresql://tokenempire:tokenempire@db:5432/tokenempire
- PORT=3001
- CORS_ORIGIN=*
- JWT_SECRET=change-me-to-a-random-secret
- REQUIRE_INVITE=true
- USER_INVITATIONS=0
depends_on:
db:
condition: service_healthy
@@ -23,13 +26,13 @@ services:
db:
image: postgres:17-alpine
environment:
- POSTGRES_USER=aitycoon
- POSTGRES_PASSWORD=aitycoon
- POSTGRES_DB=aitycoon
- POSTGRES_USER=tokenempire
- POSTGRES_PASSWORD=tokenempire
- POSTGRES_DB=tokenempire
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U aitycoon"]
test: ["CMD-SHELL", "pg_isready -U tokenempire"]
interval: 5s
timeout: 5s
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
```
ai-tycoon/
token-empire/
├── turbo.json # Turborepo task config
├── 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
- **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.
- **Save format versioning**: A `version` field in meta enables migration functions for breaking state changes.
+3 -3
View File
@@ -1,5 +1,5 @@
{
"name": "ai-tycoon",
"name": "token-empire",
"private": true,
"scripts": {
"dev": "turbo dev",
@@ -9,8 +9,8 @@
"test": "vitest run",
"test:watch": "vitest",
"clean": "turbo clean",
"simulate": "turbo simulate --filter=@ai-tycoon/game-simulation",
"simulate:ci": "pnpm --filter @ai-tycoon/game-simulation simulate:ci"
"simulate": "turbo simulate --filter=@token-empire/game-simulation",
"simulate:ci": "pnpm --filter @token-empire/game-simulation simulate:ci"
},
"devDependencies": {
"turbo": "^2.5.0",
+3 -3
View File
@@ -1,5 +1,5 @@
{
"name": "@ai-tycoon/game-engine",
"name": "@token-empire/game-engine",
"private": true,
"version": "0.0.1",
"type": "module",
@@ -11,10 +11,10 @@
"test": "vitest run"
},
"dependencies": {
"@ai-tycoon/shared": "workspace:*"
"@token-empire/shared": "workspace:*"
},
"devDependencies": {
"@ai-tycoon/tsconfig": "workspace:*",
"@token-empire/tsconfig": "workspace:*",
"typescript": "^5.8.0"
}
}
@@ -2,16 +2,16 @@ import type {
Cluster, Campus, DataCenter, DeploymentCohort,
DCNetworkSummary, CampusNetworkSummary, ClusterNetworkSummary,
TrainingPipeline, BaseModel, ModelFamily,
} from '@ai-tycoon/shared';
import { uuid } from '@ai-tycoon/shared';
} from '@token-empire/shared';
import { uuid } from '@token-empire/shared';
import type { DeepPartial } from './createTestState';
function emptyDCNetwork(): DCNetworkSummary {
return {
switchIds: [],
networkRackCount: 0,
totalByTier: {},
healthyByTier: {},
repairBatches: [],
networkRackCount: 0,
racksDisconnected: 0,
racksDegraded: 0,
averageBandwidth: 1,
@@ -20,11 +20,11 @@ function emptyDCNetwork(): DCNetworkSummary {
}
function emptyCampusNetwork(): CampusNetworkSummary {
return { switchIds: [], totalT4: 0, healthyT4: 0, crossDCBandwidth: 1 };
return { totalT4: 0, healthyT4: 0, crossDCBandwidth: 1 };
}
function emptyClusterNetwork(): ClusterNetworkSummary {
return { switchIds: [], totalT5: 0, healthyT5: 0, crossCampusBandwidth: 1 };
return { totalT5: 0, healthyT5: 0, crossCampusBandwidth: 1 };
}
export function createTestDataCenter(overrides?: DeepPartial<DataCenter>): DataCenter {
@@ -171,7 +171,6 @@ export function createTestBaseModel(overrides?: Partial<BaseModel>): BaseModel {
sizeTier: 'small',
isPointRelease: false,
sourceModelId: null,
benchmarkResults: {},
dataMix: { web: 0.4, code: 0.2, books: 0.15, academic: 0.1, conversational: 0.1, specialized: 0.05 },
};
return overrides ? { ...base, ...overrides } : base;
@@ -181,9 +180,10 @@ export function createTestModelFamily(overrides?: Partial<ModelFamily>): ModelFa
const base: ModelFamily = {
id: uuid(),
name: 'Test Family',
baseModels: [],
generation: 1,
baseModelIds: [],
variants: [],
activeEvals: [],
createdAtTick: 0,
};
return overrides ? { ...base, ...overrides } : base;
}
@@ -1,11 +1,11 @@
import type { GameState } from '@ai-tycoon/shared';
import type { GameState } from '@token-empire/shared';
import {
INITIAL_SETTINGS, SAVE_VERSION,
INITIAL_ECONOMY, INITIAL_INFRASTRUCTURE, INITIAL_COMPUTE,
INITIAL_RESEARCH, INITIAL_MODELS, INITIAL_MARKET,
INITIAL_COMPETITORS, INITIAL_TALENT, INITIAL_DATA,
INITIAL_REPUTATION, INITIAL_ACHIEVEMENTS,
} from '@ai-tycoon/shared';
} from '@token-empire/shared';
export type DeepPartial<T> = T extends object
? { [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[] = [
{
-111
View File
@@ -1,111 +0,0 @@
import type { BenchmarkDefinition } from '@ai-tycoon/shared';
export const BENCHMARKS: BenchmarkDefinition[] = [
{
id: 'arc-challenge',
name: 'ARC Challenge',
category: 'reasoning',
description: 'Advanced reasoning and comprehension tasks requiring multi-step inference.',
primaryCapability: 'reasoning',
secondaryCapability: 'knowledge',
computeCost: 0.001,
ticksToRun: 8,
unlockedAtEra: 'startup',
marketRelevance: { consumer: 0.3, enterprise: 0.5, developer: 0.4, research: 0.8 },
},
{
id: 'codeforce',
name: 'CodeForce',
category: 'coding',
description: 'Competitive programming and software engineering benchmarks.',
primaryCapability: 'coding',
secondaryCapability: 'reasoning',
computeCost: 0.001,
ticksToRun: 8,
unlockedAtEra: 'startup',
marketRelevance: { consumer: 0.2, enterprise: 0.7, developer: 0.9, research: 0.5 },
},
{
id: 'mathquest',
name: 'MathQuest',
category: 'math',
description: 'Mathematical problem-solving from algebra to graduate-level proofs.',
primaryCapability: 'math',
secondaryCapability: 'reasoning',
computeCost: 0.001,
ticksToRun: 8,
unlockedAtEra: 'startup',
marketRelevance: { consumer: 0.1, enterprise: 0.6, developer: 0.5, research: 0.9 },
},
{
id: 'worldfacts',
name: 'WorldFacts',
category: 'knowledge',
description: 'Broad factual knowledge across science, history, culture, and current events.',
primaryCapability: 'knowledge',
secondaryCapability: 'reasoning',
computeCost: 0.001,
ticksToRun: 6,
unlockedAtEra: 'startup',
marketRelevance: { consumer: 0.5, enterprise: 0.4, developer: 0.3, research: 0.6 },
},
{
id: 'chatrank',
name: 'ChatRank',
category: 'chat',
description: 'Human preference evaluation of conversational quality, helpfulness, and creativity.',
primaryCapability: 'creative',
secondaryCapability: 'knowledge',
computeCost: 0.002,
ticksToRun: 10,
unlockedAtEra: 'startup',
marketRelevance: { consumer: 0.9, enterprise: 0.3, developer: 0.2, research: 0.2 },
},
{
id: 'harmguard',
name: 'HarmGuard',
category: 'safety',
description: 'Safety evaluation measuring harm avoidance, truthfulness, and responsible behavior.',
primaryCapability: 'reasoning',
computeCost: 0.001,
ticksToRun: 8,
unlockedAtEra: 'startup',
marketRelevance: { consumer: 0.4, enterprise: 0.9, developer: 0.3, research: 0.7 },
},
{
id: 'visionbench',
name: 'VisionBench',
category: 'multimodal',
description: 'Image understanding, visual reasoning, and multimodal comprehension.',
primaryCapability: 'multimodal',
secondaryCapability: 'reasoning',
computeCost: 0.003,
ticksToRun: 12,
unlockedAtEra: 'scaleup',
marketRelevance: { consumer: 0.5, enterprise: 0.6, developer: 0.6, research: 0.7 },
},
{
id: 'agentarena',
name: 'AgentArena',
category: 'agents',
description: 'Autonomous agent tasks: tool use, multi-step planning, and environment interaction.',
primaryCapability: 'agents',
secondaryCapability: 'coding',
computeCost: 0.005,
ticksToRun: 15,
unlockedAtEra: 'bigtech',
marketRelevance: { consumer: 0.3, enterprise: 0.8, developer: 0.7, research: 0.6 },
},
{
id: 'frontier-eval',
name: 'Frontier Eval',
category: 'reasoning',
description: 'Cutting-edge capability evaluation at the frontier of AI research.',
primaryCapability: 'reasoning',
secondaryCapability: 'math',
computeCost: 0.01,
ticksToRun: 20,
unlockedAtEra: 'agi',
marketRelevance: { consumer: 0.2, enterprise: 0.5, developer: 0.5, research: 1.0 },
},
];
+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[] = [
{
@@ -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[]> = {
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.
+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[] = [
// === 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';
export interface GameEngineCallbacks {
+1 -1
View File
@@ -3,6 +3,7 @@ export { processTick, setAchievementDefinitions } from './tick';
export type { TickNotification } from './tick';
export { getAvailableResearch, getResearchNode } from './systems/researchSystem';
export { getResearchBonuses, resetResearchBonusCache } from './systems/researchBonuses';
export { resetFleetCache } from './systems/market/servingPipeline';
export type { ResearchBonuses } from './systems/researchBonuses';
export { emptyDCNetworkSummary, emptyCampusNetworkSummary, emptyClusterNetworkSummary } from './systems/infrastructureSystem';
export { onModelDeployed } from './systems/market/obsolescenceSystem';
@@ -11,4 +12,3 @@ export { TECH_TREE } from './data/techTree';
export { INITIAL_RIVALS } from './data/competitors';
export { KEY_HIRE_POOL } from './data/keyHires';
export { ACHIEVEMENT_DEFINITIONS } from './data/achievements';
export { BENCHMARKS } from './data/benchmarks';
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { processAchievements } from './achievementSystem';
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 {
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 {
achievements: AchievementState;
@@ -1,8 +1,8 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { processCompetitors } from './competitorSystem';
import { createTestState, createSeededRNG } from '../__test-utils__';
import { FRESHNESS_DECAY_RATE } from '@ai-tycoon/shared';
import type { Competitor } from '@ai-tycoon/shared';
import { FRESHNESS_DECAY_RATE } from '@token-empire/shared';
import type { Competitor } from '@token-empire/shared';
const rng = createSeededRNG(42);
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 {
COMPETITOR_PRODUCT_THRESHOLDS,
COMPETITOR_CATCHUP_SHARE_THRESHOLD,
COMPETITOR_CATCHUP_PRICE_CUT,
FRESHNESS_DECAY_RATE,
} from '@ai-tycoon/shared';
} from '@token-empire/shared';
function updateCompetitorProducts(rival: Competitor): Competitor['products'] {
const cap = rival.estimatedCapability;
@@ -1,8 +1,8 @@
import { describe, it, expect } from 'vitest';
import { createTestState } from '../__test-utils__';
import { computeCapacity, finalizeCompute } from './computeSystem';
import type { InfrastructureState } from '@ai-tycoon/shared';
import { INITIAL_INFRASTRUCTURE, FLOPS_TO_TOKENS_MULTIPLIER, COMPUTE_SNAPSHOT_INTERVAL, MAX_COMPUTE_HISTORY } 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 '@token-empire/shared';
function createInfrastructure(overrides: Partial<InfrastructureState> = {}): InfrastructureState {
return { ...INITIAL_INFRASTRUCTURE, ...overrides };
@@ -1,5 +1,5 @@
import type { GameState, ComputeState, InfrastructureState } from '@ai-tycoon/shared';
import { FLOPS_TO_TOKENS_MULTIPLIER, COMPUTE_SNAPSHOT_INTERVAL, MAX_COMPUTE_HISTORY } 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 '@token-empire/shared';
import type { ResearchBonuses } from './researchBonuses';
export interface CapacityResult {
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { processData } from './dataSystem';
import { createTestState } from '../__test-utils__';
import type { DataPartnership } from '@ai-tycoon/shared';
import type { DataPartnership } from '@token-empire/shared';
function makePartnership(tokensPerTick: number): DataPartnership {
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 {
const subscribers = state.market.consumerTiers.totalUsers;
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import { processEconomy } from './economySystem';
import { createTestState, createTestCluster } from '../__test-utils__';
import type { MarketTickResult } from './marketSystem';
import type { InfrastructureState } from '@ai-tycoon/shared';
import type { InfrastructureState } from '@token-empire/shared';
function createMarketResult(
overrides: Partial<MarketTickResult> = {},
@@ -1,5 +1,5 @@
import type { GameState, EconomyState, InfrastructureState } from '@ai-tycoon/shared';
import { FINANCIAL_SNAPSHOT_INTERVAL, MAX_FINANCIAL_HISTORY, REGULATION_COMPLIANCE_PER_CAPABILITY } 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 '@token-empire/shared';
import { TECH_TREE } from '../data/techTree';
import type { MarketTickResult } from './marketSystem';
@@ -1,5 +1,5 @@
import type { GameState, Era } from '@ai-tycoon/shared';
import { ERA_THRESHOLDS } from '@ai-tycoon/shared';
import type { GameState, Era } from '@token-empire/shared';
import { ERA_THRESHOLDS } from '@token-empire/shared';
export function checkEraTransition(state: GameState): Era | null {
const current = state.meta.currentEra;
@@ -1,5 +1,5 @@
import type { GameState, FundingState, FundingRoundType } from '@ai-tycoon/shared';
import { FUNDING_ROUNDS } from '@ai-tycoon/shared';
import type { GameState, FundingState, FundingRoundType } from '@token-empire/shared';
import { FUNDING_ROUNDS } from '@token-empire/shared';
const ROUND_ORDER: FundingRoundType[] = ['seed', 'seriesA', 'seriesB', 'seriesC', 'seriesD', 'ipo'];
@@ -1,9 +1,9 @@
import type {
GameState, InfrastructureState, Cluster, Campus, DataCenter,
DeploymentCohort, PipelineStage, RackSkuId, NetworkSwitch,
DeploymentCohort, PipelineStage, RackSkuId,
SwitchTier, DCNetworkSummary, CampusNetworkSummary, ClusterNetworkSummary,
CampusRetrofitQueue, DCTier, IntraNodeInterconnect, NetworkFabric, RackSkuConfig,
} from '@ai-tycoon/shared';
RepairBatch, CampusRetrofitQueue, DCTier, IntraNodeInterconnect, NetworkFabric, RackSkuConfig,
} from '@token-empire/shared';
import {
LOCATION_CONFIGS,
RACK_SKU_CONFIGS,
@@ -22,7 +22,7 @@ import {
COOLING_TYPE_CONFIGS,
NETWORK_FABRIC_CONFIGS,
estimateNetworkSlots,
} from '@ai-tycoon/shared';
} from '@token-empire/shared';
import type { TickNotification } from '../tick';
import type { ResearchBonuses } from './researchBonuses';
@@ -83,357 +83,202 @@ function binomialSample(n: number, p: number): number {
return base + (Math.random() < frac ? 1 : 0);
}
// --- Network Topology Construction ---
// --- Aggregate Network Model ---
let switchIdCounter = 0;
function createSwitch(
tier: SwitchTier,
dcId: string | null,
campusId: string | null,
clusterId: string | null,
): NetworkSwitch {
const config = SWITCH_TIER_CONFIGS[tier];
return {
id: `${tier}-${dcId ?? campusId ?? clusterId ?? 'x'}-${switchIdCounter++}`,
tier,
status: 'healthy',
dcId, campusId, clusterId,
uplinkIds: [],
downlinkIds: [],
activeUplinks: config.uplinkCount,
totalUplinks: config.uplinkCount,
effectiveBandwidth: 1.0,
repairProgress: 0,
repairTotal: 0,
};
}
function wireUplinks(child: NetworkSwitch, parents: NetworkSwitch[], count: number): void {
if (parents.length === 0) return;
for (let i = 0; i < count; i++) {
const parent = parents[i % parents.length];
child.uplinkIds.push(parent.id);
if (!parent.downlinkIds.includes(child.id)) {
parent.downlinkIds.push(child.id);
}
}
child.activeUplinks = count;
child.effectiveBandwidth = 1.0;
}
const DC_TIERS: SwitchTier[] = ['tor', 't1', 't2', 't3'];
export function emptyDCNetworkSummary(): DCNetworkSummary {
return {
switchIds: [], networkRackCount: 0,
totalByTier: {}, healthyByTier: {},
repairBatches: [], networkRackCount: 0,
racksDisconnected: 0, racksDegraded: 0,
averageBandwidth: 1, effectiveFlopsFraction: 1,
};
}
export function emptyCampusNetworkSummary(): CampusNetworkSummary {
return { switchIds: [], totalT4: 0, healthyT4: 0, crossDCBandwidth: 1 };
return { totalT4: 0, healthyT4: 0, crossDCBandwidth: 1 };
}
export function emptyClusterNetworkSummary(): ClusterNetworkSummary {
return { switchIds: [], totalT5: 0, healthyT5: 0, crossCampusBandwidth: 1 };
return { totalT5: 0, healthyT5: 0, crossCampusBandwidth: 1 };
}
export function buildDCTopology(
function computeTopologyCounts(
computeRackCount: number,
dcTier: DCTier,
dcId: string,
registry: Record<string, NetworkSwitch>,
): DCNetworkSummary {
if (computeRackCount <= 0) return emptyDCNetworkSummary();
const switchIds: string[] = [];
const t3Count = T3_COUNT_PER_DC_TIER[dcTier];
const t3s: NetworkSwitch[] = [];
for (let i = 0; i < t3Count; i++) {
const sw = createSwitch('t3', dcId, null, null);
sw.totalUplinks = 0;
sw.activeUplinks = 0;
t3s.push(sw);
registry[sw.id] = sw;
switchIds.push(sw.id);
}
): Partial<Record<SwitchTier, number>> {
if (computeRackCount <= 0) return {};
const t1Count = Math.ceil(computeRackCount / SWITCH_TIER_CONFIGS.t1.fanOut);
const t2Count = Math.ceil(t1Count / SWITCH_TIER_CONFIGS.t2.fanOut);
const t2s: NetworkSwitch[] = [];
for (let i = 0; i < t2Count; i++) {
const sw = createSwitch('t2', dcId, null, null);
wireUplinks(sw, t3s, SWITCH_TIER_CONFIGS.t2.uplinkCount);
t2s.push(sw);
registry[sw.id] = sw;
switchIds.push(sw.id);
}
const t1s: NetworkSwitch[] = [];
for (let i = 0; i < t1Count; i++) {
const sw = createSwitch('t1', dcId, null, null);
wireUplinks(sw, t2s, SWITCH_TIER_CONFIGS.t1.uplinkCount);
t1s.push(sw);
registry[sw.id] = sw;
switchIds.push(sw.id);
}
for (let i = 0; i < computeRackCount; i++) {
const sw = createSwitch('tor', dcId, null, null);
const primary = t1s[Math.floor(i / SWITCH_TIER_CONFIGS.t1.fanOut)];
const altIdx = (Math.floor(i / SWITCH_TIER_CONFIGS.t1.fanOut) + 1) % t1s.length;
const alt = t1s[altIdx];
if (t1s.length >= 2 && primary !== alt) {
wireUplinks(sw, [primary, alt], 2);
} else {
wireUplinks(sw, [primary], 2);
}
registry[sw.id] = sw;
switchIds.push(sw.id);
}
const networkRackCount = estimateNetworkSlots(computeRackCount, dcTier);
return buildDCSummary(switchIds, networkRackCount, registry);
const t3Count = T3_COUNT_PER_DC_TIER[dcTier];
return { tor: computeRackCount, t1: t1Count, t2: t2Count, t3: t3Count };
}
export function expandDCTopology(
existing: DCNetworkSummary,
newRackCount: number,
export function buildDCNetworkSummary(
computeRackCount: number,
dcTier: DCTier,
dcId: string,
registry: Record<string, NetworkSwitch>,
): DCNetworkSummary {
if (newRackCount <= 0) return existing;
const currentTorCount = existing.totalByTier?.tor ?? 0;
const targetTorCount = currentTorCount + newRackCount;
const t1s = existing.switchIds.map(id => registry[id]).filter((s): s is NetworkSwitch => !!s && s.tier === 't1');
const t2s = existing.switchIds.map(id => registry[id]).filter((s): s is NetworkSwitch => !!s && s.tier === 't2');
const t3s = existing.switchIds.map(id => registry[id]).filter((s): s is NetworkSwitch => !!s && s.tier === 't3');
const newIds = [...existing.switchIds];
const neededT1 = Math.ceil(targetTorCount / SWITCH_TIER_CONFIGS.t1.fanOut);
const neededT2 = Math.ceil(neededT1 / SWITCH_TIER_CONFIGS.t2.fanOut);
while (t2s.length < neededT2) {
const sw = createSwitch('t2', dcId, null, null);
wireUplinks(sw, t3s, SWITCH_TIER_CONFIGS.t2.uplinkCount);
t2s.push(sw);
registry[sw.id] = sw;
newIds.push(sw.id);
}
while (t1s.length < neededT1) {
const sw = createSwitch('t1', dcId, null, null);
wireUplinks(sw, t2s, SWITCH_TIER_CONFIGS.t1.uplinkCount);
t1s.push(sw);
registry[sw.id] = sw;
newIds.push(sw.id);
}
for (let i = 0; i < newRackCount; i++) {
const torIdx = currentTorCount + i;
const sw = createSwitch('tor', dcId, null, null);
const primary = t1s[Math.floor(torIdx / SWITCH_TIER_CONFIGS.t1.fanOut)];
const altIdx = (Math.floor(torIdx / SWITCH_TIER_CONFIGS.t1.fanOut) + 1) % t1s.length;
const alt = t1s[altIdx];
if (t1s.length >= 2 && primary !== alt) {
wireUplinks(sw, [primary, alt], 2);
} else {
wireUplinks(sw, [primary], 2);
}
registry[sw.id] = sw;
newIds.push(sw.id);
}
const networkRackCount = estimateNetworkSlots(targetTorCount, dcTier);
return buildDCSummary(newIds, networkRackCount, registry);
}
export function shrinkDCTopology(
existing: DCNetworkSummary,
removeCount: number,
dcTier: DCTier,
registry: Record<string, NetworkSwitch>,
): DCNetworkSummary {
if (removeCount <= 0) return existing;
const torIds = existing.switchIds.filter(id => registry[id]?.tier === 'tor');
const toRemove = new Set(torIds.slice(-removeCount));
for (const torId of toRemove) {
const tor = registry[torId];
if (!tor) continue;
for (const upId of tor.uplinkIds) {
const parent = registry[upId];
if (parent) parent.downlinkIds = parent.downlinkIds.filter(id => id !== torId);
}
delete registry[torId];
}
const remainingIds = existing.switchIds.filter(id => !toRemove.has(id));
const remainingTors = remainingIds.filter(id => registry[id]?.tier === 'tor').length;
return buildDCSummary(remainingIds, estimateNetworkSlots(remainingTors, dcTier), registry);
}
function computeRackBandwidth(tor: NetworkSwitch, registry: Record<string, NetworkSwitch>): number {
if (tor.status !== 'healthy') return 0;
let minBW = tor.totalUplinks > 0 ? tor.activeUplinks / tor.totalUplinks : 1;
if (minBW === 0) return 0;
const visited = new Set<string>();
let current = tor.uplinkIds.filter(id => {
const sw = registry[id];
return sw && sw.status === 'healthy';
});
while (current.length > 0) {
let tierBW = 1;
const next: string[] = [];
for (const sid of current) {
if (visited.has(sid)) continue;
visited.add(sid);
const sw = registry[sid];
if (!sw || sw.status !== 'healthy') continue;
const bw = sw.totalUplinks > 0 ? sw.activeUplinks / sw.totalUplinks : 1;
tierBW = Math.min(tierBW, bw);
for (const upId of sw.uplinkIds) {
if (registry[upId]?.status === 'healthy') next.push(upId);
}
}
minBW = Math.min(minBW, tierBW);
if (minBW === 0) return 0;
current = next;
}
return minBW;
}
function buildDCSummary(
switchIds: string[],
networkRackCount: number,
registry: Record<string, NetworkSwitch>,
): DCNetworkSummary {
const totalByTier: Partial<Record<SwitchTier, number>> = {};
const healthyByTier: Partial<Record<SwitchTier, number>> = {};
let disconnected = 0;
let degraded = 0;
let bwSum = 0;
let torCount = 0;
for (const sid of switchIds) {
const sw = registry[sid];
if (!sw) continue;
totalByTier[sw.tier] = (totalByTier[sw.tier] ?? 0) + 1;
if (sw.status === 'healthy') healthyByTier[sw.tier] = (healthyByTier[sw.tier] ?? 0) + 1;
if (sw.tier === 'tor') {
torCount++;
const bw = computeRackBandwidth(sw, registry);
bwSum += bw;
if (bw === 0) disconnected++;
else if (bw < 1) degraded++;
}
}
const avgBW = torCount > 0 ? bwSum / torCount : 1;
if (computeRackCount <= 0) return emptyDCNetworkSummary();
const totalByTier = computeTopologyCounts(computeRackCount, dcTier);
const healthyByTier = { ...totalByTier };
return {
switchIds, networkRackCount, totalByTier, healthyByTier,
racksDisconnected: disconnected, racksDegraded: degraded,
averageBandwidth: avgBW, effectiveFlopsFraction: avgBW,
totalByTier, healthyByTier,
repairBatches: [],
networkRackCount: estimateNetworkSlots(computeRackCount, dcTier),
racksDisconnected: 0, racksDegraded: 0,
averageBandwidth: 1, effectiveFlopsFraction: 1,
};
}
// --- Network Tick (failure rolls + repair) ---
export function expandDCNetwork(
existing: DCNetworkSummary,
addedRacks: number,
dcTier: DCTier,
): DCNetworkSummary {
if (addedRacks <= 0) return existing;
const oldTor = existing.totalByTier.tor ?? 0;
const newTor = oldTor + addedRacks;
const newTotal = computeTopologyCounts(newTor, dcTier);
const healthyByTier: Partial<Record<SwitchTier, number>> = {};
for (const tier of DC_TIERS) {
const oldTotal = existing.totalByTier[tier] ?? 0;
const oldHealthy = existing.healthyByTier[tier] ?? 0;
const added = (newTotal[tier] ?? 0) - oldTotal;
healthyByTier[tier] = oldHealthy + Math.max(0, added);
}
const summary: DCNetworkSummary = {
...existing,
totalByTier: newTotal,
healthyByTier,
networkRackCount: estimateNetworkSlots(newTor, dcTier),
};
return recomputeBandwidth(summary);
}
function processNetworkTick(
registry: Record<string, NetworkSwitch>,
export function shrinkDCNetwork(
existing: DCNetworkSummary,
removedRacks: number,
dcTier: DCTier,
): DCNetworkSummary {
if (removedRacks <= 0) return existing;
const oldTor = existing.totalByTier.tor ?? 0;
const newTor = Math.max(0, oldTor - removedRacks);
if (newTor === 0) return emptyDCNetworkSummary();
const newTotal = computeTopologyCounts(newTor, dcTier);
const healthyByTier: Partial<Record<SwitchTier, number>> = {};
for (const tier of DC_TIERS) {
const nt = newTotal[tier] ?? 0;
const oh = existing.healthyByTier[tier] ?? 0;
healthyByTier[tier] = Math.min(oh, nt);
}
const repairBatches = existing.repairBatches.filter(b => {
const nt = newTotal[b.tier] ?? 0;
const nh = healthyByTier[b.tier] ?? 0;
return nh < nt;
});
const summary: DCNetworkSummary = {
...existing,
totalByTier: newTotal,
healthyByTier,
repairBatches,
networkRackCount: estimateNetworkSlots(newTor, dcTier),
};
return recomputeBandwidth(summary);
}
function computeAggregateBandwidth(
summary: DCNetworkSummary,
redundancyBonus: number,
): number {
let minBW = 1;
for (const tier of DC_TIERS) {
const total = summary.totalByTier[tier] ?? 0;
if (total === 0) continue;
const healthy = summary.healthyByTier[tier] ?? 0;
const tierBW = Math.min(1, (healthy + redundancyBonus) / total);
if (tierBW < minBW) minBW = tierBW;
}
return minBW;
}
function recomputeBandwidth(summary: DCNetworkSummary, redundancyBonus = 0): DCNetworkSummary {
const avgBW = computeAggregateBandwidth(summary, redundancyBonus);
const torTotal = summary.totalByTier.tor ?? 0;
const torHealthy = summary.healthyByTier.tor ?? 0;
const torFailed = torTotal - torHealthy;
const disconnected = avgBW === 0 ? torTotal : torFailed;
const degraded = avgBW > 0 && avgBW < 1 ? Math.ceil(torTotal * (1 - avgBW)) - disconnected : 0;
return {
...summary,
averageBandwidth: avgBW,
effectiveFlopsFraction: avgBW,
racksDisconnected: Math.max(0, disconnected),
racksDegraded: Math.max(0, degraded),
};
}
function processNetworkForDC(
summary: DCNetworkSummary,
networkResearchBonus: number,
opsEff: number,
repairSpeedBonus: number,
hotStandbyTicks: number,
redundancyBonus: number,
): { switchRepairCosts: number; notifications: TickNotification[]; dirtyDCs: Set<string> } {
): { summary: DCNetworkSummary; costs: number; notifications: TickNotification[] } {
const torTotal = summary.totalByTier.tor ?? 0;
if (torTotal === 0) return { summary, costs: 0, notifications: [] };
let costs = 0;
const notifications: TickNotification[] = [];
let switchRepairCosts = 0;
const dirtyDCs = new Set<string>();
const healthyByTier = { ...summary.healthyByTier };
let dirty = false;
const healthyByTier: Partial<Record<SwitchTier, NetworkSwitch[]>> = {};
const repairing: NetworkSwitch[] = [];
for (const sw of Object.values(registry)) {
if (sw.status === 'healthy') {
(healthyByTier[sw.tier] ??= []).push(sw);
} else if (sw.status === 'repairing') {
repairing.push(sw);
}
}
const tiers: SwitchTier[] = ['tor', 't1', 't2', 't3', 't4', 't5'];
const newlyFailed: NetworkSwitch[] = [];
for (const tier of tiers) {
const healthy = healthyByTier[tier];
if (!healthy || healthy.length === 0) continue;
for (const tier of DC_TIERS) {
const healthy = healthyByTier[tier] ?? 0;
if (healthy <= 0) continue;
const rate = SWITCH_TIER_CONFIGS[tier].failureRatePerTick * (1 - networkResearchBonus);
const count = binomialSample(healthy.length, rate);
if (count > 0) {
const shuffled = [...healthy].sort(() => Math.random() - 0.5);
for (let i = 0; i < count; i++) {
const sw = shuffled[i];
const baseRepair = SWITCH_TIER_CONFIGS[tier].repairBaseTicks;
const repairTime = hotStandbyTicks > 0
? hotStandbyTicks
: baseRepair * (1 - repairSpeedBonus);
sw.status = 'repairing';
sw.repairProgress = 0;
sw.repairTotal = repairTime;
newlyFailed.push(sw);
if (sw.dcId) dirtyDCs.add(sw.dcId);
switchRepairCosts += SWITCH_TIER_CONFIGS[tier].baseCost * SWITCH_REPAIR_COST_FRACTION;
const failed = binomialSample(healthy, rate);
if (failed > 0) {
healthyByTier[tier] = healthy - failed;
const baseRepair = SWITCH_TIER_CONFIGS[tier].repairBaseTicks;
const repairTime = hotStandbyTicks > 0
? hotStandbyTicks
: baseRepair * (1 - repairSpeedBonus);
summary.repairBatches.push({ tier, count: failed, ticksRemaining: repairTime });
costs += SWITCH_TIER_CONFIGS[tier].baseCost * SWITCH_REPAIR_COST_FRACTION * failed;
dirty = true;
if (tier === 't3') {
notifications.push({ title: 'Core Network Failure', message: `Tier-3 core switch failed — potential DC disconnect!`, type: 'danger' });
} else if (tier === 't2') {
notifications.push({ title: 'Network Switch Failure', message: `Tier-2 spine switch failed — racks may be degraded.`, type: 'warning' });
}
}
}
for (const sw of repairing) {
sw.repairProgress += 1 + opsEff * 0.05;
if (sw.repairProgress >= sw.repairTotal) {
sw.status = 'healthy';
sw.repairProgress = 0;
sw.repairTotal = 0;
if (sw.dcId) dirtyDCs.add(sw.dcId);
const remainingBatches: RepairBatch[] = [];
for (const batch of summary.repairBatches) {
const newTicks = batch.ticksRemaining - (1 + opsEff * 0.05);
if (newTicks <= 0) {
healthyByTier[batch.tier] = Math.min(
summary.totalByTier[batch.tier] ?? 0,
(healthyByTier[batch.tier] ?? 0) + batch.count,
);
dirty = true;
} else {
remainingBatches.push({ ...batch, ticksRemaining: newTicks });
}
}
if (dirtyDCs.size > 0) {
for (const sw of Object.values(registry)) {
if (sw.uplinkIds.length === 0) continue;
if (sw.dcId && !dirtyDCs.has(sw.dcId)) continue;
let active = 0;
for (const upId of sw.uplinkIds) {
if (registry[upId]?.status === 'healthy') active++;
}
sw.activeUplinks = active;
sw.effectiveBandwidth = sw.totalUplinks > 0 ? Math.min(1, (active + redundancyBonus) / sw.totalUplinks) : 1;
}
}
if (!dirty) return { summary: { ...summary, repairBatches: remainingBatches }, costs, notifications };
for (const sw of newlyFailed) {
if (sw.tier === 't3') {
notifications.push({ title: 'Core Network Failure', message: `Tier-3 core switch failed — potential DC disconnect!`, type: 'danger' });
} else if (sw.tier === 't4') {
notifications.push({ title: 'Campus Network Failure', message: `Tier-4 campus switch failed — cross-DC degradation!`, type: 'danger' });
} else if (sw.tier === 't2') {
notifications.push({ title: 'Network Switch Failure', message: `Tier-2 spine switch failed — racks may be degraded.`, type: 'warning' });
}
}
return { switchRepairCosts, notifications, dirtyDCs };
const updated: DCNetworkSummary = {
...summary,
healthyByTier,
repairBatches: remainingBatches,
};
return { summary: recomputeBandwidth(updated, redundancyBonus), costs, notifications };
}
// --- Interconnect Training Multiplier ---
@@ -476,14 +321,6 @@ export function processInfrastructure(state: GameState, researchBonuses?: Resear
const hotStandbyTicks = state.research.completedResearch.includes('network-hot-standby') ? 5 : 0;
const redundancyBonus = state.research.completedResearch.includes('network-redundancy') ? 1 : 0;
// Mutate registry in-place — infrastructure returns a new state anyway
const registry = state.infrastructure.switchRegistry;
// Process network failures/repairs globally
const netResult = processNetworkTick(registry, networkResearchBonus, opsEff, repairSpeedBonus, hotStandbyTicks, redundancyBonus);
repairCosts += netResult.switchRepairCosts;
if (netResult.notifications.length > 0) notifications.push(...netResult.notifications);
let totalFlops = 0;
let totalTrainingFlops = 0;
let totalInferenceFlops = 0;
@@ -541,8 +378,6 @@ export function processInfrastructure(state: GameState, researchBonuses?: Resear
if (rs.progress >= rs.total) {
if (rs.phase === 'decommissioning') {
const installTotal = cohortStageTotal('installation', rs.toSkuId, rs.racksRemaining);
// Clear DC topology on retrofit
for (const sid of dc.networkSummary.switchIds) delete registry[sid];
return {
...dc,
computeRacksOnline: 0,
@@ -636,10 +471,11 @@ export function processInfrastructure(state: GameState, researchBonuses?: Resear
// Expand topology for newly onlined racks
let networkSummary = dc.networkSummary;
if (racksJustOnlined > 0) {
if (networkSummary.switchIds.length === 0) {
networkSummary = buildDCTopology(computeRacksOnline, dc.tier, dc.id, registry);
const torTotal = networkSummary.totalByTier.tor ?? 0;
if (torTotal === 0) {
networkSummary = buildDCNetworkSummary(computeRacksOnline, dc.tier);
} else {
networkSummary = expandDCTopology(networkSummary, racksJustOnlined, dc.tier, dc.id, registry);
networkSummary = expandDCNetwork(networkSummary, racksJustOnlined, dc.tier);
}
}
@@ -660,18 +496,20 @@ export function processInfrastructure(state: GameState, researchBonuses?: Resear
stageTotal: cohortStageTotal('repair', dc.rackSkuId, prodFailures),
repairCount: 0,
});
networkSummary = shrinkDCTopology(networkSummary, prodFailures, dc.tier, registry);
networkSummary = shrinkDCNetwork(networkSummary, prodFailures, dc.tier);
}
}
repairCosts += dcRepairCosts;
// Recompute DC network summary after failures/repairs (only if this DC's switches changed)
if (netResult.dirtyDCs.has(dc.id) && networkSummary.switchIds.length > 0) {
networkSummary = buildDCSummary(
networkSummary.switchIds, networkSummary.networkRackCount, registry,
);
}
// Process per-DC network failures and repairs (aggregate model)
const netResult = processNetworkForDC(
networkSummary, networkResearchBonus, opsEff,
repairSpeedBonus, hotStandbyTicks, redundancyBonus,
);
networkSummary = netResult.summary;
repairCosts += netResult.costs;
if (netResult.notifications.length > 0) notifications.push(...netResult.notifications);
// Rackdown: detect recovery (previously disconnected racks now have connectivity)
const prevDisconnected = dc.networkSummary.racksDisconnected;
@@ -680,7 +518,7 @@ export function processInfrastructure(state: GameState, researchBonuses?: Resear
if (currDisconnected < prevDisconnected && dc.rackSkuId) {
const recovered = prevDisconnected - currDisconnected;
computeRacksOnline -= recovered;
networkSummary = shrinkDCTopology(networkSummary, recovered, dc.tier, registry);
networkSummary = shrinkDCNetwork(networkSummary, recovered, dc.tier);
updatedCohorts.push({
id: `netrecovery-${dc.id}-${Date.now()}`,
count: recovered, skuId: dc.rackSkuId,
@@ -688,10 +526,6 @@ export function processInfrastructure(state: GameState, researchBonuses?: Resear
stageTotal: cohortStageTotal('testing', dc.rackSkuId, recovered),
repairCount: 0,
});
// Recompute summary after shrink
networkSummary = buildDCSummary(
networkSummary.switchIds, networkSummary.networkRackCount, registry,
);
}
// Compute DC aggregates
@@ -789,8 +623,6 @@ export function processInfrastructure(state: GameState, researchBonuses?: Resear
if (totalRacks <= 0) return dc;
const oldSku = RACK_SKU_CONFIGS[dc.rackSkuId as RackSkuId];
const decommTicks = Math.ceil(oldSku.pipelineTimeTicks.installation * (1 + COHORT_SCALE_FACTOR * totalRacks));
// Clear topology on retrofit start
for (const sid of dc.networkSummary.switchIds) delete registry[sid];
return {
...dc,
status: 'retrofitting' as const,
@@ -827,7 +659,6 @@ export function processInfrastructure(state: GameState, researchBonuses?: Resear
return {
infrastructure: {
clusters,
switchRegistry: registry,
totalFlops,
totalTrainingFlops,
totalInferenceFlops,
@@ -1,11 +1,11 @@
import type { ApiTierState, ApiTierId, DeveloperEcosystem, TierServingMetrics } from '@ai-tycoon/shared';
import type { ApiTierState, ApiTierId, DeveloperEcosystem, TierServingMetrics } from '@token-empire/shared';
import {
API_TIER_ORDER,
API_CONVERSION_RATES,
API_TIER_CHURN_RATES,
API_TOKENS_PER_DEVELOPER_PER_TICK,
REJECTION_CHURN_MULTIPLIER,
} from '@ai-tycoon/shared';
} from '@token-empire/shared';
export interface ApiTickResult {
apiTiers: ApiTierState;
@@ -1,4 +1,4 @@
import type { ConsumerTierState, ConsumerTierId, TierServingMetrics } from '@ai-tycoon/shared';
import type { ConsumerTierState, ConsumerTierId, TierServingMetrics } from '@token-empire/shared';
import {
CONSUMER_TIER_ORDER,
CONVERSION_RATES,
@@ -8,7 +8,13 @@ import {
NETWORK_DEGRADATION,
REJECTION_CHURN_MULTIPLIER,
QUEUE_CHURN_MULTIPLIER,
} from '@ai-tycoon/shared';
CONSUMER_TIER_BASE_PERCEIVED_VALUE,
PERCEIVED_VALUE_REPUTATION_RANGE,
PRICE_ELASTICITY_STEEPNESS,
PRICE_SATISFACTION_WEIGHT,
PRICE_CHURN_EXPONENT,
PRICE_CHURN_MAX_MULTIPLIER,
} from '@token-empire/shared';
export interface ConsumerTickResult {
consumerTiers: ConsumerTierState;
@@ -16,10 +22,23 @@ export interface ConsumerTickResult {
totalConsumerTokenDemand: number;
}
function computePerceivedValue(
tierId: ConsumerTierId,
modelQuality: number,
reputation: number,
): number {
const baseValue = CONSUMER_TIER_BASE_PERCEIVED_VALUE[tierId];
if (baseValue <= 0) return 0;
const repRange = PERCEIVED_VALUE_REPUTATION_RANGE;
const repMult = repRange.min + (Math.max(0, Math.min(100, reputation)) / 100) * (repRange.max - repRange.min);
return baseValue * Math.max(0, modelQuality) * repMult;
}
export function processConsumerTiers(
tiers: ConsumerTierState,
playerConsumerCustomers: number,
modelQuality: number,
reputation: number,
seasonalConsumerMultiplier: number,
networkLatencyPenalty: number,
consumerPaidMetrics: TierServingMetrics,
@@ -57,6 +76,7 @@ export function processConsumerTiers(
team: 'pro',
};
// --- Pass 1: Conversions only (no churn yet) ---
for (const id of CONSUMER_TIER_ORDER) {
if (id === 'free') continue;
const tier = updated.tiers[id];
@@ -69,35 +89,26 @@ export function processConsumerTiers(
const conversionKey = `${prevId}->${id}`;
const baseRate = CONVERSION_RATES[conversionKey] ?? 0;
const priceAttr = tier.config.price > 0
? Math.max(0.1, 1 - tier.config.price / 100)
: 1;
const convRate = baseRate * qualityFactor * priceAttr * seasonalConsumerMultiplier;
const perceivedValue = computePerceivedValue(id, modelQuality, reputation);
let priceAttr: number;
if (tier.config.price <= 0) {
priceAttr = 1;
} else if (perceivedValue <= 0) {
priceAttr = 0;
} else {
const ratio = tier.config.price / perceivedValue;
priceAttr = 1 / (1 + Math.pow(ratio, PRICE_ELASTICITY_STEEPNESS));
}
const convRate = baseRate * qualityFactor * priceAttr * seasonalConsumerMultiplier;
const converting = prevTier.userCount * convRate;
prevTier.userCount = Math.max(0, prevTier.userCount - converting);
tier.userCount += converting;
tier.conversionRateFromBelow = convRate;
tier.churnRate = TIER_CHURN_RATES[id];
const churnMultiplier = 1 + (1 - updated.satisfaction) * 2;
const churned = tier.userCount * tier.churnRate * churnMultiplier;
tier.userCount = Math.max(0, tier.userCount - churned);
}
let totalUsers = 0;
let subscriptionRevenue = 0;
let totalTokenDemand = 0;
for (const id of CONSUMER_TIER_ORDER) {
const tier = updated.tiers[id];
totalUsers += tier.userCount;
subscriptionRevenue += tier.userCount * (tier.config.price / 86400);
totalTokenDemand += tier.userCount * CONSUMER_TOKENS_PER_SUBSCRIBER;
}
updated.totalUsers = totalUsers;
// --- Serving penalties & serving-based extra churn ---
const paidDemand = consumerPaidMetrics.demandTokens;
const freeDemand = consumerFreeMetrics.demandTokens;
const totalDemand = paidDemand + freeDemand;
@@ -140,6 +151,7 @@ export function processConsumerTiers(
}
}
// --- Price-aware satisfaction ---
let headroomBonus = 0;
if (totalDemand > 0) {
const totalServed = consumerPaidMetrics.servedTokens + consumerFreeMetrics.servedTokens;
@@ -152,10 +164,76 @@ export function processConsumerTiers(
}
const netLatencyPenalty = networkLatencyPenalty * NETWORK_DEGRADATION.satisfactionPenaltyPerLatency;
updated.satisfaction = Math.min(1, Math.max(0,
const qualityServingSatisfaction = Math.min(1, Math.max(0,
0.3 + modelQuality * 0.5 + headroomBonus - servingPenalty - netLatencyPenalty,
));
let priceSatNumerator = 0;
let priceSatDenominator = 0;
for (const id of CONSUMER_TIER_ORDER) {
if (id === 'free') continue;
const tier = updated.tiers[id];
if (tier.userCount <= 0) continue;
const pv = computePerceivedValue(id, modelQuality, reputation);
let tierPriceSat: number;
if (tier.config.price <= 0) {
tierPriceSat = 1;
} else if (pv <= 0) {
tierPriceSat = 0;
} else {
tierPriceSat = Math.min(1, pv / tier.config.price);
}
priceSatNumerator += tierPriceSat * tier.userCount;
priceSatDenominator += tier.userCount;
}
const priceSatisfaction = priceSatDenominator > 0
? priceSatNumerator / priceSatDenominator
: 1;
const w = PRICE_SATISFACTION_WEIGHT;
updated.satisfaction = Math.min(1, Math.max(0,
(1 - w) * qualityServingSatisfaction + w * priceSatisfaction,
));
// --- Pass 2: Churn (using price-aware satisfaction) ---
for (const id of CONSUMER_TIER_ORDER) {
if (id === 'free') continue;
const tier = updated.tiers[id];
tier.churnRate = TIER_CHURN_RATES[id];
const satisfactionChurnMultiplier = 1 + (1 - updated.satisfaction) * 2;
const pv = computePerceivedValue(id, modelQuality, reputation);
let priceChurnMultiplier = 1;
if (tier.config.price > 0 && pv > 0) {
const ratio = tier.config.price / pv;
if (ratio > 1) {
priceChurnMultiplier = Math.min(
PRICE_CHURN_MAX_MULTIPLIER,
Math.pow(ratio, PRICE_CHURN_EXPONENT),
);
}
} else if (tier.config.price > 0 && pv <= 0) {
priceChurnMultiplier = PRICE_CHURN_MAX_MULTIPLIER;
}
const churned = tier.userCount * tier.churnRate * satisfactionChurnMultiplier * priceChurnMultiplier;
tier.userCount = Math.max(0, tier.userCount - churned);
}
// --- Revenue & token demand (after all churn — cancelled users don't pay) ---
let totalUsers = 0;
let subscriptionRevenue = 0;
let totalTokenDemand = 0;
for (const id of CONSUMER_TIER_ORDER) {
const tier = updated.tiers[id];
totalUsers += tier.userCount;
subscriptionRevenue += tier.userCount * (tier.config.price / 86400);
totalTokenDemand += tier.userCount * CONSUMER_TOKENS_PER_SUBSCRIBER;
}
updated.totalUsers = totalUsers;
updated.viralCoefficient = modelQuality > 0.5 ? 1 + (modelQuality - 0.5) * 2 : 0;
return {
@@ -1,4 +1,4 @@
import type { DeveloperEcosystem } from '@ai-tycoon/shared';
import type { DeveloperEcosystem } from '@token-empire/shared';
import {
BASE_DEV_GROWTH,
FREE_TIER_DEV_MULTIPLIER,
@@ -9,8 +9,8 @@ import {
STARTUP_ADOPTION_PER_DEV,
ENTERPRISE_REFERRAL_PER_STARTUP,
TAM_BASE_SIZES,
} from '@ai-tycoon/shared';
import type { Era } from '@ai-tycoon/shared';
} from '@token-empire/shared';
import type { Era } from '@token-empire/shared';
export function processDeveloperEcosystem(
eco: DeveloperEcosystem,
@@ -6,7 +6,7 @@ import type {
EnterprisePipelineStage,
DeveloperEcosystem,
TierServingMetrics,
} from '@ai-tycoon/shared';
} from '@token-empire/shared';
import {
BASE_LEAD_RATE,
LEAD_EXPIRY_TICKS,
@@ -19,7 +19,7 @@ import {
ENTERPRISE_CAPABILITY_REQUIREMENTS,
ENTERPRISE_TOKENS_PER_TICK,
ENTERPRISE_REJECTION_SLA_MULTIPLIER,
} from '@ai-tycoon/shared';
} from '@token-empire/shared';
import { ENTERPRISE_NAMES } from '../../data/enterpriseNames';
let leadIdCounter = 0;
@@ -1,7 +1,6 @@
import type { GameState, MarketState, BenchmarkResult } from '@ai-tycoon/shared';
import { CONSUMER_TOKENS_PER_SUBSCRIBER, API_TOKENS_PER_DEVELOPER_PER_TICK, BATCH_API_DEMAND_PER_DEV, makeInitialServingMetrics } from '@ai-tycoon/shared';
import type { TrafficPriority, TierServingMetrics } from '@ai-tycoon/shared';
import { BENCHMARKS } from '../../data/benchmarks';
import type { GameState, MarketState, ModelCapabilities } from '@token-empire/shared';
import { CONSUMER_TOKENS_PER_SUBSCRIBER, API_TOKENS_PER_DEVELOPER_PER_TICK, BATCH_API_DEMAND_PER_DEV, makeInitialServingMetrics } from '@token-empire/shared';
import type { TrafficPriority, TierServingMetrics } from '@token-empire/shared';
import { computeSeasonal } from './seasonalSystem';
import { updateObsolescence } from './obsolescenceSystem';
import { buildPlayerProfile, buildCompetitorProfile, computeMarketShares, updateTAMGrowth } from './tamSystem';
@@ -21,31 +20,30 @@ export interface MarketTickResult {
totalTokenDemand: number;
}
const SEGMENT_CAPABILITY_WEIGHTS: Record<string, Partial<Record<keyof ModelCapabilities, number>>> = {
consumer: { creative: 0.35, knowledge: 0.25, reasoning: 0.15, multimodal: 0.15, coding: 0.05, agents: 0.05 },
enterprise: { reasoning: 0.25, coding: 0.20, agents: 0.20, knowledge: 0.15, math: 0.10, multimodal: 0.10 },
developer: { coding: 0.35, reasoning: 0.20, agents: 0.20, math: 0.15, knowledge: 0.10 },
research: { reasoning: 0.30, math: 0.30, knowledge: 0.20, coding: 0.10, agents: 0.10 },
};
function getSegmentQuality(
segment: 'consumer' | 'enterprise' | 'developer' | 'research',
benchmarkResults: BenchmarkResult[],
capabilities: ModelCapabilities,
fallbackScore: number,
): number {
if (benchmarkResults.length === 0) return fallbackScore / 100;
const bestByBenchmark = new Map<string, number>();
for (const r of benchmarkResults) {
const prev = bestByBenchmark.get(r.benchmarkId) ?? 0;
if (r.score > prev) bestByBenchmark.set(r.benchmarkId, r.score);
}
const weights = SEGMENT_CAPABILITY_WEIGHTS[segment];
if (!weights) return fallbackScore / 100;
let weightedSum = 0;
let totalWeight = 0;
for (const bench of BENCHMARKS) {
const score = bestByBenchmark.get(bench.id);
if (score == null) continue;
const weight = bench.marketRelevance[segment];
weightedSum += (score / 100) * weight;
totalWeight += weight;
for (const [cap, weight] of Object.entries(weights)) {
const score = capabilities[cap as keyof ModelCapabilities] ?? 0;
if (score > 0) {
weightedSum += (score / 100) * weight;
totalWeight += weight;
}
}
if (totalWeight === 0) return fallbackScore / 100;
return weightedSum / totalWeight;
return totalWeight > 0 ? weightedSum / totalWeight : fallbackScore / 100;
}
export function processMarketV2(
@@ -54,9 +52,11 @@ export function processMarketV2(
effectiveInferenceFlops?: number,
researchBonuses?: ResearchBonuses,
): MarketTickResult {
const consumerQuality = getSegmentQuality('consumer', state.models.benchmarkResults, state.models.bestDeployedModelScore);
const enterpriseQuality = getSegmentQuality('enterprise', state.models.benchmarkResults, state.models.bestDeployedModelScore);
const modelQuality = state.models.benchmarkResults.length > 0
const caps = state.models.bestDeployedCapabilities;
const hasDeployed = state.models.bestDeployedModelScore > 0;
const consumerQuality = getSegmentQuality('consumer', caps, state.models.bestDeployedModelScore);
const enterpriseQuality = getSegmentQuality('enterprise', caps, state.models.bestDeployedModelScore);
const modelQuality = hasDeployed
? (consumerQuality + enterpriseQuality) / 2
: state.models.bestDeployedModelScore / 100;
@@ -115,7 +115,7 @@ export function processMarketV2(
const productResult = processProductLines(
state.market.codeAssistant,
state.market.agentsPlatform,
state.models.benchmarkResults,
caps,
playerDevCustomers,
playerEntCustomers,
seasonal.multipliers.consumer,
@@ -188,6 +188,7 @@ export function processMarketV2(
state.market.consumerTiers,
playerConsumerCustomers,
modelQuality,
state.reputation.score,
seasonal.multipliers.consumer,
state.infrastructure.networkLatencyPenalty,
sm.tierMetrics['consumer-paid'],
@@ -1,10 +1,10 @@
import type { ObsolescenceState, Era } from '@ai-tycoon/shared';
import type { ObsolescenceState, Era } from '@token-empire/shared';
import {
OBSOLESCENCE_BASELINE_GROWTH,
OBSOLESCENCE_ERA_ACCELERATOR,
FRESHNESS_DECAY_RATE,
NEW_MODEL_BOOST_TICKS,
} from '@ai-tycoon/shared';
} from '@token-empire/shared';
export function updateObsolescence(
obs: ObsolescenceState,

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