Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a6629af79 | |||
| 2912d760cb | |||
| c1cc70eeb9 | |||
| be93e57853 | |||
| fbedcec4f2 | |||
| 65afa886af | |||
| 7348b35475 | |||
| 6cf5bf76b3 | |||
| 2ab097ec8a | |||
| 066c3310ff | |||
| a061337d6f | |||
| 4881907c28 | |||
| df01ac8e35 | |||
| 63e56dc229 | |||
| 5aa9436368 | |||
| 62998d6cb2 | |||
| a240ba2e44 | |||
| 19f652b43a | |||
| 57a81be769 | |||
| bbb69a315c | |||
| db034687d6 | |||
| 04d8a4e883 | |||
| 416b6bfe8d | |||
| b906592af4 | |||
| d47afd8542 | |||
| 6105c28887 | |||
| a8746246f8 | |||
| 1f50f6c86c | |||
| cc606ae523 | |||
| 5885e33531 | |||
| 626ca51041 |
@@ -27,28 +27,15 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'pnpm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: pnpm test
|
||||||
|
|
||||||
- name: Run greedy simulation
|
- name: Run greedy simulation
|
||||||
run: pnpm --filter @ai-tycoon/game-simulation simulate:ci
|
run: pnpm --filter @token-empire/game-simulation simulate:ci
|
||||||
|
|
||||||
- name: Run random simulation
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
cd packages/game-simulation
|
|
||||||
npx tsx src/simulate.ts --strategy random --ticks 28800 --json --seed 42
|
|
||||||
|
|
||||||
- name: Upload balance reports
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: balance-reports
|
|
||||||
path: |
|
|
||||||
packages/game-simulation/balance-report*.json
|
|
||||||
packages/game-simulation/balance-metrics*.csv
|
|
||||||
|
|
||||||
multi-run-balance:
|
multi-run-balance:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -62,21 +49,13 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'pnpm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Run multi-simulation (5 runs)
|
- name: Run multi-simulation (100 runs)
|
||||||
run: pnpm --filter @ai-tycoon/game-simulation multirun -- --runs 5 --parallel 2 --strategy greedy --ticks 28800 --no-timeseries
|
run: pnpm --filter @token-empire/game-simulation multirun -- --runs 100 --parallel 10 --strategy persona --ticks 28800 --no-timeseries
|
||||||
|
|
||||||
- name: Interpret results
|
- name: Interpret results
|
||||||
if: always()
|
if: always()
|
||||||
run: pnpm --filter @ai-tycoon/game-simulation interpret -- --summary packages/game-simulation/multirun-summary.csv
|
run: pnpm --filter @token-empire/game-simulation interpret -- --summary multirun-summary.csv
|
||||||
|
|
||||||
- name: Upload multi-run reports
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: multirun-reports
|
|
||||||
path: packages/game-simulation/multirun-*.csv
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: gitea.thewrightserver.net
|
REGISTRY: gitea.thewrightserver.net
|
||||||
IMAGE_PREFIX: gitea.thewrightserver.net/josh/aihostingtycoon
|
IMAGE_PREFIX: gitea.thewrightserver.net/josh/tokenempire
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# AI Tycoon
|
# Token Empire
|
||||||
|
|
||||||
A browser-based incremental/idle game where you manage an AI company from a garage startup to building AGI. Navigate the real tensions of the AI industry: scaling compute, training frontier models, balancing safety vs capability, hiring talent, and competing with rival labs.
|
A browser-based incremental/idle game where you manage an AI company from a garage startup to building AGI. Navigate the real tensions of the AI industry: scaling compute, training frontier models, balancing safety vs capability, hiring talent, and competing with rival labs.
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ The web app starts at `http://localhost:5173` (or the next available port). The
|
|||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
ai-tycoon/
|
token-empire/
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── web/ # React frontend (Vite)
|
│ ├── web/ # React frontend (Vite)
|
||||||
│ └── server/ # Hono API backend
|
│ └── server/ # Hono API backend
|
||||||
@@ -81,7 +81,7 @@ pnpm clean # Clean build artifacts
|
|||||||
The backend requires PostgreSQL for cloud saves and leaderboards. Set the connection string in `apps/server/.env`:
|
The backend requires PostgreSQL for cloud saves and leaderboards. Set the connection string in `apps/server/.env`:
|
||||||
|
|
||||||
```
|
```
|
||||||
DATABASE_URL=postgresql://user:password@localhost:5432/ai_tycoon
|
DATABASE_URL=postgresql://user:password@localhost:5432/token_empire
|
||||||
```
|
```
|
||||||
|
|
||||||
Run migrations:
|
Run migrations:
|
||||||
|
|||||||
@@ -14,16 +14,16 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
|
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
|
||||||
COPY --from=deps /app/apps/server/node_modules ./apps/server/node_modules
|
COPY --from=deps /app/apps/server/node_modules ./apps/server/node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm --filter @ai-tycoon/shared build && \
|
RUN pnpm --filter @token-empire/shared build && \
|
||||||
pnpm --filter @ai-tycoon/server typecheck
|
pnpm --filter @token-empire/server typecheck
|
||||||
|
|
||||||
FROM base AS production
|
FROM base AS production
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||||
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 packages/shared ./packages/shared
|
COPY packages/shared ./packages/shared
|
||||||
COPY packages/tsconfig ./packages/tsconfig
|
COPY packages/tsconfig ./packages/tsconfig
|
||||||
COPY apps/server ./apps/server
|
COPY apps/server ./apps/server
|
||||||
|
RUN pnpm install --frozen-lockfile --prod
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
CMD ["node", "--import", "tsx", "apps/server/src/index.ts"]
|
WORKDIR /app/apps/server
|
||||||
|
CMD ["node", "--import", "tsx", "src/index.ts"]
|
||||||
|
|||||||
@@ -5,6 +5,6 @@ export default defineConfig({
|
|||||||
schema: './src/db/schema.ts',
|
schema: './src/db/schema.ts',
|
||||||
out: './drizzle',
|
out: './drizzle',
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DATABASE_URL ?? 'postgresql://localhost:5432/ai_tycoon',
|
url: process.env.DATABASE_URL ?? 'postgresql://localhost:5432/token_empire',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
CREATE TABLE "achievements" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"achievement_id" text NOT NULL,
|
||||||
|
"unlocked_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "invitations" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"code" text NOT NULL,
|
||||||
|
"created_by" uuid NOT NULL,
|
||||||
|
"used_by" uuid,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"expires_at" timestamp,
|
||||||
|
CONSTRAINT "invitations_code_unique" UNIQUE("code")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "leaderboard" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"company_name" text NOT NULL,
|
||||||
|
"category" text NOT NULL,
|
||||||
|
"score" integer NOT NULL,
|
||||||
|
"era" text NOT NULL,
|
||||||
|
"tick_count" integer NOT NULL,
|
||||||
|
"submitted_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "saves" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"company_name" text NOT NULL,
|
||||||
|
"save_version" integer NOT NULL,
|
||||||
|
"game_data" jsonb NOT NULL,
|
||||||
|
"tick_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"era" text DEFAULT 'startup' NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"anon_token" uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"username" text,
|
||||||
|
"email" text,
|
||||||
|
"password_hash" text,
|
||||||
|
"role" text DEFAULT 'user' NOT NULL,
|
||||||
|
"must_reset_password" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"last_seen_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "users_anon_token_unique" UNIQUE("anon_token"),
|
||||||
|
CONSTRAINT "users_username_unique" UNIQUE("username"),
|
||||||
|
CONSTRAINT "users_email_unique" UNIQUE("email")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "achievements" ADD CONSTRAINT "achievements_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_used_by_users_id_fk" FOREIGN KEY ("used_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "leaderboard" ADD CONSTRAINT "leaderboard_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "saves" ADD CONSTRAINT "saves_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "achievements_user_id_idx" ON "achievements" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "leaderboard_category_score_idx" ON "leaderboard" USING btree ("category","score");--> statement-breakpoint
|
||||||
|
CREATE INDEX "saves_user_id_idx" ON "saves" USING btree ("user_id");
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "users" ADD COLUMN "token_version" integer DEFAULT 0 NOT NULL;
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
{
|
||||||
|
"id": "8cfe4136-b228-464d-bf2c-e4f2e8c73ce1",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.achievements": {
|
||||||
|
"name": "achievements",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"achievement_id": {
|
||||||
|
"name": "achievement_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"unlocked_at": {
|
||||||
|
"name": "unlocked_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"achievements_user_id_idx": {
|
||||||
|
"name": "achievements_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"achievements_user_id_users_id_fk": {
|
||||||
|
"name": "achievements_user_id_users_id_fk",
|
||||||
|
"tableFrom": "achievements",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.invitations": {
|
||||||
|
"name": "invitations",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"code": {
|
||||||
|
"name": "code",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_by": {
|
||||||
|
"name": "created_by",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"used_by": {
|
||||||
|
"name": "used_by",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"invitations_created_by_users_id_fk": {
|
||||||
|
"name": "invitations_created_by_users_id_fk",
|
||||||
|
"tableFrom": "invitations",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"created_by"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"invitations_used_by_users_id_fk": {
|
||||||
|
"name": "invitations_used_by_users_id_fk",
|
||||||
|
"tableFrom": "invitations",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"used_by"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"invitations_code_unique": {
|
||||||
|
"name": "invitations_code_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.leaderboard": {
|
||||||
|
"name": "leaderboard",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"company_name": {
|
||||||
|
"name": "company_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"name": "category",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"score": {
|
||||||
|
"name": "score",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"era": {
|
||||||
|
"name": "era",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"tick_count": {
|
||||||
|
"name": "tick_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"submitted_at": {
|
||||||
|
"name": "submitted_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"leaderboard_category_score_idx": {
|
||||||
|
"name": "leaderboard_category_score_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "category",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "score",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"leaderboard_user_id_users_id_fk": {
|
||||||
|
"name": "leaderboard_user_id_users_id_fk",
|
||||||
|
"tableFrom": "leaderboard",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.saves": {
|
||||||
|
"name": "saves",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"company_name": {
|
||||||
|
"name": "company_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"save_version": {
|
||||||
|
"name": "save_version",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"game_data": {
|
||||||
|
"name": "game_data",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"tick_count": {
|
||||||
|
"name": "tick_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"era": {
|
||||||
|
"name": "era",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'startup'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"saves_user_id_idx": {
|
||||||
|
"name": "saves_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"saves_user_id_users_id_fk": {
|
||||||
|
"name": "saves_user_id_users_id_fk",
|
||||||
|
"tableFrom": "saves",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.users": {
|
||||||
|
"name": "users",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"anon_token": {
|
||||||
|
"name": "anon_token",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'user'"
|
||||||
|
},
|
||||||
|
"must_reset_password": {
|
||||||
|
"name": "must_reset_password",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"last_seen_at": {
|
||||||
|
"name": "last_seen_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"users_anon_token_unique": {
|
||||||
|
"name": "users_anon_token_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"anon_token"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777333216602,
|
||||||
|
"tag": "0000_tearful_hedge_knight",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777417629552,
|
||||||
|
"tag": "0001_certain_aaron_stack",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@ai-tycoon/server",
|
"name": "@token-empire/server",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -13,18 +13,19 @@
|
|||||||
"db:push": "drizzle-kit push"
|
"db:push": "drizzle-kit push"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-tycoon/shared": "workspace:*",
|
"@token-empire/shared": "workspace:*",
|
||||||
"@hono/node-server": "^1.13.8",
|
"@hono/node-server": "^1.13.8",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"drizzle-orm": "^0.44.2",
|
"drizzle-orm": "^0.44.2",
|
||||||
"hono": "^4.7.10",
|
"hono": "^4.7.10",
|
||||||
"postgres": "^3.4.7",
|
"postgres": "^3.4.7",
|
||||||
|
"tsx": "^4.19.4",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ai-tycoon/tsconfig": "workspace:*",
|
"@token-empire/tsconfig": "workspace:*",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"drizzle-kit": "^0.31.1",
|
"drizzle-kit": "^0.31.1",
|
||||||
"tsx": "^4.19.4",
|
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import { migrate } from 'drizzle-orm/postgres-js/migrator';
|
||||||
import postgres from 'postgres';
|
import postgres from 'postgres';
|
||||||
import * as schema from './schema';
|
import * as schema from './schema';
|
||||||
|
|
||||||
const connectionString = process.env.DATABASE_URL ?? 'postgresql://localhost:5432/ai_tycoon';
|
const connectionString = process.env.DATABASE_URL ?? 'postgresql://localhost:5432/token_empire';
|
||||||
|
|
||||||
const client = postgres(connectionString);
|
const client = postgres(connectionString);
|
||||||
export const db = drizzle(client, { schema });
|
export const db = drizzle(client, { schema });
|
||||||
|
|
||||||
export type Database = typeof db;
|
export type Database = typeof db;
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export async function runMigrations() {
|
||||||
|
const migrationClient = postgres(connectionString, { max: 1 });
|
||||||
|
const migrationDb = drizzle(migrationClient);
|
||||||
|
await migrate(migrationDb, { migrationsFolder: path.resolve(__dirname, '../../drizzle') });
|
||||||
|
await migrationClient.end();
|
||||||
|
console.log('Database migrations complete');
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ import { pgTable, uuid, text, timestamp, jsonb, integer, boolean, index } from '
|
|||||||
export const users = pgTable('users', {
|
export const users = pgTable('users', {
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
anonToken: uuid('anon_token').defaultRandom().notNull().unique(),
|
anonToken: uuid('anon_token').defaultRandom().notNull().unique(),
|
||||||
|
username: text('username').unique(),
|
||||||
email: text('email').unique(),
|
email: text('email').unique(),
|
||||||
passwordHash: text('password_hash'),
|
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(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
lastSeenAt: timestamp('last_seen_at').defaultNow().notNull(),
|
lastSeenAt: timestamp('last_seen_at').defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
@@ -44,3 +48,12 @@ export const achievements = pgTable('achievements', {
|
|||||||
}, (table) => [
|
}, (table) => [
|
||||||
index('achievements_user_id_idx').on(table.userId),
|
index('achievements_user_id_idx').on(table.userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const invitations = pgTable('invitations', {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
code: text('code').notNull().unique(),
|
||||||
|
createdBy: uuid('created_by').notNull().references(() => users.id),
|
||||||
|
usedBy: uuid('used_by').references(() => users.id),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
expiresAt: timestamp('expires_at'),
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { db } from './index';
|
||||||
|
import { users } from './schema';
|
||||||
|
|
||||||
|
export async function seedAdmin() {
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.role, 'admin'))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
console.log('Admin user already exists');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash('admin', 10);
|
||||||
|
await db.insert(users).values({
|
||||||
|
username: 'admin',
|
||||||
|
passwordHash,
|
||||||
|
role: 'admin',
|
||||||
|
mustResetPassword: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Admin user seeded (admin/admin — password reset required)');
|
||||||
|
}
|
||||||
@@ -5,6 +5,14 @@ import { serve } from '@hono/node-server';
|
|||||||
import { auth } from './routes/auth';
|
import { auth } from './routes/auth';
|
||||||
import { savesRouter } from './routes/saves';
|
import { savesRouter } from './routes/saves';
|
||||||
import { leaderboardRouter } from './routes/leaderboard';
|
import { leaderboardRouter } from './routes/leaderboard';
|
||||||
|
import { invitesRouter } from './routes/invites';
|
||||||
|
import { runMigrations } from './db';
|
||||||
|
import { seedAdmin } from './db/seed';
|
||||||
|
|
||||||
|
if (!process.env.JWT_SECRET) {
|
||||||
|
console.error('FATAL: JWT_SECRET environment variable is required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -18,13 +26,23 @@ app.use('*', cors({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
app.get('/health', (c) => c.json({ status: 'ok', version: '0.1.0' }));
|
app.get('/health', (c) => c.json({ status: 'ok', version: '0.1.0' }));
|
||||||
|
app.get('/api/health', (c) => c.json({ status: 'ok', version: '0.1.0' }));
|
||||||
|
|
||||||
|
app.get('/api/config', (c) => c.json({
|
||||||
|
requireInvite: process.env.REQUIRE_INVITE !== 'false',
|
||||||
|
userInvitations: parseInt(process.env.USER_INVITATIONS || '0', 10),
|
||||||
|
}));
|
||||||
|
|
||||||
app.route('/api/auth', auth);
|
app.route('/api/auth', auth);
|
||||||
app.route('/api/saves', savesRouter);
|
app.route('/api/saves', savesRouter);
|
||||||
app.route('/api/leaderboard', leaderboardRouter);
|
app.route('/api/leaderboard', leaderboardRouter);
|
||||||
|
app.route('/api/invites', invitesRouter);
|
||||||
|
|
||||||
const port = Number(process.env.PORT) || 3001;
|
const port = Number(process.env.PORT) || 3001;
|
||||||
|
|
||||||
console.log(`AI Tycoon API server starting on port ${port}...`);
|
console.log(`Token Empire API server starting on port ${port}...`);
|
||||||
|
|
||||||
|
await runMigrations();
|
||||||
|
await seedAdmin();
|
||||||
|
|
||||||
serve({ fetch: app.fetch, port });
|
serve({ fetch: app.fetch, port });
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { sign, verify } from 'hono/jwt';
|
||||||
|
|
||||||
|
const JWT_EXPIRY_SECONDS = 30 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
export function getJwtSecret(): string {
|
||||||
|
const secret = process.env.JWT_SECRET;
|
||||||
|
if (!secret) throw new Error('JWT_SECRET env var is required');
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createToken(
|
||||||
|
userId: string,
|
||||||
|
email: string | null,
|
||||||
|
role: string,
|
||||||
|
username: string | null,
|
||||||
|
mustResetPassword: boolean,
|
||||||
|
tokenVersion: number = 0,
|
||||||
|
): Promise<string> {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
return sign(
|
||||||
|
{ sub: userId, email, role, username, mustResetPassword, tokenVersion, iat: now, exp: now + JWT_EXPIRY_SECONDS },
|
||||||
|
getJwtSecret(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyToken(token: string): Promise<{
|
||||||
|
sub: string;
|
||||||
|
email: string | null;
|
||||||
|
role: string;
|
||||||
|
username: string | null;
|
||||||
|
mustResetPassword: boolean;
|
||||||
|
tokenVersion: number;
|
||||||
|
}> {
|
||||||
|
const payload = await verify(token, getJwtSecret(), 'HS256');
|
||||||
|
return {
|
||||||
|
sub: payload.sub as string,
|
||||||
|
email: (payload.email as string) ?? null,
|
||||||
|
role: (payload.role as string) ?? 'user',
|
||||||
|
username: (payload.username as string) ?? null,
|
||||||
|
mustResetPassword: (payload.mustResetPassword as boolean) ?? false,
|
||||||
|
tokenVersion: (payload.tokenVersion as number) ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { createMiddleware } from 'hono/factory';
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { users } from '../db/schema';
|
import { users } from '../db/schema';
|
||||||
|
import { verifyToken } from '../lib/jwt';
|
||||||
import type { AppEnv } from '../types';
|
import type { AppEnv } from '../types';
|
||||||
|
|
||||||
export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
|
export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
|
||||||
@@ -14,25 +15,47 @@ export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
|
|||||||
const token = authHeader.slice(7);
|
const token = authHeader.slice(7);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const payload = await verifyToken(token);
|
||||||
|
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.anonToken, token))
|
.where(eq(users.id, payload.sub))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return c.json({ error: 'Invalid token' }, 401);
|
return c.json({ error: 'Invalid token' }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.tokenVersion !== user.tokenVersion) {
|
||||||
|
return c.json({ error: 'Token has been revoked' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ lastSeenAt: new Date() })
|
.set({ lastSeenAt: new Date() })
|
||||||
.where(eq(users.id, user.id));
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
c.set('userId', user.id);
|
c.set('userId', user.id);
|
||||||
c.set('user', user as AppEnv['Variables']['user']);
|
c.set('user', {
|
||||||
|
id: user.id,
|
||||||
|
anonToken: user.anonToken,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
mustResetPassword: user.mustResetPassword,
|
||||||
|
tokenVersion: user.tokenVersion,
|
||||||
|
});
|
||||||
await next();
|
await next();
|
||||||
} catch {
|
} catch {
|
||||||
return c.json({ error: 'Authentication failed' }, 500);
|
return c.json({ error: 'Invalid or expired token' }, 401);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const requireAdmin = createMiddleware<AppEnv>(async (c, next) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
return c.json({ error: 'Forbidden' }, 403);
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|||||||
+209
-32
@@ -1,7 +1,10 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, or, sql } from 'drizzle-orm';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { users } from '../db/schema';
|
import { users } from '../db/schema';
|
||||||
|
import { createToken } from '../lib/jwt';
|
||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
import type { AppEnv } from '../types';
|
import type { AppEnv } from '../types';
|
||||||
|
|
||||||
const auth = new Hono<AppEnv>();
|
const auth = new Hono<AppEnv>();
|
||||||
@@ -12,20 +15,51 @@ auth.post('/anonymous', async (c) => {
|
|||||||
.values({})
|
.values({})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return c.json({
|
const token = await createToken(user.id, null, 'user', null, false, 0);
|
||||||
userId: user.id,
|
return c.json({ userId: user.id, token });
|
||||||
token: user.anonToken,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
auth.post('/link-email', async (c) => {
|
auth.post('/register', authMiddleware, async (c) => {
|
||||||
const userId = c.get('userId') as string;
|
const userId = c.get('userId');
|
||||||
if (!userId) return c.json({ error: 'Not authenticated' }, 401);
|
const { email, password, inviteCode } = await c.req.json<{
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
inviteCode: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
const { email, password } = await c.req.json<{ email: string; password: string }>();
|
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
return c.json({ error: 'Valid email required' }, 400);
|
||||||
|
}
|
||||||
|
if (!password || password.length < 8) {
|
||||||
|
return c.json({ error: 'Password must be at least 8 characters' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
if (!email || !password) {
|
if (process.env.REQUIRE_INVITE !== 'false') {
|
||||||
return c.json({ error: 'Email and password required' }, 400);
|
if (!inviteCode) {
|
||||||
|
return c.json({ error: 'Invite code required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { invitations } = await import('../db/schema');
|
||||||
|
const { isNull, and, sql } = await import('drizzle-orm');
|
||||||
|
|
||||||
|
const [consumed] = await db
|
||||||
|
.update(invitations)
|
||||||
|
.set({ usedBy: userId })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(invitations.code, inviteCode),
|
||||||
|
isNull(invitations.usedBy),
|
||||||
|
or(
|
||||||
|
isNull(invitations.expiresAt),
|
||||||
|
sql`${invitations.expiresAt} > NOW()`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!consumed) {
|
||||||
|
return c.json({ error: 'Invalid or used invite code' }, 400);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await db
|
const existing = await db
|
||||||
@@ -38,44 +72,187 @@ auth.post('/link-email', async (c) => {
|
|||||||
return c.json({ error: 'Email already in use' }, 409);
|
return c.json({ error: 'Email already in use' }, 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
const data = encoder.encode(password);
|
|
||||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
||||||
const hashHex = Array.from(new Uint8Array(hashBuffer))
|
|
||||||
.map(b => b.toString(16).padStart(2, '0'))
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
await db
|
const [updated] = await db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ email, passwordHash: hashHex })
|
.set({ email, passwordHash })
|
||||||
.where(eq(users.id, userId));
|
.where(eq(users.id, userId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
return c.json({ success: true });
|
const token = await createToken(updated.id, updated.email, updated.role, updated.username, false, updated.tokenVersion);
|
||||||
|
return c.json({ userId: updated.id, token });
|
||||||
});
|
});
|
||||||
|
|
||||||
auth.post('/login', async (c) => {
|
auth.post('/login', async (c) => {
|
||||||
const { email, password } = await c.req.json<{ email: string; password: string }>();
|
const { login, password } = await c.req.json<{ login: string; password: string }>();
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
if (!login || !password) {
|
||||||
const data = encoder.encode(password);
|
return c.json({ error: 'Login and password required' }, 400);
|
||||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
}
|
||||||
const hashHex = Array.from(new Uint8Array(hashBuffer))
|
|
||||||
.map(b => b.toString(16).padStart(2, '0'))
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(or(eq(users.email, login), eq(users.username, login)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user || !user.passwordHash) {
|
||||||
|
return c.json({ error: 'Invalid credentials' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||||
|
if (!valid) {
|
||||||
|
return c.json({ error: 'Invalid credentials' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await createToken(user.id, user.email, user.role, user.username, user.mustResetPassword, user.tokenVersion);
|
||||||
|
return c.json({ userId: user.id, token });
|
||||||
|
});
|
||||||
|
|
||||||
|
auth.post('/change-password', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const { currentPassword, newPassword } = await c.req.json<{
|
||||||
|
currentPassword?: string;
|
||||||
|
newPassword: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
if (!newPassword || newPassword.length < 8) {
|
||||||
|
return c.json({ error: 'New password must be at least 8 characters' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.mustResetPassword) {
|
||||||
|
if (!currentPassword) {
|
||||||
|
return c.json({ error: 'Current password required' }, 400);
|
||||||
|
}
|
||||||
|
const [dbUser] = await db
|
||||||
|
.select({ passwordHash: users.passwordHash })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, user.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!dbUser?.passwordHash) {
|
||||||
|
return c.json({ error: 'No password set' }, 400);
|
||||||
|
}
|
||||||
|
const valid = await bcrypt.compare(currentPassword, dbUser.passwordHash);
|
||||||
|
if (!valid) {
|
||||||
|
return c.json({ error: 'Current password is incorrect' }, 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(newPassword, 10);
|
||||||
|
const [updated] = await db
|
||||||
|
.update(users)
|
||||||
|
.set({ passwordHash, mustResetPassword: false, tokenVersion: sql`${users.tokenVersion} + 1` })
|
||||||
|
.where(eq(users.id, user.id))
|
||||||
|
.returning({ tokenVersion: users.tokenVersion });
|
||||||
|
|
||||||
|
const token = await createToken(user.id, user.email, user.role, user.username, false, updated.tokenVersion);
|
||||||
|
return c.json({ success: true, token });
|
||||||
|
});
|
||||||
|
|
||||||
|
auth.post('/change-username', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
if (!user.email && user.role !== 'admin') {
|
||||||
|
return c.json({ error: 'Must be registered to change username' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username } = await c.req.json<{ username: string }>();
|
||||||
|
if (!username || username.length < 2) {
|
||||||
|
return c.json({ error: 'Username must be at least 2 characters' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.username, username))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length > 0 && existing[0].id !== user.id) {
|
||||||
|
return c.json({ error: 'Username already taken' }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ username })
|
||||||
|
.where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
const token = await createToken(user.id, user.email, user.role, username, user.mustResetPassword, user.tokenVersion);
|
||||||
|
return c.json({ success: true, token });
|
||||||
|
});
|
||||||
|
|
||||||
|
auth.post('/change-email', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
|
||||||
|
if (!user.email && user.role !== 'admin') {
|
||||||
|
return c.json({ error: 'Must be registered to change email' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email, currentPassword } = await c.req.json<{
|
||||||
|
email: string;
|
||||||
|
currentPassword: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
return c.json({ error: 'Valid email required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentPassword) {
|
||||||
|
return c.json({ error: 'Current password required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [dbUser] = await db
|
||||||
|
.select({ passwordHash: users.passwordHash })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, user.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!dbUser?.passwordHash) {
|
||||||
|
return c.json({ error: 'No password set' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(currentPassword, dbUser.passwordHash);
|
||||||
|
if (!valid) {
|
||||||
|
return c.json({ error: 'Current password is incorrect' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.email, email))
|
.where(eq(users.email, email))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!user || user.passwordHash !== hashHex) {
|
if (existing.length > 0 && existing[0].id !== user.id) {
|
||||||
return c.json({ error: 'Invalid credentials' }, 401);
|
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({
|
return c.json({
|
||||||
userId: user.id,
|
id: user.id,
|
||||||
token: user.anonToken,
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { eq, and, isNull, or, sql, count, desc } from 'drizzle-orm';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { invitations, users } from '../db/schema';
|
||||||
|
import { authMiddleware, requireAdmin } from '../middleware/auth';
|
||||||
|
import type { AppEnv } from '../types';
|
||||||
|
|
||||||
|
const invitesRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
|
function generateCode(): string {
|
||||||
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
|
||||||
|
const bytes = crypto.randomBytes(8);
|
||||||
|
let code = '';
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
code += chars[bytes[i] % chars.length];
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
invitesRouter.post('/', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
|
||||||
|
if (!user.email && user.role !== 'admin') {
|
||||||
|
return c.json({ error: 'Must be registered to create invites' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
const limit = parseInt(process.env.USER_INVITATIONS || '0', 10);
|
||||||
|
if (limit <= 0) {
|
||||||
|
return c.json({ error: 'You are not allowed to create invites' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [{ value: created }] = await db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(invitations)
|
||||||
|
.where(eq(invitations.createdBy, user.id));
|
||||||
|
|
||||||
|
if (created >= limit) {
|
||||||
|
return c.json({ error: 'Invite limit reached' }, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
|
const code = generateCode();
|
||||||
|
try {
|
||||||
|
await db.insert(invitations).values({ code, createdBy: user.id });
|
||||||
|
return c.json({ code });
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const message = e instanceof Error ? e.message : '';
|
||||||
|
if (message.includes('unique') || message.includes('duplicate')) continue;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ error: 'Failed to generate unique code' }, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
invitesRouter.get('/validate/:code', async (c) => {
|
||||||
|
const code = c.req.param('code');
|
||||||
|
|
||||||
|
const [invite] = await db
|
||||||
|
.select()
|
||||||
|
.from(invitations)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(invitations.code, code),
|
||||||
|
isNull(invitations.usedBy),
|
||||||
|
or(
|
||||||
|
isNull(invitations.expiresAt),
|
||||||
|
sql`${invitations.expiresAt} > NOW()`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return c.json({ valid: !!invite });
|
||||||
|
});
|
||||||
|
|
||||||
|
invitesRouter.get('/remaining', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
|
||||||
|
if (user.role === 'admin') {
|
||||||
|
return c.json({ remaining: -1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = parseInt(process.env.USER_INVITATIONS || '0', 10);
|
||||||
|
if (limit <= 0) {
|
||||||
|
return c.json({ remaining: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [{ value: created }] = await db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(invitations)
|
||||||
|
.where(eq(invitations.createdBy, user.id));
|
||||||
|
|
||||||
|
return c.json({ remaining: Math.max(0, limit - created) });
|
||||||
|
});
|
||||||
|
|
||||||
|
invitesRouter.get('/', authMiddleware, requireAdmin, async (c) => {
|
||||||
|
const allInvites = await db
|
||||||
|
.select({
|
||||||
|
id: invitations.id,
|
||||||
|
code: invitations.code,
|
||||||
|
createdBy: invitations.createdBy,
|
||||||
|
usedBy: invitations.usedBy,
|
||||||
|
createdAt: invitations.createdAt,
|
||||||
|
expiresAt: invitations.expiresAt,
|
||||||
|
})
|
||||||
|
.from(invitations)
|
||||||
|
.orderBy(desc(invitations.createdAt));
|
||||||
|
|
||||||
|
const userIds = new Set<string>();
|
||||||
|
for (const inv of allInvites) {
|
||||||
|
userIds.add(inv.createdBy);
|
||||||
|
if (inv.usedBy) userIds.add(inv.usedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMap = new Map<string, { username: string | null; email: string | null }>();
|
||||||
|
if (userIds.size > 0) {
|
||||||
|
const userList = await db
|
||||||
|
.select({ id: users.id, username: users.username, email: users.email })
|
||||||
|
.from(users)
|
||||||
|
.where(or(...[...userIds].map(id => eq(users.id, id))));
|
||||||
|
|
||||||
|
for (const u of userList) {
|
||||||
|
userMap.set(u.id, { username: u.username, email: u.email });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const enriched = allInvites.map(inv => ({
|
||||||
|
id: inv.id,
|
||||||
|
code: inv.code,
|
||||||
|
createdBy: userMap.get(inv.createdBy) ?? { username: null, email: null },
|
||||||
|
usedBy: inv.usedBy ? (userMap.get(inv.usedBy) ?? { username: null, email: null }) : null,
|
||||||
|
createdAt: inv.createdAt,
|
||||||
|
expiresAt: inv.expiresAt,
|
||||||
|
used: !!inv.usedBy,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return c.json({ invitations: enriched });
|
||||||
|
});
|
||||||
|
|
||||||
|
invitesRouter.delete('/:id', authMiddleware, requireAdmin, async (c) => {
|
||||||
|
const inviteId = c.req.param('id');
|
||||||
|
|
||||||
|
const [invite] = await db
|
||||||
|
.select({ id: invitations.id, usedBy: invitations.usedBy })
|
||||||
|
.from(invitations)
|
||||||
|
.where(eq(invitations.id, inviteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!invite) {
|
||||||
|
return c.json({ error: 'Invitation not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invite.usedBy) {
|
||||||
|
return c.json({ error: 'Cannot revoke a used invitation' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(invitations).where(eq(invitations.id, inviteId));
|
||||||
|
|
||||||
|
return c.json({ deleted: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
export { invitesRouter };
|
||||||
@@ -28,6 +28,19 @@ savesRouter.get('/', async (c) => {
|
|||||||
return c.json({ saves: userSaves });
|
return c.json({ saves: userSaves });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
savesRouter.get('/latest', async (c) => {
|
||||||
|
const userId = c.get('userId') as string;
|
||||||
|
|
||||||
|
const [save] = await db
|
||||||
|
.select()
|
||||||
|
.from(saves)
|
||||||
|
.where(eq(saves.userId, userId))
|
||||||
|
.orderBy(desc(saves.updatedAt))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return c.json({ save: save ?? null });
|
||||||
|
});
|
||||||
|
|
||||||
savesRouter.get('/:id', async (c) => {
|
savesRouter.get('/:id', async (c) => {
|
||||||
const userId = c.get('userId') as string;
|
const userId = c.get('userId') as string;
|
||||||
const saveId = c.req.param('id');
|
const saveId = c.req.param('id');
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ export type AppEnv = {
|
|||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
anonToken: string;
|
anonToken: string;
|
||||||
|
username: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
role: string;
|
||||||
|
mustResetPassword: boolean;
|
||||||
|
tokenVersion: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "@ai-tycoon/tsconfig/node.json",
|
"extends": "@token-empire/tsconfig/node.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src"
|
"rootDir": "src"
|
||||||
|
|||||||
+4
-4
@@ -16,11 +16,11 @@ COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_module
|
|||||||
COPY --from=deps /app/packages/game-engine/node_modules ./packages/game-engine/node_modules
|
COPY --from=deps /app/packages/game-engine/node_modules ./packages/game-engine/node_modules
|
||||||
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
|
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
ARG VITE_API_URL=/api
|
ARG VITE_API_URL=
|
||||||
ENV VITE_API_URL=$VITE_API_URL
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
RUN pnpm --filter @ai-tycoon/shared build && \
|
RUN pnpm --filter @token-empire/shared build && \
|
||||||
pnpm --filter @ai-tycoon/game-engine build && \
|
pnpm --filter @token-empire/game-engine build && \
|
||||||
pnpm --filter @ai-tycoon/web build
|
pnpm --filter @token-empire/web build
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
|
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>AI Tycoon</title>
|
<title>Token Empire</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://server:3001;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@ai-tycoon/web",
|
"name": "@token-empire/web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -10,8 +10,8 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-tycoon/shared": "workspace:*",
|
"@token-empire/shared": "workspace:*",
|
||||||
"@ai-tycoon/game-engine": "workspace:*",
|
"@token-empire/game-engine": "workspace:*",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"lucide-react": "^0.475.0"
|
"lucide-react": "^0.475.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ai-tycoon/tsconfig": "workspace:*",
|
"@token-empire/tsconfig": "workspace:*",
|
||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@vitejs/plugin-react": "^4.4.0",
|
"@vitejs/plugin-react": "^4.4.0",
|
||||||
|
|||||||
+71
-3
@@ -3,10 +3,58 @@ import { useGameStore } from '@/store';
|
|||||||
import { MainLayout } from '@/components/layout/MainLayout';
|
import { MainLayout } from '@/components/layout/MainLayout';
|
||||||
import { NewGameScreen } from '@/components/game/NewGameScreen';
|
import { NewGameScreen } from '@/components/game/NewGameScreen';
|
||||||
import { OfflineCatchUp } from '@/components/game/OfflineCatchUp';
|
import { OfflineCatchUp } from '@/components/game/OfflineCatchUp';
|
||||||
|
import { InviteGateScreen } from '@/components/game/InviteGateScreen';
|
||||||
import { useGameLoop } from '@/hooks/useGameLoop';
|
import { useGameLoop } from '@/hooks/useGameLoop';
|
||||||
import { TICK_INTERVAL_MS } from '@ai-tycoon/shared';
|
import { useAuthGate } from '@/hooks/useAuthGate';
|
||||||
|
import { useCloudSave } from '@/hooks/useCloudSave';
|
||||||
|
import { TICK_INTERVAL_MS } from '@token-empire/shared';
|
||||||
|
import { Sparkles, RefreshCw, WifiOff } from 'lucide-react';
|
||||||
|
|
||||||
|
function LoadingScreen() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-surface-950 to-surface-900">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="inline-flex items-center gap-2 mb-4">
|
||||||
|
<Sparkles className="text-accent-light animate-pulse" size={32} />
|
||||||
|
<h1 className="text-4xl font-bold bg-gradient-to-r from-accent-light to-accent bg-clip-text text-transparent">
|
||||||
|
Token Empire
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-surface-500 text-sm">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BackendErrorScreen({ error, onRetry }: { error: string; onRetry: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-surface-950 to-surface-900">
|
||||||
|
<div className="max-w-md w-full mx-4 text-center">
|
||||||
|
<div className="inline-flex items-center gap-2 mb-6">
|
||||||
|
<Sparkles className="text-accent-light" size={32} />
|
||||||
|
<h1 className="text-4xl font-bold bg-gradient-to-r from-accent-light to-accent bg-clip-text text-transparent">
|
||||||
|
Token Empire
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 space-y-4">
|
||||||
|
<WifiOff className="mx-auto text-danger" size={40} />
|
||||||
|
<h2 className="text-lg font-semibold text-surface-100">Cannot Connect to Server</h2>
|
||||||
|
<p className="text-sm text-surface-400">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={onRetry}
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-accent hover:bg-accent-dark text-white font-medium text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} /> Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
|
const { loading: authLoading, backendError, needsInvite, needsPasswordReset, cloudSave, loadCloudSave, setRegistered, setNeedsPasswordReset, retry } = useAuthGate();
|
||||||
const companyName = useGameStore((s) => s.meta.companyName);
|
const companyName = useGameStore((s) => s.meta.companyName);
|
||||||
const lastTickTimestamp = useGameStore((s) => s.meta.lastTickTimestamp);
|
const lastTickTimestamp = useGameStore((s) => s.meta.lastTickTimestamp);
|
||||||
const [catchUpTicks, setCatchUpTicks] = useState<number | null>(null);
|
const [catchUpTicks, setCatchUpTicks] = useState<number | null>(null);
|
||||||
@@ -23,10 +71,30 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}, [companyName, lastTickTimestamp, catchUpDone]);
|
}, [companyName, lastTickTimestamp, catchUpDone]);
|
||||||
|
|
||||||
useGameLoop(!catchUpDone);
|
useGameLoop(!catchUpDone || authLoading || !!backendError || needsInvite || needsPasswordReset);
|
||||||
|
useCloudSave();
|
||||||
|
|
||||||
|
if (authLoading) {
|
||||||
|
return <LoadingScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backendError) {
|
||||||
|
return <BackendErrorScreen error={backendError} onRetry={retry} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsInvite || needsPasswordReset) {
|
||||||
|
return (
|
||||||
|
<InviteGateScreen
|
||||||
|
onRegistered={() => {
|
||||||
|
setRegistered(true);
|
||||||
|
setNeedsPasswordReset(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!companyName) {
|
if (!companyName) {
|
||||||
return <NewGameScreen />;
|
return <NewGameScreen cloudSave={cloudSave} onContinue={loadCloudSave} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (catchUpTicks !== null && !catchUpDone) {
|
if (catchUpTicks !== null && !catchUpDone) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { X, CheckCircle, AlertTriangle, AlertCircle, Info, Bell, Trash2 } from 'lucide-react';
|
import { X, CheckCircle, AlertTriangle, AlertCircle, Info, Bell, Trash2 } from 'lucide-react';
|
||||||
import { useGameStore, type GameNotification } from '@/store';
|
import { useGameStore, type GameNotification } from '@/store';
|
||||||
import { formatDuration } from '@ai-tycoon/shared';
|
import { formatDuration } from '@token-empire/shared';
|
||||||
|
|
||||||
const ICON_MAP = {
|
const ICON_MAP = {
|
||||||
success: { icon: CheckCircle, color: 'text-success' },
|
success: { icon: CheckCircle, color: 'text-success' },
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function DevMenu() {
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('resources');
|
const [activeTab, setActiveTab] = useState<Tab>('resources');
|
||||||
|
|
||||||
const isEnabled = import.meta.env.DEV || localStorage.getItem('ai-tycoon-dev-menu') === 'true';
|
const isEnabled = import.meta.env.DEV || localStorage.getItem('token-empire-dev-menu') === 'true';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEnabled) return;
|
if (!isEnabled) return;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import type { FundingRoundType } from '@ai-tycoon/shared';
|
import type { FundingRoundType } from '@token-empire/shared';
|
||||||
|
|
||||||
function DevButton({ onClick, children, variant = 'default' }: {
|
function DevButton({ onClick, children, variant = 'default' }: {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { formatMoney } from '@ai-tycoon/shared';
|
import { formatMoney } from '@token-empire/shared';
|
||||||
|
|
||||||
function DevButton({ onClick, children, variant = 'default' }: {
|
function DevButton({ onClick, children, variant = 'default' }: {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { formatMoney, formatNumber, formatFlops, formatPercent } from '@ai-tycoon/shared';
|
import { formatMoney, formatNumber, formatFlops, formatPercent } from '@token-empire/shared';
|
||||||
|
|
||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { processTick, setAchievementDefinitions, ACHIEVEMENT_DEFINITIONS, TECH_TREE } from '@ai-tycoon/game-engine';
|
import { processTick, setAchievementDefinitions, ACHIEVEMENT_DEFINITIONS, TECH_TREE } from '@token-empire/game-engine';
|
||||||
import type { GameState, Era } from '@ai-tycoon/shared';
|
import type { GameState, Era } from '@token-empire/shared';
|
||||||
|
|
||||||
function DevButton({ onClick, children, variant = 'default' }: {
|
function DevButton({ onClick, children, variant = 'default' }: {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { formatMoney, formatNumber, formatPercent } from '@ai-tycoon/shared';
|
import { formatMoney, formatNumber, formatPercent } from '@token-empire/shared';
|
||||||
import { Share2, Copy, Check } from 'lucide-react';
|
import { Share2, Copy, Check } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ACHIEVEMENT_DEFINITIONS } from '@ai-tycoon/game-engine';
|
import { ACHIEVEMENT_DEFINITIONS } from '@token-empire/game-engine';
|
||||||
|
|
||||||
export function CompanyStatsCard({ onClose }: { onClose: () => void }) {
|
export function CompanyStatsCard({ onClose }: { onClose: () => void }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@@ -25,7 +25,7 @@ export function CompanyStatsCard({ onClose }: { onClose: () => void }) {
|
|||||||
const minutes = Math.floor((totalPlayTime % 3600) / 60);
|
const minutes = Math.floor((totalPlayTime % 3600) / 60);
|
||||||
|
|
||||||
const statsText = [
|
const statsText = [
|
||||||
`${companyName} — AI Tycoon`,
|
`${companyName} — Token Empire`,
|
||||||
`Era: ${eraLabel} | Playtime: ${hours}h ${minutes}m`,
|
`Era: ${eraLabel} | Playtime: ${hours}h ${minutes}m`,
|
||||||
`Cash: ${formatMoney(money)} | Revenue: ${formatMoney(totalRevenue)}`,
|
`Cash: ${formatMoney(money)} | Revenue: ${formatMoney(totalRevenue)}`,
|
||||||
`Valuation: ${formatMoney(valuation)}`,
|
`Valuation: ${formatMoney(valuation)}`,
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Sparkles, ArrowLeft } from 'lucide-react';
|
||||||
|
import { api, setAuthToken, needsPasswordReset } from '@/lib/api';
|
||||||
|
|
||||||
|
type Stage = 'invite' | 'register' | 'login' | 'reset-password';
|
||||||
|
|
||||||
|
export function InviteGateScreen({ onRegistered }: { onRegistered: () => void }) {
|
||||||
|
const [stage, setStage] = useState<Stage>('invite');
|
||||||
|
const [inviteCode, setInviteCode] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [loginField, setLoginField] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmNewPassword, setConfirmNewPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const code = params.get('invite');
|
||||||
|
if (code) {
|
||||||
|
setInviteCode(code);
|
||||||
|
handleValidateCode(code);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleValidateCode(code?: string) {
|
||||||
|
const codeToValidate = code || inviteCode.trim();
|
||||||
|
if (!codeToValidate) {
|
||||||
|
setError('Please enter an invite code');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await api.invites.validate(codeToValidate);
|
||||||
|
if (result.valid) {
|
||||||
|
setInviteCode(codeToValidate);
|
||||||
|
setStage('register');
|
||||||
|
} else {
|
||||||
|
setError('Invalid or already used invite code');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Could not validate invite code');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegister() {
|
||||||
|
if (!email.trim()) { setError('Email is required'); return; }
|
||||||
|
if (password.length < 8) { setError('Password must be at least 8 characters'); return; }
|
||||||
|
if (password !== confirmPassword) { setError('Passwords do not match'); return; }
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await api.auth.register(email.trim(), password, inviteCode);
|
||||||
|
setAuthToken(result.token);
|
||||||
|
onRegistered();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Registration failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
if (!loginField.trim() || !password) { setError('Email/username and password required'); return; }
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await api.auth.login(loginField.trim(), password);
|
||||||
|
setAuthToken(result.token);
|
||||||
|
if (needsPasswordReset()) {
|
||||||
|
setStage('reset-password');
|
||||||
|
} else {
|
||||||
|
onRegistered();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Login failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePasswordReset() {
|
||||||
|
if (newPassword.length < 8) { setError('Password must be at least 8 characters'); return; }
|
||||||
|
if (newPassword !== confirmNewPassword) { setError('Passwords do not match'); return; }
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await api.auth.changePassword(newPassword);
|
||||||
|
setAuthToken(result.token);
|
||||||
|
onRegistered();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Password change failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-surface-950 to-surface-900">
|
||||||
|
<div className="max-w-md w-full mx-4">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center gap-2 mb-4">
|
||||||
|
<Sparkles className="text-accent-light" size={32} />
|
||||||
|
<h1 className="text-4xl font-bold bg-gradient-to-r from-accent-light to-accent bg-clip-text text-transparent">
|
||||||
|
Token Empire
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-surface-400 text-sm">
|
||||||
|
{stage === 'reset-password'
|
||||||
|
? 'Please set a new password to continue.'
|
||||||
|
: 'Access is invite-only during early access.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 space-y-5">
|
||||||
|
{stage === 'invite' && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-300 mb-2">
|
||||||
|
Invite Code
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inviteCode}
|
||||||
|
onChange={(e) => setInviteCode(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleValidateCode()}
|
||||||
|
placeholder="Enter your invite code"
|
||||||
|
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-3 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent font-mono tracking-wider text-center text-lg"
|
||||||
|
autoFocus
|
||||||
|
maxLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-danger text-sm">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleValidateCode()}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-accent hover:bg-accent-dark text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Validating...' : 'Continue'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => { setStage('login'); setError(''); setPassword(''); }}
|
||||||
|
className="text-sm text-surface-400 hover:text-accent-light transition-colors"
|
||||||
|
>
|
||||||
|
Already have an account? Log in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stage === 'register' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => { setStage('invite'); setError(''); }}
|
||||||
|
className="flex items-center gap-1 text-sm text-surface-400 hover:text-surface-200 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} /> Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="text-xs text-surface-500 bg-surface-800 rounded px-3 py-2 font-mono">
|
||||||
|
Invite: {inviteCode}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-300 mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-300 mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Min 8 characters"
|
||||||
|
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-300 mb-1">Confirm Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleRegister()}
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-danger text-sm">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleRegister}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-accent hover:bg-accent-dark text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Creating account...' : 'Create Account'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stage === 'login' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => { setStage('invite'); setError(''); setPassword(''); }}
|
||||||
|
className="flex items-center gap-1 text-sm text-surface-400 hover:text-surface-200 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} /> Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-300 mb-1">Email or Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={loginField}
|
||||||
|
onChange={(e) => setLoginField(e.target.value)}
|
||||||
|
placeholder="admin"
|
||||||
|
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-300 mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
||||||
|
placeholder="Your password"
|
||||||
|
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-danger text-sm">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleLogin}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-accent hover:bg-accent-dark text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Logging in...' : 'Log In'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stage === 'reset-password' && (
|
||||||
|
<>
|
||||||
|
<div className="text-sm text-warning bg-warning/10 border border-warning/20 rounded-lg px-3 py-2">
|
||||||
|
You must set a new password before continuing.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-300 mb-1">New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="Min 8 characters"
|
||||||
|
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-300 mb-1">Confirm New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmNewPassword}
|
||||||
|
onChange={(e) => setConfirmNewPassword(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handlePasswordReset()}
|
||||||
|
placeholder="Confirm your new password"
|
||||||
|
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-danger text-sm">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handlePasswordReset}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-accent hover:bg-accent-dark text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Saving...' : 'Set Password & Continue'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-surface-600 mt-6">
|
||||||
|
Manage data centers, train models, serve millions of users, and achieve AGI.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Sparkles } from 'lucide-react';
|
import { Sparkles, Cloud, Play } from 'lucide-react';
|
||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
|
import type { CloudSaveInfo } from '@/hooks/useAuthGate';
|
||||||
|
|
||||||
const SUGGESTED_NAMES = [
|
const SUGGESTED_NAMES = [
|
||||||
'Nexus AI', 'Cortex Labs', 'Synapse Technologies',
|
'Nexus AI', 'Cortex Labs', 'Synapse Technologies',
|
||||||
@@ -8,8 +9,32 @@ const SUGGESTED_NAMES = [
|
|||||||
'Neural Forge', 'DeepMind+', 'Cerebral Systems',
|
'Neural Forge', 'DeepMind+', 'Cerebral Systems',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function NewGameScreen() {
|
const ERA_LABELS: Record<string, string> = {
|
||||||
|
startup: 'Startup',
|
||||||
|
scaleup: 'Scale-Up',
|
||||||
|
bigtech: 'Big Tech',
|
||||||
|
agi: 'AGI',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTimeAgo(dateStr: string): string {
|
||||||
|
const diff = Date.now() - new Date(dateStr).getTime();
|
||||||
|
const minutes = Math.floor(diff / 60_000);
|
||||||
|
if (minutes < 1) return 'just now';
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cloudSave?: CloudSaveInfo | null;
|
||||||
|
onContinue?: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewGameScreen({ cloudSave, onContinue }: Props) {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
const startNewGame = useGameStore((s) => s.startNewGame);
|
const startNewGame = useGameStore((s) => s.startNewGame);
|
||||||
|
|
||||||
const handleStart = () => {
|
const handleStart = () => {
|
||||||
@@ -17,6 +42,16 @@ export function NewGameScreen() {
|
|||||||
startNewGame(companyName);
|
startNewGame(companyName);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleContinue = async () => {
|
||||||
|
if (!onContinue) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onContinue();
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-surface-950 to-surface-900">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-surface-950 to-surface-900">
|
||||||
<div className="max-w-md w-full mx-4">
|
<div className="max-w-md w-full mx-4">
|
||||||
@@ -24,7 +59,7 @@ export function NewGameScreen() {
|
|||||||
<div className="inline-flex items-center gap-2 mb-4">
|
<div className="inline-flex items-center gap-2 mb-4">
|
||||||
<Sparkles className="text-accent-light" size={32} />
|
<Sparkles className="text-accent-light" size={32} />
|
||||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-accent-light to-accent bg-clip-text text-transparent">
|
<h1 className="text-4xl font-bold bg-gradient-to-r from-accent-light to-accent bg-clip-text text-transparent">
|
||||||
AI Tycoon
|
Token Empire
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-surface-400 text-sm">
|
<p className="text-surface-400 text-sm">
|
||||||
@@ -32,7 +67,37 @@ export function NewGameScreen() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{cloudSave && onContinue && (
|
||||||
|
<div className="bg-surface-900 border border-accent/30 rounded-xl p-6 mb-4 space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-accent-light">
|
||||||
|
<Cloud size={18} />
|
||||||
|
<h3 className="font-semibold text-sm">Continue Your Game</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-lg font-semibold text-surface-100">{cloudSave.companyName}</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-surface-400">
|
||||||
|
<span className="px-2 py-0.5 rounded-full bg-surface-800 border border-surface-700">
|
||||||
|
{ERA_LABELS[cloudSave.era] ?? cloudSave.era}
|
||||||
|
</span>
|
||||||
|
<span>Tick {cloudSave.tickCount.toLocaleString()}</span>
|
||||||
|
<span>Saved {formatTimeAgo(cloudSave.updatedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleContinue}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full inline-flex items-center justify-center gap-2 bg-accent hover:bg-accent-dark text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Play size={16} />
|
||||||
|
{loading ? 'Loading...' : 'Continue'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 space-y-6">
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 space-y-6">
|
||||||
|
{cloudSave && onContinue && (
|
||||||
|
<div className="text-xs text-surface-500 uppercase tracking-wider font-medium">Or start fresh</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-surface-300 mb-2">
|
<label className="block text-sm font-medium text-surface-300 mb-2">
|
||||||
Name your AI company
|
Name your AI company
|
||||||
@@ -44,7 +109,7 @@ export function NewGameScreen() {
|
|||||||
onKeyDown={(e) => e.key === 'Enter' && handleStart()}
|
onKeyDown={(e) => e.key === 'Enter' && handleStart()}
|
||||||
placeholder={SUGGESTED_NAMES[0]}
|
placeholder={SUGGESTED_NAMES[0]}
|
||||||
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-3 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-3 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||||
autoFocus
|
autoFocus={!cloudSave}
|
||||||
maxLength={30}
|
maxLength={30}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { formatMoney, formatDuration, formatNumber, MAX_OFFLINE_TICKS, TICK_INTERVAL_MS } from '@ai-tycoon/shared';
|
import { formatMoney, formatDuration, formatNumber, MAX_OFFLINE_TICKS, TICK_INTERVAL_MS } from '@token-empire/shared';
|
||||||
import { GameEngine } from '@ai-tycoon/game-engine';
|
import { GameEngine } from '@token-empire/game-engine';
|
||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
|
|
||||||
interface OfflineResult {
|
interface OfflineResult {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { X, Lightbulb } from 'lucide-react';
|
import { X, Lightbulb } from 'lucide-react';
|
||||||
|
|
||||||
const DISMISSED_KEY = 'ai-tycoon-dismissed-hints';
|
const DISMISSED_KEY = 'token-empire-dismissed-hints';
|
||||||
|
|
||||||
function getDismissed(): Set<string> {
|
function getDismissed(): Set<string> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { CompetitorsPage } from '@/pages/CompetitorsPage';
|
|||||||
import { AchievementsPage } from '@/pages/AchievementsPage';
|
import { AchievementsPage } from '@/pages/AchievementsPage';
|
||||||
import { LeaderboardPage } from '@/pages/LeaderboardPage';
|
import { LeaderboardPage } from '@/pages/LeaderboardPage';
|
||||||
import { ServingPage } from '@/pages/ServingPage';
|
import { ServingPage } from '@/pages/ServingPage';
|
||||||
|
import { InvitationsPage } from '@/pages/InvitationsPage';
|
||||||
|
|
||||||
export function MainLayout() {
|
export function MainLayout() {
|
||||||
const { subPath, setSubPath } = useHashRouter();
|
const { subPath, setSubPath } = useHashRouter();
|
||||||
@@ -53,6 +54,7 @@ function PageRouter({ page, subPath, setSubPath }: { page: string; subPath: stri
|
|||||||
case 'competitors': return <CompetitorsPage />;
|
case 'competitors': return <CompetitorsPage />;
|
||||||
case 'achievements': return <AchievementsPage />;
|
case 'achievements': return <AchievementsPage />;
|
||||||
case 'leaderboard': return <LeaderboardPage />;
|
case 'leaderboard': return <LeaderboardPage />;
|
||||||
|
case 'invitations': return <InvitationsPage />;
|
||||||
case 'settings': return <SettingsPage />;
|
case 'settings': return <SettingsPage />;
|
||||||
default: return <PlaceholderPage name={page} />;
|
default: return <PlaceholderPage name={page} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { useState, useEffect, useRef } from 'react';
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard, Server, FlaskConical, Brain,
|
LayoutDashboard, Server, FlaskConical, Brain,
|
||||||
TrendingUp, Activity, Users, Database, Swords, DollarSign, Settings, Trophy, Medal,
|
TrendingUp, Activity, Users, Database, Swords, DollarSign, Settings, Trophy, Medal,
|
||||||
PanelLeftClose, PanelLeftOpen,
|
PanelLeftClose, PanelLeftOpen, Mail, UserPlus, Copy, Check,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useGameStore, type ActivePage } from '@/store';
|
import { useGameStore, type ActivePage } from '@/store';
|
||||||
|
import { isAdmin as checkIsAdmin, isRegistered as checkIsRegistered, getTokenPayload, api } from '@/lib/api';
|
||||||
|
|
||||||
const NAV_ITEMS: { page: ActivePage; label: string; icon: typeof LayoutDashboard; era?: string }[] = [
|
const NAV_ITEMS: { page: ActivePage; label: string; icon: typeof LayoutDashboard; era?: string; adminOnly?: boolean }[] = [
|
||||||
{ page: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
{ page: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ page: 'infrastructure', label: 'Infrastructure', icon: Server },
|
{ page: 'infrastructure', label: 'Infrastructure', icon: Server },
|
||||||
{ page: 'research', label: 'Research', icon: FlaskConical },
|
{ page: 'research', label: 'Research', icon: FlaskConical },
|
||||||
@@ -19,12 +20,13 @@ const NAV_ITEMS: { page: ActivePage; label: string; icon: typeof LayoutDashboard
|
|||||||
{ page: 'competitors', label: 'Competitors', icon: Swords, era: 'scaleup' },
|
{ page: 'competitors', label: 'Competitors', icon: Swords, era: 'scaleup' },
|
||||||
{ page: 'achievements', label: 'Achievements', icon: Trophy },
|
{ page: 'achievements', label: 'Achievements', icon: Trophy },
|
||||||
{ page: 'leaderboard', label: 'Leaderboard', icon: Medal },
|
{ page: 'leaderboard', label: 'Leaderboard', icon: Medal },
|
||||||
|
{ page: 'invitations', label: 'Invitations', icon: Mail, adminOnly: true },
|
||||||
{ page: 'settings', label: 'Settings', icon: Settings },
|
{ page: 'settings', label: 'Settings', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
function getInitialCollapsed(): boolean {
|
function getInitialCollapsed(): boolean {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem('ai-tycoon-sidebar-collapsed');
|
const stored = localStorage.getItem('token-empire-sidebar-collapsed');
|
||||||
if (stored !== null) return stored === 'true';
|
if (stored !== null) return stored === 'true';
|
||||||
return window.innerWidth < 1280;
|
return window.innerWidth < 1280;
|
||||||
} catch { return false; }
|
} catch { return false; }
|
||||||
@@ -36,6 +38,11 @@ export function Sidebar() {
|
|||||||
const companyName = useGameStore((s) => s.meta.companyName);
|
const companyName = useGameStore((s) => s.meta.companyName);
|
||||||
const era = useGameStore((s) => s.meta.currentEra);
|
const era = useGameStore((s) => s.meta.currentEra);
|
||||||
const [collapsed, setCollapsed] = useState(getInitialCollapsed);
|
const [collapsed, setCollapsed] = useState(getInitialCollapsed);
|
||||||
|
const [remainingInvites, setRemainingInvites] = useState(0);
|
||||||
|
const [inviteCopied, setInviteCopied] = useState(false);
|
||||||
|
|
||||||
|
const admin = checkIsAdmin();
|
||||||
|
const registered = checkIsRegistered();
|
||||||
|
|
||||||
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'];
|
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||||
const currentEraIdx = eraOrder.indexOf(era);
|
const currentEraIdx = eraOrder.indexOf(era);
|
||||||
@@ -57,6 +64,12 @@ export function Sidebar() {
|
|||||||
}
|
}
|
||||||
}, [era]);
|
}, [era]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!admin && registered) {
|
||||||
|
api.invites.remaining().then(r => setRemainingInvites(r.remaining)).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [admin, registered]);
|
||||||
|
|
||||||
const handleNavClick = (page: ActivePage) => {
|
const handleNavClick = (page: ActivePage) => {
|
||||||
setActivePage(page);
|
setActivePage(page);
|
||||||
setNewPages(prev => {
|
setNewPages(prev => {
|
||||||
@@ -69,11 +82,26 @@ export function Sidebar() {
|
|||||||
const toggleCollapse = () => {
|
const toggleCollapse = () => {
|
||||||
setCollapsed(prev => {
|
setCollapsed(prev => {
|
||||||
const next = !prev;
|
const next = !prev;
|
||||||
localStorage.setItem('ai-tycoon-sidebar-collapsed', String(next));
|
localStorage.setItem('token-empire-sidebar-collapsed', String(next));
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function handleInviteFriend() {
|
||||||
|
try {
|
||||||
|
const result = await api.invites.create();
|
||||||
|
const url = `${window.location.origin}?invite=${result.code}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
setInviteCopied(true);
|
||||||
|
setRemainingInvites(prev => Math.max(0, prev - 1));
|
||||||
|
setTimeout(() => setInviteCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showInviteButton = !admin && registered && remainingInvites > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`${collapsed ? 'w-16' : 'w-56'} bg-surface-900 border-r border-surface-700 flex flex-col h-screen transition-all duration-200`}>
|
<aside className={`${collapsed ? 'w-16' : 'w-56'} bg-surface-900 border-r border-surface-700 flex flex-col h-screen transition-all duration-200`}>
|
||||||
<div className={`${collapsed ? 'px-2 py-3' : 'p-4'} border-b border-surface-700 flex items-center ${collapsed ? 'justify-center' : 'justify-between'}`}>
|
<div className={`${collapsed ? 'px-2 py-3' : 'p-4'} border-b border-surface-700 flex items-center ${collapsed ? 'justify-center' : 'justify-between'}`}>
|
||||||
@@ -91,12 +119,13 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 py-2 overflow-y-auto">
|
<nav className="flex-1 py-2 overflow-y-auto">
|
||||||
{NAV_ITEMS.map(({ page, label, icon: Icon, era: requiredEra }) => {
|
{NAV_ITEMS.map(({ page, label, icon: Icon, era: requiredEra, adminOnly }) => {
|
||||||
if (requiredEra && eraOrder.indexOf(requiredEra) > currentEraIdx) return null;
|
if (requiredEra && eraOrder.indexOf(requiredEra) > currentEraIdx) return null;
|
||||||
|
if (adminOnly && !admin) return null;
|
||||||
|
|
||||||
const isActive = activePage === page;
|
const isActive = activePage === page;
|
||||||
const isNew = newPages.has(page);
|
const isNew = newPages.has(page);
|
||||||
const showDivider = page === 'talent' || page === 'achievements';
|
const showDivider = page === 'talent' || page === 'achievements' || page === 'invitations';
|
||||||
return (
|
return (
|
||||||
<div key={page}>
|
<div key={page}>
|
||||||
{showDivider && <div className={`border-t border-surface-700 my-1 ${collapsed ? 'mx-2' : 'mx-4'}`} />}
|
{showDivider && <div className={`border-t border-surface-700 my-1 ${collapsed ? 'mx-2' : 'mx-4'}`} />}
|
||||||
@@ -123,8 +152,26 @@ export function Sidebar() {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{showInviteButton && (
|
||||||
|
<div className={`${collapsed ? 'px-2' : 'px-3'} pb-2`}>
|
||||||
|
<button
|
||||||
|
onClick={handleInviteFriend}
|
||||||
|
className={`w-full flex items-center ${collapsed ? 'justify-center' : 'gap-2'} px-3 py-2 text-sm rounded-lg bg-accent/10 hover:bg-accent/20 text-accent-light transition-colors`}
|
||||||
|
title={collapsed ? 'Invite a Friend' : undefined}
|
||||||
|
>
|
||||||
|
{inviteCopied ? <Check size={16} /> : <UserPlus size={16} />}
|
||||||
|
{!collapsed && (inviteCopied ? 'Link Copied!' : 'Invite a Friend')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={`${collapsed ? 'px-2 py-3 text-center' : 'p-4'} border-t border-surface-700 text-xs text-surface-500`}>
|
<div className={`${collapsed ? 'px-2 py-3 text-center' : 'p-4'} border-t border-surface-700 text-xs text-surface-500`}>
|
||||||
{collapsed ? 'v0.1' : 'AI Tycoon v0.1'}
|
{!collapsed && (() => {
|
||||||
|
const payload = getTokenPayload();
|
||||||
|
const displayName = payload?.username || payload?.email || 'Guest';
|
||||||
|
return <div className="truncate mb-1 text-surface-400">{displayName}</div>;
|
||||||
|
})()}
|
||||||
|
{collapsed ? 'v0.1' : 'Token Empire v0.1'}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { Pause, Play, Bell, Share2 } from 'lucide-react';
|
|||||||
import { CompanyStatsCard } from '@/components/game/CompanyStatsCard';
|
import { CompanyStatsCard } from '@/components/game/CompanyStatsCard';
|
||||||
import { NotificationPanel } from '@/components/common/NotificationPanel';
|
import { NotificationPanel } from '@/components/common/NotificationPanel';
|
||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { formatMoney, formatNumber, formatDuration, formatPercent } from '@ai-tycoon/shared';
|
import { formatMoney, formatNumber, formatDuration, formatPercent } from '@token-empire/shared';
|
||||||
import type { GameSpeed } from '@ai-tycoon/shared';
|
import type { GameSpeed } from '@token-empire/shared';
|
||||||
import { Tooltip } from '@/components/common/Tooltip';
|
import { Tooltip } from '@/components/common/Tooltip';
|
||||||
|
|
||||||
const SPEEDS: GameSpeed[] = [1, 2, 5];
|
const SPEEDS: GameSpeed[] = [1, 2, 5];
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { api, getTokenPayload, isRegistered as checkRegistered, needsPasswordReset as checkNeedsReset, validateStoredToken } from '@/lib/api';
|
||||||
|
import { useGameStore } from '@/store';
|
||||||
|
import { ensureAuth } from './useCloudSave';
|
||||||
|
|
||||||
|
export interface CloudSaveInfo {
|
||||||
|
id: string;
|
||||||
|
companyName: string;
|
||||||
|
era: string;
|
||||||
|
tickCount: number;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthGateState {
|
||||||
|
loading: boolean;
|
||||||
|
backendError: string | null;
|
||||||
|
needsInvite: boolean;
|
||||||
|
needsPasswordReset: boolean;
|
||||||
|
registered: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
|
config: { requireInvite: boolean; userInvitations: number } | null;
|
||||||
|
cloudSave: CloudSaveInfo | null;
|
||||||
|
loadCloudSave: () => Promise<void>;
|
||||||
|
setRegistered: (value: boolean) => void;
|
||||||
|
setNeedsPasswordReset: (value: boolean) => void;
|
||||||
|
retry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuthGate(): AuthGateState {
|
||||||
|
const [config, setConfig] = useState<{ requireInvite: boolean; userInvitations: number } | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [backendError, setBackendError] = useState<string | null>(null);
|
||||||
|
const [registered, setRegistered] = useState(false);
|
||||||
|
const [passwordReset, setPasswordReset] = useState(false);
|
||||||
|
const [admin, setAdmin] = useState(false);
|
||||||
|
const [cloudSave, setCloudSave] = useState<CloudSaveInfo | null>(null);
|
||||||
|
const [initCount, setInitCount] = useState(0);
|
||||||
|
|
||||||
|
const init = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setBackendError(null);
|
||||||
|
|
||||||
|
validateStoredToken();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.health();
|
||||||
|
} catch (e) {
|
||||||
|
setBackendError(e instanceof Error ? e.message : 'Cannot connect to server');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cfg = await api.config.get();
|
||||||
|
setConfig(cfg);
|
||||||
|
} catch {
|
||||||
|
setConfig({ requireInvite: false, userInvitations: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureAuth();
|
||||||
|
} catch {
|
||||||
|
// auth failed — will show as unregistered
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = getTokenPayload();
|
||||||
|
const isReg = checkRegistered();
|
||||||
|
setRegistered(isReg);
|
||||||
|
setPasswordReset(checkNeedsReset());
|
||||||
|
setAdmin(payload?.role === 'admin');
|
||||||
|
|
||||||
|
if (isReg) {
|
||||||
|
try {
|
||||||
|
const { save } = await api.saves.latest();
|
||||||
|
if (save && save.tickCount > 0) {
|
||||||
|
setCloudSave({
|
||||||
|
id: save.id,
|
||||||
|
companyName: save.companyName,
|
||||||
|
era: save.era,
|
||||||
|
tickCount: save.tickCount,
|
||||||
|
updatedAt: save.updatedAt,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setCloudSave(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setCloudSave(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Run init on mount and on retry
|
||||||
|
useState(() => { init(); });
|
||||||
|
|
||||||
|
const retry = useCallback(() => {
|
||||||
|
setInitCount(c => c + 1);
|
||||||
|
init();
|
||||||
|
}, [init]);
|
||||||
|
|
||||||
|
const loadCloudSave = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { save } = await api.saves.latest();
|
||||||
|
if (save?.gameData) {
|
||||||
|
const gameData = save.gameData as Record<string, unknown>;
|
||||||
|
useGameStore.setState(gameData);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to new game if cloud load fails
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSetRegistered = useCallback((value: boolean) => {
|
||||||
|
setRegistered(value);
|
||||||
|
const payload = getTokenPayload();
|
||||||
|
if (payload) {
|
||||||
|
setAdmin(payload.role === 'admin');
|
||||||
|
setPasswordReset(payload.mustResetPassword);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSetPasswordReset = useCallback((value: boolean) => {
|
||||||
|
setPasswordReset(value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const needsInvite = !!(config?.requireInvite && !registered);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
backendError,
|
||||||
|
needsInvite,
|
||||||
|
needsPasswordReset: passwordReset,
|
||||||
|
registered,
|
||||||
|
isAdmin: admin,
|
||||||
|
config,
|
||||||
|
cloudSave,
|
||||||
|
loadCloudSave,
|
||||||
|
setRegistered: handleSetRegistered,
|
||||||
|
setNeedsPasswordReset: handleSetPasswordReset,
|
||||||
|
retry,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,24 +1,29 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { api, getAuthToken, setAuthToken } from '@/lib/api';
|
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() {
|
export function useCloudSave() {
|
||||||
const tickCount = useGameStore((s) => s.meta.tickCount);
|
const tickCount = useGameStore((s) => s.meta.tickCount);
|
||||||
const companyName = useGameStore((s) => s.meta.companyName);
|
const companyName = useGameStore((s) => s.meta.companyName);
|
||||||
const lastSaveTick = useRef(0);
|
const lastSaveTick = useRef(0);
|
||||||
|
const failureCount = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!companyName) return;
|
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();
|
const token = getAuthToken();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
|
if (failureCount.current >= MAX_CONSECUTIVE_FAILURES) return;
|
||||||
|
|
||||||
lastSaveTick.current = tickCount;
|
lastSaveTick.current = tickCount;
|
||||||
|
|
||||||
const state = useGameStore.getState();
|
const state = useGameStore.getState();
|
||||||
const { activePage, notifications, ...gameState } = state;
|
const { activePage, notifications, infraNav, modelsTab, ...gameState } = state;
|
||||||
|
|
||||||
api.saves.put({
|
api.saves.put({
|
||||||
companyName: state.meta.companyName,
|
companyName: state.meta.companyName,
|
||||||
@@ -26,13 +31,28 @@ export function useCloudSave() {
|
|||||||
gameData: gameState,
|
gameData: gameState,
|
||||||
tickCount: state.meta.tickCount,
|
tickCount: state.meta.tickCount,
|
||||||
era: state.meta.currentEra,
|
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]);
|
}, [tickCount, companyName]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureAuth(): Promise<string | null> {
|
export async function ensureAuth(): Promise<string | null> {
|
||||||
let token = getAuthToken();
|
const token = getAuthToken();
|
||||||
if (token) return token;
|
if (token) {
|
||||||
|
if (decodeTokenPayload(token)) return token;
|
||||||
|
clearAuthToken();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.auth.anonymous();
|
const result = await api.auth.anonymous();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { GameEngine, setAchievementDefinitions, ACHIEVEMENT_DEFINITIONS } from '@ai-tycoon/game-engine';
|
import { GameEngine, setAchievementDefinitions, ACHIEVEMENT_DEFINITIONS } from '@token-empire/game-engine';
|
||||||
import type { TickNotification } from '@ai-tycoon/game-engine';
|
import type { TickNotification } from '@token-empire/game-engine';
|
||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
|
|
||||||
export function useGameLoop(skip = false) {
|
export function useGameLoop(skip = false) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useGameStore, type ActivePage } from '@/store';
|
import { useGameStore, type ActivePage } from '@/store';
|
||||||
import type { GameSpeed } from '@ai-tycoon/shared';
|
import type { GameSpeed } from '@token-empire/shared';
|
||||||
|
|
||||||
const PAGE_SHORTCUTS: Record<string, ActivePage> = {
|
const PAGE_SHORTCUTS: Record<string, ActivePage> = {
|
||||||
d: 'dashboard',
|
d: 'dashboard',
|
||||||
|
|||||||
+135
-14
@@ -1,10 +1,10 @@
|
|||||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
const API_BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3001';
|
||||||
|
|
||||||
let authToken: string | null = localStorage.getItem('ai-tycoon-auth-token');
|
let authToken: string | null = localStorage.getItem('token-empire-auth-token');
|
||||||
|
|
||||||
export function setAuthToken(token: string) {
|
export function setAuthToken(token: string) {
|
||||||
authToken = token;
|
authToken = token;
|
||||||
localStorage.setItem('ai-tycoon-auth-token', token);
|
localStorage.setItem('token-empire-auth-token', token);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAuthToken() {
|
export function getAuthToken() {
|
||||||
@@ -13,49 +13,170 @@ export function getAuthToken() {
|
|||||||
|
|
||||||
export function clearAuthToken() {
|
export function clearAuthToken() {
|
||||||
authToken = null;
|
authToken = null;
|
||||||
localStorage.removeItem('ai-tycoon-auth-token');
|
localStorage.removeItem('token-empire-auth-token');
|
||||||
|
localStorage.removeItem('token-empire-refresh-token');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
export interface TokenPayload {
|
||||||
|
sub: string;
|
||||||
|
email: string | null;
|
||||||
|
role: string;
|
||||||
|
username: string | null;
|
||||||
|
mustResetPassword: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeTokenPayload(token: string): TokenPayload | null {
|
||||||
|
try {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
||||||
|
return {
|
||||||
|
sub: payload.sub,
|
||||||
|
email: payload.email ?? null,
|
||||||
|
role: payload.role ?? 'user',
|
||||||
|
username: payload.username ?? null,
|
||||||
|
mustResetPassword: payload.mustResetPassword ?? false,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTokenPayload(): TokenPayload | null {
|
||||||
|
const token = getAuthToken();
|
||||||
|
if (!token) return null;
|
||||||
|
return decodeTokenPayload(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRegistered(): boolean {
|
||||||
|
const payload = getTokenPayload();
|
||||||
|
if (!payload) return false;
|
||||||
|
return payload.email != null || payload.role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAdmin(): boolean {
|
||||||
|
const payload = getTokenPayload();
|
||||||
|
return payload?.role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function needsPasswordReset(): boolean {
|
||||||
|
const payload = getTokenPayload();
|
||||||
|
return payload?.mustResetPassword === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTH_PATHS = ['/api/auth/anonymous', '/api/auth/login', '/api/auth/logout', '/api/health'];
|
||||||
|
|
||||||
|
async function request<T>(path: string, options: RequestInit & { timeoutMs?: number } = {}): Promise<T> {
|
||||||
|
const { timeoutMs = 10_000, ...fetchOptions } = options;
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(options.headers as Record<string, string>),
|
...(fetchOptions.headers as Record<string, string>),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (authToken) {
|
if (authToken) {
|
||||||
headers['Authorization'] = `Bearer ${authToken}`;
|
headers['Authorization'] = `Bearer ${authToken}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
...options,
|
...fetchOptions,
|
||||||
headers,
|
headers,
|
||||||
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({ error: 'Unknown error' }));
|
if (res.status === 401 && authToken && !AUTH_PATHS.includes(path)) {
|
||||||
throw new Error(body.error || `HTTP ${res.status}`);
|
clearAuthToken();
|
||||||
|
localStorage.removeItem('token-empire-save');
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
throw new Error(body?.error || `HTTP ${res.status} ${res.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json();
|
return res.json();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof DOMException && e.name === 'AbortError') {
|
||||||
|
throw new Error('Request timed out — server may be unreachable');
|
||||||
|
}
|
||||||
|
if (e instanceof TypeError) {
|
||||||
|
throw new Error('Network error — server may be unreachable');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateStoredToken(): void {
|
||||||
|
const token = getAuthToken();
|
||||||
|
if (token && !decodeTokenPayload(token)) {
|
||||||
|
clearAuthToken();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
|
health: () => request<{ status: string }>('/api/health', { timeoutMs: 5_000 }),
|
||||||
auth: {
|
auth: {
|
||||||
anonymous: () => request<{ userId: string; token: string }>('/api/auth/anonymous', { method: 'POST' }),
|
anonymous: () => request<{ userId: string; token: string }>('/api/auth/anonymous', { method: 'POST' }),
|
||||||
login: (email: string, password: string) =>
|
login: (login: string, password: string) =>
|
||||||
request<{ userId: string; token: string }>('/api/auth/login', {
|
request<{ userId: string; token: string }>('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ login, password }),
|
||||||
}),
|
}),
|
||||||
linkEmail: (email: string, password: string) =>
|
register: (email: string, password: string, inviteCode: string) =>
|
||||||
request<{ success: boolean }>('/api/auth/link-email', {
|
request<{ userId: string; token: string }>('/api/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password, inviteCode }),
|
||||||
}),
|
}),
|
||||||
|
changePassword: (newPassword: string, currentPassword?: string) =>
|
||||||
|
request<{ success: boolean; token: string }>('/api/auth/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ newPassword, currentPassword }),
|
||||||
|
}),
|
||||||
|
changeUsername: (username: string) =>
|
||||||
|
request<{ success: boolean; token: string }>('/api/auth/change-username', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username }),
|
||||||
|
}),
|
||||||
|
changeEmail: (email: string, currentPassword: string) =>
|
||||||
|
request<{ success: boolean; token: string }>('/api/auth/change-email', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email, currentPassword }),
|
||||||
|
}),
|
||||||
|
logout: () =>
|
||||||
|
request<{ success: boolean }>('/api/auth/logout', { method: 'POST' }),
|
||||||
|
me: () =>
|
||||||
|
request<{ id: string; username: string | null; email: string | null; role: string }>('/api/auth/me'),
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
get: () => request<{ requireInvite: boolean; userInvitations: number }>('/api/config'),
|
||||||
|
},
|
||||||
|
invites: {
|
||||||
|
create: () => request<{ code: string }>('/api/invites', { method: 'POST' }),
|
||||||
|
validate: (code: string) => request<{ valid: boolean }>(`/api/invites/validate/${encodeURIComponent(code)}`),
|
||||||
|
list: () => request<{
|
||||||
|
invitations: Array<{
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
createdBy: { username: string | null; email: string | null };
|
||||||
|
usedBy: { username: string | null; email: string | null } | null;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
used: boolean;
|
||||||
|
}>;
|
||||||
|
}>('/api/invites'),
|
||||||
|
remaining: () => request<{ remaining: number }>('/api/invites/remaining'),
|
||||||
|
revoke: (id: string) => request<{ deleted: boolean }>(`/api/invites/${id}`, { method: 'DELETE' }),
|
||||||
},
|
},
|
||||||
saves: {
|
saves: {
|
||||||
list: () => request<{ saves: Array<{ id: string; companyName: string; era: string; tickCount: number; updatedAt: string }> }>('/api/saves'),
|
list: () => request<{ saves: Array<{ id: string; companyName: string; era: string; tickCount: number; updatedAt: string }> }>('/api/saves'),
|
||||||
get: (id: string) => request<{ save: { id: string; gameData: unknown } }>(`/api/saves/${id}`),
|
get: (id: string) => request<{ save: { id: string; gameData: unknown } }>(`/api/saves/${id}`),
|
||||||
|
latest: () => request<{ save: { id: string; companyName: string; era: string; tickCount: number; updatedAt: string; gameData: unknown } | null }>('/api/saves/latest'),
|
||||||
put: (data: { companyName: string; saveVersion: number; gameData: unknown; tickCount: number; era: string }) =>
|
put: (data: { companyName: string; saveVersion: number; gameData: unknown; tickCount: number; era: string }) =>
|
||||||
request<{ id: string }>('/api/saves', { method: 'PUT', body: JSON.stringify(data) }),
|
request<{ id: string }>('/api/saves', { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
delete: (id: string) => request<{ deleted: boolean }>(`/api/saves/${id}`, { method: 'DELETE' }),
|
delete: (id: string) => request<{ deleted: boolean }>(`/api/saves/${id}`, { method: 'DELETE' }),
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { ACHIEVEMENT_DEFINITIONS } from '@ai-tycoon/game-engine';
|
import { ACHIEVEMENT_DEFINITIONS } from '@token-empire/game-engine';
|
||||||
import { formatNumber } from '@ai-tycoon/shared';
|
import { formatNumber } from '@token-empire/shared';
|
||||||
import {
|
import {
|
||||||
Trophy, Lock, Server, Brain, Rocket, DollarSign, Sprout, Users,
|
Trophy, Lock, Server, Brain, Rocket, DollarSign, Sprout, Users,
|
||||||
Globe, Sparkles, TrendingUp, Building2, Atom, Cpu, FlaskConical,
|
Globe, Sparkles, TrendingUp, Building2, Atom, Cpu, FlaskConical,
|
||||||
GitBranch, Zap,
|
GitBranch, Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { AchievementCondition } from '@ai-tycoon/shared';
|
import type { AchievementCondition } from '@token-empire/shared';
|
||||||
|
|
||||||
const ICON_MAP: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
|
const ICON_MAP: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
|
||||||
Trophy, Server, Brain, Rocket, DollarSign, Sprout, Users,
|
Trophy, Server, Brain, Rocket, DollarSign, Sprout, Users,
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { Swords, TrendingUp, Shield, Users, Brain, ShoppingCart } from 'lucide-r
|
|||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { ConfirmModal } from '@/components/common/ConfirmModal';
|
import { ConfirmModal } from '@/components/common/ConfirmModal';
|
||||||
import { Tooltip } from '@/components/common/Tooltip';
|
import { Tooltip } from '@/components/common/Tooltip';
|
||||||
import { formatMoney, formatNumber } from '@ai-tycoon/shared';
|
import { formatMoney, formatNumber } from '@token-empire/shared';
|
||||||
import type { Era } from '@ai-tycoon/shared';
|
import type { Era } from '@token-empire/shared';
|
||||||
|
|
||||||
const ARCHETYPE_LABELS: Record<string, string> = {
|
const ARCHETYPE_LABELS: Record<string, string> = {
|
||||||
'safety-first': 'Safety-First Lab',
|
'safety-first': 'Safety-First Lab',
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useGameStore, type ActivePage } from '@/store';
|
import { useGameStore, type ActivePage } from '@/store';
|
||||||
import { formatMoney, formatNumber, formatPercent, formatDuration } from '@ai-tycoon/shared';
|
import { formatMoney, formatNumber, formatPercent, formatDuration } from '@token-empire/shared';
|
||||||
import type { Era } from '@ai-tycoon/shared';
|
import type { Era } from '@token-empire/shared';
|
||||||
import { TECH_TREE } from '@ai-tycoon/game-engine';
|
import { TECH_TREE } from '@token-empire/game-engine';
|
||||||
import {
|
import {
|
||||||
DollarSign, TrendingUp, TrendingDown, Minus, Cpu, Brain, Users,
|
DollarSign, TrendingUp, TrendingDown, Minus, Cpu, Brain, Users,
|
||||||
Shield, ChevronRight, Zap, Wifi, Sparkles, FlaskConical, Building2,
|
Shield, ChevronRight, Zap, Wifi, Sparkles, FlaskConical, Building2,
|
||||||
@@ -96,7 +96,7 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
{totalDCs === 0 && (
|
{totalDCs === 0 && (
|
||||||
<TutorialHint id="welcome">
|
<TutorialHint id="welcome">
|
||||||
Welcome to AI Tycoon! Start by building a cluster in the Infrastructure tab, then add a campus and data center to deploy racks and train your first AI model.
|
Welcome to Token Empire! Start by building a cluster in the Infrastructure tab, then add a campus and data center to deploy racks and train your first AI model.
|
||||||
</TutorialHint>
|
</TutorialHint>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Database, ShoppingCart, Zap } from 'lucide-react';
|
import { Database, ShoppingCart, Zap } from 'lucide-react';
|
||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { formatNumber, formatMoney, uuid } from '@ai-tycoon/shared';
|
import { formatNumber, formatMoney, uuid } from '@token-empire/shared';
|
||||||
import type { OwnedDataset, DataDomain } from '@ai-tycoon/shared';
|
import type { OwnedDataset, DataDomain } from '@token-empire/shared';
|
||||||
|
|
||||||
interface MarketplaceDataset {
|
interface MarketplaceDataset {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { formatMoney, formatPercent, formatNumber, FUNDING_ROUNDS } from '@ai-tycoon/shared';
|
import { formatMoney, formatPercent, formatNumber, FUNDING_ROUNDS } from '@token-empire/shared';
|
||||||
import type { FundingRoundType } from '@ai-tycoon/shared';
|
import type { FundingRoundType } from '@token-empire/shared';
|
||||||
import { TrendingUp, DollarSign, PiggyBank, BarChart3, Rocket, Check, X as XIcon } from 'lucide-react';
|
import { TrendingUp, DollarSign, PiggyBank, BarChart3, Rocket, Check, X as XIcon } from 'lucide-react';
|
||||||
import { AreaChart, Area, XAxis, YAxis, ResponsiveContainer, LineChart, Line, Tooltip } from 'recharts';
|
import { AreaChart, Area, XAxis, YAxis, ResponsiveContainer, LineChart, Line, Tooltip } from 'recharts';
|
||||||
import { canRaiseFunding } from '@ai-tycoon/game-engine';
|
import { canRaiseFunding } from '@token-empire/game-engine';
|
||||||
import type { GameState } from '@ai-tycoon/shared';
|
import type { GameState } from '@token-empire/shared';
|
||||||
|
|
||||||
export function FinancePage() {
|
export function FinancePage() {
|
||||||
const money = useGameStore((s) => s.economy.money);
|
const money = useGameStore((s) => s.economy.money);
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ import {
|
|||||||
SWITCH_TIER_CONFIGS,
|
SWITCH_TIER_CONFIGS,
|
||||||
DC_UPGRADE_COST_FRACTION, DC_UPGRADE_INCREMENT,
|
DC_UPGRADE_COST_FRACTION, DC_UPGRADE_INCREMENT,
|
||||||
skuTotalFlops,
|
skuTotalFlops,
|
||||||
} from '@ai-tycoon/shared';
|
} from '@token-empire/shared';
|
||||||
import type {
|
import type {
|
||||||
DCTier, RackSkuId, LocationId, PipelineStage, Era,
|
DCTier, RackSkuId, LocationId, PipelineStage, Era,
|
||||||
Cluster, Campus, DataCenter, DeploymentCohort,
|
Cluster, Campus, DataCenter, DeploymentCohort,
|
||||||
} from '@ai-tycoon/shared';
|
} from '@token-empire/shared';
|
||||||
|
|
||||||
const ERA_ORDER: Era[] = ['startup', 'scaleup', 'bigtech', 'agi'];
|
const ERA_ORDER: Era[] = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||||
|
|
||||||
@@ -162,7 +162,8 @@ function CohortStageBreakdown({ cohorts }: { cohorts: DeploymentCohort[] }) {
|
|||||||
|
|
||||||
function NetworkHealthIndicator({ dc }: { dc: DataCenter }) {
|
function NetworkHealthIndicator({ dc }: { dc: DataCenter }) {
|
||||||
const ns = dc.networkSummary;
|
const ns = dc.networkSummary;
|
||||||
if (ns.switchIds.length === 0) return null;
|
const torTotal = ns.totalByTier?.tor ?? 0;
|
||||||
|
if (torTotal === 0) return null;
|
||||||
|
|
||||||
const hasDisconnected = ns.racksDisconnected > 0;
|
const hasDisconnected = ns.racksDisconnected > 0;
|
||||||
const hasDegraded = ns.racksDegraded > 0;
|
const hasDegraded = ns.racksDegraded > 0;
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Copy, Check, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
interface Invitation {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
createdBy: { username: string | null; email: string | null };
|
||||||
|
usedBy: { username: string | null; email: string | null } | null;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
used: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayUser(u: { username: string | null; email: string | null }): string {
|
||||||
|
return u.username || u.email || 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ used, expiresAt }: { used: boolean; expiresAt: string | null }) {
|
||||||
|
if (used) {
|
||||||
|
return <span className="text-xs px-2 py-0.5 rounded-full bg-surface-700 text-surface-400">Used</span>;
|
||||||
|
}
|
||||||
|
if (expiresAt && new Date(expiresAt) < new Date()) {
|
||||||
|
return <span className="text-xs px-2 py-0.5 rounded-full bg-danger/20 text-danger">Expired</span>;
|
||||||
|
}
|
||||||
|
return <span className="text-xs px-2 py-0.5 rounded-full bg-accent/20 text-accent">Active</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InvitationsPage() {
|
||||||
|
const [invitations, setInvitations] = useState<Invitation[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [copiedCode, setCopiedCode] = useState<string | null>(null);
|
||||||
|
const [revoking, setRevoking] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchInvitations = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await api.invites.list();
|
||||||
|
setInvitations(result.invitations);
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchInvitations(); }, [fetchInvitations]);
|
||||||
|
|
||||||
|
async function handleGenerate() {
|
||||||
|
setGenerating(true);
|
||||||
|
try {
|
||||||
|
const result = await api.invites.create();
|
||||||
|
const url = `${window.location.origin}?invite=${result.code}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
setCopiedCode(result.code);
|
||||||
|
setTimeout(() => setCopiedCode(null), 2000);
|
||||||
|
await fetchInvitations();
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevoke(id: string) {
|
||||||
|
setRevoking(id);
|
||||||
|
try {
|
||||||
|
await api.invites.revoke(id);
|
||||||
|
await fetchInvitations();
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
|
setRevoking(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopyCode(code: string) {
|
||||||
|
const url = `${window.location.origin}?invite=${code}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
setCopiedCode(code);
|
||||||
|
setTimeout(() => setCopiedCode(null), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeCount = invitations.filter(i => !i.used && (!i.expiresAt || new Date(i.expiresAt) > new Date())).length;
|
||||||
|
const usedCount = invitations.filter(i => i.used).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-4xl">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold">Invitations</h2>
|
||||||
|
<p className="text-sm text-surface-400 mt-1">
|
||||||
|
{activeCount} active, {usedCount} used, {invitations.length} total
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={fetchInvitations}
|
||||||
|
className="px-3 py-2 rounded bg-surface-800 hover:bg-surface-700 border border-surface-600 text-sm flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} /> Refresh
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={generating}
|
||||||
|
className="px-4 py-2 rounded bg-accent hover:bg-accent-dark text-white font-medium text-sm flex items-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Plus size={14} /> {generating ? 'Generating...' : 'Generate Invite'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-surface-900 border border-surface-700 rounded-xl overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 text-center text-surface-500">Loading invitations...</div>
|
||||||
|
) : invitations.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-surface-500">
|
||||||
|
No invitations yet. Generate one to get started.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-surface-700 text-left text-surface-400">
|
||||||
|
<th className="px-4 py-3 font-medium">Code</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Created By</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Used By</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Created</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Status</th>
|
||||||
|
<th className="px-4 py-3 font-medium w-10"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invitations.map((inv) => (
|
||||||
|
<tr key={inv.id} className="border-b border-surface-800 hover:bg-surface-800/50">
|
||||||
|
<td className="px-4 py-3 font-mono text-surface-200">{inv.code}</td>
|
||||||
|
<td className="px-4 py-3 text-surface-300">{displayUser(inv.createdBy)}</td>
|
||||||
|
<td className="px-4 py-3 text-surface-300">
|
||||||
|
{inv.usedBy ? displayUser(inv.usedBy) : <span className="text-surface-600">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-surface-400">
|
||||||
|
{new Date(inv.createdAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<StatusBadge used={inv.used} expiresAt={inv.expiresAt} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{!inv.used && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopyCode(inv.code)}
|
||||||
|
className="text-surface-400 hover:text-surface-200 transition-colors"
|
||||||
|
title="Copy invite link"
|
||||||
|
>
|
||||||
|
{copiedCode === inv.code ? <Check size={14} className="text-accent" /> : <Copy size={14} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRevoke(inv.id)}
|
||||||
|
disabled={revoking === inv.id}
|
||||||
|
className="text-surface-400 hover:text-danger transition-colors disabled:opacity-50"
|
||||||
|
title="Revoke invitation"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Trophy, Medal, Clock, TrendingUp } from 'lucide-react';
|
import { Trophy, Medal, Clock, TrendingUp } from 'lucide-react';
|
||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { formatMoney, formatNumber } from '@ai-tycoon/shared';
|
import { formatMoney, formatNumber } from '@token-empire/shared';
|
||||||
import { api, getAuthToken } from '@/lib/api';
|
import { api, getAuthToken } from '@/lib/api';
|
||||||
|
|
||||||
interface LeaderboardEntry {
|
interface LeaderboardEntry {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Play, Rocket, Globe, ChevronDown, ChevronUp, Beaker, Shield, Zap, BarChart3 } from 'lucide-react';
|
import { Play, Rocket, Globe, ChevronDown, ChevronUp, Beaker, Shield, Zap } from 'lucide-react';
|
||||||
import { TutorialHint } from '@/components/game/TutorialHint';
|
import { TutorialHint } from '@/components/game/TutorialHint';
|
||||||
import { ConfirmModal } from '@/components/common/ConfirmModal';
|
import { ConfirmModal } from '@/components/common/ConfirmModal';
|
||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
@@ -13,13 +13,12 @@ import {
|
|||||||
SIZE_TIER_LABELS,
|
SIZE_TIER_LABELS,
|
||||||
SFT_SPECIALIZATION_BONUSES,
|
SFT_SPECIALIZATION_BONUSES,
|
||||||
PRETRAINING_BASE_TICKS,
|
PRETRAINING_BASE_TICKS,
|
||||||
} from '@ai-tycoon/shared';
|
} from '@token-empire/shared';
|
||||||
import type {
|
import type {
|
||||||
ModelArchitecture, DataMixAllocation, SFTSpecialization, AlignmentMethod,
|
ModelArchitecture, DataMixAllocation, SFTSpecialization, AlignmentMethod,
|
||||||
DataDomain, QuantizationLevel, BaseModel, ModelVariant, BenchmarkResult,
|
DataDomain, QuantizationLevel, BaseModel, ModelVariant,
|
||||||
SizeTier, ModelFamily,
|
SizeTier, ModelFamily,
|
||||||
} from '@ai-tycoon/shared';
|
} from '@token-empire/shared';
|
||||||
import { BENCHMARKS } from '@ai-tycoon/game-engine';
|
|
||||||
|
|
||||||
const DATA_MIX_PRESETS: Record<string, { label: string; mix: DataMixAllocation }> = {
|
const DATA_MIX_PRESETS: Record<string, { label: string; mix: DataMixAllocation }> = {
|
||||||
balanced: { label: 'Balanced', mix: DEFAULT_DATA_MIX },
|
balanced: { label: 'Balanced', mix: DEFAULT_DATA_MIX },
|
||||||
@@ -52,8 +51,6 @@ export function ModelsPage() {
|
|||||||
const families = useGameStore((s) => s.models.families);
|
const families = useGameStore((s) => s.models.families);
|
||||||
const pipelines = useGameStore((s) => s.models.activeTrainingPipelines);
|
const pipelines = useGameStore((s) => s.models.activeTrainingPipelines);
|
||||||
const variantJobs = useGameStore((s) => s.models.variantJobs);
|
const variantJobs = useGameStore((s) => s.models.variantJobs);
|
||||||
const evalJobs = useGameStore((s) => s.models.evalJobs);
|
|
||||||
const benchmarkResults = useGameStore((s) => s.models.benchmarkResults);
|
|
||||||
const productLines = useGameStore((s) => s.models.productLines);
|
const productLines = useGameStore((s) => s.models.productLines);
|
||||||
const totalFlops = useGameStore((s) => s.compute.totalFlops);
|
const totalFlops = useGameStore((s) => s.compute.totalFlops);
|
||||||
const totalVramGB = useGameStore((s) => s.compute.totalVramGB);
|
const totalVramGB = useGameStore((s) => s.compute.totalVramGB);
|
||||||
@@ -64,7 +61,6 @@ export function ModelsPage() {
|
|||||||
const deployModel = useGameStore((s) => s.deployModel);
|
const deployModel = useGameStore((s) => s.deployModel);
|
||||||
const deployVariant = useGameStore((s) => s.deployVariant);
|
const deployVariant = useGameStore((s) => s.deployVariant);
|
||||||
const createQuantization = useGameStore((s) => s.createQuantization);
|
const createQuantization = useGameStore((s) => s.createQuantization);
|
||||||
const startEvaluation = useGameStore((s) => s.startEvaluation);
|
|
||||||
const setTrainingAllocation = useGameStore((s) => s.setTrainingAllocation);
|
const setTrainingAllocation = useGameStore((s) => s.setTrainingAllocation);
|
||||||
const openSourceModel = useGameStore((s) => s.openSourceModel);
|
const openSourceModel = useGameStore((s) => s.openSourceModel);
|
||||||
const openSourcedModels = useGameStore((s) => s.market.openSourcedModels);
|
const openSourcedModels = useGameStore((s) => s.market.openSourcedModels);
|
||||||
@@ -96,15 +92,12 @@ export function ModelsPage() {
|
|||||||
|
|
||||||
const activePipelines = pipelines.filter(p => p.status === 'active' || p.status === 'stalled');
|
const activePipelines = pipelines.filter(p => p.status === 'active' || p.status === 'stalled');
|
||||||
const activeVariantJobs = variantJobs.filter(j => j.status === 'active');
|
const activeVariantJobs = variantJobs.filter(j => j.status === 'active');
|
||||||
const activeEvalJobs = evalJobs.filter(j => j.status === 'active');
|
|
||||||
const undeployedCount = baseModels.filter(m => !m.isDeployed).length;
|
const undeployedCount = baseModels.filter(m => !m.isDeployed).length;
|
||||||
const hasActiveJobs = activePipelines.length > 0 || activeVariantJobs.length > 0 || activeEvalJobs.length > 0;
|
const hasActiveJobs = activePipelines.length > 0 || activeVariantJobs.length > 0;
|
||||||
const noModelDeployed = baseModels.length > 0 && !baseModels.some(m => m.isDeployed);
|
const noModelDeployed = baseModels.length > 0 && !baseModels.some(m => m.isDeployed);
|
||||||
|
|
||||||
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'] as const;
|
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'] as const;
|
||||||
const currentEraIdx = eraOrder.indexOf(currentEra);
|
const currentEraIdx = eraOrder.indexOf(currentEra);
|
||||||
const availableBenchmarks = BENCHMARKS.filter(b => eraOrder.indexOf(b.unlockedAtEra) <= currentEraIdx);
|
|
||||||
|
|
||||||
const hasAlignmentResearch = completedResearch.some(r =>
|
const hasAlignmentResearch = completedResearch.some(r =>
|
||||||
r === 'alignment-research' || r === 'interpretability' || r === 'constitutional-ai',
|
r === 'alignment-research' || r === 'interpretability' || r === 'constitutional-ai',
|
||||||
);
|
);
|
||||||
@@ -186,7 +179,6 @@ export function ModelsPage() {
|
|||||||
{ id: 'overview' as const, label: 'Overview' },
|
{ id: 'overview' as const, label: 'Overview' },
|
||||||
{ id: 'train' as const, label: 'Train New' },
|
{ id: 'train' as const, label: 'Train New' },
|
||||||
{ id: 'models' as const, label: `Families${families.length > 0 ? ` (${families.length})` : ''}` },
|
{ id: 'models' as const, label: `Families${families.length > 0 ? ` (${families.length})` : ''}` },
|
||||||
{ id: 'benchmarks' as const, label: 'Benchmarks' },
|
|
||||||
{ id: 'products' as const, label: 'Products' },
|
{ id: 'products' as const, label: 'Products' },
|
||||||
]).map(tab => (
|
]).map(tab => (
|
||||||
<button
|
<button
|
||||||
@@ -347,28 +339,6 @@ export function ModelsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Active Eval Jobs */}
|
|
||||||
{modelsTab === 'overview' && activeEvalJobs.length > 0 && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="font-semibold">Running Evaluations</h3>
|
|
||||||
{activeEvalJobs.map(job => {
|
|
||||||
const model = baseModels.find(m => m.id === job.modelId) ?? families.flatMap(f => f.variants).find(v => v.id === job.modelId);
|
|
||||||
const progress = job.progressTicks / job.totalTicks;
|
|
||||||
return (
|
|
||||||
<div key={job.id} className="bg-surface-900 border border-surface-700 rounded-xl p-3">
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<span className="text-sm">{model?.name ?? 'Unknown'} — {job.benchmarkIds.length} benchmarks</span>
|
|
||||||
<span className="text-xs text-surface-400">{formatPercent(progress)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-1.5 bg-surface-800 rounded-full overflow-hidden">
|
|
||||||
<div className="h-full bg-blue-500 rounded-full transition-all" style={{ width: `${progress * 100}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Train New Model */}
|
{/* Train New Model */}
|
||||||
{modelsTab === 'train' && <div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
|
{modelsTab === 'train' && <div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
|
||||||
<h3 className="font-semibold">Train New Model</h3>
|
<h3 className="font-semibold">Train New Model</h3>
|
||||||
@@ -716,9 +686,8 @@ export function ModelsPage() {
|
|||||||
{familyModels.map(model => (
|
{familyModels.map(model => (
|
||||||
<div key={model.id} className="space-y-3">
|
<div key={model.id} className="space-y-3">
|
||||||
<h5 className="text-sm font-medium text-surface-300">{model.name}</h5>
|
<h5 className="text-sm font-medium text-surface-300">{model.name}</h5>
|
||||||
<ModelDetails model={model} benchmarkResults={benchmarkResults} />
|
<ModelDetails model={model} />
|
||||||
<QuantizationCreator model={model} completedResearch={completedResearch} onQuantize={createQuantization} />
|
<QuantizationCreator model={model} completedResearch={completedResearch} onQuantize={createQuantization} />
|
||||||
<BenchmarkEvaluator modelId={model.id} modelName={model.name} availableBenchmarks={availableBenchmarks} benchmarkResults={benchmarkResults} evalJobs={evalJobs} onStartEval={startEvaluation} />
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -730,11 +699,7 @@ export function ModelsPage() {
|
|||||||
key={variant.id}
|
key={variant.id}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
familyId={family.id}
|
familyId={family.id}
|
||||||
benchmarkResults={benchmarkResults}
|
|
||||||
availableBenchmarks={availableBenchmarks}
|
|
||||||
evalJobs={evalJobs}
|
|
||||||
onDeploy={() => deployVariant(family.id, variant.id)}
|
onDeploy={() => deployVariant(family.id, variant.id)}
|
||||||
onStartEval={startEvaluation}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -747,21 +712,6 @@ export function ModelsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Benchmark Leaderboard */}
|
|
||||||
{modelsTab === 'benchmarks' && benchmarkResults.length > 0 && (
|
|
||||||
<BenchmarkLeaderboard
|
|
||||||
benchmarkResults={benchmarkResults}
|
|
||||||
baseModels={baseModels}
|
|
||||||
families={families}
|
|
||||||
availableBenchmarks={availableBenchmarks}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{modelsTab === 'benchmarks' && benchmarkResults.length === 0 && (
|
|
||||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-8 text-center text-surface-500 text-sm">
|
|
||||||
No benchmark results yet. Run evaluations from the Models tab.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Product Lines */}
|
{/* Product Lines */}
|
||||||
{modelsTab === 'products' && <div className="space-y-3">
|
{modelsTab === 'products' && <div className="space-y-3">
|
||||||
<h3 className="font-semibold">Product Lines</h3>
|
<h3 className="font-semibold">Product Lines</h3>
|
||||||
@@ -865,9 +815,7 @@ function ModelActions({ model, isOpenSourced, onDeploy, onOpenSource }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModelDetails({ model, benchmarkResults }: { model: BaseModel; benchmarkResults: BenchmarkResult[] }) {
|
function ModelDetails({ model }: { model: BaseModel }) {
|
||||||
const modelResults = benchmarkResults.filter(r => r.modelId === model.id);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-3 gap-3 text-xs">
|
<div className="grid grid-cols-3 gap-3 text-xs">
|
||||||
@@ -907,22 +855,6 @@ function ModelDetails({ model, benchmarkResults }: { model: BaseModel; benchmark
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{modelResults.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<span className="text-xs font-medium text-surface-300">Benchmark Scores</span>
|
|
||||||
<div className="grid grid-cols-3 gap-2 mt-1">
|
|
||||||
{modelResults.map(r => {
|
|
||||||
const bench = BENCHMARKS.find(b => b.id === r.benchmarkId);
|
|
||||||
return (
|
|
||||||
<div key={r.benchmarkId} className="bg-surface-800 rounded-lg p-2 text-xs">
|
|
||||||
<span className="text-surface-400">{bench?.name ?? r.benchmarkId}</span>
|
|
||||||
<div className="font-mono mt-0.5 text-accent-light">{r.score.toFixed(1)}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -981,91 +913,12 @@ function QuantizationCreator({ model, completedResearch, onQuantize }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BenchmarkEvaluator({ modelId, modelName, availableBenchmarks, benchmarkResults, evalJobs, onStartEval }: {
|
function VariantCard({ variant, familyId, onDeploy }: {
|
||||||
modelId: string;
|
|
||||||
modelName: string;
|
|
||||||
availableBenchmarks: typeof BENCHMARKS;
|
|
||||||
benchmarkResults: BenchmarkResult[];
|
|
||||||
evalJobs: { id: string; modelId: string; status: string }[];
|
|
||||||
onStartEval: (modelId: string, benchmarkIds: string[]) => void;
|
|
||||||
}) {
|
|
||||||
const [showEval, setShowEval] = useState(false);
|
|
||||||
const [selectedBenchmarks, setSelectedBenchmarks] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const existingResults = benchmarkResults.filter(r => r.modelId === modelId);
|
|
||||||
const evaluatedIds = new Set(existingResults.map(r => r.benchmarkId));
|
|
||||||
const isEvaluating = evalJobs.some(j => j.modelId === modelId && j.status === 'active');
|
|
||||||
const unevaluated = availableBenchmarks.filter(b => !evaluatedIds.has(b.id));
|
|
||||||
|
|
||||||
if (unevaluated.length === 0 && !showEval) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!showEval) {
|
|
||||||
return (
|
|
||||||
<button onClick={() => { setShowEval(true); setSelectedBenchmarks(unevaluated.map(b => b.id)); }}
|
|
||||||
disabled={isEvaluating}
|
|
||||||
className="flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300 disabled:opacity-50">
|
|
||||||
<BarChart3 size={12} /> Run Benchmarks ({unevaluated.length} available)
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-surface-800/50 rounded-lg p-3 space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs font-medium text-surface-300">Run Evaluation</span>
|
|
||||||
<button onClick={() => setShowEval(false)} className="text-xs text-surface-500 hover:text-surface-300">Close</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{availableBenchmarks.map(bench => {
|
|
||||||
const alreadyDone = evaluatedIds.has(bench.id);
|
|
||||||
const selected = selectedBenchmarks.includes(bench.id);
|
|
||||||
return (
|
|
||||||
<button key={bench.id}
|
|
||||||
disabled={alreadyDone}
|
|
||||||
onClick={() => setSelectedBenchmarks(prev =>
|
|
||||||
prev.includes(bench.id) ? prev.filter(id => id !== bench.id) : [...prev, bench.id]
|
|
||||||
)}
|
|
||||||
className={`px-2 py-0.5 rounded text-[10px] border ${
|
|
||||||
alreadyDone ? 'bg-success/10 border-success/30 text-success cursor-default' :
|
|
||||||
selected ? 'bg-blue-500/20 border-blue-500 text-blue-300' :
|
|
||||||
'bg-surface-800 border-surface-600 text-surface-400'
|
|
||||||
}`}
|
|
||||||
title={bench.description}
|
|
||||||
>
|
|
||||||
{bench.name} {alreadyDone ? `(${existingResults.find(r => r.benchmarkId === bench.id)?.score.toFixed(0)})` : ''}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{selectedBenchmarks.length > 0 && (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-[10px] text-surface-500">
|
|
||||||
{selectedBenchmarks.length} benchmark{selectedBenchmarks.length > 1 ? 's' : ''} · ~{availableBenchmarks.filter(b => selectedBenchmarks.includes(b.id)).reduce((s, b) => s + b.ticksToRun, 0)} ticks
|
|
||||||
</span>
|
|
||||||
<button onClick={() => { onStartEval(modelId, selectedBenchmarks); setShowEval(false); }}
|
|
||||||
disabled={isEvaluating}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white rounded px-3 py-1 text-xs disabled:opacity-50">
|
|
||||||
Evaluate
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function VariantCard({ variant, familyId, benchmarkResults, availableBenchmarks, evalJobs, onDeploy, onStartEval }: {
|
|
||||||
variant: ModelVariant;
|
variant: ModelVariant;
|
||||||
familyId: string;
|
familyId: string;
|
||||||
benchmarkResults: BenchmarkResult[];
|
|
||||||
availableBenchmarks: typeof BENCHMARKS;
|
|
||||||
evalJobs: { id: string; modelId: string; status: string }[];
|
|
||||||
onDeploy: () => void;
|
onDeploy: () => void;
|
||||||
onStartEval: (modelId: string, benchmarkIds: string[]) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const variantResults = benchmarkResults.filter(r => r.modelId === variant.id);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface-800/50 rounded-lg p-3 ml-4 border-l-2 border-surface-600">
|
<div className="bg-surface-800/50 rounded-lg p-3 ml-4 border-l-2 border-surface-600">
|
||||||
@@ -1106,104 +959,8 @@ function VariantCard({ variant, familyId, benchmarkResults, availableBenchmarks,
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{variantResults.length > 0 && (
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
{variantResults.map(r => {
|
|
||||||
const bench = BENCHMARKS.find(b => b.id === r.benchmarkId);
|
|
||||||
return (
|
|
||||||
<div key={r.benchmarkId} className="bg-surface-800 rounded p-1.5 text-xs">
|
|
||||||
<span className="text-surface-400 text-[10px]">{bench?.name ?? r.benchmarkId}</span>
|
|
||||||
<div className="font-mono text-accent-light text-[11px]">{r.score.toFixed(1)}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<BenchmarkEvaluator
|
|
||||||
modelId={variant.id}
|
|
||||||
modelName={variant.name}
|
|
||||||
availableBenchmarks={availableBenchmarks}
|
|
||||||
benchmarkResults={benchmarkResults}
|
|
||||||
evalJobs={evalJobs}
|
|
||||||
onStartEval={onStartEval}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BenchmarkLeaderboard({ benchmarkResults, baseModels, families, availableBenchmarks }: {
|
|
||||||
benchmarkResults: BenchmarkResult[];
|
|
||||||
baseModels: BaseModel[];
|
|
||||||
families: { id: string; name: string; variants: ModelVariant[] }[];
|
|
||||||
availableBenchmarks: typeof BENCHMARKS;
|
|
||||||
}) {
|
|
||||||
const allModels: (BaseModel | ModelVariant)[] = [
|
|
||||||
...baseModels,
|
|
||||||
...families.flatMap(f => f.variants),
|
|
||||||
];
|
|
||||||
|
|
||||||
const modelNames = new Map(allModels.map(m => [m.id, m.name]));
|
|
||||||
const benchmarksWithResults = availableBenchmarks.filter(b =>
|
|
||||||
benchmarkResults.some(r => r.benchmarkId === b.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (benchmarksWithResults.length === 0) return null;
|
|
||||||
|
|
||||||
const modelIds = [...new Set(benchmarkResults.map(r => r.modelId))];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
|
||||||
<h3 className="font-semibold mb-3 flex items-center gap-2">
|
|
||||||
<BarChart3 size={16} /> Benchmark Leaderboard
|
|
||||||
</h3>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-surface-700">
|
|
||||||
<th className="text-left py-1.5 pr-3 text-surface-400 font-medium">Model</th>
|
|
||||||
{benchmarksWithResults.map(b => (
|
|
||||||
<th key={b.id} className="text-center py-1.5 px-2 text-surface-400 font-medium">{b.name}</th>
|
|
||||||
))}
|
|
||||||
<th className="text-center py-1.5 px-2 text-surface-400 font-medium">Avg</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{modelIds.map(modelId => {
|
|
||||||
const results = benchmarkResults.filter(r => r.modelId === modelId);
|
|
||||||
const scores = benchmarksWithResults.map(b => {
|
|
||||||
const r = results.find(r => r.benchmarkId === b.id);
|
|
||||||
return r?.score ?? null;
|
|
||||||
});
|
|
||||||
const validScores = scores.filter((s): s is number => s !== null);
|
|
||||||
const avg = validScores.length > 0 ? validScores.reduce((a, b) => a + b, 0) / validScores.length : 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={modelId} className="border-b border-surface-800">
|
|
||||||
<td className="py-1.5 pr-3 font-medium">{modelNames.get(modelId) ?? 'Unknown'}</td>
|
|
||||||
{scores.map((score, i) => (
|
|
||||||
<td key={i} className="text-center py-1.5 px-2 font-mono">
|
|
||||||
{score !== null ? (
|
|
||||||
<span className={score >= 80 ? 'text-success' : score >= 50 ? 'text-accent-light' : 'text-surface-400'}>
|
|
||||||
{score.toFixed(1)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-surface-600">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
<td className="text-center py-1.5 px-2 font-mono font-medium text-accent-light">
|
|
||||||
{avg > 0 ? avg.toFixed(1) : '—'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { FlaskConical, Lock, Check, Play } from 'lucide-react';
|
import { FlaskConical, Lock, Check, Play, ListOrdered, X } from 'lucide-react';
|
||||||
import { TutorialHint } from '@/components/game/TutorialHint';
|
import { TutorialHint } from '@/components/game/TutorialHint';
|
||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { formatDuration, formatPercent, formatNumber } from '@ai-tycoon/shared';
|
import { formatDuration, formatPercent, formatNumber, formatMoney } from '@token-empire/shared';
|
||||||
import { TECH_TREE, getAvailableResearch } from '@ai-tycoon/game-engine';
|
import { TECH_TREE, getAvailableResearch } from '@token-empire/game-engine';
|
||||||
import type { ResearchNode } from '@ai-tycoon/shared';
|
import type { ResearchNode } from '@token-empire/shared';
|
||||||
|
|
||||||
const CATEGORY_COLORS: Record<string, string> = {
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
generation: 'border-purple-500/50 bg-purple-500/10',
|
generation: 'border-purple-500/50 bg-purple-500/10',
|
||||||
@@ -24,13 +24,17 @@ const CATEGORY_LABELS: Record<string, string> = {
|
|||||||
export function ResearchPage() {
|
export function ResearchPage() {
|
||||||
const completedResearch = useGameStore((s) => s.research.completedResearch);
|
const completedResearch = useGameStore((s) => s.research.completedResearch);
|
||||||
const activeResearch = useGameStore((s) => s.research.activeResearch);
|
const activeResearch = useGameStore((s) => s.research.activeResearch);
|
||||||
|
const researchQueue = useGameStore((s) => s.research.researchQueue);
|
||||||
const researchPoints = useGameStore((s) => s.research.researchPoints);
|
const researchPoints = useGameStore((s) => s.research.researchPoints);
|
||||||
const startResearch = useGameStore((s) => s.startResearch);
|
const startResearch = useGameStore((s) => s.startResearch);
|
||||||
|
const queueResearch = useGameStore((s) => s.queueResearch);
|
||||||
|
const removeFromResearchQueue = useGameStore((s) => s.removeFromResearchQueue);
|
||||||
const era = useGameStore((s) => s.meta.currentEra);
|
const era = useGameStore((s) => s.meta.currentEra);
|
||||||
|
|
||||||
const state = useGameStore.getState();
|
const state = useGameStore.getState();
|
||||||
const available = getAvailableResearch(state);
|
const available = getAvailableResearch(state);
|
||||||
const availableIds = new Set(available.map(n => n.id));
|
const availableIds = new Set(available.map(n => n.id));
|
||||||
|
const queuedIds = new Set(researchQueue);
|
||||||
|
|
||||||
const handleStart = (node: ResearchNode) => {
|
const handleStart = (node: ResearchNode) => {
|
||||||
if (activeResearch) return;
|
if (activeResearch) return;
|
||||||
@@ -40,9 +44,14 @@ export function ResearchPage() {
|
|||||||
totalTicks: node.cost.ticks,
|
totalTicks: node.cost.ticks,
|
||||||
allocatedResearchers: state.talent.departments.research.headcount,
|
allocatedResearchers: state.talent.departments.research.headcount,
|
||||||
allocatedCompute: node.cost.compute,
|
allocatedCompute: node.cost.compute,
|
||||||
|
moneySpent: 0,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleQueue = (node: ResearchNode) => {
|
||||||
|
queueResearch(node.id);
|
||||||
|
};
|
||||||
|
|
||||||
const categories = [...new Set(TECH_TREE.map(n => n.category))];
|
const categories = [...new Set(TECH_TREE.map(n => n.category))];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -60,7 +69,7 @@ export function ResearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TutorialHint id="research-intro">
|
<TutorialHint id="research-intro">
|
||||||
Only one research project can run at a time. Complete prerequisites to unlock advanced technologies that improve your models and infrastructure.
|
Queue up multiple research projects to run in sequence. Complete prerequisites to unlock advanced technologies that improve your models and infrastructure.
|
||||||
</TutorialHint>
|
</TutorialHint>
|
||||||
|
|
||||||
{activeResearch && (
|
{activeResearch && (
|
||||||
@@ -88,6 +97,36 @@ export function ResearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{researchQueue.length > 0 && (
|
||||||
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<ListOrdered size={16} className="text-surface-400" />
|
||||||
|
<span className="text-sm font-medium text-surface-300">Queue ({researchQueue.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{researchQueue.map((id, idx) => {
|
||||||
|
const node = TECH_TREE.find(n => n.id === id);
|
||||||
|
if (!node) return null;
|
||||||
|
return (
|
||||||
|
<div key={id} className="flex items-center justify-between bg-surface-800 rounded-lg px-3 py-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-surface-500 font-mono w-5">{idx + 1}.</span>
|
||||||
|
<span className="text-sm">{node.name}</span>
|
||||||
|
<span className="text-xs text-surface-500">{formatDuration(node.cost.ticks)}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => removeFromResearchQueue(id)}
|
||||||
|
className="text-surface-500 hover:text-red-400 transition-colors p-1"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{categories.map(category => {
|
{categories.map(category => {
|
||||||
const nodes = TECH_TREE.filter(n => n.category === category);
|
const nodes = TECH_TREE.filter(n => n.category === category);
|
||||||
return (
|
return (
|
||||||
@@ -99,43 +138,63 @@ export function ResearchPage() {
|
|||||||
{nodes.map(node => {
|
{nodes.map(node => {
|
||||||
const isCompleted = completedResearch.includes(node.id);
|
const isCompleted = completedResearch.includes(node.id);
|
||||||
const isActive = activeResearch?.researchId === node.id;
|
const isActive = activeResearch?.researchId === node.id;
|
||||||
|
const isQueued = queuedIds.has(node.id);
|
||||||
const isAvailable = availableIds.has(node.id);
|
const isAvailable = availableIds.has(node.id);
|
||||||
const isLocked = !isCompleted && !isActive && !isAvailable;
|
const isLocked = !isCompleted && !isActive && !isAvailable && !isQueued;
|
||||||
|
const canStart = isAvailable && !activeResearch;
|
||||||
|
const canQueue = isAvailable && !!activeResearch;
|
||||||
|
const queuePosition = isQueued ? researchQueue.indexOf(node.id) + 1 : -1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={node.id}
|
key={node.id}
|
||||||
onClick={() => isAvailable && !activeResearch && handleStart(node)}
|
onClick={() => canStart ? handleStart(node) : canQueue ? handleQueue(node) : undefined}
|
||||||
className={`rounded-xl border p-4 transition-all ${
|
className={`rounded-xl border p-4 transition-all ${
|
||||||
isCompleted ? 'border-success/50 bg-success/5 opacity-70' :
|
isCompleted ? 'border-success/50 bg-success/5 opacity-70' :
|
||||||
isActive ? 'border-accent/50 bg-accent/5' :
|
isActive ? 'border-accent/50 bg-accent/5' :
|
||||||
isAvailable && !activeResearch ? `${CATEGORY_COLORS[category]} hover:brightness-110 cursor-pointer ring-1 ring-transparent hover:ring-accent/30` :
|
isQueued ? 'border-amber-500/50 bg-amber-500/5' :
|
||||||
isAvailable ? `${CATEGORY_COLORS[category]}` :
|
(canStart || canQueue) ? `${CATEGORY_COLORS[category]} hover:brightness-110 cursor-pointer ring-1 ring-transparent hover:ring-accent/30` :
|
||||||
'border-surface-700 bg-surface-900 opacity-50'
|
'border-surface-700 bg-surface-900 opacity-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-2">
|
||||||
<h4 className="font-medium text-sm">{node.name}</h4>
|
<h4 className="font-medium text-sm">{node.name}</h4>
|
||||||
{isCompleted && <Check size={16} className="text-success flex-shrink-0" />}
|
{isCompleted && <Check size={16} className="text-success flex-shrink-0" />}
|
||||||
|
{isQueued && <span className="text-xs text-amber-400 font-mono flex-shrink-0">#{queuePosition}</span>}
|
||||||
{isLocked && <Lock size={14} className="text-surface-500 flex-shrink-0" />}
|
{isLocked && <Lock size={14} className="text-surface-500 flex-shrink-0" />}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-surface-400 mb-3">{node.description}</p>
|
<p className="text-xs text-surface-400 mb-3">{node.description}</p>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-xs text-surface-500">
|
<div className="text-xs text-surface-500">
|
||||||
{formatDuration(node.cost.ticks)} · {formatNumber(node.cost.compute)} compute
|
{formatMoney(node.cost.money)} · {formatDuration(node.cost.ticks)} · {formatNumber(node.cost.compute)} compute
|
||||||
{node.cost.researchPoints > 0 && ` · ${node.cost.researchPoints} RP`}
|
{node.cost.researchPoints > 0 && ` · ${node.cost.researchPoints} RP`}
|
||||||
</div>
|
</div>
|
||||||
{isAvailable && !activeResearch && (
|
{canStart && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleStart(node)}
|
onClick={(e) => { e.stopPropagation(); handleStart(node); }}
|
||||||
className="flex items-center gap-1 bg-accent hover:bg-accent-dark text-white rounded px-2 py-1 text-xs"
|
className="flex items-center gap-1 bg-accent hover:bg-accent-dark text-white rounded px-2 py-1 text-xs"
|
||||||
>
|
>
|
||||||
<Play size={12} />
|
<Play size={12} />
|
||||||
Start
|
Start
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isAvailable && activeResearch && (
|
{canQueue && (
|
||||||
<span className="text-xs text-surface-500 italic">Queue after current</span>
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleQueue(node); }}
|
||||||
|
className="flex items-center gap-1 bg-amber-600 hover:bg-amber-700 text-white rounded px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<ListOrdered size={12} />
|
||||||
|
Queue
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isQueued && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); removeFromResearchQueue(node.id); }}
|
||||||
|
className="flex items-center gap-1 bg-surface-700 hover:bg-red-900 text-surface-300 hover:text-red-300 rounded px-2 py-1 text-xs transition-colors"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{node.prerequisites.length > 0 && isLocked && (
|
{node.prerequisites.length > 0 && isLocked && (
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
formatNumber, formatPercent,
|
formatNumber, formatPercent,
|
||||||
type TrafficPriority, type OverflowBehavior, type RoutingStrategy,
|
type TrafficPriority, type OverflowBehavior, type RoutingStrategy,
|
||||||
TRAFFIC_PRIORITIES,
|
TRAFFIC_PRIORITIES,
|
||||||
} from '@ai-tycoon/shared';
|
} from '@token-empire/shared';
|
||||||
import {
|
import {
|
||||||
Activity, Shield, Clock, CheckCircle, XCircle, Layers,
|
Activity, Shield, Clock, CheckCircle, XCircle, Layers,
|
||||||
AlertTriangle, Zap, Server, ArrowRight,
|
AlertTriangle, Zap, Server, ArrowRight,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
|
import { Pencil, Check, X, LogOut } from 'lucide-react';
|
||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { ConfirmModal } from '@/components/common/ConfirmModal';
|
import { ConfirmModal } from '@/components/common/ConfirmModal';
|
||||||
|
import { api, setAuthToken, getTokenPayload, isRegistered, isAdmin, clearAuthToken } from '@/lib/api';
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const settings = useGameStore((s) => s.meta.settings);
|
const settings = useGameStore((s) => s.meta.settings);
|
||||||
@@ -11,6 +13,60 @@ export function SettingsPage() {
|
|||||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
const [importData, setImportData] = useState<{ data: unknown; name: string } | null>(null);
|
const [importData, setImportData] = useState<{ data: unknown; name: string } | null>(null);
|
||||||
|
|
||||||
|
const [editingUsername, setEditingUsername] = useState(false);
|
||||||
|
const [usernameValue, setUsernameValue] = useState('');
|
||||||
|
const [usernameError, setUsernameError] = useState('');
|
||||||
|
const [usernameSaving, setUsernameSaving] = useState(false);
|
||||||
|
|
||||||
|
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
|
||||||
|
|
||||||
|
const [editingEmail, setEditingEmail] = useState(false);
|
||||||
|
const [emailValue, setEmailValue] = useState('');
|
||||||
|
const [emailPassword, setEmailPassword] = useState('');
|
||||||
|
const [emailError, setEmailError] = useState('');
|
||||||
|
const [emailSaving, setEmailSaving] = useState(false);
|
||||||
|
|
||||||
|
async function handleSaveUsername() {
|
||||||
|
setUsernameError('');
|
||||||
|
if (!usernameValue || usernameValue.length < 2) {
|
||||||
|
setUsernameError('Username must be at least 2 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUsernameSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await api.auth.changeUsername(usernameValue);
|
||||||
|
setAuthToken(result.token);
|
||||||
|
setEditingUsername(false);
|
||||||
|
} catch (e) {
|
||||||
|
setUsernameError(e instanceof Error ? e.message : 'Failed to change username');
|
||||||
|
} finally {
|
||||||
|
setUsernameSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveEmail() {
|
||||||
|
setEmailError('');
|
||||||
|
if (!emailValue || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailValue)) {
|
||||||
|
setEmailError('Valid email required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!emailPassword) {
|
||||||
|
setEmailError('Current password required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEmailSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await api.auth.changeEmail(emailValue, emailPassword);
|
||||||
|
setAuthToken(result.token);
|
||||||
|
setEditingEmail(false);
|
||||||
|
setEmailPassword('');
|
||||||
|
} catch (e) {
|
||||||
|
setEmailError(e instanceof Error ? e.message : 'Failed to change email');
|
||||||
|
} finally {
|
||||||
|
setEmailSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggleSound = () => {
|
const toggleSound = () => {
|
||||||
updateState({ meta: { ...useGameStore.getState().meta, settings: { ...settings, soundEnabled: !settings.soundEnabled } } });
|
updateState({ meta: { ...useGameStore.getState().meta, settings: { ...settings, soundEnabled: !settings.soundEnabled } } });
|
||||||
};
|
};
|
||||||
@@ -20,7 +76,7 @@ export function SettingsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
localStorage.removeItem('ai-tycoon-save');
|
localStorage.removeItem('token-empire-save');
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -31,7 +87,7 @@ export function SettingsPage() {
|
|||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `ai-tycoon-${companyName.replace(/\s+/g, '-').toLowerCase()}.json`;
|
a.download = `token-empire-${companyName.replace(/\s+/g, '-').toLowerCase()}.json`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
@@ -50,7 +106,7 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
setImportData({ data, name: data.meta.companyName });
|
setImportData({ data, name: data.meta.companyName });
|
||||||
} catch {
|
} catch {
|
||||||
addNotification({ title: 'Import Failed', message: 'Could not read save file. Make sure it is a valid AI Tycoon export.', type: 'danger', tick: useGameStore.getState().meta.tickCount });
|
addNotification({ title: 'Import Failed', message: 'Could not read save file. Make sure it is a valid Token Empire export.', type: 'danger', tick: useGameStore.getState().meta.tickCount });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
@@ -59,14 +115,112 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
const confirmImport = () => {
|
const confirmImport = () => {
|
||||||
if (!importData) return;
|
if (!importData) return;
|
||||||
localStorage.setItem('ai-tycoon-save', JSON.stringify({ state: importData.data }));
|
localStorage.setItem('token-empire-save', JSON.stringify({ state: importData.data }));
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const payload = getTokenPayload();
|
||||||
|
const registered = isRegistered();
|
||||||
|
const admin = isAdmin();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-2xl">
|
<div className="space-y-6 max-w-2xl">
|
||||||
<h2 className="text-2xl font-bold">Settings</h2>
|
<h2 className="text-2xl font-bold">Settings</h2>
|
||||||
|
|
||||||
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
|
||||||
|
<h3 className="font-semibold">Account</h3>
|
||||||
|
{registered ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{payload?.email != null && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm">Email</div>
|
||||||
|
{editingEmail ? (
|
||||||
|
<div className="mt-1 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={emailValue}
|
||||||
|
onChange={(e) => setEmailValue(e.target.value)}
|
||||||
|
className="bg-surface-800 border border-surface-600 rounded px-2 py-1 text-sm w-56"
|
||||||
|
placeholder="New email"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button onClick={handleSaveEmail} disabled={emailSaving}
|
||||||
|
className="text-accent hover:text-accent-light disabled:opacity-50"><Check size={16} /></button>
|
||||||
|
<button onClick={() => { setEditingEmail(false); setEmailError(''); setEmailPassword(''); }}
|
||||||
|
className="text-surface-400 hover:text-surface-200"><X size={16} /></button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={emailPassword}
|
||||||
|
onChange={(e) => setEmailPassword(e.target.value)}
|
||||||
|
className="bg-surface-800 border border-surface-600 rounded px-2 py-1 text-sm w-56"
|
||||||
|
placeholder="Current password"
|
||||||
|
/>
|
||||||
|
{emailError && <p className="text-xs text-danger">{emailError}</p>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-surface-400">{payload.email}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!editingEmail && (
|
||||||
|
<button onClick={() => { setEmailValue(payload.email ?? ''); setEditingEmail(true); }}
|
||||||
|
className="text-surface-400 hover:text-surface-200"><Pencil size={14} /></button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm">Username</div>
|
||||||
|
{editingUsername ? (
|
||||||
|
<div className="mt-1 space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={usernameValue}
|
||||||
|
onChange={(e) => setUsernameValue(e.target.value)}
|
||||||
|
className="bg-surface-800 border border-surface-600 rounded px-2 py-1 text-sm w-48"
|
||||||
|
placeholder="Username"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button onClick={handleSaveUsername} disabled={usernameSaving}
|
||||||
|
className="text-accent hover:text-accent-light disabled:opacity-50"><Check size={16} /></button>
|
||||||
|
<button onClick={() => { setEditingUsername(false); setUsernameError(''); }}
|
||||||
|
className="text-surface-400 hover:text-surface-200"><X size={16} /></button>
|
||||||
|
</div>
|
||||||
|
{usernameError && <p className="text-xs text-danger">{usernameError}</p>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-surface-400">{payload?.username ?? 'Not set'}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!editingUsername && (
|
||||||
|
<button onClick={() => { setUsernameValue(payload?.username ?? ''); setEditingUsername(true); }}
|
||||||
|
className="text-surface-400 hover:text-surface-200"><Pencil size={14} /></button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{admin && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-accent/20 text-accent font-medium">Admin</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-surface-400">Playing as guest.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-surface-700">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLogoutConfirm(true)}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded bg-surface-800 hover:bg-surface-700 border border-surface-600 text-sm text-surface-300 hover:text-surface-100 transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut size={14} />
|
||||||
|
{registered ? 'Log Out' : 'Sign Out'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
|
||||||
<h3 className="font-semibold">Game</h3>
|
<h3 className="font-semibold">Game</h3>
|
||||||
|
|
||||||
@@ -148,6 +302,24 @@ export function SettingsPage() {
|
|||||||
onCancel={() => setImportData(null)}
|
onCancel={() => setImportData(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showLogoutConfirm && (
|
||||||
|
<ConfirmModal
|
||||||
|
title={registered ? 'Log Out' : 'Sign Out'}
|
||||||
|
message={registered
|
||||||
|
? 'You will be logged out. Your game progress is saved to the cloud and will be available when you log back in.'
|
||||||
|
: 'You will be signed out. As a guest, your local progress will be lost. Consider registering first to save your progress.'}
|
||||||
|
confirmLabel={registered ? 'Log Out' : 'Sign Out'}
|
||||||
|
danger={!registered}
|
||||||
|
onConfirm={async () => {
|
||||||
|
try { await api.auth.logout(); } catch {}
|
||||||
|
clearAuthToken();
|
||||||
|
localStorage.removeItem('token-empire-save');
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
onCancel={() => setShowLogoutConfirm(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Users, Plus, Star, Briefcase } from 'lucide-react';
|
import { Users, Plus, Star, Briefcase } from 'lucide-react';
|
||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { formatMoney } from '@ai-tycoon/shared';
|
import { formatMoney } from '@token-empire/shared';
|
||||||
import { KEY_HIRE_POOL } from '@ai-tycoon/game-engine';
|
import { KEY_HIRE_POOL } from '@token-empire/game-engine';
|
||||||
import type { DepartmentId } from '@ai-tycoon/shared';
|
import type { DepartmentId } from '@token-empire/shared';
|
||||||
|
|
||||||
const DEPT_LABELS: Record<string, string> = {
|
const DEPT_LABELS: Record<string, string> = {
|
||||||
research: 'Research',
|
research: 'Research',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared';
|
import { formatNumber, formatMoney, formatPercent } from '@token-empire/shared';
|
||||||
import type { ApiTierId } from '@ai-tycoon/shared';
|
import type { ApiTierId } from '@token-empire/shared';
|
||||||
import { Code, Check } from 'lucide-react';
|
import { Code, Check } from 'lucide-react';
|
||||||
|
|
||||||
const TIER_ORDER: ApiTierId[] = ['free', 'payg', 'scale', 'enterprise-api'];
|
const TIER_ORDER: ApiTierId[] = ['free', 'payg', 'scale', 'enterprise-api'];
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared';
|
import { formatNumber, formatMoney, formatPercent } from '@token-empire/shared';
|
||||||
import type { ConsumerTierId } from '@ai-tycoon/shared';
|
import type { ConsumerTierId } from '@token-empire/shared';
|
||||||
import { Users, Check } from 'lucide-react';
|
import { Users, Check } from 'lucide-react';
|
||||||
|
|
||||||
const TIER_ORDER: ConsumerTierId[] = ['free', 'plus', 'pro', 'team'];
|
const TIER_ORDER: ConsumerTierId[] = ['free', 'plus', 'pro', 'team'];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared';
|
import { formatNumber, formatMoney, formatPercent } from '@token-empire/shared';
|
||||||
import { Boxes, Check } from 'lucide-react';
|
import { Boxes, Check } from 'lucide-react';
|
||||||
|
|
||||||
function useAppliedFeedback() {
|
function useAppliedFeedback() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared';
|
import { formatNumber, formatMoney, formatPercent } from '@token-empire/shared';
|
||||||
import type { EnterprisePipelineStage, EnterpriseSegment } from '@ai-tycoon/shared';
|
import type { EnterprisePipelineStage, EnterpriseSegment } from '@token-empire/shared';
|
||||||
import { Building2, AlertTriangle } from 'lucide-react';
|
import { Building2, AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
const STAGE_ORDER: EnterprisePipelineStage[] = ['lead', 'qualification', 'poc', 'negotiation'];
|
const STAGE_ORDER: EnterprisePipelineStage[] = ['lead', 'qualification', 'poc', 'negotiation'];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { formatNumber, formatPercent } from '@ai-tycoon/shared';
|
import { formatNumber, formatPercent } from '@token-empire/shared';
|
||||||
import type { TAMSegmentId } from '@ai-tycoon/shared';
|
import type { TAMSegmentId } from '@token-empire/shared';
|
||||||
import { Globe, TrendingUp, Clock, Thermometer } from 'lucide-react';
|
import { Globe, TrendingUp, Clock, Thermometer } from 'lucide-react';
|
||||||
|
|
||||||
const SEGMENT_LABELS: Record<TAMSegmentId, string> = {
|
const SEGMENT_LABELS: Record<TAMSegmentId, string> = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared';
|
import { formatNumber, formatMoney, formatPercent } from '@token-empire/shared';
|
||||||
import { Wrench, Bot, Check, Lock } from 'lucide-react';
|
import { Wrench, Bot, Check, Lock } from 'lucide-react';
|
||||||
|
|
||||||
function useAppliedFeedback() {
|
function useAppliedFeedback() {
|
||||||
|
|||||||
+53
-36
@@ -15,9 +15,8 @@ import type {
|
|||||||
TrainingPipeline, ModelFamily, DataMixAllocation,
|
TrainingPipeline, ModelFamily, DataMixAllocation,
|
||||||
ModelArchitecture, AlignmentMethod, SizeTier,
|
ModelArchitecture, AlignmentMethod, SizeTier,
|
||||||
SFTSpecialization, QuantizationLevel, VariantCreationJob,
|
SFTSpecialization, QuantizationLevel, VariantCreationJob,
|
||||||
EvalJob,
|
|
||||||
ConsumerTierId, ApiTierId,
|
ConsumerTierId, ApiTierId,
|
||||||
} from '@ai-tycoon/shared';
|
} from '@token-empire/shared';
|
||||||
import {
|
import {
|
||||||
INITIAL_SETTINGS, SAVE_VERSION,
|
INITIAL_SETTINGS, SAVE_VERSION,
|
||||||
INITIAL_ECONOMY, INITIAL_INFRASTRUCTURE, INITIAL_COMPUTE,
|
INITIAL_ECONOMY, INITIAL_INFRASTRUCTURE, INITIAL_COMPUTE,
|
||||||
@@ -40,15 +39,15 @@ import {
|
|||||||
SFT_TIME_FRACTION, ALIGNMENT_TIME_FRACTION,
|
SFT_TIME_FRACTION, ALIGNMENT_TIME_FRACTION,
|
||||||
SIZE_TIER_MAP, SIZE_TIER_LABELS,
|
SIZE_TIER_MAP, SIZE_TIER_LABELS,
|
||||||
POINT_RELEASE_TIME_FRACTION, POINT_RELEASE_MAX_VERSION,
|
POINT_RELEASE_TIME_FRACTION, POINT_RELEASE_MAX_VERSION,
|
||||||
} from '@ai-tycoon/shared';
|
} from '@token-empire/shared';
|
||||||
import {
|
import {
|
||||||
emptyDCNetworkSummary, emptyCampusNetworkSummary, emptyClusterNetworkSummary,
|
emptyDCNetworkSummary, emptyCampusNetworkSummary, emptyClusterNetworkSummary,
|
||||||
BENCHMARKS, TECH_TREE, onModelDeployed,
|
TECH_TREE, onModelDeployed,
|
||||||
} from '@ai-tycoon/game-engine';
|
} from '@token-empire/game-engine';
|
||||||
import { INITIAL_RIVALS } from '@ai-tycoon/game-engine';
|
import { INITIAL_RIVALS } from '@token-empire/game-engine';
|
||||||
|
|
||||||
export type ActivePage = 'dashboard' | 'infrastructure' | 'research' | 'models'
|
export type ActivePage = 'dashboard' | 'infrastructure' | 'research' | 'models'
|
||||||
| 'market' | 'serving' | 'talent' | 'data' | 'competitors' | 'finance' | 'achievements' | 'leaderboard' | 'settings';
|
| 'market' | 'serving' | 'talent' | 'data' | 'competitors' | 'finance' | 'achievements' | 'leaderboard' | 'invitations' | 'settings';
|
||||||
|
|
||||||
export type InfraNavLevel = 'clusters' | 'cluster' | 'campus' | 'datacenter';
|
export type InfraNavLevel = 'clusters' | 'cluster' | 'campus' | 'datacenter';
|
||||||
|
|
||||||
@@ -59,7 +58,7 @@ export interface InfraNav {
|
|||||||
datacenterId?: string;
|
datacenterId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModelsTab = 'overview' | 'train' | 'models' | 'benchmarks' | 'products';
|
type ModelsTab = 'overview' | 'train' | 'models' | 'products';
|
||||||
|
|
||||||
interface UIState {
|
interface UIState {
|
||||||
activePage: ActivePage;
|
activePage: ActivePage;
|
||||||
@@ -132,12 +131,13 @@ interface Actions {
|
|||||||
}) => void;
|
}) => void;
|
||||||
startPointRelease: (baseModelId: string) => void;
|
startPointRelease: (baseModelId: string) => void;
|
||||||
createQuantization: (baseModelId: string, level: QuantizationLevel, variantName: string) => void;
|
createQuantization: (baseModelId: string, level: QuantizationLevel, variantName: string) => void;
|
||||||
startEvaluation: (modelId: string, benchmarkIds: string[]) => void;
|
|
||||||
deployModel: (modelId: string) => void;
|
deployModel: (modelId: string) => void;
|
||||||
deployVariant: (familyId: string, variantId: string) => void;
|
deployVariant: (familyId: string, variantId: string) => void;
|
||||||
setProductPricing: (productLineId: string, field: string, value: number) => void;
|
setProductPricing: (productLineId: string, field: string, value: number) => void;
|
||||||
toggleProductLine: (productLineId: string) => void;
|
toggleProductLine: (productLineId: string) => void;
|
||||||
startResearch: (research: ActiveResearch) => void;
|
startResearch: (research: ActiveResearch) => void;
|
||||||
|
queueResearch: (researchId: string) => void;
|
||||||
|
removeFromResearchQueue: (researchId: string) => void;
|
||||||
hireDepartment: (departmentId: string, count: number) => void;
|
hireDepartment: (departmentId: string, count: number) => void;
|
||||||
purchaseDataset: (dataset: OwnedDataset, cost: number) => void;
|
purchaseDataset: (dataset: OwnedDataset, cost: number) => void;
|
||||||
raiseFunding: (roundType: FundingRoundType) => void;
|
raiseFunding: (roundType: FundingRoundType) => void;
|
||||||
@@ -1074,32 +1074,6 @@ export const useGameStore = create<Store>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
startEvaluation: (modelId, benchmarkIds) => {
|
|
||||||
let created = false;
|
|
||||||
set((s) => {
|
|
||||||
const benchmarks = BENCHMARKS.filter(b => benchmarkIds.includes(b.id));
|
|
||||||
if (benchmarks.length === 0) return s;
|
|
||||||
created = true;
|
|
||||||
const totalTicks = benchmarks.reduce((sum, b) => sum + b.ticksToRun, 0);
|
|
||||||
const computeCost = benchmarks.reduce((sum, b) => sum + b.computeCost, 0);
|
|
||||||
const job: EvalJob = {
|
|
||||||
id: uuid(),
|
|
||||||
modelId,
|
|
||||||
benchmarkIds,
|
|
||||||
progressTicks: 0,
|
|
||||||
totalTicks,
|
|
||||||
computeAllocated: computeCost,
|
|
||||||
status: 'active',
|
|
||||||
results: [],
|
|
||||||
};
|
|
||||||
return { models: { ...s.models, evalJobs: [...s.models.evalJobs, job] } };
|
|
||||||
});
|
|
||||||
if (created) {
|
|
||||||
get().addNotification({ title: 'Evaluation Started', message: `${benchmarkIds.length} benchmark${benchmarkIds.length > 1 ? 's' : ''} queued.`, type: 'info', tick: get().meta.tickCount });
|
|
||||||
set({ modelsTab: 'overview' as ModelsTab });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
deployModel: (modelId) => {
|
deployModel: (modelId) => {
|
||||||
const modelName = get().models.baseModels.find(m => m.id === modelId)?.name ?? 'Model';
|
const modelName = get().models.baseModels.find(m => m.id === modelId)?.name ?? 'Model';
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
@@ -1111,6 +1085,7 @@ export const useGameStore = create<Store>()(
|
|||||||
productLines: s.models.productLines.map(pl => ({
|
productLines: s.models.productLines.map(pl => ({
|
||||||
...pl, modelId, isActive: true,
|
...pl, modelId, isActive: true,
|
||||||
})),
|
})),
|
||||||
|
deploymentVersion: s.models.deploymentVersion + 1,
|
||||||
},
|
},
|
||||||
market: {
|
market: {
|
||||||
...s.market,
|
...s.market,
|
||||||
@@ -1130,6 +1105,7 @@ export const useGameStore = create<Store>()(
|
|||||||
? { ...f, variants: f.variants.map(v => v.id === variantId ? { ...v, isDeployed: true } : v) }
|
? { ...f, variants: f.variants.map(v => v.id === variantId ? { ...v, isDeployed: true } : v) }
|
||||||
: f,
|
: f,
|
||||||
),
|
),
|
||||||
|
deploymentVersion: s.models.deploymentVersion + 1,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
get().addNotification({ title: 'Variant Deployed', message: 'Variant is now live.', type: 'success', tick: get().meta.tickCount });
|
get().addNotification({ title: 'Variant Deployed', message: 'Variant is now live.', type: 'success', tick: get().meta.tickCount });
|
||||||
@@ -1170,6 +1146,35 @@ export const useGameStore = create<Store>()(
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
queueResearch: (researchId) => set((s) => {
|
||||||
|
if (s.research.researchQueue.includes(researchId)) return s;
|
||||||
|
const node = TECH_TREE.find(n => n.id === researchId);
|
||||||
|
if (!node) return s;
|
||||||
|
const rpCost = node.cost.researchPoints ?? 0;
|
||||||
|
if (rpCost > s.research.researchPoints) return s;
|
||||||
|
return {
|
||||||
|
research: {
|
||||||
|
...s.research,
|
||||||
|
researchQueue: [...s.research.researchQueue, researchId],
|
||||||
|
researchPoints: s.research.researchPoints - rpCost,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
removeFromResearchQueue: (researchId) => set((s) => {
|
||||||
|
const idx = s.research.researchQueue.indexOf(researchId);
|
||||||
|
if (idx === -1) return s;
|
||||||
|
const node = TECH_TREE.find(n => n.id === researchId);
|
||||||
|
const rpRefund = node?.cost.researchPoints ?? 0;
|
||||||
|
return {
|
||||||
|
research: {
|
||||||
|
...s.research,
|
||||||
|
researchQueue: s.research.researchQueue.filter(id => id !== researchId),
|
||||||
|
researchPoints: s.research.researchPoints + rpRefund,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
hireDepartment: (departmentId, count) => set((s) => {
|
hireDepartment: (departmentId, count) => set((s) => {
|
||||||
const costPerHire = 2000;
|
const costPerHire = 2000;
|
||||||
const totalCost = costPerHire * count;
|
const totalCost = costPerHire * count;
|
||||||
@@ -1423,7 +1428,7 @@ export const useGameStore = create<Store>()(
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'ai-tycoon-save',
|
name: 'token-empire-save',
|
||||||
version: SAVE_VERSION,
|
version: SAVE_VERSION,
|
||||||
partialize: (state) => {
|
partialize: (state) => {
|
||||||
const { activePage, notifications, infraNav, modelsTab, ...rest } = state;
|
const { activePage, notifications, infraNav, modelsTab, ...rest } = state;
|
||||||
@@ -1452,6 +1457,18 @@ export const useGameStore = create<Store>()(
|
|||||||
}
|
}
|
||||||
return _persisted as Store;
|
return _persisted as Store;
|
||||||
},
|
},
|
||||||
|
merge: (persisted, current) => {
|
||||||
|
const p = persisted as Record<string, unknown>;
|
||||||
|
const c = current as unknown as Record<string, unknown>;
|
||||||
|
const merged = { ...c, ...p };
|
||||||
|
for (const key of Object.keys(c)) {
|
||||||
|
if (p[key] != null && typeof p[key] === 'object' && !Array.isArray(p[key])
|
||||||
|
&& typeof c[key] === 'object' && !Array.isArray(c[key])) {
|
||||||
|
merged[key] = { ...c[key] as Record<string, unknown>, ...p[key] as Record<string, unknown> };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged as unknown as Store;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "@ai-tycoon/tsconfig/react.json",
|
"extends": "@token-empire/tsconfig/react.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
+10
-7
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
image: gitea.thewrightserver.net/josh/aihostingtycoon/web:latest
|
image: gitea.thewrightserver.net/josh/tokenempire/web:latest
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -8,13 +8,16 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
server:
|
server:
|
||||||
image: gitea.thewrightserver.net/josh/aihostingtycoon/server:latest
|
image: gitea.thewrightserver.net/josh/tokenempire/server:latest
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql://aitycoon:aitycoon@db:5432/aitycoon
|
- DATABASE_URL=postgresql://tokenempire:tokenempire@db:5432/tokenempire
|
||||||
- PORT=3001
|
- PORT=3001
|
||||||
- CORS_ORIGIN=*
|
- CORS_ORIGIN=*
|
||||||
|
- JWT_SECRET=change-me-to-a-random-secret
|
||||||
|
- REQUIRE_INVITE=true
|
||||||
|
- USER_INVITATIONS=0
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -23,13 +26,13 @@ services:
|
|||||||
db:
|
db:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=aitycoon
|
- POSTGRES_USER=tokenempire
|
||||||
- POSTGRES_PASSWORD=aitycoon
|
- POSTGRES_PASSWORD=tokenempire
|
||||||
- POSTGRES_DB=aitycoon
|
- POSTGRES_DB=tokenempire
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U aitycoon"]
|
test: ["CMD-SHELL", "pg_isready -U tokenempire"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ The game engine and simulation core have no React dependency. They can run ident
|
|||||||
## Monorepo Layout
|
## Monorepo Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
ai-tycoon/
|
token-empire/
|
||||||
├── turbo.json # Turborepo task config
|
├── turbo.json # Turborepo task config
|
||||||
├── pnpm-workspace.yaml # Workspace definition
|
├── pnpm-workspace.yaml # Workspace definition
|
||||||
│
|
│
|
||||||
@@ -122,7 +122,7 @@ The store uses a slice pattern with 14 slices, each owning a portion of the game
|
|||||||
|
|
||||||
### Persistence
|
### Persistence
|
||||||
|
|
||||||
- **localStorage**: Auto-save every 60 ticks under key `ai-tycoon-save`. The Zustand `persist` middleware handles serialization.
|
- **localStorage**: Auto-save every 60 ticks under key `token-empire-save`. The Zustand `persist` middleware handles serialization.
|
||||||
- **Cloud saves**: Optional. POST to `/api/saves` every 5 minutes when authenticated. Requires the Hono backend + PostgreSQL.
|
- **Cloud saves**: Optional. POST to `/api/saves` every 5 minutes when authenticated. Requires the Hono backend + PostgreSQL.
|
||||||
- **Save format versioning**: A `version` field in meta enables migration functions for breaking state changes.
|
- **Save format versioning**: A `version` field in meta enables migration functions for breaking state changes.
|
||||||
|
|
||||||
|
|||||||
+10
-5
@@ -1,21 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "ai-tycoon",
|
"name": "token-empire",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "turbo dev",
|
"dev": "turbo dev",
|
||||||
"build": "turbo build",
|
"build": "turbo build",
|
||||||
"typecheck": "turbo typecheck",
|
"typecheck": "turbo typecheck",
|
||||||
"lint": "turbo lint",
|
"lint": "turbo lint",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"clean": "turbo clean",
|
"clean": "turbo clean",
|
||||||
"simulate": "turbo simulate --filter=@ai-tycoon/game-simulation",
|
"simulate": "turbo simulate --filter=@token-empire/game-simulation",
|
||||||
"simulate:ci": "pnpm --filter @ai-tycoon/game-simulation simulate:ci"
|
"simulate:ci": "pnpm --filter @token-empire/game-simulation simulate:ci"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"turbo": "^2.5.0",
|
"turbo": "^2.5.0",
|
||||||
"typescript": "^5.8.0"
|
"typescript": "^5.8.0",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.33.0",
|
"packageManager": "pnpm@10.33.0",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": ["esbuild"]
|
"onlyBuiltDependencies": [
|
||||||
|
"esbuild"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@ai-tycoon/game-engine",
|
"name": "@token-empire/game-engine",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -7,13 +7,14 @@
|
|||||||
"types": "./src/index.ts",
|
"types": "./src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-tycoon/shared": "workspace:*"
|
"@token-empire/shared": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ai-tycoon/tsconfig": "workspace:*",
|
"@token-empire/tsconfig": "workspace:*",
|
||||||
"typescript": "^5.8.0"
|
"typescript": "^5.8.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import type {
|
||||||
|
Cluster, Campus, DataCenter, DeploymentCohort,
|
||||||
|
DCNetworkSummary, CampusNetworkSummary, ClusterNetworkSummary,
|
||||||
|
TrainingPipeline, BaseModel, ModelFamily,
|
||||||
|
} from '@token-empire/shared';
|
||||||
|
import { uuid } from '@token-empire/shared';
|
||||||
|
import type { DeepPartial } from './createTestState';
|
||||||
|
|
||||||
|
function emptyDCNetwork(): DCNetworkSummary {
|
||||||
|
return {
|
||||||
|
totalByTier: {},
|
||||||
|
healthyByTier: {},
|
||||||
|
repairBatches: [],
|
||||||
|
networkRackCount: 0,
|
||||||
|
racksDisconnected: 0,
|
||||||
|
racksDegraded: 0,
|
||||||
|
averageBandwidth: 1,
|
||||||
|
effectiveFlopsFraction: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyCampusNetwork(): CampusNetworkSummary {
|
||||||
|
return { totalT4: 0, healthyT4: 0, crossDCBandwidth: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyClusterNetwork(): ClusterNetworkSummary {
|
||||||
|
return { totalT5: 0, healthyT5: 0, crossCampusBandwidth: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTestDataCenter(overrides?: DeepPartial<DataCenter>): DataCenter {
|
||||||
|
const base: DataCenter = {
|
||||||
|
id: uuid(),
|
||||||
|
name: 'Test DC',
|
||||||
|
campusId: '',
|
||||||
|
tier: 'small',
|
||||||
|
status: 'operational',
|
||||||
|
constructionProgress: 0,
|
||||||
|
constructionTotal: 0,
|
||||||
|
rackSkuId: 't4-x4',
|
||||||
|
computeRacksOnline: 4,
|
||||||
|
computeRacksFailed: 0,
|
||||||
|
networkSummary: emptyDCNetwork(),
|
||||||
|
deploymentCohorts: [],
|
||||||
|
retrofitState: null,
|
||||||
|
coolingLevel: 0,
|
||||||
|
redundancyLevel: 0,
|
||||||
|
coolingType: 'air',
|
||||||
|
networkFabric: 'ethernet-100g',
|
||||||
|
effectiveComputeRacks: 4,
|
||||||
|
usedSlots: 4,
|
||||||
|
usedPowerKW: 20,
|
||||||
|
energyCostPerTick: 5,
|
||||||
|
maintenanceCostPerTick: 2,
|
||||||
|
currentUptime: 1,
|
||||||
|
dcTrainingFlops: 1e12,
|
||||||
|
dcInferenceFlops: 1e12,
|
||||||
|
dcTotalVramGB: 64,
|
||||||
|
};
|
||||||
|
return overrides ? { ...base, ...overrides } as DataCenter : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTestCampus(overrides?: DeepPartial<Campus>): Campus {
|
||||||
|
const dc = createTestDataCenter();
|
||||||
|
const campusId = uuid();
|
||||||
|
dc.campusId = campusId;
|
||||||
|
const base: Campus = {
|
||||||
|
id: campusId,
|
||||||
|
name: 'Test Campus',
|
||||||
|
clusterId: '',
|
||||||
|
dcTier: 'small',
|
||||||
|
dataCenters: [dc],
|
||||||
|
status: 'operational',
|
||||||
|
constructionProgress: 0,
|
||||||
|
constructionTotal: 0,
|
||||||
|
retrofitQueue: null,
|
||||||
|
networkSummary: emptyCampusNetwork(),
|
||||||
|
};
|
||||||
|
return overrides ? { ...base, ...overrides } as Campus : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTestCluster(overrides?: DeepPartial<Cluster>): Cluster {
|
||||||
|
const campus = createTestCampus();
|
||||||
|
const clusterId = uuid();
|
||||||
|
campus.clusterId = clusterId;
|
||||||
|
campus.dataCenters[0].campusId = campus.id;
|
||||||
|
const base: Cluster = {
|
||||||
|
id: clusterId,
|
||||||
|
name: 'Test Cluster',
|
||||||
|
locationId: 'us-east',
|
||||||
|
campuses: [campus],
|
||||||
|
status: 'operational',
|
||||||
|
constructionProgress: 0,
|
||||||
|
constructionTotal: 0,
|
||||||
|
networkSummary: emptyClusterNetwork(),
|
||||||
|
};
|
||||||
|
return overrides ? { ...base, ...overrides } as Cluster : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTestTrainingPipeline(overrides?: DeepPartial<TrainingPipeline>): TrainingPipeline {
|
||||||
|
const base: TrainingPipeline = {
|
||||||
|
id: uuid(),
|
||||||
|
familyId: 'test-family',
|
||||||
|
modelName: 'Test Model',
|
||||||
|
architecture: {
|
||||||
|
type: 'dense',
|
||||||
|
totalParameters: 7e9,
|
||||||
|
activeParameters: 7e9,
|
||||||
|
contextWindow: 8192,
|
||||||
|
vocabularySize: 32000,
|
||||||
|
},
|
||||||
|
dataMix: { web: 0.4, code: 0.2, books: 0.15, academic: 0.1, conversational: 0.1, specialized: 0.05 },
|
||||||
|
currentStage: 'pretraining',
|
||||||
|
stages: {
|
||||||
|
pretraining: {
|
||||||
|
targetTokens: 1e12,
|
||||||
|
processedTokens: 0,
|
||||||
|
computeAllocated: 0,
|
||||||
|
progressTicks: 0,
|
||||||
|
totalTicks: 1000,
|
||||||
|
lossValue: 4.0,
|
||||||
|
chinchillaRatio: 1.0,
|
||||||
|
isComplete: false,
|
||||||
|
},
|
||||||
|
sft: {
|
||||||
|
specializations: ['general'],
|
||||||
|
progressTicks: 0,
|
||||||
|
totalTicks: 100,
|
||||||
|
isComplete: false,
|
||||||
|
},
|
||||||
|
alignment: {
|
||||||
|
method: 'rlhf',
|
||||||
|
safetyWeight: 0.5,
|
||||||
|
helpfulnessWeight: 0.5,
|
||||||
|
progressTicks: 0,
|
||||||
|
totalTicks: 80,
|
||||||
|
isComplete: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: 'active',
|
||||||
|
allocatedComputeFraction: 1.0,
|
||||||
|
events: [],
|
||||||
|
startedAtTick: 0,
|
||||||
|
sizeTier: 'small',
|
||||||
|
isPointRelease: false,
|
||||||
|
sourceModelId: null,
|
||||||
|
};
|
||||||
|
return overrides ? { ...base, ...overrides } as TrainingPipeline : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTestBaseModel(overrides?: Partial<BaseModel>): BaseModel {
|
||||||
|
const base: BaseModel = {
|
||||||
|
id: uuid(),
|
||||||
|
familyId: 'test-family',
|
||||||
|
name: 'Test Model v1',
|
||||||
|
architecture: {
|
||||||
|
type: 'dense',
|
||||||
|
totalParameters: 7e9,
|
||||||
|
activeParameters: 7e9,
|
||||||
|
contextWindow: 8192,
|
||||||
|
vocabularySize: 32000,
|
||||||
|
},
|
||||||
|
rawCapability: 40,
|
||||||
|
capabilityScore: 40,
|
||||||
|
safetyScore: 50,
|
||||||
|
qualityScore: 40,
|
||||||
|
sftSpecializations: ['general'],
|
||||||
|
alignmentMethod: 'rlhf',
|
||||||
|
completedAtTick: 100,
|
||||||
|
isDeployed: true,
|
||||||
|
isOpenSourced: false,
|
||||||
|
sizeTier: 'small',
|
||||||
|
isPointRelease: false,
|
||||||
|
sourceModelId: null,
|
||||||
|
dataMix: { web: 0.4, code: 0.2, books: 0.15, academic: 0.1, conversational: 0.1, specialized: 0.05 },
|
||||||
|
};
|
||||||
|
return overrides ? { ...base, ...overrides } : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTestModelFamily(overrides?: Partial<ModelFamily>): ModelFamily {
|
||||||
|
const base: ModelFamily = {
|
||||||
|
id: uuid(),
|
||||||
|
name: 'Test Family',
|
||||||
|
generation: 1,
|
||||||
|
baseModelIds: [],
|
||||||
|
variants: [],
|
||||||
|
createdAtTick: 0,
|
||||||
|
};
|
||||||
|
return overrides ? { ...base, ...overrides } : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTestDeploymentCohort(overrides?: Partial<DeploymentCohort>): DeploymentCohort {
|
||||||
|
const base: DeploymentCohort = {
|
||||||
|
id: uuid(),
|
||||||
|
count: 4,
|
||||||
|
skuId: 't4-x4',
|
||||||
|
stage: 'production' as any,
|
||||||
|
stageProgress: 0,
|
||||||
|
stageTotal: 0,
|
||||||
|
repairCount: 0,
|
||||||
|
};
|
||||||
|
return overrides ? { ...base, ...overrides } : base;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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 '@token-empire/shared';
|
||||||
|
|
||||||
|
export type DeepPartial<T> = T extends object
|
||||||
|
? { [K in keyof T]?: DeepPartial<T[K]> }
|
||||||
|
: T;
|
||||||
|
|
||||||
|
function deepMerge<T>(target: T, source: DeepPartial<T>): T {
|
||||||
|
if (source === undefined || source === null) return target;
|
||||||
|
if (typeof target !== 'object' || target === null) return source as T;
|
||||||
|
if (Array.isArray(source)) return source as unknown as T;
|
||||||
|
|
||||||
|
const result = { ...target };
|
||||||
|
for (const key of Object.keys(source) as (keyof T)[]) {
|
||||||
|
const srcVal = source[key];
|
||||||
|
if (srcVal === undefined) continue;
|
||||||
|
const tgtVal = result[key];
|
||||||
|
if (
|
||||||
|
typeof tgtVal === 'object' && tgtVal !== null && !Array.isArray(tgtVal) &&
|
||||||
|
typeof srcVal === 'object' && srcVal !== null && !Array.isArray(srcVal)
|
||||||
|
) {
|
||||||
|
result[key] = deepMerge(tgtVal, srcVal as DeepPartial<typeof tgtVal>);
|
||||||
|
} else {
|
||||||
|
result[key] = srcVal as T[keyof T];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseState(): GameState {
|
||||||
|
return {
|
||||||
|
meta: {
|
||||||
|
saveVersion: SAVE_VERSION,
|
||||||
|
companyName: 'TestCorp',
|
||||||
|
currentEra: 'startup',
|
||||||
|
tickCount: 0,
|
||||||
|
lastTickTimestamp: Date.now(),
|
||||||
|
gameSpeed: 1,
|
||||||
|
isPaused: false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
totalPlayTime: 0,
|
||||||
|
settings: { ...INITIAL_SETTINGS },
|
||||||
|
},
|
||||||
|
economy: structuredClone(INITIAL_ECONOMY),
|
||||||
|
infrastructure: structuredClone(INITIAL_INFRASTRUCTURE),
|
||||||
|
compute: structuredClone(INITIAL_COMPUTE),
|
||||||
|
research: structuredClone(INITIAL_RESEARCH),
|
||||||
|
models: structuredClone(INITIAL_MODELS),
|
||||||
|
market: structuredClone(INITIAL_MARKET),
|
||||||
|
competitors: structuredClone(INITIAL_COMPETITORS),
|
||||||
|
talent: structuredClone(INITIAL_TALENT),
|
||||||
|
data: structuredClone(INITIAL_DATA),
|
||||||
|
reputation: structuredClone(INITIAL_REPUTATION),
|
||||||
|
achievements: structuredClone(INITIAL_ACHIEVEMENTS),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTestState(overrides?: DeepPartial<GameState>): GameState {
|
||||||
|
const state = baseState();
|
||||||
|
if (!overrides) return state;
|
||||||
|
return deepMerge(state, overrides);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export { createTestState, type DeepPartial } from './createTestState';
|
||||||
|
export {
|
||||||
|
createTestCluster,
|
||||||
|
createTestCampus,
|
||||||
|
createTestDataCenter,
|
||||||
|
createTestTrainingPipeline,
|
||||||
|
createTestBaseModel,
|
||||||
|
createTestModelFamily,
|
||||||
|
createTestDeploymentCohort,
|
||||||
|
} from './builders';
|
||||||
|
export { createSeededRNG, type SeededRNG } from './seededRandom';
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export interface SeededRNG {
|
||||||
|
random(): number;
|
||||||
|
install(): void;
|
||||||
|
uninstall(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSeededRNG(seed: number): SeededRNG {
|
||||||
|
let state = seed | 0;
|
||||||
|
const originalRandom = Math.random;
|
||||||
|
|
||||||
|
function random(): number {
|
||||||
|
state = (state + 0x6D2B79F5) | 0;
|
||||||
|
let t = Math.imul(state ^ (state >>> 15), 1 | state);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
random,
|
||||||
|
install() { Math.random = random; },
|
||||||
|
uninstall() { Math.random = originalRandom; },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AchievementDefinition } from '@ai-tycoon/shared';
|
import type { AchievementDefinition } from '@token-empire/shared';
|
||||||
|
|
||||||
export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
|
export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import type { BenchmarkDefinition } from '@ai-tycoon/shared';
|
|
||||||
|
|
||||||
export const BENCHMARKS: BenchmarkDefinition[] = [
|
|
||||||
{
|
|
||||||
id: 'arc-challenge',
|
|
||||||
name: 'ARC Challenge',
|
|
||||||
category: 'reasoning',
|
|
||||||
description: 'Advanced reasoning and comprehension tasks requiring multi-step inference.',
|
|
||||||
primaryCapability: 'reasoning',
|
|
||||||
secondaryCapability: 'knowledge',
|
|
||||||
computeCost: 0.001,
|
|
||||||
ticksToRun: 8,
|
|
||||||
unlockedAtEra: 'startup',
|
|
||||||
marketRelevance: { consumer: 0.3, enterprise: 0.5, developer: 0.4, research: 0.8 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'codeforce',
|
|
||||||
name: 'CodeForce',
|
|
||||||
category: 'coding',
|
|
||||||
description: 'Competitive programming and software engineering benchmarks.',
|
|
||||||
primaryCapability: 'coding',
|
|
||||||
secondaryCapability: 'reasoning',
|
|
||||||
computeCost: 0.001,
|
|
||||||
ticksToRun: 8,
|
|
||||||
unlockedAtEra: 'startup',
|
|
||||||
marketRelevance: { consumer: 0.2, enterprise: 0.7, developer: 0.9, research: 0.5 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'mathquest',
|
|
||||||
name: 'MathQuest',
|
|
||||||
category: 'math',
|
|
||||||
description: 'Mathematical problem-solving from algebra to graduate-level proofs.',
|
|
||||||
primaryCapability: 'math',
|
|
||||||
secondaryCapability: 'reasoning',
|
|
||||||
computeCost: 0.001,
|
|
||||||
ticksToRun: 8,
|
|
||||||
unlockedAtEra: 'startup',
|
|
||||||
marketRelevance: { consumer: 0.1, enterprise: 0.6, developer: 0.5, research: 0.9 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'worldfacts',
|
|
||||||
name: 'WorldFacts',
|
|
||||||
category: 'knowledge',
|
|
||||||
description: 'Broad factual knowledge across science, history, culture, and current events.',
|
|
||||||
primaryCapability: 'knowledge',
|
|
||||||
secondaryCapability: 'reasoning',
|
|
||||||
computeCost: 0.001,
|
|
||||||
ticksToRun: 6,
|
|
||||||
unlockedAtEra: 'startup',
|
|
||||||
marketRelevance: { consumer: 0.5, enterprise: 0.4, developer: 0.3, research: 0.6 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'chatrank',
|
|
||||||
name: 'ChatRank',
|
|
||||||
category: 'chat',
|
|
||||||
description: 'Human preference evaluation of conversational quality, helpfulness, and creativity.',
|
|
||||||
primaryCapability: 'creative',
|
|
||||||
secondaryCapability: 'knowledge',
|
|
||||||
computeCost: 0.002,
|
|
||||||
ticksToRun: 10,
|
|
||||||
unlockedAtEra: 'startup',
|
|
||||||
marketRelevance: { consumer: 0.9, enterprise: 0.3, developer: 0.2, research: 0.2 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'harmguard',
|
|
||||||
name: 'HarmGuard',
|
|
||||||
category: 'safety',
|
|
||||||
description: 'Safety evaluation measuring harm avoidance, truthfulness, and responsible behavior.',
|
|
||||||
primaryCapability: 'reasoning',
|
|
||||||
computeCost: 0.001,
|
|
||||||
ticksToRun: 8,
|
|
||||||
unlockedAtEra: 'startup',
|
|
||||||
marketRelevance: { consumer: 0.4, enterprise: 0.9, developer: 0.3, research: 0.7 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'visionbench',
|
|
||||||
name: 'VisionBench',
|
|
||||||
category: 'multimodal',
|
|
||||||
description: 'Image understanding, visual reasoning, and multimodal comprehension.',
|
|
||||||
primaryCapability: 'multimodal',
|
|
||||||
secondaryCapability: 'reasoning',
|
|
||||||
computeCost: 0.003,
|
|
||||||
ticksToRun: 12,
|
|
||||||
unlockedAtEra: 'scaleup',
|
|
||||||
marketRelevance: { consumer: 0.5, enterprise: 0.6, developer: 0.6, research: 0.7 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'agentarena',
|
|
||||||
name: 'AgentArena',
|
|
||||||
category: 'agents',
|
|
||||||
description: 'Autonomous agent tasks: tool use, multi-step planning, and environment interaction.',
|
|
||||||
primaryCapability: 'agents',
|
|
||||||
secondaryCapability: 'coding',
|
|
||||||
computeCost: 0.005,
|
|
||||||
ticksToRun: 15,
|
|
||||||
unlockedAtEra: 'bigtech',
|
|
||||||
marketRelevance: { consumer: 0.3, enterprise: 0.8, developer: 0.7, research: 0.6 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'frontier-eval',
|
|
||||||
name: 'Frontier Eval',
|
|
||||||
category: 'reasoning',
|
|
||||||
description: 'Cutting-edge capability evaluation at the frontier of AI research.',
|
|
||||||
primaryCapability: 'reasoning',
|
|
||||||
secondaryCapability: 'math',
|
|
||||||
computeCost: 0.01,
|
|
||||||
ticksToRun: 20,
|
|
||||||
unlockedAtEra: 'agi',
|
|
||||||
marketRelevance: { consumer: 0.2, enterprise: 0.5, developer: 0.5, research: 1.0 },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Competitor } from '@ai-tycoon/shared';
|
import type { Competitor } from '@token-empire/shared';
|
||||||
|
|
||||||
export const INITIAL_RIVALS: Competitor[] = [
|
export const INITIAL_RIVALS: Competitor[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { EnterpriseSegment } from '@ai-tycoon/shared';
|
import type { EnterpriseSegment } from '@token-empire/shared';
|
||||||
|
|
||||||
export const ENTERPRISE_NAMES: Record<EnterpriseSegment, string[]> = {
|
export const ENTERPRISE_NAMES: Record<EnterpriseSegment, string[]> = {
|
||||||
startup: [
|
startup: [
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { DepartmentId } from '@ai-tycoon/shared';
|
import type { DepartmentId } from '@token-empire/shared';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A recruitable key hire as it appears in the available pool.
|
* A recruitable key hire as it appears in the available pool.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ResearchNode } from '@ai-tycoon/shared';
|
import type { ResearchNode } from '@token-empire/shared';
|
||||||
|
|
||||||
export const TECH_TREE: ResearchNode[] = [
|
export const TECH_TREE: ResearchNode[] = [
|
||||||
// === COMPUTE / INFRASTRUCTURE ===
|
// === COMPUTE / INFRASTRUCTURE ===
|
||||||
@@ -9,7 +9,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'startup',
|
era: 'startup',
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
prerequisites: [],
|
prerequisites: [],
|
||||||
cost: { researchPoints: 0, compute: 5, ticks: 60 },
|
cost: { researchPoints: 0, compute: 5, ticks: 150, money: 2250 },
|
||||||
effects: [{ type: 'cost_reduction', target: 'energy', value: 0.25 }],
|
effects: [{ type: 'cost_reduction', target: 'energy', value: 0.25 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -19,7 +19,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'startup',
|
era: 'startup',
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
prerequisites: [],
|
prerequisites: [],
|
||||||
cost: { researchPoints: 0, compute: 5, ticks: 60 },
|
cost: { researchPoints: 0, compute: 5, ticks: 150, money: 2250 },
|
||||||
effects: [{ type: 'cost_reduction', target: 'failure_rate', value: 0.5 }],
|
effects: [{ type: 'cost_reduction', target: 'failure_rate', value: 0.5 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -29,7 +29,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'startup',
|
era: 'startup',
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
prerequisites: [],
|
prerequisites: [],
|
||||||
cost: { researchPoints: 0, compute: 10, ticks: 90 },
|
cost: { researchPoints: 0, compute: 10, ticks: 225, money: 3375 },
|
||||||
effects: [{ type: 'unlock_rack', target: 'a100', value: 1 }],
|
effects: [{ type: 'unlock_rack', target: 'a100', value: 1 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -39,7 +39,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'scaleup',
|
era: 'scaleup',
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
prerequisites: ['advanced-gpu-arch'],
|
prerequisites: ['advanced-gpu-arch'],
|
||||||
cost: { researchPoints: 2, compute: 40, ticks: 240 },
|
cost: { researchPoints: 2, compute: 40, ticks: 600, money: 30000 },
|
||||||
effects: [{ type: 'unlock_rack', target: 'h100', value: 1 }],
|
effects: [{ type: 'unlock_rack', target: 'h100', value: 1 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -49,7 +49,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'bigtech',
|
era: 'bigtech',
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
prerequisites: ['next-gen-gpu'],
|
prerequisites: ['next-gen-gpu'],
|
||||||
cost: { researchPoints: 5, compute: 200, ticks: 480 },
|
cost: { researchPoints: 5, compute: 200, ticks: 1200, money: 240000 },
|
||||||
effects: [{ type: 'unlock_rack', target: 'b200', value: 1 }],
|
effects: [{ type: 'unlock_rack', target: 'b200', value: 1 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -59,7 +59,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'agi',
|
era: 'agi',
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
prerequisites: ['frontier-compute'],
|
prerequisites: ['frontier-compute'],
|
||||||
cost: { researchPoints: 10, compute: 500, ticks: 900 },
|
cost: { researchPoints: 10, compute: 500, ticks: 2250, money: 1125000 },
|
||||||
effects: [{ type: 'unlock_rack', target: 'custom', value: 1 }],
|
effects: [{ type: 'unlock_rack', target: 'custom', value: 1 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -69,7 +69,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'scaleup',
|
era: 'scaleup',
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
prerequisites: ['advanced-gpu-arch'],
|
prerequisites: ['advanced-gpu-arch'],
|
||||||
cost: { researchPoints: 2, compute: 30, ticks: 200 },
|
cost: { researchPoints: 2, compute: 30, ticks: 500, money: 25000 },
|
||||||
effects: [{ type: 'unlock_rack', target: 'amd', value: 1 }],
|
effects: [{ type: 'unlock_rack', target: 'amd', value: 1 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -79,7 +79,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'scaleup',
|
era: 'scaleup',
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
prerequisites: ['quantization'],
|
prerequisites: ['quantization'],
|
||||||
cost: { researchPoints: 2, compute: 20, ticks: 150 },
|
cost: { researchPoints: 2, compute: 20, ticks: 375, money: 18750 },
|
||||||
effects: [{ type: 'unlock_rack', target: 'inference', value: 1 }],
|
effects: [{ type: 'unlock_rack', target: 'inference', value: 1 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -89,7 +89,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'agi',
|
era: 'agi',
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
prerequisites: ['frontier-compute'],
|
prerequisites: ['frontier-compute'],
|
||||||
cost: { researchPoints: 8, compute: 400, ticks: 720 },
|
cost: { researchPoints: 8, compute: 400, ticks: 1800, money: 900000 },
|
||||||
effects: [{ type: 'unlock_rack', target: 'gb200-nvl72', value: 1 }],
|
effects: [{ type: 'unlock_rack', target: 'gb200-nvl72', value: 1 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -99,7 +99,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'scaleup',
|
era: 'scaleup',
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
prerequisites: ['advanced-cooling'],
|
prerequisites: ['advanced-cooling'],
|
||||||
cost: { researchPoints: 2, compute: 25, ticks: 180 },
|
cost: { researchPoints: 2, compute: 25, ticks: 450, money: 22500 },
|
||||||
effects: [{ type: 'unlock_feature', target: 'liquid-cooling', value: 1 }],
|
effects: [{ type: 'unlock_feature', target: 'liquid-cooling', value: 1 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -109,7 +109,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'bigtech',
|
era: 'bigtech',
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
prerequisites: ['liquid-cooling-tech'],
|
prerequisites: ['liquid-cooling-tech'],
|
||||||
cost: { researchPoints: 5, compute: 100, ticks: 400 },
|
cost: { researchPoints: 5, compute: 100, ticks: 1000, money: 200000 },
|
||||||
effects: [{ type: 'unlock_feature', target: 'immersion-cooling', value: 1 }],
|
effects: [{ type: 'unlock_feature', target: 'immersion-cooling', value: 1 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -119,7 +119,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'scaleup',
|
era: 'scaleup',
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
prerequisites: ['network-engineering-i'],
|
prerequisites: ['network-engineering-i'],
|
||||||
cost: { researchPoints: 3, compute: 40, ticks: 240 },
|
cost: { researchPoints: 3, compute: 40, ticks: 600, money: 30000 },
|
||||||
effects: [{ type: 'unlock_feature', target: 'infiniband', value: 1 }],
|
effects: [{ type: 'unlock_feature', target: 'infiniband', value: 1 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -129,7 +129,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'startup',
|
era: 'startup',
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
prerequisites: ['advanced-cooling'],
|
prerequisites: ['advanced-cooling'],
|
||||||
cost: { researchPoints: 1, compute: 15, ticks: 120 },
|
cost: { researchPoints: 1, compute: 15, ticks: 300, money: 4500 },
|
||||||
effects: [{ type: 'unlock_dc_tier', target: 'medium', value: 1 }],
|
effects: [{ type: 'unlock_dc_tier', target: 'medium', value: 1 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -139,7 +139,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'scaleup',
|
era: 'scaleup',
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
prerequisites: ['dc-engineering-ii'],
|
prerequisites: ['dc-engineering-ii'],
|
||||||
cost: { researchPoints: 3, compute: 60, ticks: 300 },
|
cost: { researchPoints: 3, compute: 60, ticks: 750, money: 37500 },
|
||||||
effects: [{ type: 'unlock_dc_tier', target: 'large', value: 1 }],
|
effects: [{ type: 'unlock_dc_tier', target: 'large', value: 1 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -149,7 +149,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'bigtech',
|
era: 'bigtech',
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
prerequisites: ['dc-engineering-iii'],
|
prerequisites: ['dc-engineering-iii'],
|
||||||
cost: { researchPoints: 6, compute: 150, ticks: 600 },
|
cost: { researchPoints: 6, compute: 150, ticks: 1500, money: 300000 },
|
||||||
effects: [{ type: 'unlock_dc_tier', target: 'mega', value: 1 }],
|
effects: [{ type: 'unlock_dc_tier', target: 'mega', value: 1 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -159,7 +159,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'startup',
|
era: 'startup',
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
prerequisites: ['redundancy-protocols'],
|
prerequisites: ['redundancy-protocols'],
|
||||||
cost: { researchPoints: 1, compute: 10, ticks: 90 },
|
cost: { researchPoints: 1, compute: 10, ticks: 225, money: 3375 },
|
||||||
effects: [{ type: 'cost_reduction', target: 'test_failure_rate', value: 0.25 }],
|
effects: [{ type: 'cost_reduction', target: 'test_failure_rate', value: 0.25 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -169,7 +169,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'scaleup',
|
era: 'scaleup',
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
prerequisites: ['redundancy-protocols'],
|
prerequisites: ['redundancy-protocols'],
|
||||||
cost: { researchPoints: 2, compute: 20, ticks: 150 },
|
cost: { researchPoints: 2, compute: 20, ticks: 375, money: 18750 },
|
||||||
effects: [{ type: 'cost_reduction', target: 'network_failure_rate', value: 0.4 }],
|
effects: [{ type: 'cost_reduction', target: 'network_failure_rate', value: 0.4 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -179,7 +179,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'bigtech',
|
era: 'bigtech',
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
prerequisites: ['network-engineering-i'],
|
prerequisites: ['network-engineering-i'],
|
||||||
cost: { researchPoints: 4, compute: 80, ticks: 360 },
|
cost: { researchPoints: 4, compute: 80, ticks: 900, money: 180000 },
|
||||||
effects: [{ type: 'cost_reduction', target: 'network_failure_rate', value: 0.5 }],
|
effects: [{ type: 'cost_reduction', target: 'network_failure_rate', value: 0.5 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -189,7 +189,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'scaleup',
|
era: 'scaleup',
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
prerequisites: ['network-engineering-i'],
|
prerequisites: ['network-engineering-i'],
|
||||||
cost: { researchPoints: 3, compute: 40, ticks: 240 },
|
cost: { researchPoints: 3, compute: 40, ticks: 600, money: 30000 },
|
||||||
effects: [{ type: 'efficiency_boost', target: 'network_uplinks', value: 1 }],
|
effects: [{ type: 'efficiency_boost', target: 'network_uplinks', value: 1 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -199,7 +199,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'bigtech',
|
era: 'bigtech',
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
prerequisites: ['network-engineering-ii'],
|
prerequisites: ['network-engineering-ii'],
|
||||||
cost: { researchPoints: 5, compute: 100, ticks: 400 },
|
cost: { researchPoints: 5, compute: 100, ticks: 1000, money: 200000 },
|
||||||
effects: [{ type: 'efficiency_boost', target: 'network_repair_speed', value: 0.4 }],
|
effects: [{ type: 'efficiency_boost', target: 'network_repair_speed', value: 0.4 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -209,7 +209,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'agi',
|
era: 'agi',
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
prerequisites: ['network-fast-repair'],
|
prerequisites: ['network-fast-repair'],
|
||||||
cost: { researchPoints: 8, compute: 250, ticks: 600 },
|
cost: { researchPoints: 8, compute: 250, ticks: 1500, money: 750000 },
|
||||||
effects: [{ type: 'efficiency_boost', target: 'network_hot_standby', value: 5 }],
|
effects: [{ type: 'efficiency_boost', target: 'network_hot_standby', value: 5 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -219,7 +219,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'scaleup',
|
era: 'scaleup',
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
prerequisites: ['dc-engineering-ii'],
|
prerequisites: ['dc-engineering-ii'],
|
||||||
cost: { researchPoints: 2, compute: 25, ticks: 180 },
|
cost: { researchPoints: 2, compute: 25, ticks: 450, money: 22500 },
|
||||||
effects: [{ type: 'efficiency_boost', target: 'pipeline_speed', value: 0.2 }],
|
effects: [{ type: 'efficiency_boost', target: 'pipeline_speed', value: 0.2 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -229,7 +229,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'scaleup',
|
era: 'scaleup',
|
||||||
category: 'infrastructure',
|
category: 'infrastructure',
|
||||||
prerequisites: ['advanced-gpu-arch'],
|
prerequisites: ['advanced-gpu-arch'],
|
||||||
cost: { researchPoints: 2, compute: 30, ticks: 180 },
|
cost: { researchPoints: 2, compute: 30, ticks: 450, money: 22500 },
|
||||||
effects: [{ type: 'efficiency_boost', target: 'training_speed', value: 0.2 }],
|
effects: [{ type: 'efficiency_boost', target: 'training_speed', value: 0.2 }],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -241,7 +241,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'startup',
|
era: 'startup',
|
||||||
category: 'efficiency',
|
category: 'efficiency',
|
||||||
prerequisites: [],
|
prerequisites: [],
|
||||||
cost: { researchPoints: 0, compute: 8, ticks: 75 },
|
cost: { researchPoints: 0, compute: 8, ticks: 188, money: 2820 },
|
||||||
effects: [{ type: 'efficiency_boost', target: 'inference', value: 0.15 }],
|
effects: [{ type: 'efficiency_boost', target: 'inference', value: 0.15 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -251,7 +251,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'scaleup',
|
era: 'scaleup',
|
||||||
category: 'efficiency',
|
category: 'efficiency',
|
||||||
prerequisites: ['quantization'],
|
prerequisites: ['quantization'],
|
||||||
cost: { researchPoints: 2, compute: 25, ticks: 180 },
|
cost: { researchPoints: 2, compute: 25, ticks: 450, money: 22500 },
|
||||||
effects: [{ type: 'capability_boost', target: 'all', value: 5 }],
|
effects: [{ type: 'capability_boost', target: 'all', value: 5 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -261,7 +261,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'scaleup',
|
era: 'scaleup',
|
||||||
category: 'efficiency',
|
category: 'efficiency',
|
||||||
prerequisites: ['quantization'],
|
prerequisites: ['quantization'],
|
||||||
cost: { researchPoints: 2, compute: 20, ticks: 150 },
|
cost: { researchPoints: 2, compute: 20, ticks: 375, money: 18750 },
|
||||||
effects: [{ type: 'efficiency_boost', target: 'tokens_per_flop', value: 0.3 }],
|
effects: [{ type: 'efficiency_boost', target: 'tokens_per_flop', value: 0.3 }],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -273,7 +273,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'startup',
|
era: 'startup',
|
||||||
category: 'generation',
|
category: 'generation',
|
||||||
prerequisites: [],
|
prerequisites: [],
|
||||||
cost: { researchPoints: 0, compute: 10, ticks: 90 },
|
cost: { researchPoints: 0, compute: 10, ticks: 225, money: 3375 },
|
||||||
effects: [{ type: 'capability_boost', target: 'all', value: 10 }],
|
effects: [{ type: 'capability_boost', target: 'all', value: 10 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -284,7 +284,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
category: 'specialization',
|
category: 'specialization',
|
||||||
branch: 'reasoning',
|
branch: 'reasoning',
|
||||||
prerequisites: ['transformer-v2'],
|
prerequisites: ['transformer-v2'],
|
||||||
cost: { researchPoints: 3, compute: 40, ticks: 240 },
|
cost: { researchPoints: 3, compute: 40, ticks: 720, money: 36000 },
|
||||||
effects: [{ type: 'capability_boost', target: 'reasoning', value: 15 }],
|
effects: [{ type: 'capability_boost', target: 'reasoning', value: 15 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -295,7 +295,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
category: 'specialization',
|
category: 'specialization',
|
||||||
branch: 'coding',
|
branch: 'coding',
|
||||||
prerequisites: ['transformer-v2'],
|
prerequisites: ['transformer-v2'],
|
||||||
cost: { researchPoints: 3, compute: 35, ticks: 210 },
|
cost: { researchPoints: 3, compute: 35, ticks: 735, money: 36750 },
|
||||||
effects: [{ type: 'capability_boost', target: 'coding', value: 15 }],
|
effects: [{ type: 'capability_boost', target: 'coding', value: 15 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -306,7 +306,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
category: 'specialization',
|
category: 'specialization',
|
||||||
branch: 'creative',
|
branch: 'creative',
|
||||||
prerequisites: ['transformer-v2'],
|
prerequisites: ['transformer-v2'],
|
||||||
cost: { researchPoints: 3, compute: 30, ticks: 210 },
|
cost: { researchPoints: 3, compute: 30, ticks: 735, money: 36750 },
|
||||||
effects: [{ type: 'capability_boost', target: 'creative', value: 15 }],
|
effects: [{ type: 'capability_boost', target: 'creative', value: 15 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -317,7 +317,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
category: 'specialization',
|
category: 'specialization',
|
||||||
branch: 'multimodal',
|
branch: 'multimodal',
|
||||||
prerequisites: ['transformer-v2'],
|
prerequisites: ['transformer-v2'],
|
||||||
cost: { researchPoints: 4, compute: 50, ticks: 300 },
|
cost: { researchPoints: 4, compute: 50, ticks: 1050, money: 52500 },
|
||||||
effects: [
|
effects: [
|
||||||
{ type: 'capability_boost', target: 'multimodal', value: 20 },
|
{ type: 'capability_boost', target: 'multimodal', value: 20 },
|
||||||
{ type: 'unlock_product_line', target: 'image', value: 1 },
|
{ type: 'unlock_product_line', target: 'image', value: 1 },
|
||||||
@@ -331,7 +331,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
category: 'specialization',
|
category: 'specialization',
|
||||||
branch: 'agents',
|
branch: 'agents',
|
||||||
prerequisites: ['reasoning-enhancement', 'code-generation'],
|
prerequisites: ['reasoning-enhancement', 'code-generation'],
|
||||||
cost: { researchPoints: 6, compute: 100, ticks: 480 },
|
cost: { researchPoints: 6, compute: 100, ticks: 1680, money: 336000 },
|
||||||
effects: [
|
effects: [
|
||||||
{ type: 'capability_boost', target: 'agents', value: 20 },
|
{ type: 'capability_boost', target: 'agents', value: 20 },
|
||||||
{ type: 'unlock_product_line', target: 'agents', value: 1 },
|
{ type: 'unlock_product_line', target: 'agents', value: 1 },
|
||||||
@@ -346,7 +346,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'startup',
|
era: 'startup',
|
||||||
category: 'safety',
|
category: 'safety',
|
||||||
prerequisites: [],
|
prerequisites: [],
|
||||||
cost: { researchPoints: 0, compute: 8, ticks: 90 },
|
cost: { researchPoints: 0, compute: 8, ticks: 270, money: 4050 },
|
||||||
effects: [
|
effects: [
|
||||||
{ type: 'safety_boost', target: 'models', value: 10 },
|
{ type: 'safety_boost', target: 'models', value: 10 },
|
||||||
{ type: 'capability_boost', target: 'reputation', value: 5 },
|
{ type: 'capability_boost', target: 'reputation', value: 5 },
|
||||||
@@ -359,7 +359,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'scaleup',
|
era: 'scaleup',
|
||||||
category: 'safety',
|
category: 'safety',
|
||||||
prerequisites: ['alignment-research'],
|
prerequisites: ['alignment-research'],
|
||||||
cost: { researchPoints: 3, compute: 40, ticks: 240 },
|
cost: { researchPoints: 3, compute: 40, ticks: 720, money: 36000 },
|
||||||
effects: [
|
effects: [
|
||||||
{ type: 'safety_boost', target: 'models', value: 10 },
|
{ type: 'safety_boost', target: 'models', value: 10 },
|
||||||
{ type: 'capability_boost', target: 'reputation', value: 5 },
|
{ type: 'capability_boost', target: 'reputation', value: 5 },
|
||||||
@@ -372,7 +372,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'bigtech',
|
era: 'bigtech',
|
||||||
category: 'safety',
|
category: 'safety',
|
||||||
prerequisites: ['interpretability'],
|
prerequisites: ['interpretability'],
|
||||||
cost: { researchPoints: 5, compute: 80, ticks: 420 },
|
cost: { researchPoints: 5, compute: 80, ticks: 1260, money: 252000 },
|
||||||
effects: [
|
effects: [
|
||||||
{ type: 'safety_boost', target: 'models', value: 15 },
|
{ type: 'safety_boost', target: 'models', value: 15 },
|
||||||
{ type: 'capability_boost', target: 'reputation', value: 10 },
|
{ type: 'capability_boost', target: 'reputation', value: 10 },
|
||||||
@@ -388,7 +388,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
category: 'specialization',
|
category: 'specialization',
|
||||||
branch: 'coding',
|
branch: 'coding',
|
||||||
prerequisites: ['code-generation'],
|
prerequisites: ['code-generation'],
|
||||||
cost: { researchPoints: 2, compute: 20, ticks: 150 },
|
cost: { researchPoints: 2, compute: 20, ticks: 525, money: 26250 },
|
||||||
effects: [{ type: 'unlock_product_line', target: 'code-assistant', value: 1 }],
|
effects: [{ type: 'unlock_product_line', target: 'code-assistant', value: 1 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -398,7 +398,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'startup',
|
era: 'startup',
|
||||||
category: 'efficiency',
|
category: 'efficiency',
|
||||||
prerequisites: [],
|
prerequisites: [],
|
||||||
cost: { researchPoints: 0, compute: 3, ticks: 45 },
|
cost: { researchPoints: 0, compute: 3, ticks: 158, money: 2370 },
|
||||||
effects: [{ type: 'unlock_feature', target: 'developer-relations', value: 1 }],
|
effects: [{ type: 'unlock_feature', target: 'developer-relations', value: 1 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -408,7 +408,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'startup',
|
era: 'startup',
|
||||||
category: 'efficiency',
|
category: 'efficiency',
|
||||||
prerequisites: [],
|
prerequisites: [],
|
||||||
cost: { researchPoints: 0, compute: 3, ticks: 45 },
|
cost: { researchPoints: 0, compute: 3, ticks: 112, money: 1680 },
|
||||||
effects: [{ type: 'unlock_feature', target: 'enterprise-sales', value: 1 }],
|
effects: [{ type: 'unlock_feature', target: 'enterprise-sales', value: 1 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -418,7 +418,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'scaleup',
|
era: 'scaleup',
|
||||||
category: 'efficiency',
|
category: 'efficiency',
|
||||||
prerequisites: ['developer-relations'],
|
prerequisites: ['developer-relations'],
|
||||||
cost: { researchPoints: 2, compute: 15, ticks: 120 },
|
cost: { researchPoints: 2, compute: 15, ticks: 300, money: 15000 },
|
||||||
effects: [{ type: 'efficiency_boost', target: 'sdk_coverage', value: 0.3 }],
|
effects: [{ type: 'efficiency_boost', target: 'sdk_coverage', value: 0.3 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -429,7 +429,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
category: 'specialization',
|
category: 'specialization',
|
||||||
branch: 'agents',
|
branch: 'agents',
|
||||||
prerequisites: ['agentic-architecture'],
|
prerequisites: ['agentic-architecture'],
|
||||||
cost: { researchPoints: 4, compute: 60, ticks: 300 },
|
cost: { researchPoints: 4, compute: 60, ticks: 1050, money: 210000 },
|
||||||
effects: [{ type: 'unlock_product_line', target: 'agents-platform', value: 1 }],
|
effects: [{ type: 'unlock_product_line', target: 'agents-platform', value: 1 }],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -441,7 +441,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'scaleup',
|
era: 'scaleup',
|
||||||
category: 'efficiency',
|
category: 'efficiency',
|
||||||
prerequisites: ['inference-optimization'],
|
prerequisites: ['inference-optimization'],
|
||||||
cost: { researchPoints: 2, compute: 25, ticks: 150 },
|
cost: { researchPoints: 2, compute: 25, ticks: 375, money: 18750 },
|
||||||
effects: [{ type: 'unlock_feature', target: 'request-routing', value: 1 }],
|
effects: [{ type: 'unlock_feature', target: 'request-routing', value: 1 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -451,7 +451,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'scaleup',
|
era: 'scaleup',
|
||||||
category: 'efficiency',
|
category: 'efficiency',
|
||||||
prerequisites: ['request-routing'],
|
prerequisites: ['request-routing'],
|
||||||
cost: { researchPoints: 3, compute: 30, ticks: 180 },
|
cost: { researchPoints: 3, compute: 30, ticks: 450, money: 22500 },
|
||||||
effects: [{ type: 'unlock_feature', target: 'priority-queues', value: 1 }],
|
effects: [{ type: 'unlock_feature', target: 'priority-queues', value: 1 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -461,7 +461,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'scaleup',
|
era: 'scaleup',
|
||||||
category: 'efficiency',
|
category: 'efficiency',
|
||||||
prerequisites: ['inference-optimization'],
|
prerequisites: ['inference-optimization'],
|
||||||
cost: { researchPoints: 2, compute: 20, ticks: 120 },
|
cost: { researchPoints: 2, compute: 20, ticks: 300, money: 15000 },
|
||||||
effects: [{ type: 'unlock_feature', target: 'request-batching', value: 1 }],
|
effects: [{ type: 'unlock_feature', target: 'request-batching', value: 1 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -471,7 +471,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'bigtech',
|
era: 'bigtech',
|
||||||
category: 'efficiency',
|
category: 'efficiency',
|
||||||
prerequisites: ['request-routing'],
|
prerequisites: ['request-routing'],
|
||||||
cost: { researchPoints: 4, compute: 60, ticks: 300 },
|
cost: { researchPoints: 4, compute: 60, ticks: 750, money: 150000 },
|
||||||
effects: [{ type: 'efficiency_boost', target: 'auto_scaling', value: 0.2 }],
|
effects: [{ type: 'efficiency_boost', target: 'auto_scaling', value: 0.2 }],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -483,7 +483,7 @@ export const TECH_TREE: ResearchNode[] = [
|
|||||||
era: 'startup',
|
era: 'startup',
|
||||||
category: 'efficiency',
|
category: 'efficiency',
|
||||||
prerequisites: [],
|
prerequisites: [],
|
||||||
cost: { researchPoints: 0, compute: 5, ticks: 60 },
|
cost: { researchPoints: 0, compute: 5, ticks: 150, money: 2250 },
|
||||||
effects: [{ type: 'efficiency_boost', target: 'data_quality', value: 0.2 }],
|
effects: [{ type: 'efficiency_boost', target: 'data_quality', value: 0.2 }],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { GameState } from '@ai-tycoon/shared';
|
import type { GameState } from '@token-empire/shared';
|
||||||
import { processTick } from './tick';
|
import { processTick } from './tick';
|
||||||
|
|
||||||
export interface GameEngineCallbacks {
|
export interface GameEngineCallbacks {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export { processTick, setAchievementDefinitions } from './tick';
|
|||||||
export type { TickNotification } from './tick';
|
export type { TickNotification } from './tick';
|
||||||
export { getAvailableResearch, getResearchNode } from './systems/researchSystem';
|
export { getAvailableResearch, getResearchNode } from './systems/researchSystem';
|
||||||
export { getResearchBonuses, resetResearchBonusCache } from './systems/researchBonuses';
|
export { getResearchBonuses, resetResearchBonusCache } from './systems/researchBonuses';
|
||||||
|
export { resetFleetCache } from './systems/market/servingPipeline';
|
||||||
export type { ResearchBonuses } from './systems/researchBonuses';
|
export type { ResearchBonuses } from './systems/researchBonuses';
|
||||||
export { emptyDCNetworkSummary, emptyCampusNetworkSummary, emptyClusterNetworkSummary } from './systems/infrastructureSystem';
|
export { emptyDCNetworkSummary, emptyCampusNetworkSummary, emptyClusterNetworkSummary } from './systems/infrastructureSystem';
|
||||||
export { onModelDeployed } from './systems/market/obsolescenceSystem';
|
export { onModelDeployed } from './systems/market/obsolescenceSystem';
|
||||||
@@ -11,4 +12,3 @@ export { TECH_TREE } from './data/techTree';
|
|||||||
export { INITIAL_RIVALS } from './data/competitors';
|
export { INITIAL_RIVALS } from './data/competitors';
|
||||||
export { KEY_HIRE_POOL } from './data/keyHires';
|
export { KEY_HIRE_POOL } from './data/keyHires';
|
||||||
export { ACHIEVEMENT_DEFINITIONS } from './data/achievements';
|
export { ACHIEVEMENT_DEFINITIONS } from './data/achievements';
|
||||||
export { BENCHMARKS } from './data/benchmarks';
|
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { processAchievements } from './achievementSystem';
|
||||||
|
import { createTestState } from '../__test-utils__';
|
||||||
|
import type { AchievementDefinition } from '@token-empire/shared';
|
||||||
|
|
||||||
|
function makeDef(overrides: Partial<AchievementDefinition> = {}): AchievementDefinition {
|
||||||
|
return {
|
||||||
|
id: 'ach-1',
|
||||||
|
name: 'First Million',
|
||||||
|
description: 'Earn $1,000',
|
||||||
|
icon: 'money',
|
||||||
|
condition: { field: 'economy.money', operator: 'gte', value: 1000 },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('processAchievements', () => {
|
||||||
|
it('returns unchanged state when tickCount is not divisible by 10', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { tickCount: 7 },
|
||||||
|
economy: { money: 999999 },
|
||||||
|
});
|
||||||
|
const defs = [makeDef()];
|
||||||
|
|
||||||
|
const result = processAchievements(state, defs);
|
||||||
|
|
||||||
|
expect(result.newAchievements).toEqual([]);
|
||||||
|
expect(result.achievements).toBe(state.achievements);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks achievements when tickCount % 10 === 0', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { tickCount: 10 },
|
||||||
|
economy: { money: 5000 },
|
||||||
|
});
|
||||||
|
const defs = [makeDef({ condition: { field: 'economy.money', operator: 'gte', value: 1000 } })];
|
||||||
|
|
||||||
|
const result = processAchievements(state, defs);
|
||||||
|
|
||||||
|
expect(result.newAchievements).toEqual(['First Million']);
|
||||||
|
expect(result.achievements.unlocked).toHaveLength(1);
|
||||||
|
expect(result.achievements.unlocked[0]).toMatchObject({ id: 'ach-1', unlockedAtTick: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not unlock when gte condition is not met', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { tickCount: 10 },
|
||||||
|
economy: { money: 500 },
|
||||||
|
});
|
||||||
|
const defs = [makeDef({ condition: { field: 'economy.money', operator: 'gte', value: 1000 } })];
|
||||||
|
|
||||||
|
const result = processAchievements(state, defs);
|
||||||
|
|
||||||
|
expect(result.newAchievements).toEqual([]);
|
||||||
|
expect(result.achievements.unlocked).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles gt operator as strictly greater than', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { tickCount: 20 },
|
||||||
|
economy: { money: 1000 },
|
||||||
|
});
|
||||||
|
const defs = [makeDef({ condition: { field: 'economy.money', operator: 'gt', value: 1000 } })];
|
||||||
|
|
||||||
|
const result = processAchievements(state, defs);
|
||||||
|
|
||||||
|
expect(result.newAchievements).toEqual([]);
|
||||||
|
|
||||||
|
const state2 = createTestState({
|
||||||
|
meta: { tickCount: 20 },
|
||||||
|
economy: { money: 1001 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result2 = processAchievements(state2, defs);
|
||||||
|
|
||||||
|
expect(result2.newAchievements).toEqual(['First Million']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles eq operator as exact match', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { tickCount: 30 },
|
||||||
|
economy: { money: 1000 },
|
||||||
|
});
|
||||||
|
const defs = [makeDef({ condition: { field: 'economy.money', operator: 'eq', value: 1000 } })];
|
||||||
|
|
||||||
|
const result = processAchievements(state, defs);
|
||||||
|
|
||||||
|
expect(result.newAchievements).toEqual(['First Million']);
|
||||||
|
|
||||||
|
const state2 = createTestState({
|
||||||
|
meta: { tickCount: 30 },
|
||||||
|
economy: { money: 1001 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result2 = processAchievements(state2, defs);
|
||||||
|
|
||||||
|
expect(result2.newAchievements).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate already-unlocked achievements', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { tickCount: 20 },
|
||||||
|
economy: { money: 5000 },
|
||||||
|
achievements: {
|
||||||
|
unlocked: [{ id: 'ach-1', unlockedAtTick: 10 }],
|
||||||
|
progress: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const defs = [makeDef()];
|
||||||
|
|
||||||
|
const result = processAchievements(state, defs);
|
||||||
|
|
||||||
|
expect(result.newAchievements).toEqual([]);
|
||||||
|
expect(result.achievements.unlocked).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can unlock multiple achievements in one tick', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { tickCount: 10 },
|
||||||
|
economy: { money: 5000 },
|
||||||
|
});
|
||||||
|
const defs = [
|
||||||
|
makeDef({ id: 'ach-1', name: 'First K', condition: { field: 'economy.money', operator: 'gte', value: 1000 } }),
|
||||||
|
makeDef({ id: 'ach-2', name: 'Five K', condition: { field: 'economy.money', operator: 'gte', value: 5000 } }),
|
||||||
|
makeDef({ id: 'ach-3', name: 'Ten K', condition: { field: 'economy.money', operator: 'gte', value: 10000 } }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = processAchievements(state, defs);
|
||||||
|
|
||||||
|
expect(result.newAchievements).toEqual(['First K', 'Five K']);
|
||||||
|
expect(result.achievements.unlocked).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves meta._eraIndex for era-based achievements', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { tickCount: 10, currentEra: 'bigtech' },
|
||||||
|
});
|
||||||
|
const defs = [
|
||||||
|
makeDef({ id: 'era-ach', name: 'Big Tech Era', condition: { field: 'meta._eraIndex', operator: 'gte', value: 2 } }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = processAchievements(state, defs);
|
||||||
|
|
||||||
|
expect(result.newAchievements).toEqual(['Big Tech Era']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves meta._deployedModelCount for deployed model achievements', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { tickCount: 10 },
|
||||||
|
models: {
|
||||||
|
baseModels: [
|
||||||
|
{ isDeployed: true },
|
||||||
|
{ isDeployed: true },
|
||||||
|
{ isDeployed: false },
|
||||||
|
] as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const defs = [
|
||||||
|
makeDef({ id: 'model-ach', name: 'Two Models', condition: { field: 'meta._deployedModelCount', operator: 'eq', value: 2 } }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = processAchievements(state, defs);
|
||||||
|
|
||||||
|
expect(result.newAchievements).toEqual(['Two Models']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves nested fields like economy.money', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { tickCount: 0 },
|
||||||
|
economy: { money: 42 },
|
||||||
|
});
|
||||||
|
const defs = [
|
||||||
|
makeDef({ id: 'exact', name: 'Exact 42', condition: { field: 'economy.money', operator: 'eq', value: 42 } }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = processAchievements(state, defs);
|
||||||
|
|
||||||
|
expect(result.newAchievements).toEqual(['Exact 42']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { GameState, AchievementState, AchievementDefinition } from '@ai-tycoon/shared';
|
import type { GameState, AchievementState, AchievementDefinition } from '@token-empire/shared';
|
||||||
|
|
||||||
export interface AchievementTickResult {
|
export interface AchievementTickResult {
|
||||||
achievements: AchievementState;
|
achievements: AchievementState;
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
const rng = createSeededRNG(42);
|
||||||
|
beforeEach(() => rng.install());
|
||||||
|
afterEach(() => rng.uninstall());
|
||||||
|
|
||||||
|
function makeRival(overrides: Partial<Competitor> = {}): Competitor {
|
||||||
|
return {
|
||||||
|
id: 'rival-1',
|
||||||
|
name: 'TestAI Labs',
|
||||||
|
archetype: 'move-fast',
|
||||||
|
status: 'active' as const,
|
||||||
|
estimatedCapability: 30,
|
||||||
|
estimatedRevenue: 10000,
|
||||||
|
estimatedUsers: 5000,
|
||||||
|
reputation: 50,
|
||||||
|
modelFreshness: 0.8,
|
||||||
|
lastModelReleaseTick: 0,
|
||||||
|
latestModelName: 'TestAI-Gamma',
|
||||||
|
completedMilestones: [],
|
||||||
|
nextMilestoneAtTick: 100,
|
||||||
|
personality: {
|
||||||
|
aggression: 0.5,
|
||||||
|
researchFocus: 0.5,
|
||||||
|
marketingFocus: 0.5,
|
||||||
|
safetyFocus: 0.5,
|
||||||
|
riskTolerance: 0.5,
|
||||||
|
openSourceTendency: 0.3,
|
||||||
|
},
|
||||||
|
pricingStrategy: { aggressiveness: 0.5, premiumPositioning: 0.3 },
|
||||||
|
products: {
|
||||||
|
hasFreeTier: true,
|
||||||
|
chatPrice: 20,
|
||||||
|
apiInputPrice: 1,
|
||||||
|
apiOutputPrice: 3,
|
||||||
|
hasCodeAssistant: false,
|
||||||
|
codeAssistantPrice: 0,
|
||||||
|
hasAgentsPlatform: false,
|
||||||
|
agentsPlatformPrice: 0,
|
||||||
|
},
|
||||||
|
marketShares: { consumer: 0.2, developer: 0.15, enterprise: 0.1, government: 0.05 },
|
||||||
|
developerEcosystemScore: 30,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('processCompetitors', () => {
|
||||||
|
it('returns empty rivals and player benchmark when no rivals exist', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
competitors: { rivals: [] },
|
||||||
|
models: { bestDeployedModelScore: 25 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = processCompetitors(state);
|
||||||
|
|
||||||
|
expect(result.rivals).toEqual([]);
|
||||||
|
expect(result.industryBenchmark).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets industryBenchmark to max of player score and active rivals capability', () => {
|
||||||
|
const rival = makeRival({ estimatedCapability: 60 });
|
||||||
|
const state = createTestState({
|
||||||
|
competitors: { rivals: [rival] },
|
||||||
|
models: { bestDeployedModelScore: 40 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = processCompetitors(state);
|
||||||
|
|
||||||
|
expect(result.industryBenchmark).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses player score as benchmark when it exceeds all rivals', () => {
|
||||||
|
const rival = makeRival({ estimatedCapability: 30 });
|
||||||
|
const state = createTestState({
|
||||||
|
competitors: { rivals: [rival] },
|
||||||
|
models: { bestDeployedModelScore: 80 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = processCompetitors(state);
|
||||||
|
|
||||||
|
expect(result.industryBenchmark).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decays model freshness each tick by FRESHNESS_DECAY_RATE', () => {
|
||||||
|
const rival = makeRival({ modelFreshness: 0.8, nextMilestoneAtTick: 100 });
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { tickCount: 10 },
|
||||||
|
competitors: { rivals: [rival] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = processCompetitors(state);
|
||||||
|
|
||||||
|
expect(result.rivals[0].modelFreshness).toBeCloseTo(0.8 - FRESHNESS_DECAY_RATE, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not update capability/revenue/users before milestone tick', () => {
|
||||||
|
const rival = makeRival({
|
||||||
|
nextMilestoneAtTick: 100,
|
||||||
|
estimatedCapability: 30,
|
||||||
|
estimatedRevenue: 10000,
|
||||||
|
estimatedUsers: 5000,
|
||||||
|
});
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { tickCount: 50 },
|
||||||
|
competitors: { rivals: [rival] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = processCompetitors(state);
|
||||||
|
const updated = result.rivals[0];
|
||||||
|
|
||||||
|
expect(updated.estimatedCapability).toBe(30);
|
||||||
|
expect(updated.estimatedRevenue).toBe(10000);
|
||||||
|
expect(updated.estimatedUsers).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still decays freshness even before milestone tick', () => {
|
||||||
|
const rival = makeRival({ modelFreshness: 0.5, nextMilestoneAtTick: 200 });
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { tickCount: 50 },
|
||||||
|
competitors: { rivals: [rival] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = processCompetitors(state);
|
||||||
|
|
||||||
|
expect(result.rivals[0].modelFreshness).toBeCloseTo(0.5 - FRESHNESS_DECAY_RATE, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grows capability, revenue, and users at milestone tick', () => {
|
||||||
|
const rival = makeRival({
|
||||||
|
nextMilestoneAtTick: 100,
|
||||||
|
estimatedCapability: 30,
|
||||||
|
estimatedRevenue: 10000,
|
||||||
|
estimatedUsers: 5000,
|
||||||
|
});
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { tickCount: 100 },
|
||||||
|
competitors: { rivals: [rival] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = processCompetitors(state);
|
||||||
|
const updated = result.rivals[0];
|
||||||
|
|
||||||
|
expect(updated.estimatedCapability).toBeGreaterThan(30);
|
||||||
|
expect(updated.estimatedRevenue).toBeGreaterThan(10000);
|
||||||
|
expect(updated.estimatedUsers).toBeGreaterThan(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets modelFreshness to 1.0 at milestone tick', () => {
|
||||||
|
const rival = makeRival({ modelFreshness: 0.3, nextMilestoneAtTick: 100 });
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { tickCount: 100 },
|
||||||
|
competitors: { rivals: [rival] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = processCompetitors(state);
|
||||||
|
|
||||||
|
expect(result.rivals[0].modelFreshness).toBe(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets nextMilestoneAtTick in the future after a milestone', () => {
|
||||||
|
const rival = makeRival({ nextMilestoneAtTick: 100 });
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { tickCount: 100 },
|
||||||
|
competitors: { rivals: [rival] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = processCompetitors(state);
|
||||||
|
|
||||||
|
expect(result.rivals[0].nextMilestoneAtTick).toBeGreaterThan(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves acquired rivals completely unchanged', () => {
|
||||||
|
const rival = makeRival({
|
||||||
|
status: 'acquired' as const,
|
||||||
|
modelFreshness: 0.5,
|
||||||
|
estimatedCapability: 30,
|
||||||
|
});
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { tickCount: 200 },
|
||||||
|
competitors: { rivals: [rival] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = processCompetitors(state);
|
||||||
|
const updated = result.rivals[0];
|
||||||
|
|
||||||
|
expect(updated.status).toBe('acquired');
|
||||||
|
expect(updated.modelFreshness).toBe(0.5);
|
||||||
|
expect(updated.estimatedCapability).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores acquired rivals for industryBenchmark calculation', () => {
|
||||||
|
const acquired = makeRival({
|
||||||
|
id: 'rival-acquired',
|
||||||
|
status: 'acquired' as const,
|
||||||
|
estimatedCapability: 90,
|
||||||
|
});
|
||||||
|
const active = makeRival({
|
||||||
|
id: 'rival-active',
|
||||||
|
status: 'active' as const,
|
||||||
|
estimatedCapability: 40,
|
||||||
|
});
|
||||||
|
const state = createTestState({
|
||||||
|
competitors: { rivals: [acquired, active] },
|
||||||
|
models: { bestDeployedModelScore: 20 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = processCompetitors(state);
|
||||||
|
|
||||||
|
expect(result.industryBenchmark).toBe(40);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { GameState, CompetitorState, Competitor } from '@ai-tycoon/shared';
|
import type { GameState, CompetitorState, Competitor } from '@token-empire/shared';
|
||||||
import {
|
import {
|
||||||
COMPETITOR_PRODUCT_THRESHOLDS,
|
COMPETITOR_PRODUCT_THRESHOLDS,
|
||||||
COMPETITOR_CATCHUP_SHARE_THRESHOLD,
|
COMPETITOR_CATCHUP_SHARE_THRESHOLD,
|
||||||
COMPETITOR_CATCHUP_PRICE_CUT,
|
COMPETITOR_CATCHUP_PRICE_CUT,
|
||||||
FRESHNESS_DECAY_RATE,
|
FRESHNESS_DECAY_RATE,
|
||||||
} from '@ai-tycoon/shared';
|
} from '@token-empire/shared';
|
||||||
|
|
||||||
function updateCompetitorProducts(rival: Competitor): Competitor['products'] {
|
function updateCompetitorProducts(rival: Competitor): Competitor['products'] {
|
||||||
const cap = rival.estimatedCapability;
|
const cap = rival.estimatedCapability;
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
function createInfrastructure(overrides: Partial<InfrastructureState> = {}): InfrastructureState {
|
||||||
|
return { ...INITIAL_INFRASTRUCTURE, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('computeCapacity', () => {
|
||||||
|
it('splits allocation between training and inference', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
compute: { trainingAllocation: 0.7 },
|
||||||
|
});
|
||||||
|
const infra = createInfrastructure({
|
||||||
|
totalTrainingFlops: 1000,
|
||||||
|
totalInferenceFlops: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeCapacity(state, infra);
|
||||||
|
|
||||||
|
expect(result.trainingAllocation).toBe(0.7);
|
||||||
|
expect(result.inferenceAllocation).toBeCloseTo(0.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates effectiveTrainingFlops with cross-hardware contribution', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
compute: { trainingAllocation: 0.6 },
|
||||||
|
});
|
||||||
|
const infra = createInfrastructure({
|
||||||
|
totalTrainingFlops: 1000,
|
||||||
|
totalInferenceFlops: 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeCapacity(state, infra);
|
||||||
|
|
||||||
|
// effectiveTraining = 1000 * 0.6 + 400 * 0.6 * 0.3 = 600 + 72 = 672
|
||||||
|
expect(result.effectiveTrainingFlops).toBeCloseTo(672);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates effectiveInferenceFlops with cross-hardware contribution', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
compute: { trainingAllocation: 0.6 },
|
||||||
|
});
|
||||||
|
const infra = createInfrastructure({
|
||||||
|
totalTrainingFlops: 1000,
|
||||||
|
totalInferenceFlops: 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeCapacity(state, infra);
|
||||||
|
|
||||||
|
// inferenceAlloc = 0.4
|
||||||
|
// effectiveInference = (400 * 0.4 + 1000 * 0.4 * 0.5) * 1 = (160 + 200) * 1 = 360
|
||||||
|
expect(result.effectiveInferenceFlops).toBeCloseTo(360);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies research bonuses to inference calculation', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
compute: { trainingAllocation: 0.5 },
|
||||||
|
});
|
||||||
|
const infra = createInfrastructure({
|
||||||
|
totalTrainingFlops: 1000,
|
||||||
|
totalInferenceFlops: 1000,
|
||||||
|
});
|
||||||
|
const bonuses = {
|
||||||
|
tokensPerFlopBonus: 0.3,
|
||||||
|
inferenceEfficiencyBonus: 0.15,
|
||||||
|
energyCostReduction: 0,
|
||||||
|
pipelineSpeedBonus: 0,
|
||||||
|
trainingSpeedBonus: 0,
|
||||||
|
dataQualityBonus: 0,
|
||||||
|
sdkCoverageBonus: 0,
|
||||||
|
globalCapabilityBonus: 0,
|
||||||
|
reasoningBonus: 0,
|
||||||
|
codingBonus: 0,
|
||||||
|
creativeBonus: 0,
|
||||||
|
multimodalBonus: 0,
|
||||||
|
agentsBonus: 0,
|
||||||
|
reputationBonus: 0,
|
||||||
|
safetyBonus: 0,
|
||||||
|
autoScalingBonus: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = computeCapacity(state, infra, bonuses);
|
||||||
|
|
||||||
|
// inferenceBoost = 1 + 0.3 + 0.15 = 1.45
|
||||||
|
// effectiveInference = (1000 * 0.5 + 1000 * 0.5 * 0.5) * 1.45 = (500 + 250) * 1.45 = 1087.5
|
||||||
|
expect(result.effectiveInferenceFlops).toBeCloseTo(1087.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not apply research bonuses to training calculation', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
compute: { trainingAllocation: 0.5 },
|
||||||
|
});
|
||||||
|
const infra = createInfrastructure({
|
||||||
|
totalTrainingFlops: 1000,
|
||||||
|
totalInferenceFlops: 1000,
|
||||||
|
});
|
||||||
|
const bonuses = {
|
||||||
|
tokensPerFlopBonus: 0.5,
|
||||||
|
inferenceEfficiencyBonus: 0.5,
|
||||||
|
energyCostReduction: 0,
|
||||||
|
pipelineSpeedBonus: 0,
|
||||||
|
trainingSpeedBonus: 0,
|
||||||
|
dataQualityBonus: 0,
|
||||||
|
sdkCoverageBonus: 0,
|
||||||
|
globalCapabilityBonus: 0,
|
||||||
|
reasoningBonus: 0,
|
||||||
|
codingBonus: 0,
|
||||||
|
creativeBonus: 0,
|
||||||
|
multimodalBonus: 0,
|
||||||
|
agentsBonus: 0,
|
||||||
|
reputationBonus: 0,
|
||||||
|
safetyBonus: 0,
|
||||||
|
autoScalingBonus: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = computeCapacity(state, infra, bonuses);
|
||||||
|
|
||||||
|
// effectiveTraining = 1000 * 0.5 + 1000 * 0.5 * 0.3 = 500 + 150 = 650
|
||||||
|
// (same with or without bonuses)
|
||||||
|
expect(result.effectiveTrainingFlops).toBeCloseTo(650);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates tokensPerSecondCapacity from effectiveInferenceFlops', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
compute: { trainingAllocation: 0.0 },
|
||||||
|
});
|
||||||
|
const infra = createInfrastructure({
|
||||||
|
totalTrainingFlops: 0,
|
||||||
|
totalInferenceFlops: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeCapacity(state, infra);
|
||||||
|
|
||||||
|
// inferenceAlloc = 1.0
|
||||||
|
// effectiveInference = (100 * 1.0 + 0 * 1.0 * 0.5) * 1 = 100
|
||||||
|
// tokensPerSecond = 100 * 26 = 2600
|
||||||
|
expect(result.tokensPerSecondCapacity).toBe(100 * FLOPS_TO_TOKENS_MULTIPLIER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports totalFlops as sum of training and inference', () => {
|
||||||
|
const state = createTestState();
|
||||||
|
const infra = createInfrastructure({
|
||||||
|
totalTrainingFlops: 750,
|
||||||
|
totalInferenceFlops: 250,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeCapacity(state, infra);
|
||||||
|
|
||||||
|
expect(result.totalFlops).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through totalVramGB from infrastructure', () => {
|
||||||
|
const state = createTestState();
|
||||||
|
const infra = createInfrastructure({
|
||||||
|
totalVramGB: 512,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeCapacity(state, infra);
|
||||||
|
|
||||||
|
expect(result.totalVramGB).toBe(512);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns all zeros when infrastructure has no hardware', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
compute: { trainingAllocation: 0.5 },
|
||||||
|
});
|
||||||
|
const infra = createInfrastructure({
|
||||||
|
totalTrainingFlops: 0,
|
||||||
|
totalInferenceFlops: 0,
|
||||||
|
totalVramGB: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeCapacity(state, infra);
|
||||||
|
|
||||||
|
expect(result.totalFlops).toBe(0);
|
||||||
|
expect(result.effectiveTrainingFlops).toBe(0);
|
||||||
|
expect(result.effectiveInferenceFlops).toBe(0);
|
||||||
|
expect(result.tokensPerSecondCapacity).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles full training allocation (1.0)', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
compute: { trainingAllocation: 1.0 },
|
||||||
|
});
|
||||||
|
const infra = createInfrastructure({
|
||||||
|
totalTrainingFlops: 1000,
|
||||||
|
totalInferenceFlops: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeCapacity(state, infra);
|
||||||
|
|
||||||
|
// effectiveTraining = 1000 * 1.0 + 500 * 1.0 * 0.3 = 1000 + 150 = 1150
|
||||||
|
expect(result.effectiveTrainingFlops).toBeCloseTo(1150);
|
||||||
|
// inferenceAlloc = 0, so effectiveInference = 0
|
||||||
|
expect(result.effectiveInferenceFlops).toBe(0);
|
||||||
|
expect(result.tokensPerSecondCapacity).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('finalizeCompute', () => {
|
||||||
|
it('calculates inferenceUtilization as demand / capacity', () => {
|
||||||
|
const capacity = computeCapacity(
|
||||||
|
createTestState({ compute: { trainingAllocation: 0 } }),
|
||||||
|
createInfrastructure({ totalInferenceFlops: 100 }),
|
||||||
|
);
|
||||||
|
// tokensPerSecondCapacity = 100 * 26 = 2600
|
||||||
|
|
||||||
|
const result = finalizeCompute(capacity, 1300, [], 1);
|
||||||
|
|
||||||
|
expect(result.inferenceUtilization).toBeCloseTo(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps utilization to 1 when demand exceeds capacity', () => {
|
||||||
|
const capacity = computeCapacity(
|
||||||
|
createTestState({ compute: { trainingAllocation: 0 } }),
|
||||||
|
createInfrastructure({ totalInferenceFlops: 100 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = finalizeCompute(capacity, 999999, [], 1);
|
||||||
|
|
||||||
|
expect(result.inferenceUtilization).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets utilization to 1 when capacity is 0 but demand > 0', () => {
|
||||||
|
const capacity = computeCapacity(
|
||||||
|
createTestState({ compute: { trainingAllocation: 0.5 } }),
|
||||||
|
createInfrastructure({ totalTrainingFlops: 0, totalInferenceFlops: 0 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = finalizeCompute(capacity, 100, [], 1);
|
||||||
|
|
||||||
|
expect(result.inferenceUtilization).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets utilization to 0 when both capacity and demand are 0', () => {
|
||||||
|
const capacity = computeCapacity(
|
||||||
|
createTestState({ compute: { trainingAllocation: 0.5 } }),
|
||||||
|
createInfrastructure({ totalTrainingFlops: 0, totalInferenceFlops: 0 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = finalizeCompute(capacity, 0, [], 1);
|
||||||
|
|
||||||
|
expect(result.inferenceUtilization).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a history snapshot when tickCount is a multiple of COMPUTE_SNAPSHOT_INTERVAL', () => {
|
||||||
|
const capacity = computeCapacity(
|
||||||
|
createTestState({ compute: { trainingAllocation: 0.5 } }),
|
||||||
|
createInfrastructure({ totalTrainingFlops: 500, totalInferenceFlops: 500 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const tick = COMPUTE_SNAPSHOT_INTERVAL; // 60
|
||||||
|
const result = finalizeCompute(capacity, 200, [], tick);
|
||||||
|
|
||||||
|
expect(result.computeHistory).toHaveLength(1);
|
||||||
|
expect(result.computeHistory[0].tick).toBe(tick);
|
||||||
|
expect(result.computeHistory[0].totalFlops).toBe(capacity.totalFlops);
|
||||||
|
expect(result.computeHistory[0].tokensPerSecondDemand).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not add a snapshot when tickCount is not a multiple of COMPUTE_SNAPSHOT_INTERVAL', () => {
|
||||||
|
const capacity = computeCapacity(
|
||||||
|
createTestState({ compute: { trainingAllocation: 0.5 } }),
|
||||||
|
createInfrastructure({ totalTrainingFlops: 500, totalInferenceFlops: 500 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = finalizeCompute(capacity, 200, [], 1);
|
||||||
|
|
||||||
|
expect(result.computeHistory).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves existing history entries', () => {
|
||||||
|
const capacity = computeCapacity(
|
||||||
|
createTestState({ compute: { trainingAllocation: 0.5 } }),
|
||||||
|
createInfrastructure({ totalTrainingFlops: 500, totalInferenceFlops: 500 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingHistory = [
|
||||||
|
{
|
||||||
|
tick: 60,
|
||||||
|
totalFlops: 800,
|
||||||
|
effectiveTrainingFlops: 400,
|
||||||
|
effectiveInferenceFlops: 400,
|
||||||
|
inferenceUtilization: 0.5,
|
||||||
|
tokensPerSecondCapacity: 10400,
|
||||||
|
tokensPerSecondDemand: 5200,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = finalizeCompute(capacity, 200, existingHistory, COMPUTE_SNAPSHOT_INTERVAL * 2);
|
||||||
|
|
||||||
|
expect(result.computeHistory).toHaveLength(2);
|
||||||
|
expect(result.computeHistory[0].tick).toBe(60);
|
||||||
|
expect(result.computeHistory[1].tick).toBe(COMPUTE_SNAPSHOT_INTERVAL * 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims history when it exceeds MAX_COMPUTE_HISTORY', () => {
|
||||||
|
const capacity = computeCapacity(
|
||||||
|
createTestState({ compute: { trainingAllocation: 0.5 } }),
|
||||||
|
createInfrastructure({ totalTrainingFlops: 100, totalInferenceFlops: 100 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a history that is exactly at the limit
|
||||||
|
const fullHistory = Array.from({ length: MAX_COMPUTE_HISTORY }, (_, i) => ({
|
||||||
|
tick: (i + 1) * COMPUTE_SNAPSHOT_INTERVAL,
|
||||||
|
totalFlops: 200,
|
||||||
|
effectiveTrainingFlops: 100,
|
||||||
|
effectiveInferenceFlops: 100,
|
||||||
|
inferenceUtilization: 0.5,
|
||||||
|
tokensPerSecondCapacity: 2600,
|
||||||
|
tokensPerSecondDemand: 1300,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const nextSnapshotTick = (MAX_COMPUTE_HISTORY + 1) * COMPUTE_SNAPSHOT_INTERVAL;
|
||||||
|
const result = finalizeCompute(capacity, 1300, fullHistory, nextSnapshotTick);
|
||||||
|
|
||||||
|
expect(result.computeHistory).toHaveLength(MAX_COMPUTE_HISTORY);
|
||||||
|
// Oldest entry should have been shifted out
|
||||||
|
expect(result.computeHistory[0].tick).toBe(2 * COMPUTE_SNAPSHOT_INTERVAL);
|
||||||
|
// Newest entry should be our new snapshot
|
||||||
|
expect(result.computeHistory[result.computeHistory.length - 1].tick).toBe(nextSnapshotTick);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries capacity fields into the returned ComputeState', () => {
|
||||||
|
const capacity = computeCapacity(
|
||||||
|
createTestState({ compute: { trainingAllocation: 0.3 } }),
|
||||||
|
createInfrastructure({ totalTrainingFlops: 200, totalInferenceFlops: 800, totalVramGB: 256 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = finalizeCompute(capacity, 500, [], 1);
|
||||||
|
|
||||||
|
expect(result.totalFlops).toBe(capacity.totalFlops);
|
||||||
|
expect(result.totalTrainingFlops).toBe(capacity.totalTrainingFlops);
|
||||||
|
expect(result.totalInferenceFlops).toBe(capacity.totalInferenceFlops);
|
||||||
|
expect(result.totalVramGB).toBe(capacity.totalVramGB);
|
||||||
|
expect(result.effectiveTrainingFlops).toBe(capacity.effectiveTrainingFlops);
|
||||||
|
expect(result.effectiveInferenceFlops).toBe(capacity.effectiveInferenceFlops);
|
||||||
|
expect(result.tokensPerSecondCapacity).toBe(capacity.tokensPerSecondCapacity);
|
||||||
|
expect(result.tokensPerSecondDemand).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { GameState, ComputeState, InfrastructureState } from '@ai-tycoon/shared';
|
import type { GameState, ComputeState, InfrastructureState } from '@token-empire/shared';
|
||||||
import { FLOPS_TO_TOKENS_MULTIPLIER, COMPUTE_SNAPSHOT_INTERVAL, MAX_COMPUTE_HISTORY } from '@ai-tycoon/shared';
|
import { FLOPS_TO_TOKENS_MULTIPLIER, COMPUTE_SNAPSHOT_INTERVAL, MAX_COMPUTE_HISTORY } from '@token-empire/shared';
|
||||||
import type { ResearchBonuses } from './researchBonuses';
|
import type { ResearchBonuses } from './researchBonuses';
|
||||||
|
|
||||||
export interface CapacityResult {
|
export interface CapacityResult {
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { processData } from './dataSystem';
|
||||||
|
import { createTestState } from '../__test-utils__';
|
||||||
|
import type { DataPartnership } from '@token-empire/shared';
|
||||||
|
|
||||||
|
function makePartnership(tokensPerTick: number): DataPartnership {
|
||||||
|
return {
|
||||||
|
id: `partner-${tokensPerTick}`,
|
||||||
|
partnerName: 'TestPartner',
|
||||||
|
domain: 'web',
|
||||||
|
tokensPerTick,
|
||||||
|
costPerTick: 10,
|
||||||
|
exclusivity: false,
|
||||||
|
durationTicks: 1000,
|
||||||
|
startTick: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('processData', () => {
|
||||||
|
it('returns unchanged tokens when there are zero users and no partnerships', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
market: { consumerTiers: { totalUsers: 0 } },
|
||||||
|
data: { partnerships: [], totalTrainingTokens: 500 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = processData(state);
|
||||||
|
|
||||||
|
expect(result.totalTrainingTokens).toBe(500);
|
||||||
|
expect(result.userDataGenerationRate).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates data at 0.5 tokens per user', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
market: { consumerTiers: { totalUsers: 200 } },
|
||||||
|
data: { partnerships: [], totalTrainingTokens: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = processData(state);
|
||||||
|
|
||||||
|
expect(result.userDataGenerationRate).toBe(100);
|
||||||
|
expect(result.totalTrainingTokens).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes partnership tokensPerTick contributions', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
market: { consumerTiers: { totalUsers: 0 } },
|
||||||
|
data: {
|
||||||
|
partnerships: [makePartnership(50), makePartnership(30)],
|
||||||
|
totalTrainingTokens: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = processData(state);
|
||||||
|
|
||||||
|
expect(result.totalTrainingTokens).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accumulates tokens over successive ticks', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
market: { consumerTiers: { totalUsers: 100 } },
|
||||||
|
data: { partnerships: [], totalTrainingTokens: 1000 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = processData(state);
|
||||||
|
|
||||||
|
// 1000 existing + 100 * 0.5 = 1050
|
||||||
|
expect(result.totalTrainingTokens).toBe(1050);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combines user data and partnership tokens', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
market: { consumerTiers: { totalUsers: 400 } },
|
||||||
|
data: {
|
||||||
|
partnerships: [makePartnership(100)],
|
||||||
|
totalTrainingTokens: 500,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = processData(state);
|
||||||
|
|
||||||
|
// users: 400 * 0.5 = 200, partnerships: 100, existing: 500
|
||||||
|
expect(result.userDataGenerationRate).toBe(200);
|
||||||
|
expect(result.totalTrainingTokens).toBe(800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a single user generating fractional tokens', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
market: { consumerTiers: { totalUsers: 1 } },
|
||||||
|
data: { partnerships: [], totalTrainingTokens: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = processData(state);
|
||||||
|
|
||||||
|
expect(result.userDataGenerationRate).toBe(0.5);
|
||||||
|
expect(result.totalTrainingTokens).toBe(0.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { GameState, DataState } from '@ai-tycoon/shared';
|
import type { GameState, DataState } from '@token-empire/shared';
|
||||||
|
|
||||||
export function processData(state: GameState): DataState {
|
export function processData(state: GameState): DataState {
|
||||||
const subscribers = state.market.consumerTiers.totalUsers;
|
const subscribers = state.market.consumerTiers.totalUsers;
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
function createMarketResult(
|
||||||
|
overrides: Partial<MarketTickResult> = {},
|
||||||
|
): MarketTickResult {
|
||||||
|
return {
|
||||||
|
marketState: {} as MarketTickResult['marketState'],
|
||||||
|
apiRevenue: 0,
|
||||||
|
subscriptionRevenue: 0,
|
||||||
|
totalTokenDemand: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInfraWithCosts(
|
||||||
|
energyCosts: number[],
|
||||||
|
maintenanceCosts: number[],
|
||||||
|
): InfrastructureState {
|
||||||
|
const clusters = energyCosts.map((energy, i) => {
|
||||||
|
const cluster = createTestCluster();
|
||||||
|
const dc = cluster.campuses[0].dataCenters[0];
|
||||||
|
dc.energyCostPerTick = energy;
|
||||||
|
dc.maintenanceCostPerTick = maintenanceCosts[i];
|
||||||
|
return cluster;
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = createTestState();
|
||||||
|
return { ...state.infrastructure, clusters };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('processEconomy', () => {
|
||||||
|
it('computes revenue as apiRevenue + subscriptionRevenue', () => {
|
||||||
|
const state = createTestState();
|
||||||
|
const market = createMarketResult({
|
||||||
|
apiRevenue: 200,
|
||||||
|
subscriptionRevenue: 300,
|
||||||
|
});
|
||||||
|
const infra = createInfraWithCosts([], []);
|
||||||
|
|
||||||
|
const result = processEconomy(state, market, infra);
|
||||||
|
|
||||||
|
expect(result.revenuePerTick).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes infrastructure energy and maintenance in expenses', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
talent: { totalSalaryPerTick: 0 },
|
||||||
|
data: { partnerships: [] },
|
||||||
|
models: { bestDeployedModelScore: 0 },
|
||||||
|
market: { developerEcosystem: { devRelSpending: 0 } },
|
||||||
|
});
|
||||||
|
const market = createMarketResult();
|
||||||
|
const infra = createInfraWithCosts([10, 20], [5, 15]);
|
||||||
|
|
||||||
|
const result = processEconomy(state, market, infra);
|
||||||
|
|
||||||
|
// 10 + 5 + 20 + 15 = 50
|
||||||
|
expect(result.expensesPerTick).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes talent salary in expenses', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
talent: { totalSalaryPerTick: 100 },
|
||||||
|
data: { partnerships: [] },
|
||||||
|
models: { bestDeployedModelScore: 0 },
|
||||||
|
market: { developerEcosystem: { devRelSpending: 0 } },
|
||||||
|
});
|
||||||
|
const market = createMarketResult();
|
||||||
|
const infra = createInfraWithCosts([], []);
|
||||||
|
|
||||||
|
const result = processEconomy(state, market, infra);
|
||||||
|
|
||||||
|
expect(result.expensesPerTick).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes data partnership costs in expenses', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
talent: { totalSalaryPerTick: 0 },
|
||||||
|
data: {
|
||||||
|
partnerships: [
|
||||||
|
{ costPerTick: 25 },
|
||||||
|
{ costPerTick: 75 },
|
||||||
|
] as any,
|
||||||
|
},
|
||||||
|
models: { bestDeployedModelScore: 0 },
|
||||||
|
market: { developerEcosystem: { devRelSpending: 0 } },
|
||||||
|
});
|
||||||
|
const market = createMarketResult();
|
||||||
|
const infra = createInfraWithCosts([], []);
|
||||||
|
|
||||||
|
const result = processEconomy(state, market, infra);
|
||||||
|
|
||||||
|
expect(result.expensesPerTick).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes compliance cost when bestCapability > 30', () => {
|
||||||
|
// compliance = bestCapability * 50 * (1 + eraIdx * 0.5) / 100
|
||||||
|
// bestCapability = 60, era = startup (idx 0)
|
||||||
|
// 60 * 50 * (1 + 0) / 100 = 30
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { currentEra: 'startup' },
|
||||||
|
talent: { totalSalaryPerTick: 0 },
|
||||||
|
data: { partnerships: [] },
|
||||||
|
models: { bestDeployedModelScore: 60 },
|
||||||
|
market: { developerEcosystem: { devRelSpending: 0 } },
|
||||||
|
});
|
||||||
|
const market = createMarketResult();
|
||||||
|
const infra = createInfraWithCosts([], []);
|
||||||
|
|
||||||
|
const result = processEconomy(state, market, infra);
|
||||||
|
|
||||||
|
expect(result.expensesPerTick).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales compliance cost with era index', () => {
|
||||||
|
// bestCapability = 60, era = bigtech (idx 2)
|
||||||
|
// 60 * 50 * (1 + 2 * 0.5) / 100 = 60
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { currentEra: 'bigtech' },
|
||||||
|
talent: { totalSalaryPerTick: 0 },
|
||||||
|
data: { partnerships: [] },
|
||||||
|
models: { bestDeployedModelScore: 60 },
|
||||||
|
market: { developerEcosystem: { devRelSpending: 0 } },
|
||||||
|
});
|
||||||
|
const market = createMarketResult();
|
||||||
|
const infra = createInfraWithCosts([], []);
|
||||||
|
|
||||||
|
const result = processEconomy(state, market, infra);
|
||||||
|
|
||||||
|
expect(result.expensesPerTick).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has zero compliance cost when bestCapability <= 30', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
talent: { totalSalaryPerTick: 0 },
|
||||||
|
data: { partnerships: [] },
|
||||||
|
models: { bestDeployedModelScore: 30 },
|
||||||
|
market: { developerEcosystem: { devRelSpending: 0 } },
|
||||||
|
});
|
||||||
|
const market = createMarketResult();
|
||||||
|
const infra = createInfraWithCosts([], []);
|
||||||
|
|
||||||
|
const result = processEconomy(state, market, infra);
|
||||||
|
|
||||||
|
expect(result.expensesPerTick).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes devRel spending in expenses', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
talent: { totalSalaryPerTick: 0 },
|
||||||
|
data: { partnerships: [] },
|
||||||
|
models: { bestDeployedModelScore: 0 },
|
||||||
|
market: { developerEcosystem: { devRelSpending: 42 } },
|
||||||
|
});
|
||||||
|
const market = createMarketResult();
|
||||||
|
const infra = createInfraWithCosts([], []);
|
||||||
|
|
||||||
|
const result = processEconomy(state, market, infra);
|
||||||
|
|
||||||
|
expect(result.expensesPerTick).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes extraCosts in expenses', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
talent: { totalSalaryPerTick: 0 },
|
||||||
|
data: { partnerships: [] },
|
||||||
|
models: { bestDeployedModelScore: 0 },
|
||||||
|
market: { developerEcosystem: { devRelSpending: 0 } },
|
||||||
|
});
|
||||||
|
const market = createMarketResult();
|
||||||
|
const infra = createInfraWithCosts([], []);
|
||||||
|
|
||||||
|
const result = processEconomy(state, market, infra, 200);
|
||||||
|
|
||||||
|
expect(result.expensesPerTick).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes money as previousMoney + revenue - expenses', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
economy: { money: 1000 },
|
||||||
|
talent: { totalSalaryPerTick: 0 },
|
||||||
|
data: { partnerships: [] },
|
||||||
|
models: { bestDeployedModelScore: 0 },
|
||||||
|
market: { developerEcosystem: { devRelSpending: 0 } },
|
||||||
|
});
|
||||||
|
const market = createMarketResult({
|
||||||
|
apiRevenue: 300,
|
||||||
|
subscriptionRevenue: 200,
|
||||||
|
});
|
||||||
|
const infra = createInfraWithCosts([], []);
|
||||||
|
|
||||||
|
const result = processEconomy(state, market, infra);
|
||||||
|
|
||||||
|
expect(result.money).toBe(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('floors money at zero', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
economy: { money: 100 },
|
||||||
|
talent: { totalSalaryPerTick: 0 },
|
||||||
|
data: { partnerships: [] },
|
||||||
|
models: { bestDeployedModelScore: 0 },
|
||||||
|
market: { developerEcosystem: { devRelSpending: 0 } },
|
||||||
|
});
|
||||||
|
const market = createMarketResult({ apiRevenue: 0, subscriptionRevenue: 0 });
|
||||||
|
const infra = createInfraWithCosts([], []);
|
||||||
|
|
||||||
|
const result = processEconomy(state, market, infra, 500);
|
||||||
|
|
||||||
|
expect(result.money).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accumulates totalRevenue and totalExpenses', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
economy: { money: 10_000, totalRevenue: 5000, totalExpenses: 2000 },
|
||||||
|
talent: { totalSalaryPerTick: 50 },
|
||||||
|
data: { partnerships: [] },
|
||||||
|
models: { bestDeployedModelScore: 0 },
|
||||||
|
market: { developerEcosystem: { devRelSpending: 0 } },
|
||||||
|
});
|
||||||
|
const market = createMarketResult({
|
||||||
|
apiRevenue: 100,
|
||||||
|
subscriptionRevenue: 200,
|
||||||
|
});
|
||||||
|
const infra = createInfraWithCosts([], []);
|
||||||
|
|
||||||
|
const result = processEconomy(state, market, infra);
|
||||||
|
|
||||||
|
expect(result.totalRevenue).toBe(5300);
|
||||||
|
expect(result.totalExpenses).toBe(2050);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds financial history snapshot when tickCount % 60 === 0', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { tickCount: 120 },
|
||||||
|
economy: { money: 5000, financialHistory: [] },
|
||||||
|
talent: { totalSalaryPerTick: 0 },
|
||||||
|
data: { partnerships: [] },
|
||||||
|
models: { bestDeployedModelScore: 0 },
|
||||||
|
market: { developerEcosystem: { devRelSpending: 0 } },
|
||||||
|
});
|
||||||
|
const market = createMarketResult({
|
||||||
|
apiRevenue: 100,
|
||||||
|
subscriptionRevenue: 0,
|
||||||
|
});
|
||||||
|
const infra = createInfraWithCosts([], []);
|
||||||
|
|
||||||
|
const result = processEconomy(state, market, infra);
|
||||||
|
|
||||||
|
expect(result.financialHistory).toHaveLength(1);
|
||||||
|
expect(result.financialHistory[0]).toMatchObject({
|
||||||
|
tick: 120,
|
||||||
|
revenue: 100,
|
||||||
|
expenses: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not add financial history snapshot when tickCount % 60 !== 0', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { tickCount: 61 },
|
||||||
|
economy: { money: 5000, financialHistory: [] },
|
||||||
|
talent: { totalSalaryPerTick: 0 },
|
||||||
|
data: { partnerships: [] },
|
||||||
|
models: { bestDeployedModelScore: 0 },
|
||||||
|
market: { developerEcosystem: { devRelSpending: 0 } },
|
||||||
|
});
|
||||||
|
const market = createMarketResult();
|
||||||
|
const infra = createInfraWithCosts([], []);
|
||||||
|
|
||||||
|
const result = processEconomy(state, market, infra);
|
||||||
|
|
||||||
|
expect(result.financialHistory).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims financial history when it exceeds 1000 entries', () => {
|
||||||
|
const existingHistory = Array.from({ length: 1000 }, (_, i) => ({
|
||||||
|
tick: i * 60,
|
||||||
|
money: 1000,
|
||||||
|
revenue: 10,
|
||||||
|
expenses: 5,
|
||||||
|
valuation: 1_000_000,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { tickCount: 60000 },
|
||||||
|
economy: { money: 5000, financialHistory: existingHistory },
|
||||||
|
talent: { totalSalaryPerTick: 0 },
|
||||||
|
data: { partnerships: [] },
|
||||||
|
models: { bestDeployedModelScore: 0 },
|
||||||
|
market: { developerEcosystem: { devRelSpending: 0 } },
|
||||||
|
});
|
||||||
|
const market = createMarketResult();
|
||||||
|
const infra = createInfraWithCosts([], []);
|
||||||
|
|
||||||
|
const result = processEconomy(state, market, infra);
|
||||||
|
|
||||||
|
// 1000 existing + 1 new = 1001 -> shift -> 1000
|
||||||
|
expect(result.financialHistory).toHaveLength(1000);
|
||||||
|
// The oldest entry (tick 0) should have been shifted off
|
||||||
|
expect(result.financialHistory[0].tick).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sums all expense categories together', () => {
|
||||||
|
// infra: 10+5 = 15, talent: 20, data: 30, compliance (cap=50, era=scaleup idx=1): 50*50*(1+0.5)/100 = 37.5, devRel: 10, extra: 8
|
||||||
|
// total = 15 + 20 + 30 + 37.5 + 10 + 8 = 120.5
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { currentEra: 'scaleup' },
|
||||||
|
talent: { totalSalaryPerTick: 20 },
|
||||||
|
data: {
|
||||||
|
partnerships: [{ costPerTick: 30 }] as any,
|
||||||
|
},
|
||||||
|
models: { bestDeployedModelScore: 50 },
|
||||||
|
market: { developerEcosystem: { devRelSpending: 10 } },
|
||||||
|
});
|
||||||
|
const market = createMarketResult({
|
||||||
|
apiRevenue: 500,
|
||||||
|
subscriptionRevenue: 500,
|
||||||
|
});
|
||||||
|
const infra = createInfraWithCosts([10], [5]);
|
||||||
|
|
||||||
|
const result = processEconomy(state, market, infra, 8);
|
||||||
|
|
||||||
|
expect(result.expensesPerTick).toBe(120.5);
|
||||||
|
expect(result.revenuePerTick).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { GameState, EconomyState, InfrastructureState } from '@ai-tycoon/shared';
|
import type { GameState, EconomyState, InfrastructureState } from '@token-empire/shared';
|
||||||
import { FINANCIAL_SNAPSHOT_INTERVAL, MAX_FINANCIAL_HISTORY, REGULATION_COMPLIANCE_PER_CAPABILITY } from '@ai-tycoon/shared';
|
import { FINANCIAL_SNAPSHOT_INTERVAL, MAX_FINANCIAL_HISTORY, REGULATION_COMPLIANCE_PER_CAPABILITY } from '@token-empire/shared';
|
||||||
|
import { TECH_TREE } from '../data/techTree';
|
||||||
import type { MarketTickResult } from './marketSystem';
|
import type { MarketTickResult } from './marketSystem';
|
||||||
|
|
||||||
export function processEconomy(
|
export function processEconomy(
|
||||||
@@ -27,7 +28,16 @@ export function processEconomy(
|
|||||||
const complianceCost = bestCapability > 30 ? bestCapability * REGULATION_COMPLIANCE_PER_CAPABILITY * (1 + eraIdx * 0.5) / 100 : 0;
|
const complianceCost = bestCapability > 30 ? bestCapability * REGULATION_COMPLIANCE_PER_CAPABILITY * (1 + eraIdx * 0.5) / 100 : 0;
|
||||||
|
|
||||||
const devRelExpenses = state.market.developerEcosystem.devRelSpending;
|
const devRelExpenses = state.market.developerEcosystem.devRelSpending;
|
||||||
const expenses = infraExpenses + talentExpenses + dataExpenses + complianceCost + devRelExpenses + extraCosts;
|
|
||||||
|
let researchExpenses = 0;
|
||||||
|
if (state.research.activeResearch) {
|
||||||
|
const node = TECH_TREE.find(n => n.id === state.research.activeResearch!.researchId);
|
||||||
|
if (node) {
|
||||||
|
researchExpenses = node.cost.money / node.cost.ticks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expenses = infraExpenses + talentExpenses + dataExpenses + complianceCost + devRelExpenses + researchExpenses + extraCosts;
|
||||||
|
|
||||||
const money = state.economy.money + revenue - expenses;
|
const money = state.economy.money + revenue - expenses;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { checkEraTransition } from './eraSystem';
|
||||||
|
import { createTestState } from '../__test-utils__';
|
||||||
|
|
||||||
|
describe('checkEraTransition', () => {
|
||||||
|
it('returns null when already at agi era', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { currentEra: 'agi' },
|
||||||
|
economy: { totalRevenue: 999_999_999_999 },
|
||||||
|
models: { bestDeployedModelScore: 100 },
|
||||||
|
reputation: { score: 100 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(checkEraTransition(state)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns scaleup when all thresholds met in startup era', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { currentEra: 'startup' },
|
||||||
|
economy: { totalRevenue: 5000 },
|
||||||
|
models: { bestDeployedModelScore: 10 },
|
||||||
|
reputation: { score: 40 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(checkEraTransition(state)).toBe('scaleup');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when revenue threshold not met for scaleup', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { currentEra: 'startup' },
|
||||||
|
economy: { totalRevenue: 4999 },
|
||||||
|
models: { bestDeployedModelScore: 10 },
|
||||||
|
reputation: { score: 40 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(checkEraTransition(state)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when capability threshold not met for scaleup', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { currentEra: 'startup' },
|
||||||
|
economy: { totalRevenue: 5000 },
|
||||||
|
models: { bestDeployedModelScore: 9 },
|
||||||
|
reputation: { score: 40 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(checkEraTransition(state)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when reputation threshold not met for scaleup', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { currentEra: 'startup' },
|
||||||
|
economy: { totalRevenue: 5000 },
|
||||||
|
models: { bestDeployedModelScore: 10 },
|
||||||
|
reputation: { score: 39 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(checkEraTransition(state)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns bigtech from scaleup era when all thresholds met', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { currentEra: 'scaleup' },
|
||||||
|
economy: { totalRevenue: 10_000_000 },
|
||||||
|
models: { bestDeployedModelScore: 55 },
|
||||||
|
reputation: { score: 65 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(checkEraTransition(state)).toBe('bigtech');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null from scaleup when bigtech thresholds not met', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { currentEra: 'scaleup' },
|
||||||
|
economy: { totalRevenue: 9_999_999 },
|
||||||
|
models: { bestDeployedModelScore: 55 },
|
||||||
|
reputation: { score: 65 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(checkEraTransition(state)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns agi from bigtech era when all thresholds met', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { currentEra: 'bigtech' },
|
||||||
|
economy: { totalRevenue: 1_000_000_000 },
|
||||||
|
models: { bestDeployedModelScore: 93 },
|
||||||
|
reputation: { score: 80 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(checkEraTransition(state)).toBe('agi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null from bigtech when agi thresholds not met', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { currentEra: 'bigtech' },
|
||||||
|
economy: { totalRevenue: 1_000_000_000 },
|
||||||
|
models: { bestDeployedModelScore: 92 },
|
||||||
|
reputation: { score: 80 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(checkEraTransition(state)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only transitions to the next era, not skipping', () => {
|
||||||
|
// In startup with bigtech-level stats should only transition to scaleup
|
||||||
|
const state = createTestState({
|
||||||
|
meta: { currentEra: 'startup' },
|
||||||
|
economy: { totalRevenue: 2_000_000_000 },
|
||||||
|
models: { bestDeployedModelScore: 99 },
|
||||||
|
reputation: { score: 95 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(checkEraTransition(state)).toBe('scaleup');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { GameState, Era } from '@ai-tycoon/shared';
|
import type { GameState, Era } from '@token-empire/shared';
|
||||||
import { ERA_THRESHOLDS } from '@ai-tycoon/shared';
|
import { ERA_THRESHOLDS } from '@token-empire/shared';
|
||||||
|
|
||||||
export function checkEraTransition(state: GameState): Era | null {
|
export function checkEraTransition(state: GameState): Era | null {
|
||||||
const current = state.meta.currentEra;
|
const current = state.meta.currentEra;
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createTestState } from '../__test-utils__';
|
||||||
|
import { getNextFundingRound, canRaiseFunding, computeValuation } from './fundingSystem';
|
||||||
|
|
||||||
|
describe('getNextFundingRound', () => {
|
||||||
|
it('returns seed when no rounds completed', () => {
|
||||||
|
const funding = createTestState().economy.funding;
|
||||||
|
expect(getNextFundingRound(funding)).toBe('seed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns seriesA when seed completed', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
economy: {
|
||||||
|
funding: {
|
||||||
|
completedRounds: [
|
||||||
|
{ type: 'seed', amount: 500_000, dilution: 0.1, completedAtTick: 100 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(getNextFundingRound(state.economy.funding)).toBe('seriesA');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns seriesC when seed through seriesB completed', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
economy: {
|
||||||
|
funding: {
|
||||||
|
completedRounds: [
|
||||||
|
{ type: 'seed', amount: 500_000, dilution: 0.1, completedAtTick: 100 },
|
||||||
|
{ type: 'seriesA', amount: 2_000_000, dilution: 0.15, completedAtTick: 200 },
|
||||||
|
{ type: 'seriesB', amount: 10_000_000, dilution: 0.12, completedAtTick: 300 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(getNextFundingRound(state.economy.funding)).toBe('seriesC');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when isPublic', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
economy: {
|
||||||
|
funding: {
|
||||||
|
isPublic: true,
|
||||||
|
completedRounds: [
|
||||||
|
{ type: 'seed', amount: 500_000, dilution: 0.1, completedAtTick: 100 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(getNextFundingRound(state.economy.funding)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when all rounds completed', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
economy: {
|
||||||
|
funding: {
|
||||||
|
completedRounds: [
|
||||||
|
{ type: 'seed', amount: 500_000, dilution: 0.1, completedAtTick: 100 },
|
||||||
|
{ type: 'seriesA', amount: 2_000_000, dilution: 0.15, completedAtTick: 200 },
|
||||||
|
{ type: 'seriesB', amount: 10_000_000, dilution: 0.12, completedAtTick: 300 },
|
||||||
|
{ type: 'seriesC', amount: 50_000_000, dilution: 0.1, completedAtTick: 400 },
|
||||||
|
{ type: 'seriesD', amount: 200_000_000, dilution: 0.08, completedAtTick: 500 },
|
||||||
|
{ type: 'ipo', amount: 1_000_000_000, dilution: 0.2, completedAtTick: 600 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(getNextFundingRound(state.economy.funding)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canRaiseFunding', () => {
|
||||||
|
it('returns canRaise true when seed requirements met', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
economy: { totalRevenue: 200 },
|
||||||
|
});
|
||||||
|
const result = canRaiseFunding(state);
|
||||||
|
expect(result.canRaise).toBe(true);
|
||||||
|
expect(result.nextRound).toBe('seed');
|
||||||
|
expect(result.reason).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns canRaise true for seriesA when all requirements met', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
economy: {
|
||||||
|
totalRevenue: 5_000,
|
||||||
|
funding: {
|
||||||
|
completedRounds: [
|
||||||
|
{ type: 'seed', amount: 500_000, dilution: 0.1, completedAtTick: 100 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
market: { consumerTiers: { totalUsers: 100 } },
|
||||||
|
reputation: { score: 30 },
|
||||||
|
});
|
||||||
|
const result = canRaiseFunding(state);
|
||||||
|
expect(result.canRaise).toBe(true);
|
||||||
|
expect(result.nextRound).toBe('seriesA');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns canRaise false with revenue reason when revenue too low', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
economy: {
|
||||||
|
totalRevenue: 500,
|
||||||
|
funding: {
|
||||||
|
completedRounds: [
|
||||||
|
{ type: 'seed', amount: 500_000, dilution: 0.1, completedAtTick: 100 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
market: { consumerTiers: { totalUsers: 100 } },
|
||||||
|
reputation: { score: 30 },
|
||||||
|
});
|
||||||
|
const result = canRaiseFunding(state);
|
||||||
|
expect(result.canRaise).toBe(false);
|
||||||
|
expect(result.nextRound).toBe('seriesA');
|
||||||
|
expect(result.reason).toContain('revenue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns canRaise false with subscribers reason when users too low', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
economy: {
|
||||||
|
totalRevenue: 5_000,
|
||||||
|
funding: {
|
||||||
|
completedRounds: [
|
||||||
|
{ type: 'seed', amount: 500_000, dilution: 0.1, completedAtTick: 100 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
market: { consumerTiers: { totalUsers: 10 } },
|
||||||
|
reputation: { score: 30 },
|
||||||
|
});
|
||||||
|
const result = canRaiseFunding(state);
|
||||||
|
expect(result.canRaise).toBe(false);
|
||||||
|
expect(result.nextRound).toBe('seriesA');
|
||||||
|
expect(result.reason).toContain('subscribers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns canRaise false with reputation reason when reputation too low', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
economy: {
|
||||||
|
totalRevenue: 5_000,
|
||||||
|
funding: {
|
||||||
|
completedRounds: [
|
||||||
|
{ type: 'seed', amount: 500_000, dilution: 0.1, completedAtTick: 100 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
market: { consumerTiers: { totalUsers: 100 } },
|
||||||
|
reputation: { score: 5 },
|
||||||
|
});
|
||||||
|
const result = canRaiseFunding(state);
|
||||||
|
expect(result.canRaise).toBe(false);
|
||||||
|
expect(result.nextRound).toBe('seriesA');
|
||||||
|
expect(result.reason).toContain('reputation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns canRaise false when no more rounds available', () => {
|
||||||
|
const state = createTestState({
|
||||||
|
economy: {
|
||||||
|
funding: {
|
||||||
|
isPublic: true,
|
||||||
|
completedRounds: [
|
||||||
|
{ type: 'seed', amount: 500_000, dilution: 0.1, completedAtTick: 100 },
|
||||||
|
{ type: 'seriesA', amount: 2_000_000, dilution: 0.15, completedAtTick: 200 },
|
||||||
|
{ type: 'seriesB', amount: 10_000_000, dilution: 0.12, completedAtTick: 300 },
|
||||||
|
{ type: 'seriesC', amount: 50_000_000, dilution: 0.1, completedAtTick: 400 },
|
||||||
|
{ type: 'seriesD', amount: 200_000_000, dilution: 0.08, completedAtTick: 500 },
|
||||||
|
{ type: 'ipo', amount: 1_000_000_000, dilution: 0.2, completedAtTick: 600 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = canRaiseFunding(state);
|
||||||
|
expect(result.canRaise).toBe(false);
|
||||||
|
expect(result.nextRound).toBeNull();
|
||||||
|
expect(result.reason).toContain('No more funding rounds');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeValuation', () => {
|
||||||
|
it('returns minimum valuation of 100,000 for a fresh state', () => {
|
||||||
|
const state = createTestState();
|
||||||
|
expect(computeValuation(state)).toBe(100_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows the formula: max(100k, revenuePerTick * 86400 * 365 * 10 + totalUsers * 500 + bestModelScore^2 * 1000)', () => {
|
||||||
|
const revenuePerTick = 0.5;
|
||||||
|
const totalUsers = 200;
|
||||||
|
const bestScore = 30;
|
||||||
|
const state = createTestState({
|
||||||
|
economy: { revenuePerTick },
|
||||||
|
market: { consumerTiers: { totalUsers } },
|
||||||
|
models: { bestDeployedModelScore: bestScore },
|
||||||
|
});
|
||||||
|
|
||||||
|
const expected = revenuePerTick * 86400 * 365 * 10 + totalUsers * 500 + bestScore ** 2 * 1000;
|
||||||
|
expect(computeValuation(state)).toBe(Math.max(100_000, expected));
|
||||||
|
// Verify the computed value is actually above the minimum for these inputs
|
||||||
|
expect(expected).toBeGreaterThan(100_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('higher revenue increases valuation', () => {
|
||||||
|
const low = createTestState({ economy: { revenuePerTick: 0.01 } });
|
||||||
|
const high = createTestState({ economy: { revenuePerTick: 1.0 } });
|
||||||
|
expect(computeValuation(high)).toBeGreaterThan(computeValuation(low));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('higher subscribers increase valuation', () => {
|
||||||
|
const low = createTestState({ market: { consumerTiers: { totalUsers: 10 } } });
|
||||||
|
const high = createTestState({ market: { consumerTiers: { totalUsers: 10_000 } } });
|
||||||
|
expect(computeValuation(high)).toBeGreaterThan(computeValuation(low));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('higher model score increases valuation quadratically', () => {
|
||||||
|
const low = createTestState({ models: { bestDeployedModelScore: 10 } });
|
||||||
|
const high = createTestState({ models: { bestDeployedModelScore: 50 } });
|
||||||
|
expect(computeValuation(high)).toBeGreaterThan(computeValuation(low));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { GameState, FundingState, FundingRoundType } from '@ai-tycoon/shared';
|
import type { GameState, FundingState, FundingRoundType } from '@token-empire/shared';
|
||||||
import { FUNDING_ROUNDS } from '@ai-tycoon/shared';
|
import { FUNDING_ROUNDS } from '@token-empire/shared';
|
||||||
|
|
||||||
const ROUND_ORDER: FundingRoundType[] = ['seed', 'seriesA', 'seriesB', 'seriesC', 'seriesD', 'ipo'];
|
const ROUND_ORDER: FundingRoundType[] = ['seed', 'seriesA', 'seriesB', 'seriesC', 'seriesD', 'ipo'];
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type {
|
import type {
|
||||||
GameState, InfrastructureState, Cluster, Campus, DataCenter,
|
GameState, InfrastructureState, Cluster, Campus, DataCenter,
|
||||||
DeploymentCohort, PipelineStage, RackSkuId, NetworkSwitch,
|
DeploymentCohort, PipelineStage, RackSkuId,
|
||||||
SwitchTier, DCNetworkSummary, CampusNetworkSummary, ClusterNetworkSummary,
|
SwitchTier, DCNetworkSummary, CampusNetworkSummary, ClusterNetworkSummary,
|
||||||
CampusRetrofitQueue, DCTier, IntraNodeInterconnect, NetworkFabric, RackSkuConfig,
|
RepairBatch, CampusRetrofitQueue, DCTier, IntraNodeInterconnect, NetworkFabric, RackSkuConfig,
|
||||||
} from '@ai-tycoon/shared';
|
} from '@token-empire/shared';
|
||||||
import {
|
import {
|
||||||
LOCATION_CONFIGS,
|
LOCATION_CONFIGS,
|
||||||
RACK_SKU_CONFIGS,
|
RACK_SKU_CONFIGS,
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
COOLING_TYPE_CONFIGS,
|
COOLING_TYPE_CONFIGS,
|
||||||
NETWORK_FABRIC_CONFIGS,
|
NETWORK_FABRIC_CONFIGS,
|
||||||
estimateNetworkSlots,
|
estimateNetworkSlots,
|
||||||
} from '@ai-tycoon/shared';
|
} from '@token-empire/shared';
|
||||||
import type { TickNotification } from '../tick';
|
import type { TickNotification } from '../tick';
|
||||||
import type { ResearchBonuses } from './researchBonuses';
|
import type { ResearchBonuses } from './researchBonuses';
|
||||||
|
|
||||||
@@ -83,357 +83,202 @@ function binomialSample(n: number, p: number): number {
|
|||||||
return base + (Math.random() < frac ? 1 : 0);
|
return base + (Math.random() < frac ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Network Topology Construction ---
|
// --- Aggregate Network Model ---
|
||||||
|
|
||||||
let switchIdCounter = 0;
|
const DC_TIERS: SwitchTier[] = ['tor', 't1', 't2', 't3'];
|
||||||
|
|
||||||
function createSwitch(
|
|
||||||
tier: SwitchTier,
|
|
||||||
dcId: string | null,
|
|
||||||
campusId: string | null,
|
|
||||||
clusterId: string | null,
|
|
||||||
): NetworkSwitch {
|
|
||||||
const config = SWITCH_TIER_CONFIGS[tier];
|
|
||||||
return {
|
|
||||||
id: `${tier}-${dcId ?? campusId ?? clusterId ?? 'x'}-${switchIdCounter++}`,
|
|
||||||
tier,
|
|
||||||
status: 'healthy',
|
|
||||||
dcId, campusId, clusterId,
|
|
||||||
uplinkIds: [],
|
|
||||||
downlinkIds: [],
|
|
||||||
activeUplinks: config.uplinkCount,
|
|
||||||
totalUplinks: config.uplinkCount,
|
|
||||||
effectiveBandwidth: 1.0,
|
|
||||||
repairProgress: 0,
|
|
||||||
repairTotal: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function wireUplinks(child: NetworkSwitch, parents: NetworkSwitch[], count: number): void {
|
|
||||||
if (parents.length === 0) return;
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const parent = parents[i % parents.length];
|
|
||||||
child.uplinkIds.push(parent.id);
|
|
||||||
if (!parent.downlinkIds.includes(child.id)) {
|
|
||||||
parent.downlinkIds.push(child.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
child.activeUplinks = count;
|
|
||||||
child.effectiveBandwidth = 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function emptyDCNetworkSummary(): DCNetworkSummary {
|
export function emptyDCNetworkSummary(): DCNetworkSummary {
|
||||||
return {
|
return {
|
||||||
switchIds: [], networkRackCount: 0,
|
|
||||||
totalByTier: {}, healthyByTier: {},
|
totalByTier: {}, healthyByTier: {},
|
||||||
|
repairBatches: [], networkRackCount: 0,
|
||||||
racksDisconnected: 0, racksDegraded: 0,
|
racksDisconnected: 0, racksDegraded: 0,
|
||||||
averageBandwidth: 1, effectiveFlopsFraction: 1,
|
averageBandwidth: 1, effectiveFlopsFraction: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emptyCampusNetworkSummary(): CampusNetworkSummary {
|
export function emptyCampusNetworkSummary(): CampusNetworkSummary {
|
||||||
return { switchIds: [], totalT4: 0, healthyT4: 0, crossDCBandwidth: 1 };
|
return { totalT4: 0, healthyT4: 0, crossDCBandwidth: 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emptyClusterNetworkSummary(): ClusterNetworkSummary {
|
export function emptyClusterNetworkSummary(): ClusterNetworkSummary {
|
||||||
return { switchIds: [], totalT5: 0, healthyT5: 0, crossCampusBandwidth: 1 };
|
return { totalT5: 0, healthyT5: 0, crossCampusBandwidth: 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDCTopology(
|
function computeTopologyCounts(
|
||||||
computeRackCount: number,
|
computeRackCount: number,
|
||||||
dcTier: DCTier,
|
dcTier: DCTier,
|
||||||
dcId: string,
|
): Partial<Record<SwitchTier, number>> {
|
||||||
registry: Record<string, NetworkSwitch>,
|
if (computeRackCount <= 0) return {};
|
||||||
): DCNetworkSummary {
|
|
||||||
if (computeRackCount <= 0) return emptyDCNetworkSummary();
|
|
||||||
|
|
||||||
const switchIds: string[] = [];
|
|
||||||
|
|
||||||
const t3Count = T3_COUNT_PER_DC_TIER[dcTier];
|
|
||||||
const t3s: NetworkSwitch[] = [];
|
|
||||||
for (let i = 0; i < t3Count; i++) {
|
|
||||||
const sw = createSwitch('t3', dcId, null, null);
|
|
||||||
sw.totalUplinks = 0;
|
|
||||||
sw.activeUplinks = 0;
|
|
||||||
t3s.push(sw);
|
|
||||||
registry[sw.id] = sw;
|
|
||||||
switchIds.push(sw.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const t1Count = Math.ceil(computeRackCount / SWITCH_TIER_CONFIGS.t1.fanOut);
|
const t1Count = Math.ceil(computeRackCount / SWITCH_TIER_CONFIGS.t1.fanOut);
|
||||||
const t2Count = Math.ceil(t1Count / SWITCH_TIER_CONFIGS.t2.fanOut);
|
const t2Count = Math.ceil(t1Count / SWITCH_TIER_CONFIGS.t2.fanOut);
|
||||||
|
const t3Count = T3_COUNT_PER_DC_TIER[dcTier];
|
||||||
const t2s: NetworkSwitch[] = [];
|
return { tor: computeRackCount, t1: t1Count, t2: t2Count, t3: t3Count };
|
||||||
for (let i = 0; i < t2Count; i++) {
|
|
||||||
const sw = createSwitch('t2', dcId, null, null);
|
|
||||||
wireUplinks(sw, t3s, SWITCH_TIER_CONFIGS.t2.uplinkCount);
|
|
||||||
t2s.push(sw);
|
|
||||||
registry[sw.id] = sw;
|
|
||||||
switchIds.push(sw.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const t1s: NetworkSwitch[] = [];
|
export function buildDCNetworkSummary(
|
||||||
for (let i = 0; i < t1Count; i++) {
|
computeRackCount: number,
|
||||||
const sw = createSwitch('t1', dcId, null, null);
|
|
||||||
wireUplinks(sw, t2s, SWITCH_TIER_CONFIGS.t1.uplinkCount);
|
|
||||||
t1s.push(sw);
|
|
||||||
registry[sw.id] = sw;
|
|
||||||
switchIds.push(sw.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < computeRackCount; i++) {
|
|
||||||
const sw = createSwitch('tor', dcId, null, null);
|
|
||||||
const primary = t1s[Math.floor(i / SWITCH_TIER_CONFIGS.t1.fanOut)];
|
|
||||||
const altIdx = (Math.floor(i / SWITCH_TIER_CONFIGS.t1.fanOut) + 1) % t1s.length;
|
|
||||||
const alt = t1s[altIdx];
|
|
||||||
if (t1s.length >= 2 && primary !== alt) {
|
|
||||||
wireUplinks(sw, [primary, alt], 2);
|
|
||||||
} else {
|
|
||||||
wireUplinks(sw, [primary], 2);
|
|
||||||
}
|
|
||||||
registry[sw.id] = sw;
|
|
||||||
switchIds.push(sw.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const networkRackCount = estimateNetworkSlots(computeRackCount, dcTier);
|
|
||||||
return buildDCSummary(switchIds, networkRackCount, registry);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function expandDCTopology(
|
|
||||||
existing: DCNetworkSummary,
|
|
||||||
newRackCount: number,
|
|
||||||
dcTier: DCTier,
|
dcTier: DCTier,
|
||||||
dcId: string,
|
|
||||||
registry: Record<string, NetworkSwitch>,
|
|
||||||
): DCNetworkSummary {
|
): DCNetworkSummary {
|
||||||
if (newRackCount <= 0) return existing;
|
if (computeRackCount <= 0) return emptyDCNetworkSummary();
|
||||||
|
const totalByTier = computeTopologyCounts(computeRackCount, dcTier);
|
||||||
const currentTorCount = existing.totalByTier?.tor ?? 0;
|
const healthyByTier = { ...totalByTier };
|
||||||
const targetTorCount = currentTorCount + newRackCount;
|
|
||||||
|
|
||||||
const t1s = existing.switchIds.map(id => registry[id]).filter((s): s is NetworkSwitch => !!s && s.tier === 't1');
|
|
||||||
const t2s = existing.switchIds.map(id => registry[id]).filter((s): s is NetworkSwitch => !!s && s.tier === 't2');
|
|
||||||
const t3s = existing.switchIds.map(id => registry[id]).filter((s): s is NetworkSwitch => !!s && s.tier === 't3');
|
|
||||||
|
|
||||||
const newIds = [...existing.switchIds];
|
|
||||||
|
|
||||||
const neededT1 = Math.ceil(targetTorCount / SWITCH_TIER_CONFIGS.t1.fanOut);
|
|
||||||
const neededT2 = Math.ceil(neededT1 / SWITCH_TIER_CONFIGS.t2.fanOut);
|
|
||||||
|
|
||||||
while (t2s.length < neededT2) {
|
|
||||||
const sw = createSwitch('t2', dcId, null, null);
|
|
||||||
wireUplinks(sw, t3s, SWITCH_TIER_CONFIGS.t2.uplinkCount);
|
|
||||||
t2s.push(sw);
|
|
||||||
registry[sw.id] = sw;
|
|
||||||
newIds.push(sw.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
while (t1s.length < neededT1) {
|
|
||||||
const sw = createSwitch('t1', dcId, null, null);
|
|
||||||
wireUplinks(sw, t2s, SWITCH_TIER_CONFIGS.t1.uplinkCount);
|
|
||||||
t1s.push(sw);
|
|
||||||
registry[sw.id] = sw;
|
|
||||||
newIds.push(sw.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < newRackCount; i++) {
|
|
||||||
const torIdx = currentTorCount + i;
|
|
||||||
const sw = createSwitch('tor', dcId, null, null);
|
|
||||||
const primary = t1s[Math.floor(torIdx / SWITCH_TIER_CONFIGS.t1.fanOut)];
|
|
||||||
const altIdx = (Math.floor(torIdx / SWITCH_TIER_CONFIGS.t1.fanOut) + 1) % t1s.length;
|
|
||||||
const alt = t1s[altIdx];
|
|
||||||
if (t1s.length >= 2 && primary !== alt) {
|
|
||||||
wireUplinks(sw, [primary, alt], 2);
|
|
||||||
} else {
|
|
||||||
wireUplinks(sw, [primary], 2);
|
|
||||||
}
|
|
||||||
registry[sw.id] = sw;
|
|
||||||
newIds.push(sw.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const networkRackCount = estimateNetworkSlots(targetTorCount, dcTier);
|
|
||||||
return buildDCSummary(newIds, networkRackCount, registry);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shrinkDCTopology(
|
|
||||||
existing: DCNetworkSummary,
|
|
||||||
removeCount: number,
|
|
||||||
dcTier: DCTier,
|
|
||||||
registry: Record<string, NetworkSwitch>,
|
|
||||||
): DCNetworkSummary {
|
|
||||||
if (removeCount <= 0) return existing;
|
|
||||||
|
|
||||||
const torIds = existing.switchIds.filter(id => registry[id]?.tier === 'tor');
|
|
||||||
const toRemove = new Set(torIds.slice(-removeCount));
|
|
||||||
|
|
||||||
for (const torId of toRemove) {
|
|
||||||
const tor = registry[torId];
|
|
||||||
if (!tor) continue;
|
|
||||||
for (const upId of tor.uplinkIds) {
|
|
||||||
const parent = registry[upId];
|
|
||||||
if (parent) parent.downlinkIds = parent.downlinkIds.filter(id => id !== torId);
|
|
||||||
}
|
|
||||||
delete registry[torId];
|
|
||||||
}
|
|
||||||
|
|
||||||
const remainingIds = existing.switchIds.filter(id => !toRemove.has(id));
|
|
||||||
const remainingTors = remainingIds.filter(id => registry[id]?.tier === 'tor').length;
|
|
||||||
return buildDCSummary(remainingIds, estimateNetworkSlots(remainingTors, dcTier), registry);
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeRackBandwidth(tor: NetworkSwitch, registry: Record<string, NetworkSwitch>): number {
|
|
||||||
if (tor.status !== 'healthy') return 0;
|
|
||||||
|
|
||||||
let minBW = tor.totalUplinks > 0 ? tor.activeUplinks / tor.totalUplinks : 1;
|
|
||||||
if (minBW === 0) return 0;
|
|
||||||
|
|
||||||
const visited = new Set<string>();
|
|
||||||
let current = tor.uplinkIds.filter(id => {
|
|
||||||
const sw = registry[id];
|
|
||||||
return sw && sw.status === 'healthy';
|
|
||||||
});
|
|
||||||
|
|
||||||
while (current.length > 0) {
|
|
||||||
let tierBW = 1;
|
|
||||||
const next: string[] = [];
|
|
||||||
for (const sid of current) {
|
|
||||||
if (visited.has(sid)) continue;
|
|
||||||
visited.add(sid);
|
|
||||||
const sw = registry[sid];
|
|
||||||
if (!sw || sw.status !== 'healthy') continue;
|
|
||||||
const bw = sw.totalUplinks > 0 ? sw.activeUplinks / sw.totalUplinks : 1;
|
|
||||||
tierBW = Math.min(tierBW, bw);
|
|
||||||
for (const upId of sw.uplinkIds) {
|
|
||||||
if (registry[upId]?.status === 'healthy') next.push(upId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
minBW = Math.min(minBW, tierBW);
|
|
||||||
if (minBW === 0) return 0;
|
|
||||||
current = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
return minBW;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDCSummary(
|
|
||||||
switchIds: string[],
|
|
||||||
networkRackCount: number,
|
|
||||||
registry: Record<string, NetworkSwitch>,
|
|
||||||
): DCNetworkSummary {
|
|
||||||
const totalByTier: Partial<Record<SwitchTier, number>> = {};
|
|
||||||
const healthyByTier: Partial<Record<SwitchTier, number>> = {};
|
|
||||||
let disconnected = 0;
|
|
||||||
let degraded = 0;
|
|
||||||
let bwSum = 0;
|
|
||||||
let torCount = 0;
|
|
||||||
|
|
||||||
for (const sid of switchIds) {
|
|
||||||
const sw = registry[sid];
|
|
||||||
if (!sw) continue;
|
|
||||||
totalByTier[sw.tier] = (totalByTier[sw.tier] ?? 0) + 1;
|
|
||||||
if (sw.status === 'healthy') healthyByTier[sw.tier] = (healthyByTier[sw.tier] ?? 0) + 1;
|
|
||||||
if (sw.tier === 'tor') {
|
|
||||||
torCount++;
|
|
||||||
const bw = computeRackBandwidth(sw, registry);
|
|
||||||
bwSum += bw;
|
|
||||||
if (bw === 0) disconnected++;
|
|
||||||
else if (bw < 1) degraded++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const avgBW = torCount > 0 ? bwSum / torCount : 1;
|
|
||||||
return {
|
return {
|
||||||
switchIds, networkRackCount, totalByTier, healthyByTier,
|
totalByTier, healthyByTier,
|
||||||
racksDisconnected: disconnected, racksDegraded: degraded,
|
repairBatches: [],
|
||||||
averageBandwidth: avgBW, effectiveFlopsFraction: avgBW,
|
networkRackCount: estimateNetworkSlots(computeRackCount, dcTier),
|
||||||
|
racksDisconnected: 0, racksDegraded: 0,
|
||||||
|
averageBandwidth: 1, effectiveFlopsFraction: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Network Tick (failure rolls + repair) ---
|
export function expandDCNetwork(
|
||||||
|
existing: DCNetworkSummary,
|
||||||
|
addedRacks: number,
|
||||||
|
dcTier: DCTier,
|
||||||
|
): DCNetworkSummary {
|
||||||
|
if (addedRacks <= 0) return existing;
|
||||||
|
const oldTor = existing.totalByTier.tor ?? 0;
|
||||||
|
const newTor = oldTor + addedRacks;
|
||||||
|
const newTotal = computeTopologyCounts(newTor, dcTier);
|
||||||
|
const healthyByTier: Partial<Record<SwitchTier, number>> = {};
|
||||||
|
for (const tier of DC_TIERS) {
|
||||||
|
const oldTotal = existing.totalByTier[tier] ?? 0;
|
||||||
|
const oldHealthy = existing.healthyByTier[tier] ?? 0;
|
||||||
|
const added = (newTotal[tier] ?? 0) - oldTotal;
|
||||||
|
healthyByTier[tier] = oldHealthy + Math.max(0, added);
|
||||||
|
}
|
||||||
|
const summary: DCNetworkSummary = {
|
||||||
|
...existing,
|
||||||
|
totalByTier: newTotal,
|
||||||
|
healthyByTier,
|
||||||
|
networkRackCount: estimateNetworkSlots(newTor, dcTier),
|
||||||
|
};
|
||||||
|
return recomputeBandwidth(summary);
|
||||||
|
}
|
||||||
|
|
||||||
function processNetworkTick(
|
export function shrinkDCNetwork(
|
||||||
registry: Record<string, NetworkSwitch>,
|
existing: DCNetworkSummary,
|
||||||
|
removedRacks: number,
|
||||||
|
dcTier: DCTier,
|
||||||
|
): DCNetworkSummary {
|
||||||
|
if (removedRacks <= 0) return existing;
|
||||||
|
const oldTor = existing.totalByTier.tor ?? 0;
|
||||||
|
const newTor = Math.max(0, oldTor - removedRacks);
|
||||||
|
if (newTor === 0) return emptyDCNetworkSummary();
|
||||||
|
const newTotal = computeTopologyCounts(newTor, dcTier);
|
||||||
|
const healthyByTier: Partial<Record<SwitchTier, number>> = {};
|
||||||
|
for (const tier of DC_TIERS) {
|
||||||
|
const nt = newTotal[tier] ?? 0;
|
||||||
|
const oh = existing.healthyByTier[tier] ?? 0;
|
||||||
|
healthyByTier[tier] = Math.min(oh, nt);
|
||||||
|
}
|
||||||
|
const repairBatches = existing.repairBatches.filter(b => {
|
||||||
|
const nt = newTotal[b.tier] ?? 0;
|
||||||
|
const nh = healthyByTier[b.tier] ?? 0;
|
||||||
|
return nh < nt;
|
||||||
|
});
|
||||||
|
const summary: DCNetworkSummary = {
|
||||||
|
...existing,
|
||||||
|
totalByTier: newTotal,
|
||||||
|
healthyByTier,
|
||||||
|
repairBatches,
|
||||||
|
networkRackCount: estimateNetworkSlots(newTor, dcTier),
|
||||||
|
};
|
||||||
|
return recomputeBandwidth(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeAggregateBandwidth(
|
||||||
|
summary: DCNetworkSummary,
|
||||||
|
redundancyBonus: number,
|
||||||
|
): number {
|
||||||
|
let minBW = 1;
|
||||||
|
for (const tier of DC_TIERS) {
|
||||||
|
const total = summary.totalByTier[tier] ?? 0;
|
||||||
|
if (total === 0) continue;
|
||||||
|
const healthy = summary.healthyByTier[tier] ?? 0;
|
||||||
|
const tierBW = Math.min(1, (healthy + redundancyBonus) / total);
|
||||||
|
if (tierBW < minBW) minBW = tierBW;
|
||||||
|
}
|
||||||
|
return minBW;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recomputeBandwidth(summary: DCNetworkSummary, redundancyBonus = 0): DCNetworkSummary {
|
||||||
|
const avgBW = computeAggregateBandwidth(summary, redundancyBonus);
|
||||||
|
const torTotal = summary.totalByTier.tor ?? 0;
|
||||||
|
const torHealthy = summary.healthyByTier.tor ?? 0;
|
||||||
|
const torFailed = torTotal - torHealthy;
|
||||||
|
const disconnected = avgBW === 0 ? torTotal : torFailed;
|
||||||
|
const degraded = avgBW > 0 && avgBW < 1 ? Math.ceil(torTotal * (1 - avgBW)) - disconnected : 0;
|
||||||
|
return {
|
||||||
|
...summary,
|
||||||
|
averageBandwidth: avgBW,
|
||||||
|
effectiveFlopsFraction: avgBW,
|
||||||
|
racksDisconnected: Math.max(0, disconnected),
|
||||||
|
racksDegraded: Math.max(0, degraded),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function processNetworkForDC(
|
||||||
|
summary: DCNetworkSummary,
|
||||||
networkResearchBonus: number,
|
networkResearchBonus: number,
|
||||||
opsEff: number,
|
opsEff: number,
|
||||||
repairSpeedBonus: number,
|
repairSpeedBonus: number,
|
||||||
hotStandbyTicks: number,
|
hotStandbyTicks: number,
|
||||||
redundancyBonus: number,
|
redundancyBonus: number,
|
||||||
): { switchRepairCosts: number; notifications: TickNotification[]; dirtyDCs: Set<string> } {
|
): { summary: DCNetworkSummary; costs: number; notifications: TickNotification[] } {
|
||||||
|
const torTotal = summary.totalByTier.tor ?? 0;
|
||||||
|
if (torTotal === 0) return { summary, costs: 0, notifications: [] };
|
||||||
|
|
||||||
|
let costs = 0;
|
||||||
const notifications: TickNotification[] = [];
|
const notifications: TickNotification[] = [];
|
||||||
let switchRepairCosts = 0;
|
const healthyByTier = { ...summary.healthyByTier };
|
||||||
const dirtyDCs = new Set<string>();
|
let dirty = false;
|
||||||
|
|
||||||
const healthyByTier: Partial<Record<SwitchTier, NetworkSwitch[]>> = {};
|
for (const tier of DC_TIERS) {
|
||||||
const repairing: NetworkSwitch[] = [];
|
const healthy = healthyByTier[tier] ?? 0;
|
||||||
|
if (healthy <= 0) continue;
|
||||||
for (const sw of Object.values(registry)) {
|
|
||||||
if (sw.status === 'healthy') {
|
|
||||||
(healthyByTier[sw.tier] ??= []).push(sw);
|
|
||||||
} else if (sw.status === 'repairing') {
|
|
||||||
repairing.push(sw);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const tiers: SwitchTier[] = ['tor', 't1', 't2', 't3', 't4', 't5'];
|
|
||||||
const newlyFailed: NetworkSwitch[] = [];
|
|
||||||
|
|
||||||
for (const tier of tiers) {
|
|
||||||
const healthy = healthyByTier[tier];
|
|
||||||
if (!healthy || healthy.length === 0) continue;
|
|
||||||
const rate = SWITCH_TIER_CONFIGS[tier].failureRatePerTick * (1 - networkResearchBonus);
|
const rate = SWITCH_TIER_CONFIGS[tier].failureRatePerTick * (1 - networkResearchBonus);
|
||||||
const count = binomialSample(healthy.length, rate);
|
const failed = binomialSample(healthy, rate);
|
||||||
if (count > 0) {
|
if (failed > 0) {
|
||||||
const shuffled = [...healthy].sort(() => Math.random() - 0.5);
|
healthyByTier[tier] = healthy - failed;
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const sw = shuffled[i];
|
|
||||||
const baseRepair = SWITCH_TIER_CONFIGS[tier].repairBaseTicks;
|
const baseRepair = SWITCH_TIER_CONFIGS[tier].repairBaseTicks;
|
||||||
const repairTime = hotStandbyTicks > 0
|
const repairTime = hotStandbyTicks > 0
|
||||||
? hotStandbyTicks
|
? hotStandbyTicks
|
||||||
: baseRepair * (1 - repairSpeedBonus);
|
: baseRepair * (1 - repairSpeedBonus);
|
||||||
sw.status = 'repairing';
|
summary.repairBatches.push({ tier, count: failed, ticksRemaining: repairTime });
|
||||||
sw.repairProgress = 0;
|
costs += SWITCH_TIER_CONFIGS[tier].baseCost * SWITCH_REPAIR_COST_FRACTION * failed;
|
||||||
sw.repairTotal = repairTime;
|
dirty = true;
|
||||||
newlyFailed.push(sw);
|
|
||||||
if (sw.dcId) dirtyDCs.add(sw.dcId);
|
|
||||||
switchRepairCosts += SWITCH_TIER_CONFIGS[tier].baseCost * SWITCH_REPAIR_COST_FRACTION;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const sw of repairing) {
|
if (tier === 't3') {
|
||||||
sw.repairProgress += 1 + opsEff * 0.05;
|
|
||||||
if (sw.repairProgress >= sw.repairTotal) {
|
|
||||||
sw.status = 'healthy';
|
|
||||||
sw.repairProgress = 0;
|
|
||||||
sw.repairTotal = 0;
|
|
||||||
if (sw.dcId) dirtyDCs.add(sw.dcId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dirtyDCs.size > 0) {
|
|
||||||
for (const sw of Object.values(registry)) {
|
|
||||||
if (sw.uplinkIds.length === 0) continue;
|
|
||||||
if (sw.dcId && !dirtyDCs.has(sw.dcId)) continue;
|
|
||||||
let active = 0;
|
|
||||||
for (const upId of sw.uplinkIds) {
|
|
||||||
if (registry[upId]?.status === 'healthy') active++;
|
|
||||||
}
|
|
||||||
sw.activeUplinks = active;
|
|
||||||
sw.effectiveBandwidth = sw.totalUplinks > 0 ? Math.min(1, (active + redundancyBonus) / sw.totalUplinks) : 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const sw of newlyFailed) {
|
|
||||||
if (sw.tier === 't3') {
|
|
||||||
notifications.push({ title: 'Core Network Failure', message: `Tier-3 core switch failed — potential DC disconnect!`, type: 'danger' });
|
notifications.push({ title: 'Core Network Failure', message: `Tier-3 core switch failed — potential DC disconnect!`, type: 'danger' });
|
||||||
} else if (sw.tier === 't4') {
|
} else if (tier === 't2') {
|
||||||
notifications.push({ title: 'Campus Network Failure', message: `Tier-4 campus switch failed — cross-DC degradation!`, type: 'danger' });
|
|
||||||
} else if (sw.tier === 't2') {
|
|
||||||
notifications.push({ title: 'Network Switch Failure', message: `Tier-2 spine switch failed — racks may be degraded.`, type: 'warning' });
|
notifications.push({ title: 'Network Switch Failure', message: `Tier-2 spine switch failed — racks may be degraded.`, type: 'warning' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { switchRepairCosts, notifications, dirtyDCs };
|
const remainingBatches: RepairBatch[] = [];
|
||||||
|
for (const batch of summary.repairBatches) {
|
||||||
|
const newTicks = batch.ticksRemaining - (1 + opsEff * 0.05);
|
||||||
|
if (newTicks <= 0) {
|
||||||
|
healthyByTier[batch.tier] = Math.min(
|
||||||
|
summary.totalByTier[batch.tier] ?? 0,
|
||||||
|
(healthyByTier[batch.tier] ?? 0) + batch.count,
|
||||||
|
);
|
||||||
|
dirty = true;
|
||||||
|
} else {
|
||||||
|
remainingBatches.push({ ...batch, ticksRemaining: newTicks });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dirty) return { summary: { ...summary, repairBatches: remainingBatches }, costs, notifications };
|
||||||
|
|
||||||
|
const updated: DCNetworkSummary = {
|
||||||
|
...summary,
|
||||||
|
healthyByTier,
|
||||||
|
repairBatches: remainingBatches,
|
||||||
|
};
|
||||||
|
return { summary: recomputeBandwidth(updated, redundancyBonus), costs, notifications };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Interconnect Training Multiplier ---
|
// --- Interconnect Training Multiplier ---
|
||||||
@@ -476,14 +321,6 @@ export function processInfrastructure(state: GameState, researchBonuses?: Resear
|
|||||||
const hotStandbyTicks = state.research.completedResearch.includes('network-hot-standby') ? 5 : 0;
|
const hotStandbyTicks = state.research.completedResearch.includes('network-hot-standby') ? 5 : 0;
|
||||||
const redundancyBonus = state.research.completedResearch.includes('network-redundancy') ? 1 : 0;
|
const redundancyBonus = state.research.completedResearch.includes('network-redundancy') ? 1 : 0;
|
||||||
|
|
||||||
// Mutate registry in-place — infrastructure returns a new state anyway
|
|
||||||
const registry = state.infrastructure.switchRegistry;
|
|
||||||
|
|
||||||
// Process network failures/repairs globally
|
|
||||||
const netResult = processNetworkTick(registry, networkResearchBonus, opsEff, repairSpeedBonus, hotStandbyTicks, redundancyBonus);
|
|
||||||
repairCosts += netResult.switchRepairCosts;
|
|
||||||
if (netResult.notifications.length > 0) notifications.push(...netResult.notifications);
|
|
||||||
|
|
||||||
let totalFlops = 0;
|
let totalFlops = 0;
|
||||||
let totalTrainingFlops = 0;
|
let totalTrainingFlops = 0;
|
||||||
let totalInferenceFlops = 0;
|
let totalInferenceFlops = 0;
|
||||||
@@ -541,8 +378,6 @@ export function processInfrastructure(state: GameState, researchBonuses?: Resear
|
|||||||
if (rs.progress >= rs.total) {
|
if (rs.progress >= rs.total) {
|
||||||
if (rs.phase === 'decommissioning') {
|
if (rs.phase === 'decommissioning') {
|
||||||
const installTotal = cohortStageTotal('installation', rs.toSkuId, rs.racksRemaining);
|
const installTotal = cohortStageTotal('installation', rs.toSkuId, rs.racksRemaining);
|
||||||
// Clear DC topology on retrofit
|
|
||||||
for (const sid of dc.networkSummary.switchIds) delete registry[sid];
|
|
||||||
return {
|
return {
|
||||||
...dc,
|
...dc,
|
||||||
computeRacksOnline: 0,
|
computeRacksOnline: 0,
|
||||||
@@ -636,10 +471,11 @@ export function processInfrastructure(state: GameState, researchBonuses?: Resear
|
|||||||
// Expand topology for newly onlined racks
|
// Expand topology for newly onlined racks
|
||||||
let networkSummary = dc.networkSummary;
|
let networkSummary = dc.networkSummary;
|
||||||
if (racksJustOnlined > 0) {
|
if (racksJustOnlined > 0) {
|
||||||
if (networkSummary.switchIds.length === 0) {
|
const torTotal = networkSummary.totalByTier.tor ?? 0;
|
||||||
networkSummary = buildDCTopology(computeRacksOnline, dc.tier, dc.id, registry);
|
if (torTotal === 0) {
|
||||||
|
networkSummary = buildDCNetworkSummary(computeRacksOnline, dc.tier);
|
||||||
} else {
|
} else {
|
||||||
networkSummary = expandDCTopology(networkSummary, racksJustOnlined, dc.tier, dc.id, registry);
|
networkSummary = expandDCNetwork(networkSummary, racksJustOnlined, dc.tier);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -660,18 +496,20 @@ export function processInfrastructure(state: GameState, researchBonuses?: Resear
|
|||||||
stageTotal: cohortStageTotal('repair', dc.rackSkuId, prodFailures),
|
stageTotal: cohortStageTotal('repair', dc.rackSkuId, prodFailures),
|
||||||
repairCount: 0,
|
repairCount: 0,
|
||||||
});
|
});
|
||||||
networkSummary = shrinkDCTopology(networkSummary, prodFailures, dc.tier, registry);
|
networkSummary = shrinkDCNetwork(networkSummary, prodFailures, dc.tier);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
repairCosts += dcRepairCosts;
|
repairCosts += dcRepairCosts;
|
||||||
|
|
||||||
// Recompute DC network summary after failures/repairs (only if this DC's switches changed)
|
// Process per-DC network failures and repairs (aggregate model)
|
||||||
if (netResult.dirtyDCs.has(dc.id) && networkSummary.switchIds.length > 0) {
|
const netResult = processNetworkForDC(
|
||||||
networkSummary = buildDCSummary(
|
networkSummary, networkResearchBonus, opsEff,
|
||||||
networkSummary.switchIds, networkSummary.networkRackCount, registry,
|
repairSpeedBonus, hotStandbyTicks, redundancyBonus,
|
||||||
);
|
);
|
||||||
}
|
networkSummary = netResult.summary;
|
||||||
|
repairCosts += netResult.costs;
|
||||||
|
if (netResult.notifications.length > 0) notifications.push(...netResult.notifications);
|
||||||
|
|
||||||
// Rackdown: detect recovery (previously disconnected racks now have connectivity)
|
// Rackdown: detect recovery (previously disconnected racks now have connectivity)
|
||||||
const prevDisconnected = dc.networkSummary.racksDisconnected;
|
const prevDisconnected = dc.networkSummary.racksDisconnected;
|
||||||
@@ -680,7 +518,7 @@ export function processInfrastructure(state: GameState, researchBonuses?: Resear
|
|||||||
if (currDisconnected < prevDisconnected && dc.rackSkuId) {
|
if (currDisconnected < prevDisconnected && dc.rackSkuId) {
|
||||||
const recovered = prevDisconnected - currDisconnected;
|
const recovered = prevDisconnected - currDisconnected;
|
||||||
computeRacksOnline -= recovered;
|
computeRacksOnline -= recovered;
|
||||||
networkSummary = shrinkDCTopology(networkSummary, recovered, dc.tier, registry);
|
networkSummary = shrinkDCNetwork(networkSummary, recovered, dc.tier);
|
||||||
updatedCohorts.push({
|
updatedCohorts.push({
|
||||||
id: `netrecovery-${dc.id}-${Date.now()}`,
|
id: `netrecovery-${dc.id}-${Date.now()}`,
|
||||||
count: recovered, skuId: dc.rackSkuId,
|
count: recovered, skuId: dc.rackSkuId,
|
||||||
@@ -688,10 +526,6 @@ export function processInfrastructure(state: GameState, researchBonuses?: Resear
|
|||||||
stageTotal: cohortStageTotal('testing', dc.rackSkuId, recovered),
|
stageTotal: cohortStageTotal('testing', dc.rackSkuId, recovered),
|
||||||
repairCount: 0,
|
repairCount: 0,
|
||||||
});
|
});
|
||||||
// Recompute summary after shrink
|
|
||||||
networkSummary = buildDCSummary(
|
|
||||||
networkSummary.switchIds, networkSummary.networkRackCount, registry,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute DC aggregates
|
// Compute DC aggregates
|
||||||
@@ -789,8 +623,6 @@ export function processInfrastructure(state: GameState, researchBonuses?: Resear
|
|||||||
if (totalRacks <= 0) return dc;
|
if (totalRacks <= 0) return dc;
|
||||||
const oldSku = RACK_SKU_CONFIGS[dc.rackSkuId as RackSkuId];
|
const oldSku = RACK_SKU_CONFIGS[dc.rackSkuId as RackSkuId];
|
||||||
const decommTicks = Math.ceil(oldSku.pipelineTimeTicks.installation * (1 + COHORT_SCALE_FACTOR * totalRacks));
|
const decommTicks = Math.ceil(oldSku.pipelineTimeTicks.installation * (1 + COHORT_SCALE_FACTOR * totalRacks));
|
||||||
// Clear topology on retrofit start
|
|
||||||
for (const sid of dc.networkSummary.switchIds) delete registry[sid];
|
|
||||||
return {
|
return {
|
||||||
...dc,
|
...dc,
|
||||||
status: 'retrofitting' as const,
|
status: 'retrofitting' as const,
|
||||||
@@ -827,7 +659,6 @@ export function processInfrastructure(state: GameState, researchBonuses?: Resear
|
|||||||
return {
|
return {
|
||||||
infrastructure: {
|
infrastructure: {
|
||||||
clusters,
|
clusters,
|
||||||
switchRegistry: registry,
|
|
||||||
totalFlops,
|
totalFlops,
|
||||||
totalTrainingFlops,
|
totalTrainingFlops,
|
||||||
totalInferenceFlops,
|
totalInferenceFlops,
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { ApiTierState, ApiTierId, DeveloperEcosystem, TierServingMetrics } from '@ai-tycoon/shared';
|
import type { ApiTierState, ApiTierId, DeveloperEcosystem, TierServingMetrics } from '@token-empire/shared';
|
||||||
import {
|
import {
|
||||||
API_TIER_ORDER,
|
API_TIER_ORDER,
|
||||||
API_CONVERSION_RATES,
|
API_CONVERSION_RATES,
|
||||||
API_TIER_CHURN_RATES,
|
API_TIER_CHURN_RATES,
|
||||||
API_TOKENS_PER_DEVELOPER_PER_TICK,
|
API_TOKENS_PER_DEVELOPER_PER_TICK,
|
||||||
REJECTION_CHURN_MULTIPLIER,
|
REJECTION_CHURN_MULTIPLIER,
|
||||||
} from '@ai-tycoon/shared';
|
} from '@token-empire/shared';
|
||||||
|
|
||||||
export interface ApiTickResult {
|
export interface ApiTickResult {
|
||||||
apiTiers: ApiTierState;
|
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 {
|
import {
|
||||||
CONSUMER_TIER_ORDER,
|
CONSUMER_TIER_ORDER,
|
||||||
CONVERSION_RATES,
|
CONVERSION_RATES,
|
||||||
@@ -8,7 +8,13 @@ import {
|
|||||||
NETWORK_DEGRADATION,
|
NETWORK_DEGRADATION,
|
||||||
REJECTION_CHURN_MULTIPLIER,
|
REJECTION_CHURN_MULTIPLIER,
|
||||||
QUEUE_CHURN_MULTIPLIER,
|
QUEUE_CHURN_MULTIPLIER,
|
||||||
} from '@ai-tycoon/shared';
|
CONSUMER_TIER_BASE_PERCEIVED_VALUE,
|
||||||
|
PERCEIVED_VALUE_REPUTATION_RANGE,
|
||||||
|
PRICE_ELASTICITY_STEEPNESS,
|
||||||
|
PRICE_SATISFACTION_WEIGHT,
|
||||||
|
PRICE_CHURN_EXPONENT,
|
||||||
|
PRICE_CHURN_MAX_MULTIPLIER,
|
||||||
|
} from '@token-empire/shared';
|
||||||
|
|
||||||
export interface ConsumerTickResult {
|
export interface ConsumerTickResult {
|
||||||
consumerTiers: ConsumerTierState;
|
consumerTiers: ConsumerTierState;
|
||||||
@@ -16,10 +22,23 @@ export interface ConsumerTickResult {
|
|||||||
totalConsumerTokenDemand: number;
|
totalConsumerTokenDemand: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function computePerceivedValue(
|
||||||
|
tierId: ConsumerTierId,
|
||||||
|
modelQuality: number,
|
||||||
|
reputation: number,
|
||||||
|
): number {
|
||||||
|
const baseValue = CONSUMER_TIER_BASE_PERCEIVED_VALUE[tierId];
|
||||||
|
if (baseValue <= 0) return 0;
|
||||||
|
const repRange = PERCEIVED_VALUE_REPUTATION_RANGE;
|
||||||
|
const repMult = repRange.min + (Math.max(0, Math.min(100, reputation)) / 100) * (repRange.max - repRange.min);
|
||||||
|
return baseValue * Math.max(0, modelQuality) * repMult;
|
||||||
|
}
|
||||||
|
|
||||||
export function processConsumerTiers(
|
export function processConsumerTiers(
|
||||||
tiers: ConsumerTierState,
|
tiers: ConsumerTierState,
|
||||||
playerConsumerCustomers: number,
|
playerConsumerCustomers: number,
|
||||||
modelQuality: number,
|
modelQuality: number,
|
||||||
|
reputation: number,
|
||||||
seasonalConsumerMultiplier: number,
|
seasonalConsumerMultiplier: number,
|
||||||
networkLatencyPenalty: number,
|
networkLatencyPenalty: number,
|
||||||
consumerPaidMetrics: TierServingMetrics,
|
consumerPaidMetrics: TierServingMetrics,
|
||||||
@@ -57,6 +76,7 @@ export function processConsumerTiers(
|
|||||||
team: 'pro',
|
team: 'pro',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Pass 1: Conversions only (no churn yet) ---
|
||||||
for (const id of CONSUMER_TIER_ORDER) {
|
for (const id of CONSUMER_TIER_ORDER) {
|
||||||
if (id === 'free') continue;
|
if (id === 'free') continue;
|
||||||
const tier = updated.tiers[id];
|
const tier = updated.tiers[id];
|
||||||
@@ -69,35 +89,26 @@ export function processConsumerTiers(
|
|||||||
|
|
||||||
const conversionKey = `${prevId}->${id}`;
|
const conversionKey = `${prevId}->${id}`;
|
||||||
const baseRate = CONVERSION_RATES[conversionKey] ?? 0;
|
const baseRate = CONVERSION_RATES[conversionKey] ?? 0;
|
||||||
const priceAttr = tier.config.price > 0
|
|
||||||
? Math.max(0.1, 1 - tier.config.price / 100)
|
|
||||||
: 1;
|
|
||||||
const convRate = baseRate * qualityFactor * priceAttr * seasonalConsumerMultiplier;
|
|
||||||
|
|
||||||
|
const perceivedValue = computePerceivedValue(id, modelQuality, reputation);
|
||||||
|
let priceAttr: number;
|
||||||
|
if (tier.config.price <= 0) {
|
||||||
|
priceAttr = 1;
|
||||||
|
} else if (perceivedValue <= 0) {
|
||||||
|
priceAttr = 0;
|
||||||
|
} else {
|
||||||
|
const ratio = tier.config.price / perceivedValue;
|
||||||
|
priceAttr = 1 / (1 + Math.pow(ratio, PRICE_ELASTICITY_STEEPNESS));
|
||||||
|
}
|
||||||
|
|
||||||
|
const convRate = baseRate * qualityFactor * priceAttr * seasonalConsumerMultiplier;
|
||||||
const converting = prevTier.userCount * convRate;
|
const converting = prevTier.userCount * convRate;
|
||||||
prevTier.userCount = Math.max(0, prevTier.userCount - converting);
|
prevTier.userCount = Math.max(0, prevTier.userCount - converting);
|
||||||
tier.userCount += converting;
|
tier.userCount += converting;
|
||||||
tier.conversionRateFromBelow = convRate;
|
tier.conversionRateFromBelow = convRate;
|
||||||
|
|
||||||
tier.churnRate = TIER_CHURN_RATES[id];
|
|
||||||
const churnMultiplier = 1 + (1 - updated.satisfaction) * 2;
|
|
||||||
const churned = tier.userCount * tier.churnRate * churnMultiplier;
|
|
||||||
tier.userCount = Math.max(0, tier.userCount - churned);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let totalUsers = 0;
|
// --- Serving penalties & serving-based extra churn ---
|
||||||
let subscriptionRevenue = 0;
|
|
||||||
let totalTokenDemand = 0;
|
|
||||||
|
|
||||||
for (const id of CONSUMER_TIER_ORDER) {
|
|
||||||
const tier = updated.tiers[id];
|
|
||||||
totalUsers += tier.userCount;
|
|
||||||
subscriptionRevenue += tier.userCount * (tier.config.price / 86400);
|
|
||||||
totalTokenDemand += tier.userCount * CONSUMER_TOKENS_PER_SUBSCRIBER;
|
|
||||||
}
|
|
||||||
|
|
||||||
updated.totalUsers = totalUsers;
|
|
||||||
|
|
||||||
const paidDemand = consumerPaidMetrics.demandTokens;
|
const paidDemand = consumerPaidMetrics.demandTokens;
|
||||||
const freeDemand = consumerFreeMetrics.demandTokens;
|
const freeDemand = consumerFreeMetrics.demandTokens;
|
||||||
const totalDemand = paidDemand + freeDemand;
|
const totalDemand = paidDemand + freeDemand;
|
||||||
@@ -140,6 +151,7 @@ export function processConsumerTiers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Price-aware satisfaction ---
|
||||||
let headroomBonus = 0;
|
let headroomBonus = 0;
|
||||||
if (totalDemand > 0) {
|
if (totalDemand > 0) {
|
||||||
const totalServed = consumerPaidMetrics.servedTokens + consumerFreeMetrics.servedTokens;
|
const totalServed = consumerPaidMetrics.servedTokens + consumerFreeMetrics.servedTokens;
|
||||||
@@ -152,10 +164,76 @@ export function processConsumerTiers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const netLatencyPenalty = networkLatencyPenalty * NETWORK_DEGRADATION.satisfactionPenaltyPerLatency;
|
const netLatencyPenalty = networkLatencyPenalty * NETWORK_DEGRADATION.satisfactionPenaltyPerLatency;
|
||||||
updated.satisfaction = Math.min(1, Math.max(0,
|
const qualityServingSatisfaction = Math.min(1, Math.max(0,
|
||||||
0.3 + modelQuality * 0.5 + headroomBonus - servingPenalty - netLatencyPenalty,
|
0.3 + modelQuality * 0.5 + headroomBonus - servingPenalty - netLatencyPenalty,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
let priceSatNumerator = 0;
|
||||||
|
let priceSatDenominator = 0;
|
||||||
|
for (const id of CONSUMER_TIER_ORDER) {
|
||||||
|
if (id === 'free') continue;
|
||||||
|
const tier = updated.tiers[id];
|
||||||
|
if (tier.userCount <= 0) continue;
|
||||||
|
const pv = computePerceivedValue(id, modelQuality, reputation);
|
||||||
|
let tierPriceSat: number;
|
||||||
|
if (tier.config.price <= 0) {
|
||||||
|
tierPriceSat = 1;
|
||||||
|
} else if (pv <= 0) {
|
||||||
|
tierPriceSat = 0;
|
||||||
|
} else {
|
||||||
|
tierPriceSat = Math.min(1, pv / tier.config.price);
|
||||||
|
}
|
||||||
|
priceSatNumerator += tierPriceSat * tier.userCount;
|
||||||
|
priceSatDenominator += tier.userCount;
|
||||||
|
}
|
||||||
|
const priceSatisfaction = priceSatDenominator > 0
|
||||||
|
? priceSatNumerator / priceSatDenominator
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
const w = PRICE_SATISFACTION_WEIGHT;
|
||||||
|
updated.satisfaction = Math.min(1, Math.max(0,
|
||||||
|
(1 - w) * qualityServingSatisfaction + w * priceSatisfaction,
|
||||||
|
));
|
||||||
|
|
||||||
|
// --- Pass 2: Churn (using price-aware satisfaction) ---
|
||||||
|
for (const id of CONSUMER_TIER_ORDER) {
|
||||||
|
if (id === 'free') continue;
|
||||||
|
const tier = updated.tiers[id];
|
||||||
|
|
||||||
|
tier.churnRate = TIER_CHURN_RATES[id];
|
||||||
|
const satisfactionChurnMultiplier = 1 + (1 - updated.satisfaction) * 2;
|
||||||
|
|
||||||
|
const pv = computePerceivedValue(id, modelQuality, reputation);
|
||||||
|
let priceChurnMultiplier = 1;
|
||||||
|
if (tier.config.price > 0 && pv > 0) {
|
||||||
|
const ratio = tier.config.price / pv;
|
||||||
|
if (ratio > 1) {
|
||||||
|
priceChurnMultiplier = Math.min(
|
||||||
|
PRICE_CHURN_MAX_MULTIPLIER,
|
||||||
|
Math.pow(ratio, PRICE_CHURN_EXPONENT),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (tier.config.price > 0 && pv <= 0) {
|
||||||
|
priceChurnMultiplier = PRICE_CHURN_MAX_MULTIPLIER;
|
||||||
|
}
|
||||||
|
|
||||||
|
const churned = tier.userCount * tier.churnRate * satisfactionChurnMultiplier * priceChurnMultiplier;
|
||||||
|
tier.userCount = Math.max(0, tier.userCount - churned);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Revenue & token demand (after all churn — cancelled users don't pay) ---
|
||||||
|
let totalUsers = 0;
|
||||||
|
let subscriptionRevenue = 0;
|
||||||
|
let totalTokenDemand = 0;
|
||||||
|
|
||||||
|
for (const id of CONSUMER_TIER_ORDER) {
|
||||||
|
const tier = updated.tiers[id];
|
||||||
|
totalUsers += tier.userCount;
|
||||||
|
subscriptionRevenue += tier.userCount * (tier.config.price / 86400);
|
||||||
|
totalTokenDemand += tier.userCount * CONSUMER_TOKENS_PER_SUBSCRIBER;
|
||||||
|
}
|
||||||
|
|
||||||
|
updated.totalUsers = totalUsers;
|
||||||
updated.viralCoefficient = modelQuality > 0.5 ? 1 + (modelQuality - 0.5) * 2 : 0;
|
updated.viralCoefficient = modelQuality > 0.5 ? 1 + (modelQuality - 0.5) * 2 : 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user