Compare commits

..

5 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
136 changed files with 1215 additions and 295 deletions
+3 -3
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
@@ -54,8 +54,8 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Run multi-simulation (100 runs)
run: pnpm --filter @ai-tycoon/game-simulation multirun -- --runs 100 --parallel 10 --strategy persona --ticks 28800 --no-timeseries
run: pnpm --filter @token-empire/game-simulation multirun -- --runs 100 --parallel 10 --strategy persona --ticks 28800 --no-timeseries
- name: Interpret results
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:
+2 -2
View File
@@ -14,8 +14,8 @@ COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=deps /app/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 pnpm-lock.yaml pnpm-workspace.yaml package.json ./
+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 @@
ALTER TABLE "users" ADD COLUMN "token_version" integer DEFAULT 0 NOT NULL;
+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": {}
}
}
+7
View File
@@ -8,6 +8,13 @@
"when": 1777333216602,
"tag": "0000_tearful_hedge_knight",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1777417629552,
"tag": "0001_certain_aaron_stack",
"breakpoints": true
}
]
}
+3 -3
View File
@@ -1,5 +1,5 @@
{
"name": "@ai-tycoon/server",
"name": "@token-empire/server",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -13,7 +13,7 @@
"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",
@@ -23,7 +23,7 @@
"uuid": "^11.1.0"
},
"devDependencies": {
"@ai-tycoon/tsconfig": "workspace:*",
"@token-empire/tsconfig": "workspace:*",
"@types/node": "^25.6.0",
"drizzle-kit": "^0.31.1",
"typescript": "^5.8.3"
+1 -1
View File
@@ -5,7 +5,7 @@ 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 });
+1
View File
@@ -8,6 +8,7 @@ export const users = pgTable('users', {
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(),
});
+1 -1
View File
@@ -7,7 +7,7 @@ export async function seedAdmin() {
const [existing] = await db
.select()
.from(users)
.where(eq(users.username, 'admin'))
.where(eq(users.role, 'admin'))
.limit(1);
if (existing) {
+1 -1
View File
@@ -40,7 +40,7 @@ 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();
+4 -1
View File
@@ -14,10 +14,11 @@ export async function createToken(
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, iat: now, exp: now + JWT_EXPIRY_SECONDS },
{ sub: userId, email, role, username, mustResetPassword, tokenVersion, iat: now, exp: now + JWT_EXPIRY_SECONDS },
getJwtSecret(),
);
}
@@ -28,6 +29,7 @@ export async function verifyToken(token: string): Promise<{
role: string;
username: string | null;
mustResetPassword: boolean;
tokenVersion: number;
}> {
const payload = await verify(token, getJwtSecret(), 'HS256');
return {
@@ -36,5 +38,6 @@ export async function verifyToken(token: string): Promise<{
role: (payload.role as string) ?? 'user',
username: (payload.username as string) ?? null,
mustResetPassword: (payload.mustResetPassword as boolean) ?? false,
tokenVersion: (payload.tokenVersion as number) ?? 0,
};
}
+5
View File
@@ -27,6 +27,10 @@ export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
return c.json({ error: 'Invalid token' }, 401);
}
if (payload.tokenVersion !== user.tokenVersion) {
return c.json({ error: 'Token has been revoked' }, 401);
}
await db
.update(users)
.set({ lastSeenAt: new Date() })
@@ -40,6 +44,7 @@ export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
email: user.email,
role: user.role,
mustResetPassword: user.mustResetPassword,
tokenVersion: user.tokenVersion,
});
await next();
} catch {
+87 -11
View File
@@ -1,5 +1,5 @@
import { Hono } from 'hono';
import { eq, or } from 'drizzle-orm';
import { eq, or, sql } from 'drizzle-orm';
import bcrypt from 'bcryptjs';
import { db } from '../db';
import { users } from '../db/schema';
@@ -15,7 +15,7 @@ auth.post('/anonymous', async (c) => {
.values({})
.returning();
const token = await createToken(user.id, null, 'user', null, false);
const token = await createToken(user.id, null, 'user', null, false, 0);
return c.json({ userId: user.id, token });
});
@@ -80,7 +80,7 @@ auth.post('/register', authMiddleware, async (c) => {
.where(eq(users.id, userId))
.returning();
const token = await createToken(updated.id, updated.email, updated.role, updated.username, false);
const token = await createToken(updated.id, updated.email, updated.role, updated.username, false, updated.tokenVersion);
return c.json({ userId: updated.id, token });
});
@@ -106,7 +106,7 @@ auth.post('/login', async (c) => {
return c.json({ error: 'Invalid credentials' }, 401);
}
const token = await createToken(user.id, user.email, user.role, user.username, user.mustResetPassword);
const token = await createToken(user.id, user.email, user.role, user.username, user.mustResetPassword, user.tokenVersion);
return c.json({ userId: user.id, token });
});
@@ -141,19 +141,20 @@ auth.post('/change-password', authMiddleware, async (c) => {
}
const passwordHash = await bcrypt.hash(newPassword, 10);
await db
const [updated] = await db
.update(users)
.set({ passwordHash, mustResetPassword: false })
.where(eq(users.id, user.id));
.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);
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.role !== 'admin') {
return c.json({ error: 'Forbidden' }, 403);
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 }>();
@@ -176,8 +177,83 @@ auth.post('/change-username', authMiddleware, async (c) => {
.set({ username })
.where(eq(users.id, user.id));
const token = await createToken(user.id, user.email, user.role, username, user.mustResetPassword);
const token = await createToken(user.id, user.email, user.role, username, user.mustResetPassword, user.tokenVersion);
return c.json({ success: true, token });
});
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 (existing.length > 0 && existing[0].id !== user.id) {
return c.json({ error: 'Email already in use' }, 409);
}
await db
.update(users)
.set({ email })
.where(eq(users.id, user.id));
const token = await createToken(user.id, email, user.role, user.username, user.mustResetPassword, user.tokenVersion);
return c.json({ success: true, token });
});
auth.post('/logout', authMiddleware, async (c) => {
const user = c.get('user');
await db
.update(users)
.set({ tokenVersion: sql`${users.tokenVersion} + 1` })
.where(eq(users.id, user.id));
return c.json({ success: true });
});
auth.get('/me', authMiddleware, async (c) => {
const user = c.get('user');
return c.json({
id: user.id,
username: user.username,
email: user.email,
role: user.role,
});
});
export { auth };
+22
View File
@@ -141,4 +141,26 @@ invitesRouter.get('/', authMiddleware, requireAdmin, async (c) => {
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');
+1
View File
@@ -8,6 +8,7 @@ export type AppEnv = {
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">
+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",
+7 -5
View File
@@ -6,7 +6,8 @@ import { OfflineCatchUp } from '@/components/game/OfflineCatchUp';
import { InviteGateScreen } from '@/components/game/InviteGateScreen';
import { useGameLoop } from '@/hooks/useGameLoop';
import { useAuthGate } from '@/hooks/useAuthGate';
import { TICK_INTERVAL_MS } from '@ai-tycoon/shared';
import { useCloudSave } from '@/hooks/useCloudSave';
import { TICK_INTERVAL_MS } from '@token-empire/shared';
import { Sparkles, RefreshCw, WifiOff } from 'lucide-react';
function LoadingScreen() {
@@ -16,7 +17,7 @@ function LoadingScreen() {
<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">
AI Tycoon
Token Empire
</h1>
</div>
<p className="text-surface-500 text-sm">Loading...</p>
@@ -32,7 +33,7 @@ function BackendErrorScreen({ error, onRetry }: { error: string; onRetry: () =>
<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">
AI Tycoon
Token Empire
</h1>
</div>
@@ -53,7 +54,7 @@ function BackendErrorScreen({ error, onRetry }: { error: string; onRetry: () =>
}
export function App() {
const { loading: authLoading, backendError, needsInvite, needsPasswordReset, setRegistered, setNeedsPasswordReset, retry } = useAuthGate();
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);
@@ -71,6 +72,7 @@ export function App() {
}, [companyName, lastTickTimestamp, catchUpDone]);
useGameLoop(!catchUpDone || authLoading || !!backendError || needsInvite || needsPasswordReset);
useCloudSave();
if (authLoading) {
return <LoadingScreen />;
@@ -92,7 +94,7 @@ export function App() {
}
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)}`,
@@ -111,7 +111,7 @@ export function InviteGateScreen({ onRegistered }: { onRegistered: () => void })
<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">
+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 {
+9 -4
View File
@@ -5,7 +5,7 @@ import {
PanelLeftClose, PanelLeftOpen, Mail, UserPlus, Copy, Check,
} from 'lucide-react';
import { useGameStore, type ActivePage } from '@/store';
import { isAdmin as checkIsAdmin, isRegistered as checkIsRegistered, api } from '@/lib/api';
import { isAdmin as checkIsAdmin, isRegistered as checkIsRegistered, getTokenPayload, api } from '@/lib/api';
const NAV_ITEMS: { page: ActivePage; label: string; icon: typeof LayoutDashboard; era?: string; adminOnly?: boolean }[] = [
{ page: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
@@ -26,7 +26,7 @@ const NAV_ITEMS: { page: ActivePage; label: string; icon: typeof LayoutDashboard
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; }
@@ -82,7 +82,7 @@ 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;
});
};
@@ -166,7 +166,12 @@ export function Sidebar() {
)}
<div className={`${collapsed ? 'px-2 py-3 text-center' : 'p-4'} border-t border-surface-700 text-xs text-surface-500`}>
{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];
+48 -1
View File
@@ -1,7 +1,16 @@
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;
@@ -10,6 +19,8 @@ interface AuthGateState {
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;
@@ -22,6 +33,7 @@ export function useAuthGate(): AuthGateState {
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 () => {
@@ -52,9 +64,30 @@ export function useAuthGate(): AuthGateState {
}
const payload = getTokenPayload();
setRegistered(checkRegistered());
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);
}, []);
@@ -66,6 +99,18 @@ export function useAuthGate(): AuthGateState {
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();
@@ -89,6 +134,8 @@ export function useAuthGate(): AuthGateState {
registered,
isAdmin: admin,
config,
cloudSave,
loadCloudSave,
setRegistered: handleSetRegistered,
setNeedsPasswordReset: handleSetPasswordReset,
retry,
+21 -4
View File
@@ -1,24 +1,29 @@
import { useEffect, useRef } from 'react';
import { useGameStore } from '@/store';
import { api, getAuthToken, setAuthToken, clearAuthToken, decodeTokenPayload } from '@/lib/api';
import { AUTO_SAVE_INTERVAL_TICKS } from '@ai-tycoon/shared';
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,7 +31,19 @@ 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]);
}
+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',
+24 -5
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,7 +13,8 @@ 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');
}
export interface TokenPayload {
@@ -63,6 +64,8 @@ export function needsPasswordReset(): boolean {
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;
@@ -86,6 +89,11 @@ async function request<T>(path: string, options: RequestInit & { timeoutMs?: num
});
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}`);
}
@@ -112,7 +120,7 @@ export function validateStoredToken(): void {
}
export const api = {
health: () => request<{ status: string }>('/health', { timeoutMs: 5_000 }),
health: () => request<{ status: string }>('/api/health', { timeoutMs: 5_000 }),
auth: {
anonymous: () => request<{ userId: string; token: string }>('/api/auth/anonymous', { method: 'POST' }),
login: (login: string, password: string) =>
@@ -135,6 +143,15 @@ export const api = {
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'),
@@ -154,10 +171,12 @@ export const api = {
}>;
}>('/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);
+2 -2
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'];
+32 -9
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { Copy, Check, Plus, RefreshCw } from 'lucide-react';
import { Copy, Check, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { api } from '@/lib/api';
interface Invitation {
@@ -31,6 +31,7 @@ export function InvitationsPage() {
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 {
@@ -53,7 +54,7 @@ export function InvitationsPage() {
await navigator.clipboard.writeText(url);
setCopiedCode(result.code);
setTimeout(() => setCopiedCode(null), 2000);
fetchInvitations();
await fetchInvitations();
} catch {
// silent
} finally {
@@ -61,6 +62,18 @@ export function InvitationsPage() {
}
}
async function handleRevoke(id: string) {
setRevoking(id);
try {
await api.invites.revoke(id);
await fetchInvitations();
} catch {
// silent
} finally {
setRevoking(null);
}
}
async function handleCopyCode(code: string) {
const url = `${window.location.origin}?invite=${code}`;
await navigator.clipboard.writeText(url);
@@ -132,13 +145,23 @@ export function InvitationsPage() {
</td>
<td className="px-4 py-3">
{!inv.used && (
<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>
<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>
+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 {
+2 -2
View File
@@ -13,12 +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,
SizeTier, ModelFamily,
} from '@ai-tycoon/shared';
} from '@token-empire/shared';
const DATA_MIX_PRESETS: Record<string, { label: string; mix: DataMixAllocation }> = {
balanced: { label: 'Balanced', mix: DEFAULT_DATA_MIX },
+3 -3
View File
@@ -1,9 +1,9 @@
import { FlaskConical, Lock, Check, Play, ListOrdered, X } from 'lucide-react';
import { 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,
+152 -16
View File
@@ -1,7 +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 { getTokenPayload, isRegistered, isAdmin } from '@/lib/api';
import { api, setAuthToken, getTokenPayload, isRegistered, isAdmin, clearAuthToken } from '@/lib/api';
export function SettingsPage() {
const settings = useGameStore((s) => s.meta.settings);
@@ -12,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 } } });
};
@@ -21,7 +76,7 @@ export function SettingsPage() {
};
const handleReset = () => {
localStorage.removeItem('ai-tycoon-save');
localStorage.removeItem('token-empire-save');
window.location.reload();
};
@@ -32,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);
};
@@ -51,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);
@@ -60,7 +115,7 @@ 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();
};
@@ -75,23 +130,76 @@ export function SettingsPage() {
<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-2">
{payload?.email && (
<div className="space-y-3">
{payload?.email != null && (
<div className="flex items-center justify-between">
<div>
<div className="flex-1">
<div className="text-sm">Email</div>
<div className="text-xs text-surface-400">{payload.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>
)}
{payload?.username && (
<div className="flex items-center justify-between">
<div>
<div className="text-sm">Username</div>
<div className="text-xs text-surface-400">{payload.username}</div>
</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>
@@ -101,6 +209,16 @@ export function SettingsPage() {
) : (
<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">
@@ -184,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() {
+5 -5
View File
@@ -16,7 +16,7 @@ import type {
ModelArchitecture, AlignmentMethod, SizeTier,
SFTSpecialization, QuantizationLevel, VariantCreationJob,
ConsumerTierId, ApiTierId,
} from '@ai-tycoon/shared';
} from '@token-empire/shared';
import {
INITIAL_SETTINGS, SAVE_VERSION,
INITIAL_ECONOMY, INITIAL_INFRASTRUCTURE, INITIAL_COMPUTE,
@@ -39,12 +39,12 @@ 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,
TECH_TREE, onModelDeployed,
} from '@ai-tycoon/game-engine';
import { INITIAL_RIVALS } from '@ai-tycoon/game-engine';
} 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' | 'invitations' | 'settings';
@@ -1428,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": {
+7 -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,11 +8,11 @@ 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
@@ -26,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,8 +2,8 @@ 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 {
@@ -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[] = [
{
+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,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'];
@@ -3,7 +3,7 @@ import type {
DeploymentCohort, PipelineStage, RackSkuId,
SwitchTier, DCNetworkSummary, CampusNetworkSummary, ClusterNetworkSummary,
RepairBatch, CampusRetrofitQueue, DCTier, IntraNodeInterconnect, NetworkFabric, RackSkuConfig,
} from '@ai-tycoon/shared';
} 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';
@@ -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,
@@ -14,7 +14,7 @@ import {
PRICE_SATISFACTION_WEIGHT,
PRICE_CHURN_EXPONENT,
PRICE_CHURN_MAX_MULTIPLIER,
} from '@ai-tycoon/shared';
} from '@token-empire/shared';
export interface ConsumerTickResult {
consumerTiers: ConsumerTierState;
@@ -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,6 +1,6 @@
import type { GameState, MarketState, ModelCapabilities } 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 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';
@@ -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,
@@ -1,4 +1,4 @@
import type { CodeAssistantState, AgentsPlatformState, ModelCapabilities } from '@ai-tycoon/shared';
import type { CodeAssistantState, AgentsPlatformState, ModelCapabilities } from '@token-empire/shared';
import {
CODE_ASSISTANT_MIN_CODING_SCORE,
CODE_ASSISTANT_BASE_ADOPTION_RATE,
@@ -6,7 +6,7 @@ import {
AGENTS_PLATFORM_MIN_AGENTS_SCORE,
AGENTS_PLATFORM_BASE_ADOPTION_RATE,
AGENTS_PLATFORM_CHURN_RATE,
} from '@ai-tycoon/shared';
} from '@token-empire/shared';
export interface ProductLineResult {
codeAssistant: CodeAssistantState;
@@ -1,5 +1,5 @@
import type { SeasonalPhase } from '@ai-tycoon/shared';
import { SEASONAL_CYCLE_TICKS, SEASONAL_MULTIPLIERS } from '@ai-tycoon/shared';
import type { SeasonalPhase } from '@token-empire/shared';
import { SEASONAL_CYCLE_TICKS, SEASONAL_MULTIPLIERS } from '@token-empire/shared';
export interface SeasonalResult {
phase: SeasonalPhase;
@@ -5,8 +5,8 @@ import type {
ServingMetrics,
ModelUtilizationEntry,
BatchApiState,
} from '@ai-tycoon/shared';
import type { BaseModel, ModelsState, SizeTier } from '@ai-tycoon/shared';
} from '@token-empire/shared';
import type { BaseModel, ModelsState, SizeTier } from '@token-empire/shared';
import {
MODEL_SIZE_THROUGHPUT_SCALER,
MOE_SPEED_MULTIPLIER,
@@ -18,8 +18,8 @@ import {
BASE_LATENCY_MS,
QUEUE_LATENCY_MS_PER_PERCENT,
BATCH_API_MAX_PENDING,
} from '@ai-tycoon/shared';
import { makeInitialServingMetrics } from '@ai-tycoon/shared';
} from '@token-empire/shared';
import { makeInitialServingMetrics } from '@token-empire/shared';
export interface ModelServingSlot {
modelId: string;
@@ -6,7 +6,7 @@ import type {
Era,
ObsolescenceState,
DeveloperEcosystem,
} from '@ai-tycoon/shared';
} from '@token-empire/shared';
import {
TAM_BASE_SIZES,
TAM_GROWTH_PER_TICK,
@@ -17,7 +17,7 @@ import {
NEW_MODEL_BOOST_VALUE,
CONSUMER_TIER_BASE_PERCEIVED_VALUE,
PERCEIVED_VALUE_REPUTATION_RANGE,
} from '@ai-tycoon/shared';
} from '@token-empire/shared';
export interface ParticipantProfile {
id: string;
@@ -1,4 +1,4 @@
import type { GameState } from '@ai-tycoon/shared';
import type { GameState } from '@token-empire/shared';
import { processMarketV2 } from './market/index';
import type { ResearchBonuses } from './researchBonuses';
@@ -10,7 +10,7 @@ import { processModels } from './modelSystem';
import {
POINT_RELEASE_CAPABILITY_GAIN,
VRAM_REQUIREMENTS_BY_GENERATION,
} from '@ai-tycoon/shared';
} from '@token-empire/shared';
import type { ResearchBonuses } from './researchBonuses';
const rng = createSeededRNG(42);

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