Compare commits

..

5 Commits

137 changed files with 301 additions and 1221 deletions
+3 -3
View File
@@ -35,7 +35,7 @@ jobs:
run: pnpm test
- name: Run greedy simulation
run: pnpm --filter @token-empire/game-simulation simulate:ci
run: pnpm --filter @ai-tycoon/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 @token-empire/game-simulation multirun -- --runs 100 --parallel 10 --strategy persona --ticks 28800 --no-timeseries
run: pnpm --filter @ai-tycoon/game-simulation multirun -- --runs 100 --parallel 10 --strategy persona --ticks 28800 --no-timeseries
- name: Interpret results
if: always()
run: pnpm --filter @token-empire/game-simulation interpret -- --summary multirun-summary.csv
run: pnpm --filter @ai-tycoon/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/tokenempire
IMAGE_PREFIX: gitea.thewrightserver.net/josh/aihostingtycoon
jobs:
build-and-push:
+3 -3
View File
@@ -1,4 +1,4 @@
# Token Empire
# AI Tycoon
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
```
token-empire/
ai-tycoon/
├── 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/token_empire
DATABASE_URL=postgresql://user:password@localhost:5432/ai_tycoon
```
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 @token-empire/shared build && \
pnpm --filter @token-empire/server typecheck
RUN pnpm --filter @ai-tycoon/shared build && \
pnpm --filter @ai-tycoon/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/token_empire',
url: process.env.DATABASE_URL ?? 'postgresql://localhost:5432/ai_tycoon',
},
});
@@ -1 +0,0 @@
ALTER TABLE "users" ADD COLUMN "token_version" integer DEFAULT 0 NOT NULL;
-477
View File
@@ -1,477 +0,0 @@
{
"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,13 +8,6 @@
"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": "@token-empire/server",
"name": "@ai-tycoon/server",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -13,7 +13,7 @@
"db:push": "drizzle-kit push"
},
"dependencies": {
"@token-empire/shared": "workspace:*",
"@ai-tycoon/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": {
"@token-empire/tsconfig": "workspace:*",
"@ai-tycoon/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/token_empire';
const connectionString = process.env.DATABASE_URL ?? 'postgresql://localhost:5432/ai_tycoon';
const client = postgres(connectionString);
export const db = drizzle(client, { schema });
-1
View File
@@ -8,7 +8,6 @@ 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.role, 'admin'))
.where(eq(users.username, 'admin'))
.limit(1);
if (existing) {
+1 -2
View File
@@ -26,7 +26,6 @@ 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',
@@ -40,7 +39,7 @@ app.route('/api/invites', invitesRouter);
const port = Number(process.env.PORT) || 3001;
console.log(`Token Empire API server starting on port ${port}...`);
console.log(`AI Tycoon API server starting on port ${port}...`);
await runMigrations();
await seedAdmin();
+1 -4
View File
@@ -14,11 +14,10 @@ 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, tokenVersion, iat: now, exp: now + JWT_EXPIRY_SECONDS },
{ sub: userId, email, role, username, mustResetPassword, iat: now, exp: now + JWT_EXPIRY_SECONDS },
getJwtSecret(),
);
}
@@ -29,7 +28,6 @@ export async function verifyToken(token: string): Promise<{
role: string;
username: string | null;
mustResetPassword: boolean;
tokenVersion: number;
}> {
const payload = await verify(token, getJwtSecret(), 'HS256');
return {
@@ -38,6 +36,5 @@ 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,10 +27,6 @@ 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() })
@@ -44,7 +40,6 @@ export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
email: user.email,
role: user.role,
mustResetPassword: user.mustResetPassword,
tokenVersion: user.tokenVersion,
});
await next();
} catch {
+11 -87
View File
@@ -1,5 +1,5 @@
import { Hono } from 'hono';
import { eq, or, sql } from 'drizzle-orm';
import { eq, or } 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, 0);
const token = await createToken(user.id, null, 'user', null, false);
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, updated.tokenVersion);
const token = await createToken(updated.id, updated.email, updated.role, updated.username, false);
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, user.tokenVersion);
const token = await createToken(user.id, user.email, user.role, user.username, user.mustResetPassword);
return c.json({ userId: user.id, token });
});
@@ -141,20 +141,19 @@ auth.post('/change-password', authMiddleware, async (c) => {
}
const passwordHash = await bcrypt.hash(newPassword, 10);
const [updated] = await db
await db
.update(users)
.set({ passwordHash, mustResetPassword: false, tokenVersion: sql`${users.tokenVersion} + 1` })
.where(eq(users.id, user.id))
.returning({ tokenVersion: users.tokenVersion });
.set({ passwordHash, mustResetPassword: false })
.where(eq(users.id, user.id));
const token = await createToken(user.id, user.email, user.role, user.username, false, updated.tokenVersion);
const token = await createToken(user.id, user.email, user.role, user.username, false);
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);
if (user.role !== 'admin') {
return c.json({ error: 'Forbidden' }, 403);
}
const { username } = await c.req.json<{ username: string }>();
@@ -177,83 +176,8 @@ 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, user.tokenVersion);
const token = await createToken(user.id, user.email, user.role, username, user.mustResetPassword);
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,26 +141,4 @@ 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,19 +28,6 @@ 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,7 +8,6 @@ export type AppEnv = {
email: string | null;
role: string;
mustResetPassword: boolean;
tokenVersion: number;
};
};
};
+1 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "@token-empire/tsconfig/node.json",
"extends": "@ai-tycoon/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=
ARG VITE_API_URL=/api
ENV VITE_API_URL=$VITE_API_URL
RUN pnpm --filter @token-empire/shared build && \
pnpm --filter @token-empire/game-engine build && \
pnpm --filter @token-empire/web build
RUN pnpm --filter @ai-tycoon/shared build && \
pnpm --filter @ai-tycoon/game-engine build && \
pnpm --filter @ai-tycoon/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>Token Empire</title>
<title>AI Tycoon</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
View File
@@ -10,6 +10,10 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /health {
proxy_pass http://server:3001;
}
location / {
try_files $uri $uri/ /index.html;
}
+4 -4
View File
@@ -1,5 +1,5 @@
{
"name": "@token-empire/web",
"name": "@ai-tycoon/web",
"private": true,
"version": "0.0.1",
"type": "module",
@@ -10,8 +10,8 @@
"preview": "vite preview"
},
"dependencies": {
"@token-empire/shared": "workspace:*",
"@token-empire/game-engine": "workspace:*",
"@ai-tycoon/shared": "workspace:*",
"@ai-tycoon/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": {
"@token-empire/tsconfig": "workspace:*",
"@ai-tycoon/tsconfig": "workspace:*",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.4.0",
+5 -7
View File
@@ -6,8 +6,7 @@ import { OfflineCatchUp } from '@/components/game/OfflineCatchUp';
import { InviteGateScreen } from '@/components/game/InviteGateScreen';
import { useGameLoop } from '@/hooks/useGameLoop';
import { useAuthGate } from '@/hooks/useAuthGate';
import { useCloudSave } from '@/hooks/useCloudSave';
import { TICK_INTERVAL_MS } from '@token-empire/shared';
import { TICK_INTERVAL_MS } from '@ai-tycoon/shared';
import { Sparkles, RefreshCw, WifiOff } from 'lucide-react';
function LoadingScreen() {
@@ -17,7 +16,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">
Token Empire
AI Tycoon
</h1>
</div>
<p className="text-surface-500 text-sm">Loading...</p>
@@ -33,7 +32,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">
Token Empire
AI Tycoon
</h1>
</div>
@@ -54,7 +53,7 @@ function BackendErrorScreen({ error, onRetry }: { error: string; onRetry: () =>
}
export function App() {
const { loading: authLoading, backendError, needsInvite, needsPasswordReset, cloudSave, loadCloudSave, setRegistered, setNeedsPasswordReset, retry } = useAuthGate();
const { loading: authLoading, backendError, needsInvite, needsPasswordReset, 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);
@@ -72,7 +71,6 @@ export function App() {
}, [companyName, lastTickTimestamp, catchUpDone]);
useGameLoop(!catchUpDone || authLoading || !!backendError || needsInvite || needsPasswordReset);
useCloudSave();
if (authLoading) {
return <LoadingScreen />;
@@ -94,7 +92,7 @@ export function App() {
}
if (!companyName) {
return <NewGameScreen cloudSave={cloudSave} onContinue={loadCloudSave} />;
return <NewGameScreen />;
}
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 '@token-empire/shared';
import { formatDuration } from '@ai-tycoon/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('token-empire-dev-menu') === 'true';
const isEnabled = import.meta.env.DEV || localStorage.getItem('ai-tycoon-dev-menu') === 'true';
useEffect(() => {
if (!isEnabled) return;
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useGameStore } from '@/store';
import type { FundingRoundType } from '@token-empire/shared';
import type { FundingRoundType } from '@ai-tycoon/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 '@token-empire/shared';
import { formatMoney } from '@ai-tycoon/shared';
function DevButton({ onClick, children, variant = 'default' }: {
onClick: () => void;
@@ -1,5 +1,5 @@
import { useGameStore } from '@/store';
import { formatMoney, formatNumber, formatFlops, formatPercent } from '@token-empire/shared';
import { formatMoney, formatNumber, formatFlops, formatPercent } from '@ai-tycoon/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 '@token-empire/game-engine';
import type { GameState, Era } from '@token-empire/shared';
import { processTick, setAchievementDefinitions, ACHIEVEMENT_DEFINITIONS, TECH_TREE } from '@ai-tycoon/game-engine';
import type { GameState, Era } from '@ai-tycoon/shared';
function DevButton({ onClick, children, variant = 'default' }: {
onClick: () => void;
@@ -1,8 +1,8 @@
import { useGameStore } from '@/store';
import { formatMoney, formatNumber, formatPercent } from '@token-empire/shared';
import { formatMoney, formatNumber, formatPercent } from '@ai-tycoon/shared';
import { Share2, Copy, Check } from 'lucide-react';
import { useState } from 'react';
import { ACHIEVEMENT_DEFINITIONS } from '@token-empire/game-engine';
import { ACHIEVEMENT_DEFINITIONS } from '@ai-tycoon/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}Token Empire`,
`${companyName}AI Tycoon`,
`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">
Token Empire
AI Tycoon
</h1>
</div>
<p className="text-surface-400 text-sm">
+4 -69
View File
@@ -1,7 +1,6 @@
import { useState } from 'react';
import { Sparkles, Cloud, Play } from 'lucide-react';
import { Sparkles } from 'lucide-react';
import { useGameStore } from '@/store';
import type { CloudSaveInfo } from '@/hooks/useAuthGate';
const SUGGESTED_NAMES = [
'Nexus AI', 'Cortex Labs', 'Synapse Technologies',
@@ -9,32 +8,8 @@ const SUGGESTED_NAMES = [
'Neural Forge', 'DeepMind+', 'Cerebral Systems',
];
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) {
export function NewGameScreen() {
const [name, setName] = useState('');
const [loading, setLoading] = useState(false);
const startNewGame = useGameStore((s) => s.startNewGame);
const handleStart = () => {
@@ -42,16 +17,6 @@ export function NewGameScreen({ cloudSave, onContinue }: Props) {
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">
@@ -59,7 +24,7 @@ export function NewGameScreen({ cloudSave, onContinue }: Props) {
<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
AI Tycoon
</h1>
</div>
<p className="text-surface-400 text-sm">
@@ -67,37 +32,7 @@ export function NewGameScreen({ cloudSave, onContinue }: Props) {
</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
@@ -109,7 +44,7 @@ export function NewGameScreen({ cloudSave, onContinue }: Props) {
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={!cloudSave}
autoFocus
maxLength={30}
/>
</div>
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { formatMoney, formatDuration, formatNumber, MAX_OFFLINE_TICKS, TICK_INTERVAL_MS } from '@token-empire/shared';
import { GameEngine } from '@token-empire/game-engine';
import { formatMoney, formatDuration, formatNumber, MAX_OFFLINE_TICKS, TICK_INTERVAL_MS } from '@ai-tycoon/shared';
import { GameEngine } from '@ai-tycoon/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 = 'token-empire-dismissed-hints';
const DISMISSED_KEY = 'ai-tycoon-dismissed-hints';
function getDismissed(): Set<string> {
try {
+4 -9
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, getTokenPayload, api } from '@/lib/api';
import { isAdmin as checkIsAdmin, isRegistered as checkIsRegistered, 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('token-empire-sidebar-collapsed');
const stored = localStorage.getItem('ai-tycoon-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('token-empire-sidebar-collapsed', String(next));
localStorage.setItem('ai-tycoon-sidebar-collapsed', String(next));
return next;
});
};
@@ -166,12 +166,7 @@ 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 && (() => {
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'}
{collapsed ? 'v0.1' : 'AI Tycoon 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 '@token-empire/shared';
import type { GameSpeed } from '@token-empire/shared';
import { formatMoney, formatNumber, formatDuration, formatPercent } from '@ai-tycoon/shared';
import type { GameSpeed } from '@ai-tycoon/shared';
import { Tooltip } from '@/components/common/Tooltip';
const SPEEDS: GameSpeed[] = [1, 2, 5];
+1 -48
View File
@@ -1,16 +1,7 @@
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;
@@ -19,8 +10,6 @@ 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;
@@ -33,7 +22,6 @@ 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 () => {
@@ -64,30 +52,9 @@ export function useAuthGate(): AuthGateState {
}
const payload = getTokenPayload();
const isReg = checkRegistered();
setRegistered(isReg);
setRegistered(checkRegistered());
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);
}, []);
@@ -99,18 +66,6 @@ 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();
@@ -134,8 +89,6 @@ export function useAuthGate(): AuthGateState {
registered,
isAdmin: admin,
config,
cloudSave,
loadCloudSave,
setRegistered: handleSetRegistered,
setNeedsPasswordReset: handleSetPasswordReset,
retry,
+4 -21
View File
@@ -1,29 +1,24 @@
import { useEffect, useRef } from 'react';
import { useGameStore } from '@/store';
import { api, getAuthToken, setAuthToken, clearAuthToken, decodeTokenPayload } from '@/lib/api';
import { AUTO_SAVE_INTERVAL_TICKS } from '@token-empire/shared';
const MAX_CONSECUTIVE_FAILURES = 3;
import { AUTO_SAVE_INTERVAL_TICKS } from '@ai-tycoon/shared';
export function useCloudSave() {
const tickCount = useGameStore((s) => s.meta.tickCount);
const companyName = useGameStore((s) => s.meta.companyName);
const lastSaveTick = useRef(0);
const failureCount = useRef(0);
useEffect(() => {
if (!companyName) return;
if (tickCount - lastSaveTick.current < AUTO_SAVE_INTERVAL_TICKS) return;
if (tickCount - lastSaveTick.current < AUTO_SAVE_INTERVAL_TICKS * 5) return;
const token = getAuthToken();
if (!token) return;
if (failureCount.current >= MAX_CONSECUTIVE_FAILURES) return;
lastSaveTick.current = tickCount;
const state = useGameStore.getState();
const { activePage, notifications, infraNav, modelsTab, ...gameState } = state;
const { activePage, notifications, ...gameState } = state;
api.saves.put({
companyName: state.meta.companyName,
@@ -31,19 +26,7 @@ export function useCloudSave() {
gameData: gameState,
tickCount: state.meta.tickCount,
era: state.meta.currentEra,
}).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,
});
}
});
}).catch(() => {});
}, [tickCount, companyName]);
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
import { GameEngine, setAchievementDefinitions, ACHIEVEMENT_DEFINITIONS } from '@token-empire/game-engine';
import type { TickNotification } from '@token-empire/game-engine';
import { GameEngine, setAchievementDefinitions, ACHIEVEMENT_DEFINITIONS } from '@ai-tycoon/game-engine';
import type { TickNotification } from '@ai-tycoon/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 '@token-empire/shared';
import type { GameSpeed } from '@ai-tycoon/shared';
const PAGE_SHORTCUTS: Record<string, ActivePage> = {
d: 'dashboard',
+7 -29
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('token-empire-auth-token');
let authToken: string | null = localStorage.getItem('ai-tycoon-auth-token');
export function setAuthToken(token: string) {
authToken = token;
localStorage.setItem('token-empire-auth-token', token);
localStorage.setItem('ai-tycoon-auth-token', token);
}
export function getAuthToken() {
@@ -13,8 +13,7 @@ export function getAuthToken() {
export function clearAuthToken() {
authToken = null;
localStorage.removeItem('token-empire-auth-token');
localStorage.removeItem('token-empire-refresh-token');
localStorage.removeItem('ai-tycoon-auth-token');
}
export interface TokenPayload {
@@ -64,8 +63,6 @@ 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;
@@ -89,13 +86,8 @@ 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}`);
const body = await res.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(body.error || `HTTP ${res.status}`);
}
return res.json();
@@ -103,9 +95,6 @@ async function request<T>(path: string, options: RequestInit & { timeoutMs?: num
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);
@@ -120,7 +109,7 @@ export function validateStoredToken(): void {
}
export const api = {
health: () => request<{ status: string }>('/api/health', { timeoutMs: 5_000 }),
health: () => request<{ status: string }>('/health', { timeoutMs: 5_000 }),
auth: {
anonymous: () => request<{ userId: string; token: string }>('/api/auth/anonymous', { method: 'POST' }),
login: (login: string, password: string) =>
@@ -143,15 +132,6 @@ 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'),
@@ -171,12 +151,10 @@ 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 '@token-empire/game-engine';
import { formatNumber } from '@token-empire/shared';
import { ACHIEVEMENT_DEFINITIONS } from '@ai-tycoon/game-engine';
import { formatNumber } from '@ai-tycoon/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 '@token-empire/shared';
import type { AchievementCondition } from '@ai-tycoon/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 '@token-empire/shared';
import type { Era } from '@token-empire/shared';
import { formatMoney, formatNumber } from '@ai-tycoon/shared';
import type { Era } from '@ai-tycoon/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 '@token-empire/shared';
import type { Era } from '@token-empire/shared';
import { TECH_TREE } from '@token-empire/game-engine';
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 {
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 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.
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.
</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 '@token-empire/shared';
import type { OwnedDataset, DataDomain } from '@token-empire/shared';
import { formatNumber, formatMoney, uuid } from '@ai-tycoon/shared';
import type { OwnedDataset, DataDomain } from '@ai-tycoon/shared';
interface MarketplaceDataset {
name: string;
+4 -4
View File
@@ -1,10 +1,10 @@
import { useGameStore } from '@/store';
import { formatMoney, formatPercent, formatNumber, FUNDING_ROUNDS } from '@token-empire/shared';
import type { FundingRoundType } from '@token-empire/shared';
import { formatMoney, formatPercent, formatNumber, FUNDING_ROUNDS } from '@ai-tycoon/shared';
import type { FundingRoundType } from '@ai-tycoon/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 '@token-empire/game-engine';
import type { GameState } from '@token-empire/shared';
import { canRaiseFunding } from '@ai-tycoon/game-engine';
import type { GameState } from '@ai-tycoon/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 '@token-empire/shared';
} from '@ai-tycoon/shared';
import type {
DCTier, RackSkuId, LocationId, PipelineStage, Era,
Cluster, Campus, DataCenter, DeploymentCohort,
} from '@token-empire/shared';
} from '@ai-tycoon/shared';
const ERA_ORDER: Era[] = ['startup', 'scaleup', 'bigtech', 'agi'];
+9 -32
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { Copy, Check, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { Copy, Check, Plus, RefreshCw } from 'lucide-react';
import { api } from '@/lib/api';
interface Invitation {
@@ -31,7 +31,6 @@ 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 {
@@ -54,7 +53,7 @@ export function InvitationsPage() {
await navigator.clipboard.writeText(url);
setCopiedCode(result.code);
setTimeout(() => setCopiedCode(null), 2000);
await fetchInvitations();
fetchInvitations();
} catch {
// silent
} finally {
@@ -62,18 +61,6 @@ 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);
@@ -145,23 +132,13 @@ export function InvitationsPage() {
</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>
<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>
)}
</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 '@token-empire/shared';
import { formatMoney, formatNumber } from '@ai-tycoon/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 '@token-empire/shared';
} from '@ai-tycoon/shared';
import type {
ModelArchitecture, DataMixAllocation, SFTSpecialization, AlignmentMethod,
DataDomain, QuantizationLevel, BaseModel, ModelVariant,
SizeTier, ModelFamily,
} from '@token-empire/shared';
} from '@ai-tycoon/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 '@token-empire/shared';
import { TECH_TREE, getAvailableResearch } from '@token-empire/game-engine';
import type { ResearchNode } from '@token-empire/shared';
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';
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 '@token-empire/shared';
} from '@ai-tycoon/shared';
import {
Activity, Shield, Clock, CheckCircle, XCircle, Layers,
AlertTriangle, Zap, Server, ArrowRight,
+16 -152
View File
@@ -1,8 +1,7 @@
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';
import { getTokenPayload, isRegistered, isAdmin } from '@/lib/api';
export function SettingsPage() {
const settings = useGameStore((s) => s.meta.settings);
@@ -13,60 +12,6 @@ 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 } } });
};
@@ -76,7 +21,7 @@ export function SettingsPage() {
};
const handleReset = () => {
localStorage.removeItem('token-empire-save');
localStorage.removeItem('ai-tycoon-save');
window.location.reload();
};
@@ -87,7 +32,7 @@ export function SettingsPage() {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `token-empire-${companyName.replace(/\s+/g, '-').toLowerCase()}.json`;
a.download = `ai-tycoon-${companyName.replace(/\s+/g, '-').toLowerCase()}.json`;
a.click();
URL.revokeObjectURL(url);
};
@@ -106,7 +51,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 Token Empire export.', type: 'danger', tick: useGameStore.getState().meta.tickCount });
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 });
}
};
reader.readAsText(file);
@@ -115,7 +60,7 @@ export function SettingsPage() {
const confirmImport = () => {
if (!importData) return;
localStorage.setItem('token-empire-save', JSON.stringify({ state: importData.data }));
localStorage.setItem('ai-tycoon-save', JSON.stringify({ state: importData.data }));
window.location.reload();
};
@@ -130,76 +75,23 @@ 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-3">
{payload?.email != null && (
<div className="space-y-2">
{payload?.email && (
<div className="flex items-center justify-between">
<div className="flex-1">
<div>
<div className="text-sm">Email</div>
{editingEmail ? (
<div className="mt-1 space-y-2">
<div className="flex items-center gap-2">
<input
type="email"
value={emailValue}
onChange={(e) => setEmailValue(e.target.value)}
className="bg-surface-800 border border-surface-600 rounded px-2 py-1 text-sm w-56"
placeholder="New email"
autoFocus
/>
<button onClick={handleSaveEmail} disabled={emailSaving}
className="text-accent hover:text-accent-light disabled:opacity-50"><Check size={16} /></button>
<button onClick={() => { setEditingEmail(false); setEmailError(''); setEmailPassword(''); }}
className="text-surface-400 hover:text-surface-200"><X size={16} /></button>
</div>
<input
type="password"
value={emailPassword}
onChange={(e) => setEmailPassword(e.target.value)}
className="bg-surface-800 border border-surface-600 rounded px-2 py-1 text-sm w-56"
placeholder="Current password"
/>
{emailError && <p className="text-xs text-danger">{emailError}</p>}
</div>
) : (
<div className="text-xs text-surface-400">{payload.email}</div>
)}
<div className="text-xs text-surface-400">{payload.email}</div>
</div>
{!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>
)}
{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>
{!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>
@@ -209,16 +101,6 @@ 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">
@@ -302,24 +184,6 @@ 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 '@token-empire/shared';
import { KEY_HIRE_POOL } from '@token-empire/game-engine';
import type { DepartmentId } from '@token-empire/shared';
import { formatMoney } from '@ai-tycoon/shared';
import { KEY_HIRE_POOL } from '@ai-tycoon/game-engine';
import type { DepartmentId } from '@ai-tycoon/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 '@token-empire/shared';
import type { ApiTierId } from '@token-empire/shared';
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared';
import type { ApiTierId } from '@ai-tycoon/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 '@token-empire/shared';
import type { ConsumerTierId } from '@token-empire/shared';
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared';
import type { ConsumerTierId } from '@ai-tycoon/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 '@token-empire/shared';
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared';
import { Boxes, Check } from 'lucide-react';
function useAppliedFeedback() {
@@ -1,6 +1,6 @@
import { useGameStore } from '@/store';
import { formatNumber, formatMoney, formatPercent } from '@token-empire/shared';
import type { EnterprisePipelineStage, EnterpriseSegment } from '@token-empire/shared';
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared';
import type { EnterprisePipelineStage, EnterpriseSegment } from '@ai-tycoon/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 '@token-empire/shared';
import type { TAMSegmentId } from '@token-empire/shared';
import { formatNumber, formatPercent } from '@ai-tycoon/shared';
import type { TAMSegmentId } from '@ai-tycoon/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 '@token-empire/shared';
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/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 '@token-empire/shared';
} from '@ai-tycoon/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 '@token-empire/shared';
} from '@ai-tycoon/shared';
import {
emptyDCNetworkSummary, emptyCampusNetworkSummary, emptyClusterNetworkSummary,
TECH_TREE, onModelDeployed,
} from '@token-empire/game-engine';
import { INITIAL_RIVALS } from '@token-empire/game-engine';
} from '@ai-tycoon/game-engine';
import { INITIAL_RIVALS } from '@ai-tycoon/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: 'token-empire-save',
name: 'ai-tycoon-save',
version: SAVE_VERSION,
partialize: (state) => {
const { activePage, notifications, infraNav, modelsTab, ...rest } = state;
+1 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "@token-empire/tsconfig/react.json",
"extends": "@ai-tycoon/tsconfig/react.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
+7 -7
View File
@@ -1,6 +1,6 @@
services:
web:
image: gitea.thewrightserver.net/josh/tokenempire/web:latest
image: gitea.thewrightserver.net/josh/aihostingtycoon/web:latest
ports:
- "80:80"
depends_on:
@@ -8,11 +8,11 @@ services:
restart: unless-stopped
server:
image: gitea.thewrightserver.net/josh/tokenempire/server:latest
image: gitea.thewrightserver.net/josh/aihostingtycoon/server:latest
ports:
- "3001:3001"
environment:
- DATABASE_URL=postgresql://tokenempire:tokenempire@db:5432/tokenempire
- DATABASE_URL=postgresql://aitycoon:aitycoon@db:5432/aitycoon
- 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=tokenempire
- POSTGRES_PASSWORD=tokenempire
- POSTGRES_DB=tokenempire
- POSTGRES_USER=aitycoon
- POSTGRES_PASSWORD=aitycoon
- POSTGRES_DB=aitycoon
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U tokenempire"]
test: ["CMD-SHELL", "pg_isready -U aitycoon"]
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
```
token-empire/
ai-tycoon/
├── 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 `token-empire-save`. The Zustand `persist` middleware handles serialization.
- **localStorage**: Auto-save every 60 ticks under key `ai-tycoon-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": "token-empire",
"name": "ai-tycoon",
"private": true,
"scripts": {
"dev": "turbo dev",
@@ -9,8 +9,8 @@
"test": "vitest run",
"test:watch": "vitest",
"clean": "turbo clean",
"simulate": "turbo simulate --filter=@token-empire/game-simulation",
"simulate:ci": "pnpm --filter @token-empire/game-simulation simulate:ci"
"simulate": "turbo simulate --filter=@ai-tycoon/game-simulation",
"simulate:ci": "pnpm --filter @ai-tycoon/game-simulation simulate:ci"
},
"devDependencies": {
"turbo": "^2.5.0",
+3 -3
View File
@@ -1,5 +1,5 @@
{
"name": "@token-empire/game-engine",
"name": "@ai-tycoon/game-engine",
"private": true,
"version": "0.0.1",
"type": "module",
@@ -11,10 +11,10 @@
"test": "vitest run"
},
"dependencies": {
"@token-empire/shared": "workspace:*"
"@ai-tycoon/shared": "workspace:*"
},
"devDependencies": {
"@token-empire/tsconfig": "workspace:*",
"@ai-tycoon/tsconfig": "workspace:*",
"typescript": "^5.8.0"
}
}
@@ -2,8 +2,8 @@ import type {
Cluster, Campus, DataCenter, DeploymentCohort,
DCNetworkSummary, CampusNetworkSummary, ClusterNetworkSummary,
TrainingPipeline, BaseModel, ModelFamily,
} from '@token-empire/shared';
import { uuid } from '@token-empire/shared';
} from '@ai-tycoon/shared';
import { uuid } from '@ai-tycoon/shared';
import type { DeepPartial } from './createTestState';
function emptyDCNetwork(): DCNetworkSummary {
@@ -1,11 +1,11 @@
import type { GameState } from '@token-empire/shared';
import type { GameState } from '@ai-tycoon/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 '@token-empire/shared';
} from '@ai-tycoon/shared';
export type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
@@ -1,4 +1,4 @@
import type { AchievementDefinition } from '@token-empire/shared';
import type { AchievementDefinition } from '@ai-tycoon/shared';
export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
{
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Competitor } from '@token-empire/shared';
import type { Competitor } from '@ai-tycoon/shared';
export const INITIAL_RIVALS: Competitor[] = [
{
@@ -1,4 +1,4 @@
import type { EnterpriseSegment } from '@token-empire/shared';
import type { EnterpriseSegment } from '@ai-tycoon/shared';
export const ENTERPRISE_NAMES: Record<EnterpriseSegment, string[]> = {
startup: [
+1 -1
View File
@@ -1,4 +1,4 @@
import type { DepartmentId } from '@token-empire/shared';
import type { DepartmentId } from '@ai-tycoon/shared';
/**
* A recruitable key hire as it appears in the available pool.
+1 -1
View File
@@ -1,4 +1,4 @@
import type { ResearchNode } from '@token-empire/shared';
import type { ResearchNode } from '@ai-tycoon/shared';
export const TECH_TREE: ResearchNode[] = [
// === COMPUTE / INFRASTRUCTURE ===
+1 -1
View File
@@ -1,4 +1,4 @@
import type { GameState } from '@token-empire/shared';
import type { GameState } from '@ai-tycoon/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 '@token-empire/shared';
import type { AchievementDefinition } from '@ai-tycoon/shared';
function makeDef(overrides: Partial<AchievementDefinition> = {}): AchievementDefinition {
return {
@@ -1,4 +1,4 @@
import type { GameState, AchievementState, AchievementDefinition } from '@token-empire/shared';
import type { GameState, AchievementState, AchievementDefinition } from '@ai-tycoon/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 '@token-empire/shared';
import type { Competitor } from '@token-empire/shared';
import { FRESHNESS_DECAY_RATE } from '@ai-tycoon/shared';
import type { Competitor } from '@ai-tycoon/shared';
const rng = createSeededRNG(42);
beforeEach(() => rng.install());
@@ -1,10 +1,10 @@
import type { GameState, CompetitorState, Competitor } from '@token-empire/shared';
import type { GameState, CompetitorState, Competitor } from '@ai-tycoon/shared';
import {
COMPETITOR_PRODUCT_THRESHOLDS,
COMPETITOR_CATCHUP_SHARE_THRESHOLD,
COMPETITOR_CATCHUP_PRICE_CUT,
FRESHNESS_DECAY_RATE,
} from '@token-empire/shared';
} from '@ai-tycoon/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 '@token-empire/shared';
import { INITIAL_INFRASTRUCTURE, FLOPS_TO_TOKENS_MULTIPLIER, COMPUTE_SNAPSHOT_INTERVAL, MAX_COMPUTE_HISTORY } from '@token-empire/shared';
import type { InfrastructureState } from '@ai-tycoon/shared';
import { INITIAL_INFRASTRUCTURE, FLOPS_TO_TOKENS_MULTIPLIER, COMPUTE_SNAPSHOT_INTERVAL, MAX_COMPUTE_HISTORY } from '@ai-tycoon/shared';
function createInfrastructure(overrides: Partial<InfrastructureState> = {}): InfrastructureState {
return { ...INITIAL_INFRASTRUCTURE, ...overrides };
@@ -1,5 +1,5 @@
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 { GameState, ComputeState, InfrastructureState } from '@ai-tycoon/shared';
import { FLOPS_TO_TOKENS_MULTIPLIER, COMPUTE_SNAPSHOT_INTERVAL, MAX_COMPUTE_HISTORY } from '@ai-tycoon/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 '@token-empire/shared';
import type { DataPartnership } from '@ai-tycoon/shared';
function makePartnership(tokensPerTick: number): DataPartnership {
return {
@@ -1,4 +1,4 @@
import type { GameState, DataState } from '@token-empire/shared';
import type { GameState, DataState } from '@ai-tycoon/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 '@token-empire/shared';
import type { InfrastructureState } from '@ai-tycoon/shared';
function createMarketResult(
overrides: Partial<MarketTickResult> = {},
@@ -1,5 +1,5 @@
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 type { GameState, EconomyState, InfrastructureState } from '@ai-tycoon/shared';
import { FINANCIAL_SNAPSHOT_INTERVAL, MAX_FINANCIAL_HISTORY, REGULATION_COMPLIANCE_PER_CAPABILITY } from '@ai-tycoon/shared';
import { TECH_TREE } from '../data/techTree';
import type { MarketTickResult } from './marketSystem';
@@ -1,5 +1,5 @@
import type { GameState, Era } from '@token-empire/shared';
import { ERA_THRESHOLDS } from '@token-empire/shared';
import type { GameState, Era } from '@ai-tycoon/shared';
import { ERA_THRESHOLDS } from '@ai-tycoon/shared';
export function checkEraTransition(state: GameState): Era | null {
const current = state.meta.currentEra;
@@ -1,5 +1,5 @@
import type { GameState, FundingState, FundingRoundType } from '@token-empire/shared';
import { FUNDING_ROUNDS } from '@token-empire/shared';
import type { GameState, FundingState, FundingRoundType } from '@ai-tycoon/shared';
import { FUNDING_ROUNDS } from '@ai-tycoon/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 '@token-empire/shared';
} from '@ai-tycoon/shared';
import {
LOCATION_CONFIGS,
RACK_SKU_CONFIGS,
@@ -22,7 +22,7 @@ import {
COOLING_TYPE_CONFIGS,
NETWORK_FABRIC_CONFIGS,
estimateNetworkSlots,
} from '@token-empire/shared';
} from '@ai-tycoon/shared';
import type { TickNotification } from '../tick';
import type { ResearchBonuses } from './researchBonuses';
@@ -1,11 +1,11 @@
import type { ApiTierState, ApiTierId, DeveloperEcosystem, TierServingMetrics } from '@token-empire/shared';
import type { ApiTierState, ApiTierId, DeveloperEcosystem, TierServingMetrics } from '@ai-tycoon/shared';
import {
API_TIER_ORDER,
API_CONVERSION_RATES,
API_TIER_CHURN_RATES,
API_TOKENS_PER_DEVELOPER_PER_TICK,
REJECTION_CHURN_MULTIPLIER,
} from '@token-empire/shared';
} from '@ai-tycoon/shared';
export interface ApiTickResult {
apiTiers: ApiTierState;
@@ -1,4 +1,4 @@
import type { ConsumerTierState, ConsumerTierId, TierServingMetrics } from '@token-empire/shared';
import type { ConsumerTierState, ConsumerTierId, TierServingMetrics } from '@ai-tycoon/shared';
import {
CONSUMER_TIER_ORDER,
CONVERSION_RATES,
@@ -14,7 +14,7 @@ import {
PRICE_SATISFACTION_WEIGHT,
PRICE_CHURN_EXPONENT,
PRICE_CHURN_MAX_MULTIPLIER,
} from '@token-empire/shared';
} from '@ai-tycoon/shared';
export interface ConsumerTickResult {
consumerTiers: ConsumerTierState;
@@ -1,4 +1,4 @@
import type { DeveloperEcosystem } from '@token-empire/shared';
import type { DeveloperEcosystem } from '@ai-tycoon/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 '@token-empire/shared';
import type { Era } from '@token-empire/shared';
} from '@ai-tycoon/shared';
import type { Era } from '@ai-tycoon/shared';
export function processDeveloperEcosystem(
eco: DeveloperEcosystem,
@@ -6,7 +6,7 @@ import type {
EnterprisePipelineStage,
DeveloperEcosystem,
TierServingMetrics,
} from '@token-empire/shared';
} from '@ai-tycoon/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 '@token-empire/shared';
} from '@ai-tycoon/shared';
import { ENTERPRISE_NAMES } from '../../data/enterpriseNames';
let leadIdCounter = 0;
@@ -1,6 +1,6 @@
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 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 { computeSeasonal } from './seasonalSystem';
import { updateObsolescence } from './obsolescenceSystem';
import { buildPlayerProfile, buildCompetitorProfile, computeMarketShares, updateTAMGrowth } from './tamSystem';
@@ -1,10 +1,10 @@
import type { ObsolescenceState, Era } from '@token-empire/shared';
import type { ObsolescenceState, Era } from '@ai-tycoon/shared';
import {
OBSOLESCENCE_BASELINE_GROWTH,
OBSOLESCENCE_ERA_ACCELERATOR,
FRESHNESS_DECAY_RATE,
NEW_MODEL_BOOST_TICKS,
} from '@token-empire/shared';
} from '@ai-tycoon/shared';
export function updateObsolescence(
obs: ObsolescenceState,
@@ -1,4 +1,4 @@
import type { CodeAssistantState, AgentsPlatformState, ModelCapabilities } from '@token-empire/shared';
import type { CodeAssistantState, AgentsPlatformState, ModelCapabilities } from '@ai-tycoon/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 '@token-empire/shared';
} from '@ai-tycoon/shared';
export interface ProductLineResult {
codeAssistant: CodeAssistantState;
@@ -1,5 +1,5 @@
import type { SeasonalPhase } from '@token-empire/shared';
import { SEASONAL_CYCLE_TICKS, SEASONAL_MULTIPLIERS } from '@token-empire/shared';
import type { SeasonalPhase } from '@ai-tycoon/shared';
import { SEASONAL_CYCLE_TICKS, SEASONAL_MULTIPLIERS } from '@ai-tycoon/shared';
export interface SeasonalResult {
phase: SeasonalPhase;
@@ -5,8 +5,8 @@ import type {
ServingMetrics,
ModelUtilizationEntry,
BatchApiState,
} from '@token-empire/shared';
import type { BaseModel, ModelsState, SizeTier } from '@token-empire/shared';
} from '@ai-tycoon/shared';
import type { BaseModel, ModelsState, SizeTier } from '@ai-tycoon/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 '@token-empire/shared';
import { makeInitialServingMetrics } from '@token-empire/shared';
} from '@ai-tycoon/shared';
import { makeInitialServingMetrics } from '@ai-tycoon/shared';
export interface ModelServingSlot {
modelId: string;
@@ -6,7 +6,7 @@ import type {
Era,
ObsolescenceState,
DeveloperEcosystem,
} from '@token-empire/shared';
} from '@ai-tycoon/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 '@token-empire/shared';
} from '@ai-tycoon/shared';
export interface ParticipantProfile {
id: string;
@@ -1,4 +1,4 @@
import type { GameState } from '@token-empire/shared';
import type { GameState } from '@ai-tycoon/shared';
import { processMarketV2 } from './market/index';
import type { ResearchBonuses } from './researchBonuses';

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