Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a6629af79 | |||
| 2912d760cb | |||
| c1cc70eeb9 | |||
| be93e57853 | |||
| fbedcec4f2 | |||
| 65afa886af | |||
| 7348b35475 | |||
| 6cf5bf76b3 | |||
| 2ab097ec8a | |||
| 066c3310ff | |||
| a061337d6f | |||
| 4881907c28 | |||
| df01ac8e35 | |||
| 63e56dc229 |
@@ -35,7 +35,7 @@ jobs:
|
||||
run: pnpm test
|
||||
|
||||
- name: Run greedy simulation
|
||||
run: pnpm --filter @ai-tycoon/game-simulation simulate:ci
|
||||
run: pnpm --filter @token-empire/game-simulation simulate:ci
|
||||
|
||||
multi-run-balance:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -54,8 +54,8 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run multi-simulation (100 runs)
|
||||
run: pnpm --filter @ai-tycoon/game-simulation multirun -- --runs 100 --parallel 10 --strategy persona --ticks 28800 --no-timeseries
|
||||
run: pnpm --filter @token-empire/game-simulation multirun -- --runs 100 --parallel 10 --strategy persona --ticks 28800 --no-timeseries
|
||||
|
||||
- name: Interpret results
|
||||
if: always()
|
||||
run: pnpm --filter @ai-tycoon/game-simulation interpret -- --summary multirun-summary.csv
|
||||
run: pnpm --filter @token-empire/game-simulation interpret -- --summary multirun-summary.csv
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
|
||||
env:
|
||||
REGISTRY: gitea.thewrightserver.net
|
||||
IMAGE_PREFIX: gitea.thewrightserver.net/josh/aihostingtycoon
|
||||
IMAGE_PREFIX: gitea.thewrightserver.net/josh/tokenempire
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# AI Tycoon
|
||||
# Token Empire
|
||||
|
||||
A browser-based incremental/idle game where you manage an AI company from a garage startup to building AGI. Navigate the real tensions of the AI industry: scaling compute, training frontier models, balancing safety vs capability, hiring talent, and competing with rival labs.
|
||||
|
||||
@@ -29,7 +29,7 @@ The web app starts at `http://localhost:5173` (or the next available port). The
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
ai-tycoon/
|
||||
token-empire/
|
||||
├── apps/
|
||||
│ ├── web/ # React frontend (Vite)
|
||||
│ └── server/ # Hono API backend
|
||||
@@ -81,7 +81,7 @@ pnpm clean # Clean build artifacts
|
||||
The backend requires PostgreSQL for cloud saves and leaderboards. Set the connection string in `apps/server/.env`:
|
||||
|
||||
```
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/ai_tycoon
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/token_empire
|
||||
```
|
||||
|
||||
Run migrations:
|
||||
|
||||
@@ -14,16 +14,16 @@ COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
|
||||
COPY --from=deps /app/apps/server/node_modules ./apps/server/node_modules
|
||||
COPY . .
|
||||
RUN pnpm --filter @ai-tycoon/shared build && \
|
||||
pnpm --filter @ai-tycoon/server typecheck
|
||||
RUN pnpm --filter @token-empire/shared build && \
|
||||
pnpm --filter @token-empire/server typecheck
|
||||
|
||||
FROM base AS production
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
|
||||
COPY --from=deps /app/apps/server/node_modules ./apps/server/node_modules
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY packages/shared ./packages/shared
|
||||
COPY packages/tsconfig ./packages/tsconfig
|
||||
COPY apps/server ./apps/server
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3001
|
||||
CMD ["node", "--import", "tsx", "apps/server/src/index.ts"]
|
||||
WORKDIR /app/apps/server
|
||||
CMD ["node", "--import", "tsx", "src/index.ts"]
|
||||
|
||||
@@ -5,6 +5,6 @@ export default defineConfig({
|
||||
schema: './src/db/schema.ts',
|
||||
out: './drizzle',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? 'postgresql://localhost:5432/ai_tycoon',
|
||||
url: process.env.DATABASE_URL ?? 'postgresql://localhost:5432/token_empire',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
CREATE TABLE "achievements" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"achievement_id" text NOT NULL,
|
||||
"unlocked_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "invitations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"code" text NOT NULL,
|
||||
"created_by" uuid NOT NULL,
|
||||
"used_by" uuid,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"expires_at" timestamp,
|
||||
CONSTRAINT "invitations_code_unique" UNIQUE("code")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "leaderboard" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"company_name" text NOT NULL,
|
||||
"category" text NOT NULL,
|
||||
"score" integer NOT NULL,
|
||||
"era" text NOT NULL,
|
||||
"tick_count" integer NOT NULL,
|
||||
"submitted_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "saves" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"company_name" text NOT NULL,
|
||||
"save_version" integer NOT NULL,
|
||||
"game_data" jsonb NOT NULL,
|
||||
"tick_count" integer DEFAULT 0 NOT NULL,
|
||||
"era" text DEFAULT 'startup' NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"anon_token" uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
"username" text,
|
||||
"email" text,
|
||||
"password_hash" text,
|
||||
"role" text DEFAULT 'user' NOT NULL,
|
||||
"must_reset_password" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"last_seen_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "users_anon_token_unique" UNIQUE("anon_token"),
|
||||
CONSTRAINT "users_username_unique" UNIQUE("username"),
|
||||
CONSTRAINT "users_email_unique" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "achievements" ADD CONSTRAINT "achievements_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_used_by_users_id_fk" FOREIGN KEY ("used_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "leaderboard" ADD CONSTRAINT "leaderboard_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "saves" ADD CONSTRAINT "saves_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "achievements_user_id_idx" ON "achievements" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "leaderboard_category_score_idx" ON "leaderboard" USING btree ("category","score");--> statement-breakpoint
|
||||
CREATE INDEX "saves_user_id_idx" ON "saves" USING btree ("user_id");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ADD COLUMN "token_version" integer DEFAULT 0 NOT NULL;
|
||||
@@ -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",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -13,18 +13,19 @@
|
||||
"db:push": "drizzle-kit push"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-tycoon/shared": "workspace:*",
|
||||
"@token-empire/shared": "workspace:*",
|
||||
"@hono/node-server": "^1.13.8",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"hono": "^4.7.10",
|
||||
"postgres": "^3.4.7",
|
||||
"tsx": "^4.19.4",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ai-tycoon/tsconfig": "workspace:*",
|
||||
"@token-empire/tsconfig": "workspace:*",
|
||||
"@types/node": "^25.6.0",
|
||||
"drizzle-kit": "^0.31.1",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import { migrate } from 'drizzle-orm/postgres-js/migrator';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema';
|
||||
|
||||
const connectionString = process.env.DATABASE_URL ?? 'postgresql://localhost:5432/ai_tycoon';
|
||||
const connectionString = process.env.DATABASE_URL ?? 'postgresql://localhost:5432/token_empire';
|
||||
|
||||
const client = postgres(connectionString);
|
||||
export const db = drizzle(client, { schema });
|
||||
|
||||
export type Database = typeof db;
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export async function runMigrations() {
|
||||
const migrationClient = postgres(connectionString, { max: 1 });
|
||||
const migrationDb = drizzle(migrationClient);
|
||||
await migrate(migrationDb, { migrationsFolder: path.resolve(__dirname, '../../drizzle') });
|
||||
await migrationClient.end();
|
||||
console.log('Database migrations complete');
|
||||
}
|
||||
|
||||
@@ -3,8 +3,12 @@ import { pgTable, uuid, text, timestamp, jsonb, integer, boolean, index } from '
|
||||
export const users = pgTable('users', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
anonToken: uuid('anon_token').defaultRandom().notNull().unique(),
|
||||
username: text('username').unique(),
|
||||
email: text('email').unique(),
|
||||
passwordHash: text('password_hash'),
|
||||
role: text('role').notNull().default('user'),
|
||||
mustResetPassword: boolean('must_reset_password').notNull().default(false),
|
||||
tokenVersion: integer('token_version').notNull().default(0),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
lastSeenAt: timestamp('last_seen_at').defaultNow().notNull(),
|
||||
});
|
||||
@@ -44,3 +48,12 @@ export const achievements = pgTable('achievements', {
|
||||
}, (table) => [
|
||||
index('achievements_user_id_idx').on(table.userId),
|
||||
]);
|
||||
|
||||
export const invitations = pgTable('invitations', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
code: text('code').notNull().unique(),
|
||||
createdBy: uuid('created_by').notNull().references(() => users.id),
|
||||
usedBy: uuid('used_by').references(() => users.id),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
expiresAt: timestamp('expires_at'),
|
||||
});
|
||||
|
||||
@@ -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 { savesRouter } from './routes/saves';
|
||||
import { leaderboardRouter } from './routes/leaderboard';
|
||||
import { invitesRouter } from './routes/invites';
|
||||
import { runMigrations } from './db';
|
||||
import { seedAdmin } from './db/seed';
|
||||
|
||||
if (!process.env.JWT_SECRET) {
|
||||
console.error('FATAL: JWT_SECRET environment variable is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -18,13 +26,23 @@ app.use('*', cors({
|
||||
}));
|
||||
|
||||
app.get('/health', (c) => c.json({ status: 'ok', version: '0.1.0' }));
|
||||
app.get('/api/health', (c) => c.json({ status: 'ok', version: '0.1.0' }));
|
||||
|
||||
app.get('/api/config', (c) => c.json({
|
||||
requireInvite: process.env.REQUIRE_INVITE !== 'false',
|
||||
userInvitations: parseInt(process.env.USER_INVITATIONS || '0', 10),
|
||||
}));
|
||||
|
||||
app.route('/api/auth', auth);
|
||||
app.route('/api/saves', savesRouter);
|
||||
app.route('/api/leaderboard', leaderboardRouter);
|
||||
app.route('/api/invites', invitesRouter);
|
||||
|
||||
const port = Number(process.env.PORT) || 3001;
|
||||
|
||||
console.log(`AI Tycoon API server starting on port ${port}...`);
|
||||
console.log(`Token Empire API server starting on port ${port}...`);
|
||||
|
||||
await runMigrations();
|
||||
await seedAdmin();
|
||||
|
||||
serve({ fetch: app.fetch, port });
|
||||
|
||||
@@ -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 { db } from '../db';
|
||||
import { users } from '../db/schema';
|
||||
import { verifyToken } from '../lib/jwt';
|
||||
import type { AppEnv } from '../types';
|
||||
|
||||
export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
|
||||
@@ -14,25 +15,47 @@ export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
|
||||
const token = authHeader.slice(7);
|
||||
|
||||
try {
|
||||
const payload = await verifyToken(token);
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.anonToken, token))
|
||||
.where(eq(users.id, payload.sub))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'Invalid token' }, 401);
|
||||
}
|
||||
|
||||
if (payload.tokenVersion !== user.tokenVersion) {
|
||||
return c.json({ error: 'Token has been revoked' }, 401);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ lastSeenAt: new Date() })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
c.set('userId', user.id);
|
||||
c.set('user', user as AppEnv['Variables']['user']);
|
||||
c.set('user', {
|
||||
id: user.id,
|
||||
anonToken: user.anonToken,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
mustResetPassword: user.mustResetPassword,
|
||||
tokenVersion: user.tokenVersion,
|
||||
});
|
||||
await next();
|
||||
} catch {
|
||||
return c.json({ error: 'Authentication failed' }, 500);
|
||||
return c.json({ error: 'Invalid or expired token' }, 401);
|
||||
}
|
||||
});
|
||||
|
||||
export const requireAdmin = createMiddleware<AppEnv>(async (c, next) => {
|
||||
const user = c.get('user');
|
||||
if (user.role !== 'admin') {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
+209
-32
@@ -1,7 +1,10 @@
|
||||
import { Hono } from 'hono';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, or, sql } from 'drizzle-orm';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { db } from '../db';
|
||||
import { users } from '../db/schema';
|
||||
import { createToken } from '../lib/jwt';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import type { AppEnv } from '../types';
|
||||
|
||||
const auth = new Hono<AppEnv>();
|
||||
@@ -12,20 +15,51 @@ auth.post('/anonymous', async (c) => {
|
||||
.values({})
|
||||
.returning();
|
||||
|
||||
return c.json({
|
||||
userId: user.id,
|
||||
token: user.anonToken,
|
||||
});
|
||||
const token = await createToken(user.id, null, 'user', null, false, 0);
|
||||
return c.json({ userId: user.id, token });
|
||||
});
|
||||
|
||||
auth.post('/link-email', async (c) => {
|
||||
const userId = c.get('userId') as string;
|
||||
if (!userId) return c.json({ error: 'Not authenticated' }, 401);
|
||||
auth.post('/register', authMiddleware, async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const { email, password, inviteCode } = await c.req.json<{
|
||||
email: string;
|
||||
password: string;
|
||||
inviteCode: string;
|
||||
}>();
|
||||
|
||||
const { email, password } = await c.req.json<{ email: string; password: string }>();
|
||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return c.json({ error: 'Valid email required' }, 400);
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
return c.json({ error: 'Password must be at least 8 characters' }, 400);
|
||||
}
|
||||
|
||||
if (!email || !password) {
|
||||
return c.json({ error: 'Email and password required' }, 400);
|
||||
if (process.env.REQUIRE_INVITE !== 'false') {
|
||||
if (!inviteCode) {
|
||||
return c.json({ error: 'Invite code required' }, 400);
|
||||
}
|
||||
|
||||
const { invitations } = await import('../db/schema');
|
||||
const { isNull, and, sql } = await import('drizzle-orm');
|
||||
|
||||
const [consumed] = await db
|
||||
.update(invitations)
|
||||
.set({ usedBy: userId })
|
||||
.where(
|
||||
and(
|
||||
eq(invitations.code, inviteCode),
|
||||
isNull(invitations.usedBy),
|
||||
or(
|
||||
isNull(invitations.expiresAt),
|
||||
sql`${invitations.expiresAt} > NOW()`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!consumed) {
|
||||
return c.json({ error: 'Invalid or used invite code' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
@@ -38,44 +72,187 @@ auth.post('/link-email', async (c) => {
|
||||
return c.json({ error: 'Email already in use' }, 409);
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(password);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashHex = Array.from(new Uint8Array(hashBuffer))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
await db
|
||||
const [updated] = await db
|
||||
.update(users)
|
||||
.set({ email, passwordHash: hashHex })
|
||||
.where(eq(users.id, userId));
|
||||
.set({ email, passwordHash })
|
||||
.where(eq(users.id, userId))
|
||||
.returning();
|
||||
|
||||
return c.json({ success: true });
|
||||
const token = await createToken(updated.id, updated.email, updated.role, updated.username, false, updated.tokenVersion);
|
||||
return c.json({ userId: updated.id, token });
|
||||
});
|
||||
|
||||
auth.post('/login', async (c) => {
|
||||
const { email, password } = await c.req.json<{ email: string; password: string }>();
|
||||
const { login, password } = await c.req.json<{ login: string; password: string }>();
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(password);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashHex = Array.from(new Uint8Array(hashBuffer))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
if (!login || !password) {
|
||||
return c.json({ error: 'Login and password required' }, 400);
|
||||
}
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(or(eq(users.email, login), eq(users.username, login)))
|
||||
.limit(1);
|
||||
|
||||
if (!user || !user.passwordHash) {
|
||||
return c.json({ error: 'Invalid credentials' }, 401);
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!valid) {
|
||||
return c.json({ error: 'Invalid credentials' }, 401);
|
||||
}
|
||||
|
||||
const token = await createToken(user.id, user.email, user.role, user.username, user.mustResetPassword, user.tokenVersion);
|
||||
return c.json({ userId: user.id, token });
|
||||
});
|
||||
|
||||
auth.post('/change-password', authMiddleware, async (c) => {
|
||||
const user = c.get('user');
|
||||
const { currentPassword, newPassword } = await c.req.json<{
|
||||
currentPassword?: string;
|
||||
newPassword: string;
|
||||
}>();
|
||||
|
||||
if (!newPassword || newPassword.length < 8) {
|
||||
return c.json({ error: 'New password must be at least 8 characters' }, 400);
|
||||
}
|
||||
|
||||
if (!user.mustResetPassword) {
|
||||
if (!currentPassword) {
|
||||
return c.json({ error: 'Current password required' }, 400);
|
||||
}
|
||||
const [dbUser] = await db
|
||||
.select({ passwordHash: users.passwordHash })
|
||||
.from(users)
|
||||
.where(eq(users.id, user.id))
|
||||
.limit(1);
|
||||
|
||||
if (!dbUser?.passwordHash) {
|
||||
return c.json({ error: 'No password set' }, 400);
|
||||
}
|
||||
const valid = await bcrypt.compare(currentPassword, dbUser.passwordHash);
|
||||
if (!valid) {
|
||||
return c.json({ error: 'Current password is incorrect' }, 401);
|
||||
}
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, 10);
|
||||
const [updated] = await db
|
||||
.update(users)
|
||||
.set({ passwordHash, mustResetPassword: false, tokenVersion: sql`${users.tokenVersion} + 1` })
|
||||
.where(eq(users.id, user.id))
|
||||
.returning({ tokenVersion: users.tokenVersion });
|
||||
|
||||
const token = await createToken(user.id, user.email, user.role, user.username, false, updated.tokenVersion);
|
||||
return c.json({ success: true, token });
|
||||
});
|
||||
|
||||
auth.post('/change-username', authMiddleware, async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user.email && user.role !== 'admin') {
|
||||
return c.json({ error: 'Must be registered to change username' }, 403);
|
||||
}
|
||||
|
||||
const { username } = await c.req.json<{ username: string }>();
|
||||
if (!username || username.length < 2) {
|
||||
return c.json({ error: 'Username must be at least 2 characters' }, 400);
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0 && existing[0].id !== user.id) {
|
||||
return c.json({ error: 'Username already taken' }, 409);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ username })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
const token = await createToken(user.id, user.email, user.role, username, user.mustResetPassword, user.tokenVersion);
|
||||
return c.json({ success: true, token });
|
||||
});
|
||||
|
||||
auth.post('/change-email', authMiddleware, async (c) => {
|
||||
const user = c.get('user');
|
||||
|
||||
if (!user.email && user.role !== 'admin') {
|
||||
return c.json({ error: 'Must be registered to change email' }, 403);
|
||||
}
|
||||
|
||||
const { email, currentPassword } = await c.req.json<{
|
||||
email: string;
|
||||
currentPassword: string;
|
||||
}>();
|
||||
|
||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return c.json({ error: 'Valid email required' }, 400);
|
||||
}
|
||||
|
||||
if (!currentPassword) {
|
||||
return c.json({ error: 'Current password required' }, 400);
|
||||
}
|
||||
|
||||
const [dbUser] = await db
|
||||
.select({ passwordHash: users.passwordHash })
|
||||
.from(users)
|
||||
.where(eq(users.id, user.id))
|
||||
.limit(1);
|
||||
|
||||
if (!dbUser?.passwordHash) {
|
||||
return c.json({ error: 'No password set' }, 400);
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(currentPassword, dbUser.passwordHash);
|
||||
if (!valid) {
|
||||
return c.json({ error: 'Current password is incorrect' }, 401);
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (!user || user.passwordHash !== hashHex) {
|
||||
return c.json({ error: 'Invalid credentials' }, 401);
|
||||
if (existing.length > 0 && existing[0].id !== user.id) {
|
||||
return c.json({ error: 'Email already in use' }, 409);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ email })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
const token = await createToken(user.id, email, user.role, user.username, user.mustResetPassword, user.tokenVersion);
|
||||
return c.json({ success: true, token });
|
||||
});
|
||||
|
||||
auth.post('/logout', authMiddleware, async (c) => {
|
||||
const user = c.get('user');
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ tokenVersion: sql`${users.tokenVersion} + 1` })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
auth.get('/me', authMiddleware, async (c) => {
|
||||
const user = c.get('user');
|
||||
return c.json({
|
||||
userId: user.id,
|
||||
token: user.anonToken,
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
savesRouter.get('/latest', async (c) => {
|
||||
const userId = c.get('userId') as string;
|
||||
|
||||
const [save] = await db
|
||||
.select()
|
||||
.from(saves)
|
||||
.where(eq(saves.userId, userId))
|
||||
.orderBy(desc(saves.updatedAt))
|
||||
.limit(1);
|
||||
|
||||
return c.json({ save: save ?? null });
|
||||
});
|
||||
|
||||
savesRouter.get('/:id', async (c) => {
|
||||
const userId = c.get('userId') as string;
|
||||
const saveId = c.req.param('id');
|
||||
|
||||
@@ -4,7 +4,11 @@ export type AppEnv = {
|
||||
user: {
|
||||
id: string;
|
||||
anonToken: string;
|
||||
username: 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": {
|
||||
"outDir": "dist",
|
||||
"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/apps/web/node_modules ./apps/web/node_modules
|
||||
COPY . .
|
||||
ARG VITE_API_URL=/api
|
||||
ARG VITE_API_URL=
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
RUN pnpm --filter @ai-tycoon/shared build && \
|
||||
pnpm --filter @ai-tycoon/game-engine build && \
|
||||
pnpm --filter @ai-tycoon/web build
|
||||
RUN pnpm --filter @token-empire/shared build && \
|
||||
pnpm --filter @token-empire/game-engine build && \
|
||||
pnpm --filter @token-empire/web build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI Tycoon</title>
|
||||
<title>Token Empire</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
|
||||
@@ -3,6 +3,13 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://server:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@ai-tycoon/web",
|
||||
"name": "@token-empire/web",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
@@ -10,8 +10,8 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-tycoon/shared": "workspace:*",
|
||||
"@ai-tycoon/game-engine": "workspace:*",
|
||||
"@token-empire/shared": "workspace:*",
|
||||
"@token-empire/game-engine": "workspace:*",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"recharts": "^2.15.0",
|
||||
@@ -19,7 +19,7 @@
|
||||
"lucide-react": "^0.475.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ai-tycoon/tsconfig": "workspace:*",
|
||||
"@token-empire/tsconfig": "workspace:*",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.4.0",
|
||||
|
||||
+71
-3
@@ -3,10 +3,58 @@ import { useGameStore } from '@/store';
|
||||
import { MainLayout } from '@/components/layout/MainLayout';
|
||||
import { NewGameScreen } from '@/components/game/NewGameScreen';
|
||||
import { OfflineCatchUp } from '@/components/game/OfflineCatchUp';
|
||||
import { InviteGateScreen } from '@/components/game/InviteGateScreen';
|
||||
import { useGameLoop } from '@/hooks/useGameLoop';
|
||||
import { TICK_INTERVAL_MS } from '@ai-tycoon/shared';
|
||||
import { useAuthGate } from '@/hooks/useAuthGate';
|
||||
import { useCloudSave } from '@/hooks/useCloudSave';
|
||||
import { TICK_INTERVAL_MS } from '@token-empire/shared';
|
||||
import { Sparkles, RefreshCw, WifiOff } from 'lucide-react';
|
||||
|
||||
function LoadingScreen() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-surface-950 to-surface-900">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center gap-2 mb-4">
|
||||
<Sparkles className="text-accent-light animate-pulse" size={32} />
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-accent-light to-accent bg-clip-text text-transparent">
|
||||
Token Empire
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-surface-500 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackendErrorScreen({ error, onRetry }: { error: string; onRetry: () => void }) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-surface-950 to-surface-900">
|
||||
<div className="max-w-md w-full mx-4 text-center">
|
||||
<div className="inline-flex items-center gap-2 mb-6">
|
||||
<Sparkles className="text-accent-light" size={32} />
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-accent-light to-accent bg-clip-text text-transparent">
|
||||
Token Empire
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 space-y-4">
|
||||
<WifiOff className="mx-auto text-danger" size={40} />
|
||||
<h2 className="text-lg font-semibold text-surface-100">Cannot Connect to Server</h2>
|
||||
<p className="text-sm text-surface-400">{error}</p>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-accent hover:bg-accent-dark text-white font-medium text-sm transition-colors"
|
||||
>
|
||||
<RefreshCw size={16} /> Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const { loading: authLoading, backendError, needsInvite, needsPasswordReset, cloudSave, loadCloudSave, setRegistered, setNeedsPasswordReset, retry } = useAuthGate();
|
||||
const companyName = useGameStore((s) => s.meta.companyName);
|
||||
const lastTickTimestamp = useGameStore((s) => s.meta.lastTickTimestamp);
|
||||
const [catchUpTicks, setCatchUpTicks] = useState<number | null>(null);
|
||||
@@ -23,10 +71,30 @@ export function App() {
|
||||
}
|
||||
}, [companyName, lastTickTimestamp, catchUpDone]);
|
||||
|
||||
useGameLoop(!catchUpDone);
|
||||
useGameLoop(!catchUpDone || authLoading || !!backendError || needsInvite || needsPasswordReset);
|
||||
useCloudSave();
|
||||
|
||||
if (authLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (backendError) {
|
||||
return <BackendErrorScreen error={backendError} onRetry={retry} />;
|
||||
}
|
||||
|
||||
if (needsInvite || needsPasswordReset) {
|
||||
return (
|
||||
<InviteGateScreen
|
||||
onRegistered={() => {
|
||||
setRegistered(true);
|
||||
setNeedsPasswordReset(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!companyName) {
|
||||
return <NewGameScreen />;
|
||||
return <NewGameScreen cloudSave={cloudSave} onContinue={loadCloudSave} />;
|
||||
}
|
||||
|
||||
if (catchUpTicks !== null && !catchUpDone) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { X, CheckCircle, AlertTriangle, AlertCircle, Info, Bell, Trash2 } from 'lucide-react';
|
||||
import { useGameStore, type GameNotification } from '@/store';
|
||||
import { formatDuration } from '@ai-tycoon/shared';
|
||||
import { formatDuration } from '@token-empire/shared';
|
||||
|
||||
const ICON_MAP = {
|
||||
success: { icon: CheckCircle, color: 'text-success' },
|
||||
|
||||
@@ -18,7 +18,7 @@ export function DevMenu() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<Tab>('resources');
|
||||
|
||||
const isEnabled = import.meta.env.DEV || localStorage.getItem('ai-tycoon-dev-menu') === 'true';
|
||||
const isEnabled = import.meta.env.DEV || localStorage.getItem('token-empire-dev-menu') === 'true';
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useGameStore } from '@/store';
|
||||
import type { FundingRoundType } from '@ai-tycoon/shared';
|
||||
import type { FundingRoundType } from '@token-empire/shared';
|
||||
|
||||
function DevButton({ onClick, children, variant = 'default' }: {
|
||||
onClick: () => void;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatMoney } from '@ai-tycoon/shared';
|
||||
import { formatMoney } from '@token-empire/shared';
|
||||
|
||||
function DevButton({ onClick, children, variant = 'default' }: {
|
||||
onClick: () => void;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatMoney, formatNumber, formatFlops, formatPercent } from '@ai-tycoon/shared';
|
||||
import { formatMoney, formatNumber, formatFlops, formatPercent } from '@token-empire/shared';
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useGameStore } from '@/store';
|
||||
import { processTick, setAchievementDefinitions, ACHIEVEMENT_DEFINITIONS, TECH_TREE } from '@ai-tycoon/game-engine';
|
||||
import type { GameState, Era } from '@ai-tycoon/shared';
|
||||
import { processTick, setAchievementDefinitions, ACHIEVEMENT_DEFINITIONS, TECH_TREE } from '@token-empire/game-engine';
|
||||
import type { GameState, Era } from '@token-empire/shared';
|
||||
|
||||
function DevButton({ onClick, children, variant = 'default' }: {
|
||||
onClick: () => void;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatMoney, formatNumber, formatPercent } from '@ai-tycoon/shared';
|
||||
import { formatMoney, formatNumber, formatPercent } from '@token-empire/shared';
|
||||
import { Share2, Copy, Check } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { ACHIEVEMENT_DEFINITIONS } from '@ai-tycoon/game-engine';
|
||||
import { ACHIEVEMENT_DEFINITIONS } from '@token-empire/game-engine';
|
||||
|
||||
export function CompanyStatsCard({ onClose }: { onClose: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -25,7 +25,7 @@ export function CompanyStatsCard({ onClose }: { onClose: () => void }) {
|
||||
const minutes = Math.floor((totalPlayTime % 3600) / 60);
|
||||
|
||||
const statsText = [
|
||||
`${companyName} — AI Tycoon`,
|
||||
`${companyName} — Token Empire`,
|
||||
`Era: ${eraLabel} | Playtime: ${hours}h ${minutes}m`,
|
||||
`Cash: ${formatMoney(money)} | Revenue: ${formatMoney(totalRevenue)}`,
|
||||
`Valuation: ${formatMoney(valuation)}`,
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Sparkles, ArrowLeft } from 'lucide-react';
|
||||
import { api, setAuthToken, needsPasswordReset } from '@/lib/api';
|
||||
|
||||
type Stage = 'invite' | 'register' | 'login' | 'reset-password';
|
||||
|
||||
export function InviteGateScreen({ onRegistered }: { onRegistered: () => void }) {
|
||||
const [stage, setStage] = useState<Stage>('invite');
|
||||
const [inviteCode, setInviteCode] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [loginField, setLoginField] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmNewPassword, setConfirmNewPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('invite');
|
||||
if (code) {
|
||||
setInviteCode(code);
|
||||
handleValidateCode(code);
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function handleValidateCode(code?: string) {
|
||||
const codeToValidate = code || inviteCode.trim();
|
||||
if (!codeToValidate) {
|
||||
setError('Please enter an invite code');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await api.invites.validate(codeToValidate);
|
||||
if (result.valid) {
|
||||
setInviteCode(codeToValidate);
|
||||
setStage('register');
|
||||
} else {
|
||||
setError('Invalid or already used invite code');
|
||||
}
|
||||
} catch {
|
||||
setError('Could not validate invite code');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
if (!email.trim()) { setError('Email is required'); return; }
|
||||
if (password.length < 8) { setError('Password must be at least 8 characters'); return; }
|
||||
if (password !== confirmPassword) { setError('Passwords do not match'); return; }
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await api.auth.register(email.trim(), password, inviteCode);
|
||||
setAuthToken(result.token);
|
||||
onRegistered();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Registration failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
if (!loginField.trim() || !password) { setError('Email/username and password required'); return; }
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await api.auth.login(loginField.trim(), password);
|
||||
setAuthToken(result.token);
|
||||
if (needsPasswordReset()) {
|
||||
setStage('reset-password');
|
||||
} else {
|
||||
onRegistered();
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Login failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasswordReset() {
|
||||
if (newPassword.length < 8) { setError('Password must be at least 8 characters'); return; }
|
||||
if (newPassword !== confirmNewPassword) { setError('Passwords do not match'); return; }
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await api.auth.changePassword(newPassword);
|
||||
setAuthToken(result.token);
|
||||
onRegistered();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Password change failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-surface-950 to-surface-900">
|
||||
<div className="max-w-md w-full mx-4">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center gap-2 mb-4">
|
||||
<Sparkles className="text-accent-light" size={32} />
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-accent-light to-accent bg-clip-text text-transparent">
|
||||
Token Empire
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-surface-400 text-sm">
|
||||
{stage === 'reset-password'
|
||||
? 'Please set a new password to continue.'
|
||||
: 'Access is invite-only during early access.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 space-y-5">
|
||||
{stage === 'invite' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-surface-300 mb-2">
|
||||
Invite Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inviteCode}
|
||||
onChange={(e) => setInviteCode(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleValidateCode()}
|
||||
placeholder="Enter your invite code"
|
||||
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-3 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent font-mono tracking-wider text-center text-lg"
|
||||
autoFocus
|
||||
maxLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-danger text-sm">{error}</p>}
|
||||
|
||||
<button
|
||||
onClick={() => handleValidateCode()}
|
||||
disabled={loading}
|
||||
className="w-full bg-accent hover:bg-accent-dark text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Validating...' : 'Continue'}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={() => { setStage('login'); setError(''); setPassword(''); }}
|
||||
className="text-sm text-surface-400 hover:text-accent-light transition-colors"
|
||||
>
|
||||
Already have an account? Log in
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{stage === 'register' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setStage('invite'); setError(''); }}
|
||||
className="flex items-center gap-1 text-sm text-surface-400 hover:text-surface-200 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={14} /> Back
|
||||
</button>
|
||||
|
||||
<div className="text-xs text-surface-500 bg-surface-800 rounded px-3 py-2 font-mono">
|
||||
Invite: {inviteCode}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-surface-300 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-surface-300 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min 8 characters"
|
||||
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-surface-300 mb-1">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleRegister()}
|
||||
placeholder="Confirm your password"
|
||||
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-danger text-sm">{error}</p>}
|
||||
|
||||
<button
|
||||
onClick={handleRegister}
|
||||
disabled={loading}
|
||||
className="w-full bg-accent hover:bg-accent-dark text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create Account'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{stage === 'login' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setStage('invite'); setError(''); setPassword(''); }}
|
||||
className="flex items-center gap-1 text-sm text-surface-400 hover:text-surface-200 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={14} /> Back
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-surface-300 mb-1">Email or Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={loginField}
|
||||
onChange={(e) => setLoginField(e.target.value)}
|
||||
placeholder="admin"
|
||||
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-surface-300 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
||||
placeholder="Your password"
|
||||
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-danger text-sm">{error}</p>}
|
||||
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={loading}
|
||||
className="w-full bg-accent hover:bg-accent-dark text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Logging in...' : 'Log In'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{stage === 'reset-password' && (
|
||||
<>
|
||||
<div className="text-sm text-warning bg-warning/10 border border-warning/20 rounded-lg px-3 py-2">
|
||||
You must set a new password before continuing.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-surface-300 mb-1">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Min 8 characters"
|
||||
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-surface-300 mb-1">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmNewPassword}
|
||||
onChange={(e) => setConfirmNewPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handlePasswordReset()}
|
||||
placeholder="Confirm your new password"
|
||||
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-2.5 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-danger text-sm">{error}</p>}
|
||||
|
||||
<button
|
||||
onClick={handlePasswordReset}
|
||||
disabled={loading}
|
||||
className="w-full bg-accent hover:bg-accent-dark text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Saving...' : 'Set Password & Continue'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-surface-600 mt-6">
|
||||
Manage data centers, train models, serve millions of users, and achieve AGI.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { Sparkles, Cloud, Play } from 'lucide-react';
|
||||
import { useGameStore } from '@/store';
|
||||
import type { CloudSaveInfo } from '@/hooks/useAuthGate';
|
||||
|
||||
const SUGGESTED_NAMES = [
|
||||
'Nexus AI', 'Cortex Labs', 'Synapse Technologies',
|
||||
@@ -8,8 +9,32 @@ const SUGGESTED_NAMES = [
|
||||
'Neural Forge', 'DeepMind+', 'Cerebral Systems',
|
||||
];
|
||||
|
||||
export function NewGameScreen() {
|
||||
const ERA_LABELS: Record<string, string> = {
|
||||
startup: 'Startup',
|
||||
scaleup: 'Scale-Up',
|
||||
bigtech: 'Big Tech',
|
||||
agi: 'AGI',
|
||||
};
|
||||
|
||||
function formatTimeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
cloudSave?: CloudSaveInfo | null;
|
||||
onContinue?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function NewGameScreen({ cloudSave, onContinue }: Props) {
|
||||
const [name, setName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const startNewGame = useGameStore((s) => s.startNewGame);
|
||||
|
||||
const handleStart = () => {
|
||||
@@ -17,6 +42,16 @@ export function NewGameScreen() {
|
||||
startNewGame(companyName);
|
||||
};
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (!onContinue) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await onContinue();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-surface-950 to-surface-900">
|
||||
<div className="max-w-md w-full mx-4">
|
||||
@@ -24,7 +59,7 @@ export function NewGameScreen() {
|
||||
<div className="inline-flex items-center gap-2 mb-4">
|
||||
<Sparkles className="text-accent-light" size={32} />
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-accent-light to-accent bg-clip-text text-transparent">
|
||||
AI Tycoon
|
||||
Token Empire
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-surface-400 text-sm">
|
||||
@@ -32,7 +67,37 @@ export function NewGameScreen() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{cloudSave && onContinue && (
|
||||
<div className="bg-surface-900 border border-accent/30 rounded-xl p-6 mb-4 space-y-4">
|
||||
<div className="flex items-center gap-2 text-accent-light">
|
||||
<Cloud size={18} />
|
||||
<h3 className="font-semibold text-sm">Continue Your Game</h3>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-lg font-semibold text-surface-100">{cloudSave.companyName}</div>
|
||||
<div className="flex items-center gap-3 text-xs text-surface-400">
|
||||
<span className="px-2 py-0.5 rounded-full bg-surface-800 border border-surface-700">
|
||||
{ERA_LABELS[cloudSave.era] ?? cloudSave.era}
|
||||
</span>
|
||||
<span>Tick {cloudSave.tickCount.toLocaleString()}</span>
|
||||
<span>Saved {formatTimeAgo(cloudSave.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
disabled={loading}
|
||||
className="w-full inline-flex items-center justify-center gap-2 bg-accent hover:bg-accent-dark text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Play size={16} />
|
||||
{loading ? 'Loading...' : 'Continue'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 space-y-6">
|
||||
{cloudSave && onContinue && (
|
||||
<div className="text-xs text-surface-500 uppercase tracking-wider font-medium">Or start fresh</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-surface-300 mb-2">
|
||||
Name your AI company
|
||||
@@ -44,7 +109,7 @@ export function NewGameScreen() {
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleStart()}
|
||||
placeholder={SUGGESTED_NAMES[0]}
|
||||
className="w-full bg-surface-800 border border-surface-600 rounded-lg px-4 py-3 text-surface-100 placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent"
|
||||
autoFocus
|
||||
autoFocus={!cloudSave}
|
||||
maxLength={30}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { formatMoney, formatDuration, formatNumber, MAX_OFFLINE_TICKS, TICK_INTERVAL_MS } from '@ai-tycoon/shared';
|
||||
import { GameEngine } from '@ai-tycoon/game-engine';
|
||||
import { formatMoney, formatDuration, formatNumber, MAX_OFFLINE_TICKS, TICK_INTERVAL_MS } from '@token-empire/shared';
|
||||
import { GameEngine } from '@token-empire/game-engine';
|
||||
import { useGameStore } from '@/store';
|
||||
|
||||
interface OfflineResult {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { X, Lightbulb } from 'lucide-react';
|
||||
|
||||
const DISMISSED_KEY = 'ai-tycoon-dismissed-hints';
|
||||
const DISMISSED_KEY = 'token-empire-dismissed-hints';
|
||||
|
||||
function getDismissed(): Set<string> {
|
||||
try {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { CompetitorsPage } from '@/pages/CompetitorsPage';
|
||||
import { AchievementsPage } from '@/pages/AchievementsPage';
|
||||
import { LeaderboardPage } from '@/pages/LeaderboardPage';
|
||||
import { ServingPage } from '@/pages/ServingPage';
|
||||
import { InvitationsPage } from '@/pages/InvitationsPage';
|
||||
|
||||
export function MainLayout() {
|
||||
const { subPath, setSubPath } = useHashRouter();
|
||||
@@ -53,6 +54,7 @@ function PageRouter({ page, subPath, setSubPath }: { page: string; subPath: stri
|
||||
case 'competitors': return <CompetitorsPage />;
|
||||
case 'achievements': return <AchievementsPage />;
|
||||
case 'leaderboard': return <LeaderboardPage />;
|
||||
case 'invitations': return <InvitationsPage />;
|
||||
case 'settings': return <SettingsPage />;
|
||||
default: return <PlaceholderPage name={page} />;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
LayoutDashboard, Server, FlaskConical, Brain,
|
||||
TrendingUp, Activity, Users, Database, Swords, DollarSign, Settings, Trophy, Medal,
|
||||
PanelLeftClose, PanelLeftOpen,
|
||||
PanelLeftClose, PanelLeftOpen, Mail, UserPlus, Copy, Check,
|
||||
} from 'lucide-react';
|
||||
import { useGameStore, type ActivePage } from '@/store';
|
||||
import { isAdmin as checkIsAdmin, isRegistered as checkIsRegistered, getTokenPayload, api } from '@/lib/api';
|
||||
|
||||
const NAV_ITEMS: { page: ActivePage; label: string; icon: typeof LayoutDashboard; era?: string }[] = [
|
||||
const NAV_ITEMS: { page: ActivePage; label: string; icon: typeof LayoutDashboard; era?: string; adminOnly?: boolean }[] = [
|
||||
{ page: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ page: 'infrastructure', label: 'Infrastructure', icon: Server },
|
||||
{ page: 'research', label: 'Research', icon: FlaskConical },
|
||||
@@ -19,12 +20,13 @@ const NAV_ITEMS: { page: ActivePage; label: string; icon: typeof LayoutDashboard
|
||||
{ page: 'competitors', label: 'Competitors', icon: Swords, era: 'scaleup' },
|
||||
{ page: 'achievements', label: 'Achievements', icon: Trophy },
|
||||
{ page: 'leaderboard', label: 'Leaderboard', icon: Medal },
|
||||
{ page: 'invitations', label: 'Invitations', icon: Mail, adminOnly: true },
|
||||
{ page: 'settings', label: 'Settings', icon: Settings },
|
||||
];
|
||||
|
||||
function getInitialCollapsed(): boolean {
|
||||
try {
|
||||
const stored = localStorage.getItem('ai-tycoon-sidebar-collapsed');
|
||||
const stored = localStorage.getItem('token-empire-sidebar-collapsed');
|
||||
if (stored !== null) return stored === 'true';
|
||||
return window.innerWidth < 1280;
|
||||
} catch { return false; }
|
||||
@@ -36,6 +38,11 @@ export function Sidebar() {
|
||||
const companyName = useGameStore((s) => s.meta.companyName);
|
||||
const era = useGameStore((s) => s.meta.currentEra);
|
||||
const [collapsed, setCollapsed] = useState(getInitialCollapsed);
|
||||
const [remainingInvites, setRemainingInvites] = useState(0);
|
||||
const [inviteCopied, setInviteCopied] = useState(false);
|
||||
|
||||
const admin = checkIsAdmin();
|
||||
const registered = checkIsRegistered();
|
||||
|
||||
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||
const currentEraIdx = eraOrder.indexOf(era);
|
||||
@@ -57,6 +64,12 @@ export function Sidebar() {
|
||||
}
|
||||
}, [era]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!admin && registered) {
|
||||
api.invites.remaining().then(r => setRemainingInvites(r.remaining)).catch(() => {});
|
||||
}
|
||||
}, [admin, registered]);
|
||||
|
||||
const handleNavClick = (page: ActivePage) => {
|
||||
setActivePage(page);
|
||||
setNewPages(prev => {
|
||||
@@ -69,11 +82,26 @@ export function Sidebar() {
|
||||
const toggleCollapse = () => {
|
||||
setCollapsed(prev => {
|
||||
const next = !prev;
|
||||
localStorage.setItem('ai-tycoon-sidebar-collapsed', String(next));
|
||||
localStorage.setItem('token-empire-sidebar-collapsed', String(next));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
async function handleInviteFriend() {
|
||||
try {
|
||||
const result = await api.invites.create();
|
||||
const url = `${window.location.origin}?invite=${result.code}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
setInviteCopied(true);
|
||||
setRemainingInvites(prev => Math.max(0, prev - 1));
|
||||
setTimeout(() => setInviteCopied(false), 2000);
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
const showInviteButton = !admin && registered && remainingInvites > 0;
|
||||
|
||||
return (
|
||||
<aside className={`${collapsed ? 'w-16' : 'w-56'} bg-surface-900 border-r border-surface-700 flex flex-col h-screen transition-all duration-200`}>
|
||||
<div className={`${collapsed ? 'px-2 py-3' : 'p-4'} border-b border-surface-700 flex items-center ${collapsed ? 'justify-center' : 'justify-between'}`}>
|
||||
@@ -91,12 +119,13 @@ export function Sidebar() {
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 py-2 overflow-y-auto">
|
||||
{NAV_ITEMS.map(({ page, label, icon: Icon, era: requiredEra }) => {
|
||||
{NAV_ITEMS.map(({ page, label, icon: Icon, era: requiredEra, adminOnly }) => {
|
||||
if (requiredEra && eraOrder.indexOf(requiredEra) > currentEraIdx) return null;
|
||||
if (adminOnly && !admin) return null;
|
||||
|
||||
const isActive = activePage === page;
|
||||
const isNew = newPages.has(page);
|
||||
const showDivider = page === 'talent' || page === 'achievements';
|
||||
const showDivider = page === 'talent' || page === 'achievements' || page === 'invitations';
|
||||
return (
|
||||
<div key={page}>
|
||||
{showDivider && <div className={`border-t border-surface-700 my-1 ${collapsed ? 'mx-2' : 'mx-4'}`} />}
|
||||
@@ -123,8 +152,26 @@ export function Sidebar() {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{showInviteButton && (
|
||||
<div className={`${collapsed ? 'px-2' : 'px-3'} pb-2`}>
|
||||
<button
|
||||
onClick={handleInviteFriend}
|
||||
className={`w-full flex items-center ${collapsed ? 'justify-center' : 'gap-2'} px-3 py-2 text-sm rounded-lg bg-accent/10 hover:bg-accent/20 text-accent-light transition-colors`}
|
||||
title={collapsed ? 'Invite a Friend' : undefined}
|
||||
>
|
||||
{inviteCopied ? <Check size={16} /> : <UserPlus size={16} />}
|
||||
{!collapsed && (inviteCopied ? 'Link Copied!' : 'Invite a Friend')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`${collapsed ? 'px-2 py-3 text-center' : 'p-4'} border-t border-surface-700 text-xs text-surface-500`}>
|
||||
{collapsed ? 'v0.1' : 'AI Tycoon v0.1'}
|
||||
{!collapsed && (() => {
|
||||
const payload = getTokenPayload();
|
||||
const displayName = payload?.username || payload?.email || 'Guest';
|
||||
return <div className="truncate mb-1 text-surface-400">{displayName}</div>;
|
||||
})()}
|
||||
{collapsed ? 'v0.1' : 'Token Empire v0.1'}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Pause, Play, Bell, Share2 } from 'lucide-react';
|
||||
import { CompanyStatsCard } from '@/components/game/CompanyStatsCard';
|
||||
import { NotificationPanel } from '@/components/common/NotificationPanel';
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatMoney, formatNumber, formatDuration, formatPercent } from '@ai-tycoon/shared';
|
||||
import type { GameSpeed } from '@ai-tycoon/shared';
|
||||
import { formatMoney, formatNumber, formatDuration, formatPercent } from '@token-empire/shared';
|
||||
import type { GameSpeed } from '@token-empire/shared';
|
||||
import { Tooltip } from '@/components/common/Tooltip';
|
||||
|
||||
const SPEEDS: GameSpeed[] = [1, 2, 5];
|
||||
|
||||
@@ -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 { useGameStore } from '@/store';
|
||||
import { api, getAuthToken, setAuthToken } from '@/lib/api';
|
||||
import { AUTO_SAVE_INTERVAL_TICKS } from '@ai-tycoon/shared';
|
||||
import { api, getAuthToken, setAuthToken, clearAuthToken, decodeTokenPayload } from '@/lib/api';
|
||||
import { AUTO_SAVE_INTERVAL_TICKS } from '@token-empire/shared';
|
||||
|
||||
const MAX_CONSECUTIVE_FAILURES = 3;
|
||||
|
||||
export function useCloudSave() {
|
||||
const tickCount = useGameStore((s) => s.meta.tickCount);
|
||||
const companyName = useGameStore((s) => s.meta.companyName);
|
||||
const lastSaveTick = useRef(0);
|
||||
const failureCount = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!companyName) return;
|
||||
if (tickCount - lastSaveTick.current < AUTO_SAVE_INTERVAL_TICKS * 5) return;
|
||||
if (tickCount - lastSaveTick.current < AUTO_SAVE_INTERVAL_TICKS) return;
|
||||
|
||||
const token = getAuthToken();
|
||||
if (!token) return;
|
||||
|
||||
if (failureCount.current >= MAX_CONSECUTIVE_FAILURES) return;
|
||||
|
||||
lastSaveTick.current = tickCount;
|
||||
|
||||
const state = useGameStore.getState();
|
||||
const { activePage, notifications, ...gameState } = state;
|
||||
const { activePage, notifications, infraNav, modelsTab, ...gameState } = state;
|
||||
|
||||
api.saves.put({
|
||||
companyName: state.meta.companyName,
|
||||
@@ -26,13 +31,28 @@ export function useCloudSave() {
|
||||
gameData: gameState,
|
||||
tickCount: state.meta.tickCount,
|
||||
era: state.meta.currentEra,
|
||||
}).catch(() => {});
|
||||
}).then(() => {
|
||||
failureCount.current = 0;
|
||||
}).catch(() => {
|
||||
failureCount.current++;
|
||||
if (failureCount.current === MAX_CONSECUTIVE_FAILURES) {
|
||||
useGameStore.getState().addNotification({
|
||||
title: 'Cloud Save Failed',
|
||||
message: 'Unable to save to cloud. Your progress is still saved locally.',
|
||||
type: 'danger',
|
||||
tick: useGameStore.getState().meta.tickCount,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [tickCount, companyName]);
|
||||
}
|
||||
|
||||
export async function ensureAuth(): Promise<string | null> {
|
||||
let token = getAuthToken();
|
||||
if (token) return token;
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
if (decodeTokenPayload(token)) return token;
|
||||
clearAuthToken();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.auth.anonymous();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { GameEngine, setAchievementDefinitions, ACHIEVEMENT_DEFINITIONS } from '@ai-tycoon/game-engine';
|
||||
import type { TickNotification } from '@ai-tycoon/game-engine';
|
||||
import { GameEngine, setAchievementDefinitions, ACHIEVEMENT_DEFINITIONS } from '@token-empire/game-engine';
|
||||
import type { TickNotification } from '@token-empire/game-engine';
|
||||
import { useGameStore } from '@/store';
|
||||
|
||||
export function useGameLoop(skip = false) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useGameStore, type ActivePage } from '@/store';
|
||||
import type { GameSpeed } from '@ai-tycoon/shared';
|
||||
import type { GameSpeed } from '@token-empire/shared';
|
||||
|
||||
const PAGE_SHORTCUTS: Record<string, ActivePage> = {
|
||||
d: 'dashboard',
|
||||
|
||||
+140
-19
@@ -1,10 +1,10 @@
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3001';
|
||||
|
||||
let authToken: string | null = localStorage.getItem('ai-tycoon-auth-token');
|
||||
let authToken: string | null = localStorage.getItem('token-empire-auth-token');
|
||||
|
||||
export function setAuthToken(token: string) {
|
||||
authToken = token;
|
||||
localStorage.setItem('ai-tycoon-auth-token', token);
|
||||
localStorage.setItem('token-empire-auth-token', token);
|
||||
}
|
||||
|
||||
export function getAuthToken() {
|
||||
@@ -13,49 +13,170 @@ export function getAuthToken() {
|
||||
|
||||
export function clearAuthToken() {
|
||||
authToken = null;
|
||||
localStorage.removeItem('ai-tycoon-auth-token');
|
||||
localStorage.removeItem('token-empire-auth-token');
|
||||
localStorage.removeItem('token-empire-refresh-token');
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
export interface TokenPayload {
|
||||
sub: string;
|
||||
email: string | null;
|
||||
role: string;
|
||||
username: string | null;
|
||||
mustResetPassword: boolean;
|
||||
}
|
||||
|
||||
export function decodeTokenPayload(token: string): TokenPayload | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
||||
return {
|
||||
sub: payload.sub,
|
||||
email: payload.email ?? null,
|
||||
role: payload.role ?? 'user',
|
||||
username: payload.username ?? null,
|
||||
mustResetPassword: payload.mustResetPassword ?? false,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getTokenPayload(): TokenPayload | null {
|
||||
const token = getAuthToken();
|
||||
if (!token) return null;
|
||||
return decodeTokenPayload(token);
|
||||
}
|
||||
|
||||
export function isRegistered(): boolean {
|
||||
const payload = getTokenPayload();
|
||||
if (!payload) return false;
|
||||
return payload.email != null || payload.role === 'admin';
|
||||
}
|
||||
|
||||
export function isAdmin(): boolean {
|
||||
const payload = getTokenPayload();
|
||||
return payload?.role === 'admin';
|
||||
}
|
||||
|
||||
export function needsPasswordReset(): boolean {
|
||||
const payload = getTokenPayload();
|
||||
return payload?.mustResetPassword === true;
|
||||
}
|
||||
|
||||
const AUTH_PATHS = ['/api/auth/anonymous', '/api/auth/login', '/api/auth/logout', '/api/health'];
|
||||
|
||||
async function request<T>(path: string, options: RequestInit & { timeoutMs?: number } = {}): Promise<T> {
|
||||
const { timeoutMs = 10_000, ...fetchOptions } = options;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
...(fetchOptions.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(body.error || `HTTP ${res.status}`);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...fetchOptions,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 && authToken && !AUTH_PATHS.includes(path)) {
|
||||
clearAuthToken();
|
||||
localStorage.removeItem('token-empire-save');
|
||||
window.location.reload();
|
||||
}
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(body?.error || `HTTP ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') {
|
||||
throw new Error('Request timed out — server may be unreachable');
|
||||
}
|
||||
if (e instanceof TypeError) {
|
||||
throw new Error('Network error — server may be unreachable');
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
return res.json();
|
||||
export function validateStoredToken(): void {
|
||||
const token = getAuthToken();
|
||||
if (token && !decodeTokenPayload(token)) {
|
||||
clearAuthToken();
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
health: () => request<{ status: string }>('/api/health', { timeoutMs: 5_000 }),
|
||||
auth: {
|
||||
anonymous: () => request<{ userId: string; token: string }>('/api/auth/anonymous', { method: 'POST' }),
|
||||
login: (email: string, password: string) =>
|
||||
login: (login: string, password: string) =>
|
||||
request<{ userId: string; token: string }>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
body: JSON.stringify({ login, password }),
|
||||
}),
|
||||
linkEmail: (email: string, password: string) =>
|
||||
request<{ success: boolean }>('/api/auth/link-email', {
|
||||
register: (email: string, password: string, inviteCode: string) =>
|
||||
request<{ userId: string; token: string }>('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
body: JSON.stringify({ email, password, inviteCode }),
|
||||
}),
|
||||
changePassword: (newPassword: string, currentPassword?: string) =>
|
||||
request<{ success: boolean; token: string }>('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ newPassword, currentPassword }),
|
||||
}),
|
||||
changeUsername: (username: string) =>
|
||||
request<{ success: boolean; token: string }>('/api/auth/change-username', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username }),
|
||||
}),
|
||||
changeEmail: (email: string, currentPassword: string) =>
|
||||
request<{ success: boolean; token: string }>('/api/auth/change-email', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, currentPassword }),
|
||||
}),
|
||||
logout: () =>
|
||||
request<{ success: boolean }>('/api/auth/logout', { method: 'POST' }),
|
||||
me: () =>
|
||||
request<{ id: string; username: string | null; email: string | null; role: string }>('/api/auth/me'),
|
||||
},
|
||||
config: {
|
||||
get: () => request<{ requireInvite: boolean; userInvitations: number }>('/api/config'),
|
||||
},
|
||||
invites: {
|
||||
create: () => request<{ code: string }>('/api/invites', { method: 'POST' }),
|
||||
validate: (code: string) => request<{ valid: boolean }>(`/api/invites/validate/${encodeURIComponent(code)}`),
|
||||
list: () => request<{
|
||||
invitations: Array<{
|
||||
id: string;
|
||||
code: string;
|
||||
createdBy: { username: string | null; email: string | null };
|
||||
usedBy: { username: string | null; email: string | null } | null;
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
used: boolean;
|
||||
}>;
|
||||
}>('/api/invites'),
|
||||
remaining: () => request<{ remaining: number }>('/api/invites/remaining'),
|
||||
revoke: (id: string) => request<{ deleted: boolean }>(`/api/invites/${id}`, { method: 'DELETE' }),
|
||||
},
|
||||
saves: {
|
||||
list: () => request<{ saves: Array<{ id: string; companyName: string; era: string; tickCount: number; updatedAt: string }> }>('/api/saves'),
|
||||
get: (id: string) => request<{ save: { id: string; gameData: unknown } }>(`/api/saves/${id}`),
|
||||
latest: () => request<{ save: { id: string; companyName: string; era: string; tickCount: number; updatedAt: string; gameData: unknown } | null }>('/api/saves/latest'),
|
||||
put: (data: { companyName: string; saveVersion: number; gameData: unknown; tickCount: number; era: string }) =>
|
||||
request<{ id: string }>('/api/saves', { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (id: string) => request<{ deleted: boolean }>(`/api/saves/${id}`, { method: 'DELETE' }),
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useGameStore } from '@/store';
|
||||
import { ACHIEVEMENT_DEFINITIONS } from '@ai-tycoon/game-engine';
|
||||
import { formatNumber } from '@ai-tycoon/shared';
|
||||
import { ACHIEVEMENT_DEFINITIONS } from '@token-empire/game-engine';
|
||||
import { formatNumber } from '@token-empire/shared';
|
||||
import {
|
||||
Trophy, Lock, Server, Brain, Rocket, DollarSign, Sprout, Users,
|
||||
Globe, Sparkles, TrendingUp, Building2, Atom, Cpu, FlaskConical,
|
||||
GitBranch, Zap,
|
||||
} from 'lucide-react';
|
||||
import type { AchievementCondition } from '@ai-tycoon/shared';
|
||||
import type { AchievementCondition } from '@token-empire/shared';
|
||||
|
||||
const ICON_MAP: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
|
||||
Trophy, Server, Brain, Rocket, DollarSign, Sprout, Users,
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Swords, TrendingUp, Shield, Users, Brain, ShoppingCart } from 'lucide-r
|
||||
import { useGameStore } from '@/store';
|
||||
import { ConfirmModal } from '@/components/common/ConfirmModal';
|
||||
import { Tooltip } from '@/components/common/Tooltip';
|
||||
import { formatMoney, formatNumber } from '@ai-tycoon/shared';
|
||||
import type { Era } from '@ai-tycoon/shared';
|
||||
import { formatMoney, formatNumber } from '@token-empire/shared';
|
||||
import type { Era } from '@token-empire/shared';
|
||||
|
||||
const ARCHETYPE_LABELS: Record<string, string> = {
|
||||
'safety-first': 'Safety-First Lab',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type React from 'react';
|
||||
import { useGameStore, type ActivePage } from '@/store';
|
||||
import { formatMoney, formatNumber, formatPercent, formatDuration } from '@ai-tycoon/shared';
|
||||
import type { Era } from '@ai-tycoon/shared';
|
||||
import { TECH_TREE } from '@ai-tycoon/game-engine';
|
||||
import { formatMoney, formatNumber, formatPercent, formatDuration } from '@token-empire/shared';
|
||||
import type { Era } from '@token-empire/shared';
|
||||
import { TECH_TREE } from '@token-empire/game-engine';
|
||||
import {
|
||||
DollarSign, TrendingUp, TrendingDown, Minus, Cpu, Brain, Users,
|
||||
Shield, ChevronRight, Zap, Wifi, Sparkles, FlaskConical, Building2,
|
||||
@@ -96,7 +96,7 @@ export function DashboardPage() {
|
||||
|
||||
{totalDCs === 0 && (
|
||||
<TutorialHint id="welcome">
|
||||
Welcome to AI Tycoon! Start by building a cluster in the Infrastructure tab, then add a campus and data center to deploy racks and train your first AI model.
|
||||
Welcome to Token Empire! Start by building a cluster in the Infrastructure tab, then add a campus and data center to deploy racks and train your first AI model.
|
||||
</TutorialHint>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { Database, ShoppingCart, Zap } from 'lucide-react';
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatNumber, formatMoney, uuid } from '@ai-tycoon/shared';
|
||||
import type { OwnedDataset, DataDomain } from '@ai-tycoon/shared';
|
||||
import { formatNumber, formatMoney, uuid } from '@token-empire/shared';
|
||||
import type { OwnedDataset, DataDomain } from '@token-empire/shared';
|
||||
|
||||
interface MarketplaceDataset {
|
||||
name: string;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatMoney, formatPercent, formatNumber, FUNDING_ROUNDS } from '@ai-tycoon/shared';
|
||||
import type { FundingRoundType } from '@ai-tycoon/shared';
|
||||
import { formatMoney, formatPercent, formatNumber, FUNDING_ROUNDS } from '@token-empire/shared';
|
||||
import type { FundingRoundType } from '@token-empire/shared';
|
||||
import { TrendingUp, DollarSign, PiggyBank, BarChart3, Rocket, Check, X as XIcon } from 'lucide-react';
|
||||
import { AreaChart, Area, XAxis, YAxis, ResponsiveContainer, LineChart, Line, Tooltip } from 'recharts';
|
||||
import { canRaiseFunding } from '@ai-tycoon/game-engine';
|
||||
import type { GameState } from '@ai-tycoon/shared';
|
||||
import { canRaiseFunding } from '@token-empire/game-engine';
|
||||
import type { GameState } from '@token-empire/shared';
|
||||
|
||||
export function FinancePage() {
|
||||
const money = useGameStore((s) => s.economy.money);
|
||||
|
||||
@@ -18,11 +18,11 @@ import {
|
||||
SWITCH_TIER_CONFIGS,
|
||||
DC_UPGRADE_COST_FRACTION, DC_UPGRADE_INCREMENT,
|
||||
skuTotalFlops,
|
||||
} from '@ai-tycoon/shared';
|
||||
} from '@token-empire/shared';
|
||||
import type {
|
||||
DCTier, RackSkuId, LocationId, PipelineStage, Era,
|
||||
Cluster, Campus, DataCenter, DeploymentCohort,
|
||||
} from '@ai-tycoon/shared';
|
||||
} from '@token-empire/shared';
|
||||
|
||||
const ERA_ORDER: Era[] = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||
|
||||
|
||||
@@ -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 { Trophy, Medal, Clock, TrendingUp } from 'lucide-react';
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatMoney, formatNumber } from '@ai-tycoon/shared';
|
||||
import { formatMoney, formatNumber } from '@token-empire/shared';
|
||||
import { api, getAuthToken } from '@/lib/api';
|
||||
|
||||
interface LeaderboardEntry {
|
||||
|
||||
@@ -13,12 +13,12 @@ import {
|
||||
SIZE_TIER_LABELS,
|
||||
SFT_SPECIALIZATION_BONUSES,
|
||||
PRETRAINING_BASE_TICKS,
|
||||
} from '@ai-tycoon/shared';
|
||||
} from '@token-empire/shared';
|
||||
import type {
|
||||
ModelArchitecture, DataMixAllocation, SFTSpecialization, AlignmentMethod,
|
||||
DataDomain, QuantizationLevel, BaseModel, ModelVariant,
|
||||
SizeTier, ModelFamily,
|
||||
} from '@ai-tycoon/shared';
|
||||
} from '@token-empire/shared';
|
||||
|
||||
const DATA_MIX_PRESETS: Record<string, { label: string; mix: DataMixAllocation }> = {
|
||||
balanced: { label: 'Balanced', mix: DEFAULT_DATA_MIX },
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { FlaskConical, Lock, Check, Play, ListOrdered, X } from 'lucide-react';
|
||||
import { TutorialHint } from '@/components/game/TutorialHint';
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatDuration, formatPercent, formatNumber, formatMoney } from '@ai-tycoon/shared';
|
||||
import { TECH_TREE, getAvailableResearch } from '@ai-tycoon/game-engine';
|
||||
import type { ResearchNode } from '@ai-tycoon/shared';
|
||||
import { formatDuration, formatPercent, formatNumber, formatMoney } from '@token-empire/shared';
|
||||
import { TECH_TREE, getAvailableResearch } from '@token-empire/game-engine';
|
||||
import type { ResearchNode } from '@token-empire/shared';
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
generation: 'border-purple-500/50 bg-purple-500/10',
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
formatNumber, formatPercent,
|
||||
type TrafficPriority, type OverflowBehavior, type RoutingStrategy,
|
||||
TRAFFIC_PRIORITIES,
|
||||
} from '@ai-tycoon/shared';
|
||||
} from '@token-empire/shared';
|
||||
import {
|
||||
Activity, Shield, Clock, CheckCircle, XCircle, Layers,
|
||||
AlertTriangle, Zap, Server, ArrowRight,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Pencil, Check, X, LogOut } from 'lucide-react';
|
||||
import { useGameStore } from '@/store';
|
||||
import { ConfirmModal } from '@/components/common/ConfirmModal';
|
||||
import { api, setAuthToken, getTokenPayload, isRegistered, isAdmin, clearAuthToken } from '@/lib/api';
|
||||
|
||||
export function SettingsPage() {
|
||||
const settings = useGameStore((s) => s.meta.settings);
|
||||
@@ -11,6 +13,60 @@ export function SettingsPage() {
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
const [importData, setImportData] = useState<{ data: unknown; name: string } | null>(null);
|
||||
|
||||
const [editingUsername, setEditingUsername] = useState(false);
|
||||
const [usernameValue, setUsernameValue] = useState('');
|
||||
const [usernameError, setUsernameError] = useState('');
|
||||
const [usernameSaving, setUsernameSaving] = useState(false);
|
||||
|
||||
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
|
||||
|
||||
const [editingEmail, setEditingEmail] = useState(false);
|
||||
const [emailValue, setEmailValue] = useState('');
|
||||
const [emailPassword, setEmailPassword] = useState('');
|
||||
const [emailError, setEmailError] = useState('');
|
||||
const [emailSaving, setEmailSaving] = useState(false);
|
||||
|
||||
async function handleSaveUsername() {
|
||||
setUsernameError('');
|
||||
if (!usernameValue || usernameValue.length < 2) {
|
||||
setUsernameError('Username must be at least 2 characters');
|
||||
return;
|
||||
}
|
||||
setUsernameSaving(true);
|
||||
try {
|
||||
const result = await api.auth.changeUsername(usernameValue);
|
||||
setAuthToken(result.token);
|
||||
setEditingUsername(false);
|
||||
} catch (e) {
|
||||
setUsernameError(e instanceof Error ? e.message : 'Failed to change username');
|
||||
} finally {
|
||||
setUsernameSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveEmail() {
|
||||
setEmailError('');
|
||||
if (!emailValue || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailValue)) {
|
||||
setEmailError('Valid email required');
|
||||
return;
|
||||
}
|
||||
if (!emailPassword) {
|
||||
setEmailError('Current password required');
|
||||
return;
|
||||
}
|
||||
setEmailSaving(true);
|
||||
try {
|
||||
const result = await api.auth.changeEmail(emailValue, emailPassword);
|
||||
setAuthToken(result.token);
|
||||
setEditingEmail(false);
|
||||
setEmailPassword('');
|
||||
} catch (e) {
|
||||
setEmailError(e instanceof Error ? e.message : 'Failed to change email');
|
||||
} finally {
|
||||
setEmailSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSound = () => {
|
||||
updateState({ meta: { ...useGameStore.getState().meta, settings: { ...settings, soundEnabled: !settings.soundEnabled } } });
|
||||
};
|
||||
@@ -20,7 +76,7 @@ export function SettingsPage() {
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
localStorage.removeItem('ai-tycoon-save');
|
||||
localStorage.removeItem('token-empire-save');
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
@@ -31,7 +87,7 @@ export function SettingsPage() {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `ai-tycoon-${companyName.replace(/\s+/g, '-').toLowerCase()}.json`;
|
||||
a.download = `token-empire-${companyName.replace(/\s+/g, '-').toLowerCase()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
@@ -50,7 +106,7 @@ export function SettingsPage() {
|
||||
}
|
||||
setImportData({ data, name: data.meta.companyName });
|
||||
} catch {
|
||||
addNotification({ title: 'Import Failed', message: 'Could not read save file. Make sure it is a valid AI Tycoon export.', type: 'danger', tick: useGameStore.getState().meta.tickCount });
|
||||
addNotification({ title: 'Import Failed', message: 'Could not read save file. Make sure it is a valid Token Empire export.', type: 'danger', tick: useGameStore.getState().meta.tickCount });
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
@@ -59,14 +115,112 @@ export function SettingsPage() {
|
||||
|
||||
const confirmImport = () => {
|
||||
if (!importData) return;
|
||||
localStorage.setItem('ai-tycoon-save', JSON.stringify({ state: importData.data }));
|
||||
localStorage.setItem('token-empire-save', JSON.stringify({ state: importData.data }));
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const payload = getTokenPayload();
|
||||
const registered = isRegistered();
|
||||
const admin = isAdmin();
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<h2 className="text-2xl font-bold">Settings</h2>
|
||||
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
|
||||
<h3 className="font-semibold">Account</h3>
|
||||
{registered ? (
|
||||
<div className="space-y-3">
|
||||
{payload?.email != null && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm">Email</div>
|
||||
{editingEmail ? (
|
||||
<div className="mt-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="email"
|
||||
value={emailValue}
|
||||
onChange={(e) => setEmailValue(e.target.value)}
|
||||
className="bg-surface-800 border border-surface-600 rounded px-2 py-1 text-sm w-56"
|
||||
placeholder="New email"
|
||||
autoFocus
|
||||
/>
|
||||
<button onClick={handleSaveEmail} disabled={emailSaving}
|
||||
className="text-accent hover:text-accent-light disabled:opacity-50"><Check size={16} /></button>
|
||||
<button onClick={() => { setEditingEmail(false); setEmailError(''); setEmailPassword(''); }}
|
||||
className="text-surface-400 hover:text-surface-200"><X size={16} /></button>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={emailPassword}
|
||||
onChange={(e) => setEmailPassword(e.target.value)}
|
||||
className="bg-surface-800 border border-surface-600 rounded px-2 py-1 text-sm w-56"
|
||||
placeholder="Current password"
|
||||
/>
|
||||
{emailError && <p className="text-xs text-danger">{emailError}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-surface-400">{payload.email}</div>
|
||||
)}
|
||||
</div>
|
||||
{!editingEmail && (
|
||||
<button onClick={() => { setEmailValue(payload.email ?? ''); setEditingEmail(true); }}
|
||||
className="text-surface-400 hover:text-surface-200"><Pencil size={14} /></button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm">Username</div>
|
||||
{editingUsername ? (
|
||||
<div className="mt-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={usernameValue}
|
||||
onChange={(e) => setUsernameValue(e.target.value)}
|
||||
className="bg-surface-800 border border-surface-600 rounded px-2 py-1 text-sm w-48"
|
||||
placeholder="Username"
|
||||
autoFocus
|
||||
/>
|
||||
<button onClick={handleSaveUsername} disabled={usernameSaving}
|
||||
className="text-accent hover:text-accent-light disabled:opacity-50"><Check size={16} /></button>
|
||||
<button onClick={() => { setEditingUsername(false); setUsernameError(''); }}
|
||||
className="text-surface-400 hover:text-surface-200"><X size={16} /></button>
|
||||
</div>
|
||||
{usernameError && <p className="text-xs text-danger">{usernameError}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-surface-400">{payload?.username ?? 'Not set'}</div>
|
||||
)}
|
||||
</div>
|
||||
{!editingUsername && (
|
||||
<button onClick={() => { setUsernameValue(payload?.username ?? ''); setEditingUsername(true); }}
|
||||
className="text-surface-400 hover:text-surface-200"><Pencil size={14} /></button>
|
||||
)}
|
||||
</div>
|
||||
{admin && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-accent/20 text-accent font-medium">Admin</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-surface-400">Playing as guest.</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2 border-t border-surface-700">
|
||||
<button
|
||||
onClick={() => setShowLogoutConfirm(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded bg-surface-800 hover:bg-surface-700 border border-surface-600 text-sm text-surface-300 hover:text-surface-100 transition-colors"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
{registered ? 'Log Out' : 'Sign Out'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
|
||||
<h3 className="font-semibold">Game</h3>
|
||||
|
||||
@@ -148,6 +302,24 @@ export function SettingsPage() {
|
||||
onCancel={() => setImportData(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showLogoutConfirm && (
|
||||
<ConfirmModal
|
||||
title={registered ? 'Log Out' : 'Sign Out'}
|
||||
message={registered
|
||||
? 'You will be logged out. Your game progress is saved to the cloud and will be available when you log back in.'
|
||||
: 'You will be signed out. As a guest, your local progress will be lost. Consider registering first to save your progress.'}
|
||||
confirmLabel={registered ? 'Log Out' : 'Sign Out'}
|
||||
danger={!registered}
|
||||
onConfirm={async () => {
|
||||
try { await api.auth.logout(); } catch {}
|
||||
clearAuthToken();
|
||||
localStorage.removeItem('token-empire-save');
|
||||
window.location.reload();
|
||||
}}
|
||||
onCancel={() => setShowLogoutConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { Users, Plus, Star, Briefcase } from 'lucide-react';
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatMoney } from '@ai-tycoon/shared';
|
||||
import { KEY_HIRE_POOL } from '@ai-tycoon/game-engine';
|
||||
import type { DepartmentId } from '@ai-tycoon/shared';
|
||||
import { formatMoney } from '@token-empire/shared';
|
||||
import { KEY_HIRE_POOL } from '@token-empire/game-engine';
|
||||
import type { DepartmentId } from '@token-empire/shared';
|
||||
|
||||
const DEPT_LABELS: Record<string, string> = {
|
||||
research: 'Research',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared';
|
||||
import type { ApiTierId } from '@ai-tycoon/shared';
|
||||
import { formatNumber, formatMoney, formatPercent } from '@token-empire/shared';
|
||||
import type { ApiTierId } from '@token-empire/shared';
|
||||
import { Code, Check } from 'lucide-react';
|
||||
|
||||
const TIER_ORDER: ApiTierId[] = ['free', 'payg', 'scale', 'enterprise-api'];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared';
|
||||
import type { ConsumerTierId } from '@ai-tycoon/shared';
|
||||
import { formatNumber, formatMoney, formatPercent } from '@token-empire/shared';
|
||||
import type { ConsumerTierId } from '@token-empire/shared';
|
||||
import { Users, Check } from 'lucide-react';
|
||||
|
||||
const TIER_ORDER: ConsumerTierId[] = ['free', 'plus', 'pro', 'team'];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared';
|
||||
import { formatNumber, formatMoney, formatPercent } from '@token-empire/shared';
|
||||
import { Boxes, Check } from 'lucide-react';
|
||||
|
||||
function useAppliedFeedback() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared';
|
||||
import type { EnterprisePipelineStage, EnterpriseSegment } from '@ai-tycoon/shared';
|
||||
import { formatNumber, formatMoney, formatPercent } from '@token-empire/shared';
|
||||
import type { EnterprisePipelineStage, EnterpriseSegment } from '@token-empire/shared';
|
||||
import { Building2, AlertTriangle } from 'lucide-react';
|
||||
|
||||
const STAGE_ORDER: EnterprisePipelineStage[] = ['lead', 'qualification', 'poc', 'negotiation'];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatNumber, formatPercent } from '@ai-tycoon/shared';
|
||||
import type { TAMSegmentId } from '@ai-tycoon/shared';
|
||||
import { formatNumber, formatPercent } from '@token-empire/shared';
|
||||
import type { TAMSegmentId } from '@token-empire/shared';
|
||||
import { Globe, TrendingUp, Clock, Thermometer } from 'lucide-react';
|
||||
|
||||
const SEGMENT_LABELS: Record<TAMSegmentId, string> = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatNumber, formatMoney, formatPercent } from '@ai-tycoon/shared';
|
||||
import { formatNumber, formatMoney, formatPercent } from '@token-empire/shared';
|
||||
import { Wrench, Bot, Check, Lock } from 'lucide-react';
|
||||
|
||||
function useAppliedFeedback() {
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
ModelArchitecture, AlignmentMethod, SizeTier,
|
||||
SFTSpecialization, QuantizationLevel, VariantCreationJob,
|
||||
ConsumerTierId, ApiTierId,
|
||||
} from '@ai-tycoon/shared';
|
||||
} from '@token-empire/shared';
|
||||
import {
|
||||
INITIAL_SETTINGS, SAVE_VERSION,
|
||||
INITIAL_ECONOMY, INITIAL_INFRASTRUCTURE, INITIAL_COMPUTE,
|
||||
@@ -39,15 +39,15 @@ import {
|
||||
SFT_TIME_FRACTION, ALIGNMENT_TIME_FRACTION,
|
||||
SIZE_TIER_MAP, SIZE_TIER_LABELS,
|
||||
POINT_RELEASE_TIME_FRACTION, POINT_RELEASE_MAX_VERSION,
|
||||
} from '@ai-tycoon/shared';
|
||||
} from '@token-empire/shared';
|
||||
import {
|
||||
emptyDCNetworkSummary, emptyCampusNetworkSummary, emptyClusterNetworkSummary,
|
||||
TECH_TREE, onModelDeployed,
|
||||
} from '@ai-tycoon/game-engine';
|
||||
import { INITIAL_RIVALS } from '@ai-tycoon/game-engine';
|
||||
} from '@token-empire/game-engine';
|
||||
import { INITIAL_RIVALS } from '@token-empire/game-engine';
|
||||
|
||||
export type ActivePage = 'dashboard' | 'infrastructure' | 'research' | 'models'
|
||||
| 'market' | 'serving' | 'talent' | 'data' | 'competitors' | 'finance' | 'achievements' | 'leaderboard' | 'settings';
|
||||
| 'market' | 'serving' | 'talent' | 'data' | 'competitors' | 'finance' | 'achievements' | 'leaderboard' | 'invitations' | 'settings';
|
||||
|
||||
export type InfraNavLevel = 'clusters' | 'cluster' | 'campus' | 'datacenter';
|
||||
|
||||
@@ -1428,7 +1428,7 @@ export const useGameStore = create<Store>()(
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'ai-tycoon-save',
|
||||
name: 'token-empire-save',
|
||||
version: SAVE_VERSION,
|
||||
partialize: (state) => {
|
||||
const { activePage, notifications, infraNav, modelsTab, ...rest } = state;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "@ai-tycoon/tsconfig/react.json",
|
||||
"extends": "@token-empire/tsconfig/react.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
|
||||
+10
-7
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
web:
|
||||
image: gitea.thewrightserver.net/josh/aihostingtycoon/web:latest
|
||||
image: gitea.thewrightserver.net/josh/tokenempire/web:latest
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
@@ -8,13 +8,16 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
server:
|
||||
image: gitea.thewrightserver.net/josh/aihostingtycoon/server:latest
|
||||
image: gitea.thewrightserver.net/josh/tokenempire/server:latest
|
||||
ports:
|
||||
- "3001:3001"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://aitycoon:aitycoon@db:5432/aitycoon
|
||||
- DATABASE_URL=postgresql://tokenempire:tokenempire@db:5432/tokenempire
|
||||
- PORT=3001
|
||||
- CORS_ORIGIN=*
|
||||
- JWT_SECRET=change-me-to-a-random-secret
|
||||
- REQUIRE_INVITE=true
|
||||
- USER_INVITATIONS=0
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -23,13 +26,13 @@ services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
- POSTGRES_USER=aitycoon
|
||||
- POSTGRES_PASSWORD=aitycoon
|
||||
- POSTGRES_DB=aitycoon
|
||||
- POSTGRES_USER=tokenempire
|
||||
- POSTGRES_PASSWORD=tokenempire
|
||||
- POSTGRES_DB=tokenempire
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U aitycoon"]
|
||||
test: ["CMD-SHELL", "pg_isready -U tokenempire"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@@ -17,7 +17,7 @@ The game engine and simulation core have no React dependency. They can run ident
|
||||
## Monorepo Layout
|
||||
|
||||
```
|
||||
ai-tycoon/
|
||||
token-empire/
|
||||
├── turbo.json # Turborepo task config
|
||||
├── pnpm-workspace.yaml # Workspace definition
|
||||
│
|
||||
@@ -122,7 +122,7 @@ The store uses a slice pattern with 14 slices, each owning a portion of the game
|
||||
|
||||
### Persistence
|
||||
|
||||
- **localStorage**: Auto-save every 60 ticks under key `ai-tycoon-save`. The Zustand `persist` middleware handles serialization.
|
||||
- **localStorage**: Auto-save every 60 ticks under key `token-empire-save`. The Zustand `persist` middleware handles serialization.
|
||||
- **Cloud saves**: Optional. POST to `/api/saves` every 5 minutes when authenticated. Requires the Hono backend + PostgreSQL.
|
||||
- **Save format versioning**: A `version` field in meta enables migration functions for breaking state changes.
|
||||
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "ai-tycoon",
|
||||
"name": "token-empire",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo dev",
|
||||
@@ -9,8 +9,8 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"clean": "turbo clean",
|
||||
"simulate": "turbo simulate --filter=@ai-tycoon/game-simulation",
|
||||
"simulate:ci": "pnpm --filter @ai-tycoon/game-simulation simulate:ci"
|
||||
"simulate": "turbo simulate --filter=@token-empire/game-simulation",
|
||||
"simulate:ci": "pnpm --filter @token-empire/game-simulation simulate:ci"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.5.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@ai-tycoon/game-engine",
|
||||
"name": "@token-empire/game-engine",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
@@ -11,10 +11,10 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-tycoon/shared": "workspace:*"
|
||||
"@token-empire/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ai-tycoon/tsconfig": "workspace:*",
|
||||
"@token-empire/tsconfig": "workspace:*",
|
||||
"typescript": "^5.8.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import type {
|
||||
Cluster, Campus, DataCenter, DeploymentCohort,
|
||||
DCNetworkSummary, CampusNetworkSummary, ClusterNetworkSummary,
|
||||
TrainingPipeline, BaseModel, ModelFamily,
|
||||
} from '@ai-tycoon/shared';
|
||||
import { uuid } from '@ai-tycoon/shared';
|
||||
} from '@token-empire/shared';
|
||||
import { uuid } from '@token-empire/shared';
|
||||
import type { DeepPartial } from './createTestState';
|
||||
|
||||
function emptyDCNetwork(): DCNetworkSummary {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { GameState } from '@ai-tycoon/shared';
|
||||
import type { GameState } from '@token-empire/shared';
|
||||
import {
|
||||
INITIAL_SETTINGS, SAVE_VERSION,
|
||||
INITIAL_ECONOMY, INITIAL_INFRASTRUCTURE, INITIAL_COMPUTE,
|
||||
INITIAL_RESEARCH, INITIAL_MODELS, INITIAL_MARKET,
|
||||
INITIAL_COMPETITORS, INITIAL_TALENT, INITIAL_DATA,
|
||||
INITIAL_REPUTATION, INITIAL_ACHIEVEMENTS,
|
||||
} from '@ai-tycoon/shared';
|
||||
} from '@token-empire/shared';
|
||||
|
||||
export type DeepPartial<T> = T extends object
|
||||
? { [K in keyof T]?: DeepPartial<T[K]> }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AchievementDefinition } from '@ai-tycoon/shared';
|
||||
import type { AchievementDefinition } from '@token-empire/shared';
|
||||
|
||||
export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Competitor } from '@ai-tycoon/shared';
|
||||
import type { Competitor } from '@token-empire/shared';
|
||||
|
||||
export const INITIAL_RIVALS: Competitor[] = [
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { EnterpriseSegment } from '@ai-tycoon/shared';
|
||||
import type { EnterpriseSegment } from '@token-empire/shared';
|
||||
|
||||
export const ENTERPRISE_NAMES: Record<EnterpriseSegment, string[]> = {
|
||||
startup: [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DepartmentId } from '@ai-tycoon/shared';
|
||||
import type { DepartmentId } from '@token-empire/shared';
|
||||
|
||||
/**
|
||||
* A recruitable key hire as it appears in the available pool.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ResearchNode } from '@ai-tycoon/shared';
|
||||
import type { ResearchNode } from '@token-empire/shared';
|
||||
|
||||
export const TECH_TREE: ResearchNode[] = [
|
||||
// === COMPUTE / INFRASTRUCTURE ===
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GameState } from '@ai-tycoon/shared';
|
||||
import type { GameState } from '@token-empire/shared';
|
||||
import { processTick } from './tick';
|
||||
|
||||
export interface GameEngineCallbacks {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { processAchievements } from './achievementSystem';
|
||||
import { createTestState } from '../__test-utils__';
|
||||
import type { AchievementDefinition } from '@ai-tycoon/shared';
|
||||
import type { AchievementDefinition } from '@token-empire/shared';
|
||||
|
||||
function makeDef(overrides: Partial<AchievementDefinition> = {}): AchievementDefinition {
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GameState, AchievementState, AchievementDefinition } from '@ai-tycoon/shared';
|
||||
import type { GameState, AchievementState, AchievementDefinition } from '@token-empire/shared';
|
||||
|
||||
export interface AchievementTickResult {
|
||||
achievements: AchievementState;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { processCompetitors } from './competitorSystem';
|
||||
import { createTestState, createSeededRNG } from '../__test-utils__';
|
||||
import { FRESHNESS_DECAY_RATE } from '@ai-tycoon/shared';
|
||||
import type { Competitor } from '@ai-tycoon/shared';
|
||||
import { FRESHNESS_DECAY_RATE } from '@token-empire/shared';
|
||||
import type { Competitor } from '@token-empire/shared';
|
||||
|
||||
const rng = createSeededRNG(42);
|
||||
beforeEach(() => rng.install());
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { GameState, CompetitorState, Competitor } from '@ai-tycoon/shared';
|
||||
import type { GameState, CompetitorState, Competitor } from '@token-empire/shared';
|
||||
import {
|
||||
COMPETITOR_PRODUCT_THRESHOLDS,
|
||||
COMPETITOR_CATCHUP_SHARE_THRESHOLD,
|
||||
COMPETITOR_CATCHUP_PRICE_CUT,
|
||||
FRESHNESS_DECAY_RATE,
|
||||
} from '@ai-tycoon/shared';
|
||||
} from '@token-empire/shared';
|
||||
|
||||
function updateCompetitorProducts(rival: Competitor): Competitor['products'] {
|
||||
const cap = rival.estimatedCapability;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createTestState } from '../__test-utils__';
|
||||
import { computeCapacity, finalizeCompute } from './computeSystem';
|
||||
import type { InfrastructureState } from '@ai-tycoon/shared';
|
||||
import { INITIAL_INFRASTRUCTURE, FLOPS_TO_TOKENS_MULTIPLIER, COMPUTE_SNAPSHOT_INTERVAL, MAX_COMPUTE_HISTORY } from '@ai-tycoon/shared';
|
||||
import type { InfrastructureState } from '@token-empire/shared';
|
||||
import { INITIAL_INFRASTRUCTURE, FLOPS_TO_TOKENS_MULTIPLIER, COMPUTE_SNAPSHOT_INTERVAL, MAX_COMPUTE_HISTORY } from '@token-empire/shared';
|
||||
|
||||
function createInfrastructure(overrides: Partial<InfrastructureState> = {}): InfrastructureState {
|
||||
return { ...INITIAL_INFRASTRUCTURE, ...overrides };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GameState, ComputeState, InfrastructureState } from '@ai-tycoon/shared';
|
||||
import { FLOPS_TO_TOKENS_MULTIPLIER, COMPUTE_SNAPSHOT_INTERVAL, MAX_COMPUTE_HISTORY } from '@ai-tycoon/shared';
|
||||
import type { GameState, ComputeState, InfrastructureState } from '@token-empire/shared';
|
||||
import { FLOPS_TO_TOKENS_MULTIPLIER, COMPUTE_SNAPSHOT_INTERVAL, MAX_COMPUTE_HISTORY } from '@token-empire/shared';
|
||||
import type { ResearchBonuses } from './researchBonuses';
|
||||
|
||||
export interface CapacityResult {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { processData } from './dataSystem';
|
||||
import { createTestState } from '../__test-utils__';
|
||||
import type { DataPartnership } from '@ai-tycoon/shared';
|
||||
import type { DataPartnership } from '@token-empire/shared';
|
||||
|
||||
function makePartnership(tokensPerTick: number): DataPartnership {
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GameState, DataState } from '@ai-tycoon/shared';
|
||||
import type { GameState, DataState } from '@token-empire/shared';
|
||||
|
||||
export function processData(state: GameState): DataState {
|
||||
const subscribers = state.market.consumerTiers.totalUsers;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
|
||||
import { processEconomy } from './economySystem';
|
||||
import { createTestState, createTestCluster } from '../__test-utils__';
|
||||
import type { MarketTickResult } from './marketSystem';
|
||||
import type { InfrastructureState } from '@ai-tycoon/shared';
|
||||
import type { InfrastructureState } from '@token-empire/shared';
|
||||
|
||||
function createMarketResult(
|
||||
overrides: Partial<MarketTickResult> = {},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GameState, EconomyState, InfrastructureState } from '@ai-tycoon/shared';
|
||||
import { FINANCIAL_SNAPSHOT_INTERVAL, MAX_FINANCIAL_HISTORY, REGULATION_COMPLIANCE_PER_CAPABILITY } from '@ai-tycoon/shared';
|
||||
import type { GameState, EconomyState, InfrastructureState } from '@token-empire/shared';
|
||||
import { FINANCIAL_SNAPSHOT_INTERVAL, MAX_FINANCIAL_HISTORY, REGULATION_COMPLIANCE_PER_CAPABILITY } from '@token-empire/shared';
|
||||
import { TECH_TREE } from '../data/techTree';
|
||||
import type { MarketTickResult } from './marketSystem';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GameState, Era } from '@ai-tycoon/shared';
|
||||
import { ERA_THRESHOLDS } from '@ai-tycoon/shared';
|
||||
import type { GameState, Era } from '@token-empire/shared';
|
||||
import { ERA_THRESHOLDS } from '@token-empire/shared';
|
||||
|
||||
export function checkEraTransition(state: GameState): Era | null {
|
||||
const current = state.meta.currentEra;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GameState, FundingState, FundingRoundType } from '@ai-tycoon/shared';
|
||||
import { FUNDING_ROUNDS } from '@ai-tycoon/shared';
|
||||
import type { GameState, FundingState, FundingRoundType } from '@token-empire/shared';
|
||||
import { FUNDING_ROUNDS } from '@token-empire/shared';
|
||||
|
||||
const ROUND_ORDER: FundingRoundType[] = ['seed', 'seriesA', 'seriesB', 'seriesC', 'seriesD', 'ipo'];
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
DeploymentCohort, PipelineStage, RackSkuId,
|
||||
SwitchTier, DCNetworkSummary, CampusNetworkSummary, ClusterNetworkSummary,
|
||||
RepairBatch, CampusRetrofitQueue, DCTier, IntraNodeInterconnect, NetworkFabric, RackSkuConfig,
|
||||
} from '@ai-tycoon/shared';
|
||||
} from '@token-empire/shared';
|
||||
import {
|
||||
LOCATION_CONFIGS,
|
||||
RACK_SKU_CONFIGS,
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
COOLING_TYPE_CONFIGS,
|
||||
NETWORK_FABRIC_CONFIGS,
|
||||
estimateNetworkSlots,
|
||||
} from '@ai-tycoon/shared';
|
||||
} from '@token-empire/shared';
|
||||
import type { TickNotification } from '../tick';
|
||||
import type { ResearchBonuses } from './researchBonuses';
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { ApiTierState, ApiTierId, DeveloperEcosystem, TierServingMetrics } from '@ai-tycoon/shared';
|
||||
import type { ApiTierState, ApiTierId, DeveloperEcosystem, TierServingMetrics } from '@token-empire/shared';
|
||||
import {
|
||||
API_TIER_ORDER,
|
||||
API_CONVERSION_RATES,
|
||||
API_TIER_CHURN_RATES,
|
||||
API_TOKENS_PER_DEVELOPER_PER_TICK,
|
||||
REJECTION_CHURN_MULTIPLIER,
|
||||
} from '@ai-tycoon/shared';
|
||||
} from '@token-empire/shared';
|
||||
|
||||
export interface ApiTickResult {
|
||||
apiTiers: ApiTierState;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ConsumerTierState, ConsumerTierId, TierServingMetrics } from '@ai-tycoon/shared';
|
||||
import type { ConsumerTierState, ConsumerTierId, TierServingMetrics } from '@token-empire/shared';
|
||||
import {
|
||||
CONSUMER_TIER_ORDER,
|
||||
CONVERSION_RATES,
|
||||
@@ -8,7 +8,13 @@ import {
|
||||
NETWORK_DEGRADATION,
|
||||
REJECTION_CHURN_MULTIPLIER,
|
||||
QUEUE_CHURN_MULTIPLIER,
|
||||
} from '@ai-tycoon/shared';
|
||||
CONSUMER_TIER_BASE_PERCEIVED_VALUE,
|
||||
PERCEIVED_VALUE_REPUTATION_RANGE,
|
||||
PRICE_ELASTICITY_STEEPNESS,
|
||||
PRICE_SATISFACTION_WEIGHT,
|
||||
PRICE_CHURN_EXPONENT,
|
||||
PRICE_CHURN_MAX_MULTIPLIER,
|
||||
} from '@token-empire/shared';
|
||||
|
||||
export interface ConsumerTickResult {
|
||||
consumerTiers: ConsumerTierState;
|
||||
@@ -16,10 +22,23 @@ export interface ConsumerTickResult {
|
||||
totalConsumerTokenDemand: number;
|
||||
}
|
||||
|
||||
function computePerceivedValue(
|
||||
tierId: ConsumerTierId,
|
||||
modelQuality: number,
|
||||
reputation: number,
|
||||
): number {
|
||||
const baseValue = CONSUMER_TIER_BASE_PERCEIVED_VALUE[tierId];
|
||||
if (baseValue <= 0) return 0;
|
||||
const repRange = PERCEIVED_VALUE_REPUTATION_RANGE;
|
||||
const repMult = repRange.min + (Math.max(0, Math.min(100, reputation)) / 100) * (repRange.max - repRange.min);
|
||||
return baseValue * Math.max(0, modelQuality) * repMult;
|
||||
}
|
||||
|
||||
export function processConsumerTiers(
|
||||
tiers: ConsumerTierState,
|
||||
playerConsumerCustomers: number,
|
||||
modelQuality: number,
|
||||
reputation: number,
|
||||
seasonalConsumerMultiplier: number,
|
||||
networkLatencyPenalty: number,
|
||||
consumerPaidMetrics: TierServingMetrics,
|
||||
@@ -57,6 +76,7 @@ export function processConsumerTiers(
|
||||
team: 'pro',
|
||||
};
|
||||
|
||||
// --- Pass 1: Conversions only (no churn yet) ---
|
||||
for (const id of CONSUMER_TIER_ORDER) {
|
||||
if (id === 'free') continue;
|
||||
const tier = updated.tiers[id];
|
||||
@@ -69,35 +89,26 @@ export function processConsumerTiers(
|
||||
|
||||
const conversionKey = `${prevId}->${id}`;
|
||||
const baseRate = CONVERSION_RATES[conversionKey] ?? 0;
|
||||
const priceAttr = tier.config.price > 0
|
||||
? Math.max(0.1, 1 - tier.config.price / 100)
|
||||
: 1;
|
||||
const convRate = baseRate * qualityFactor * priceAttr * seasonalConsumerMultiplier;
|
||||
|
||||
const perceivedValue = computePerceivedValue(id, modelQuality, reputation);
|
||||
let priceAttr: number;
|
||||
if (tier.config.price <= 0) {
|
||||
priceAttr = 1;
|
||||
} else if (perceivedValue <= 0) {
|
||||
priceAttr = 0;
|
||||
} else {
|
||||
const ratio = tier.config.price / perceivedValue;
|
||||
priceAttr = 1 / (1 + Math.pow(ratio, PRICE_ELASTICITY_STEEPNESS));
|
||||
}
|
||||
|
||||
const convRate = baseRate * qualityFactor * priceAttr * seasonalConsumerMultiplier;
|
||||
const converting = prevTier.userCount * convRate;
|
||||
prevTier.userCount = Math.max(0, prevTier.userCount - converting);
|
||||
tier.userCount += converting;
|
||||
tier.conversionRateFromBelow = convRate;
|
||||
|
||||
tier.churnRate = TIER_CHURN_RATES[id];
|
||||
const churnMultiplier = 1 + (1 - updated.satisfaction) * 2;
|
||||
const churned = tier.userCount * tier.churnRate * churnMultiplier;
|
||||
tier.userCount = Math.max(0, tier.userCount - churned);
|
||||
}
|
||||
|
||||
let totalUsers = 0;
|
||||
let subscriptionRevenue = 0;
|
||||
let totalTokenDemand = 0;
|
||||
|
||||
for (const id of CONSUMER_TIER_ORDER) {
|
||||
const tier = updated.tiers[id];
|
||||
totalUsers += tier.userCount;
|
||||
subscriptionRevenue += tier.userCount * (tier.config.price / 86400);
|
||||
totalTokenDemand += tier.userCount * CONSUMER_TOKENS_PER_SUBSCRIBER;
|
||||
}
|
||||
|
||||
updated.totalUsers = totalUsers;
|
||||
|
||||
// --- Serving penalties & serving-based extra churn ---
|
||||
const paidDemand = consumerPaidMetrics.demandTokens;
|
||||
const freeDemand = consumerFreeMetrics.demandTokens;
|
||||
const totalDemand = paidDemand + freeDemand;
|
||||
@@ -140,6 +151,7 @@ export function processConsumerTiers(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Price-aware satisfaction ---
|
||||
let headroomBonus = 0;
|
||||
if (totalDemand > 0) {
|
||||
const totalServed = consumerPaidMetrics.servedTokens + consumerFreeMetrics.servedTokens;
|
||||
@@ -152,10 +164,76 @@ export function processConsumerTiers(
|
||||
}
|
||||
|
||||
const netLatencyPenalty = networkLatencyPenalty * NETWORK_DEGRADATION.satisfactionPenaltyPerLatency;
|
||||
updated.satisfaction = Math.min(1, Math.max(0,
|
||||
const qualityServingSatisfaction = Math.min(1, Math.max(0,
|
||||
0.3 + modelQuality * 0.5 + headroomBonus - servingPenalty - netLatencyPenalty,
|
||||
));
|
||||
|
||||
let priceSatNumerator = 0;
|
||||
let priceSatDenominator = 0;
|
||||
for (const id of CONSUMER_TIER_ORDER) {
|
||||
if (id === 'free') continue;
|
||||
const tier = updated.tiers[id];
|
||||
if (tier.userCount <= 0) continue;
|
||||
const pv = computePerceivedValue(id, modelQuality, reputation);
|
||||
let tierPriceSat: number;
|
||||
if (tier.config.price <= 0) {
|
||||
tierPriceSat = 1;
|
||||
} else if (pv <= 0) {
|
||||
tierPriceSat = 0;
|
||||
} else {
|
||||
tierPriceSat = Math.min(1, pv / tier.config.price);
|
||||
}
|
||||
priceSatNumerator += tierPriceSat * tier.userCount;
|
||||
priceSatDenominator += tier.userCount;
|
||||
}
|
||||
const priceSatisfaction = priceSatDenominator > 0
|
||||
? priceSatNumerator / priceSatDenominator
|
||||
: 1;
|
||||
|
||||
const w = PRICE_SATISFACTION_WEIGHT;
|
||||
updated.satisfaction = Math.min(1, Math.max(0,
|
||||
(1 - w) * qualityServingSatisfaction + w * priceSatisfaction,
|
||||
));
|
||||
|
||||
// --- Pass 2: Churn (using price-aware satisfaction) ---
|
||||
for (const id of CONSUMER_TIER_ORDER) {
|
||||
if (id === 'free') continue;
|
||||
const tier = updated.tiers[id];
|
||||
|
||||
tier.churnRate = TIER_CHURN_RATES[id];
|
||||
const satisfactionChurnMultiplier = 1 + (1 - updated.satisfaction) * 2;
|
||||
|
||||
const pv = computePerceivedValue(id, modelQuality, reputation);
|
||||
let priceChurnMultiplier = 1;
|
||||
if (tier.config.price > 0 && pv > 0) {
|
||||
const ratio = tier.config.price / pv;
|
||||
if (ratio > 1) {
|
||||
priceChurnMultiplier = Math.min(
|
||||
PRICE_CHURN_MAX_MULTIPLIER,
|
||||
Math.pow(ratio, PRICE_CHURN_EXPONENT),
|
||||
);
|
||||
}
|
||||
} else if (tier.config.price > 0 && pv <= 0) {
|
||||
priceChurnMultiplier = PRICE_CHURN_MAX_MULTIPLIER;
|
||||
}
|
||||
|
||||
const churned = tier.userCount * tier.churnRate * satisfactionChurnMultiplier * priceChurnMultiplier;
|
||||
tier.userCount = Math.max(0, tier.userCount - churned);
|
||||
}
|
||||
|
||||
// --- Revenue & token demand (after all churn — cancelled users don't pay) ---
|
||||
let totalUsers = 0;
|
||||
let subscriptionRevenue = 0;
|
||||
let totalTokenDemand = 0;
|
||||
|
||||
for (const id of CONSUMER_TIER_ORDER) {
|
||||
const tier = updated.tiers[id];
|
||||
totalUsers += tier.userCount;
|
||||
subscriptionRevenue += tier.userCount * (tier.config.price / 86400);
|
||||
totalTokenDemand += tier.userCount * CONSUMER_TOKENS_PER_SUBSCRIBER;
|
||||
}
|
||||
|
||||
updated.totalUsers = totalUsers;
|
||||
updated.viralCoefficient = modelQuality > 0.5 ? 1 + (modelQuality - 0.5) * 2 : 0;
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DeveloperEcosystem } from '@ai-tycoon/shared';
|
||||
import type { DeveloperEcosystem } from '@token-empire/shared';
|
||||
import {
|
||||
BASE_DEV_GROWTH,
|
||||
FREE_TIER_DEV_MULTIPLIER,
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
STARTUP_ADOPTION_PER_DEV,
|
||||
ENTERPRISE_REFERRAL_PER_STARTUP,
|
||||
TAM_BASE_SIZES,
|
||||
} from '@ai-tycoon/shared';
|
||||
import type { Era } from '@ai-tycoon/shared';
|
||||
} from '@token-empire/shared';
|
||||
import type { Era } from '@token-empire/shared';
|
||||
|
||||
export function processDeveloperEcosystem(
|
||||
eco: DeveloperEcosystem,
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
EnterprisePipelineStage,
|
||||
DeveloperEcosystem,
|
||||
TierServingMetrics,
|
||||
} from '@ai-tycoon/shared';
|
||||
} from '@token-empire/shared';
|
||||
import {
|
||||
BASE_LEAD_RATE,
|
||||
LEAD_EXPIRY_TICKS,
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
ENTERPRISE_CAPABILITY_REQUIREMENTS,
|
||||
ENTERPRISE_TOKENS_PER_TICK,
|
||||
ENTERPRISE_REJECTION_SLA_MULTIPLIER,
|
||||
} from '@ai-tycoon/shared';
|
||||
} from '@token-empire/shared';
|
||||
import { ENTERPRISE_NAMES } from '../../data/enterpriseNames';
|
||||
|
||||
let leadIdCounter = 0;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { GameState, MarketState, ModelCapabilities } from '@ai-tycoon/shared';
|
||||
import { CONSUMER_TOKENS_PER_SUBSCRIBER, API_TOKENS_PER_DEVELOPER_PER_TICK, BATCH_API_DEMAND_PER_DEV, makeInitialServingMetrics } from '@ai-tycoon/shared';
|
||||
import type { TrafficPriority, TierServingMetrics } from '@ai-tycoon/shared';
|
||||
import type { GameState, MarketState, ModelCapabilities } from '@token-empire/shared';
|
||||
import { CONSUMER_TOKENS_PER_SUBSCRIBER, API_TOKENS_PER_DEVELOPER_PER_TICK, BATCH_API_DEMAND_PER_DEV, makeInitialServingMetrics } from '@token-empire/shared';
|
||||
import type { TrafficPriority, TierServingMetrics } from '@token-empire/shared';
|
||||
import { computeSeasonal } from './seasonalSystem';
|
||||
import { updateObsolescence } from './obsolescenceSystem';
|
||||
import { buildPlayerProfile, buildCompetitorProfile, computeMarketShares, updateTAMGrowth } from './tamSystem';
|
||||
@@ -188,6 +188,7 @@ export function processMarketV2(
|
||||
state.market.consumerTiers,
|
||||
playerConsumerCustomers,
|
||||
modelQuality,
|
||||
state.reputation.score,
|
||||
seasonal.multipliers.consumer,
|
||||
state.infrastructure.networkLatencyPenalty,
|
||||
sm.tierMetrics['consumer-paid'],
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ObsolescenceState, Era } from '@ai-tycoon/shared';
|
||||
import type { ObsolescenceState, Era } from '@token-empire/shared';
|
||||
import {
|
||||
OBSOLESCENCE_BASELINE_GROWTH,
|
||||
OBSOLESCENCE_ERA_ACCELERATOR,
|
||||
FRESHNESS_DECAY_RATE,
|
||||
NEW_MODEL_BOOST_TICKS,
|
||||
} from '@ai-tycoon/shared';
|
||||
} from '@token-empire/shared';
|
||||
|
||||
export function updateObsolescence(
|
||||
obs: ObsolescenceState,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CodeAssistantState, AgentsPlatformState, ModelCapabilities } from '@ai-tycoon/shared';
|
||||
import type { CodeAssistantState, AgentsPlatformState, ModelCapabilities } from '@token-empire/shared';
|
||||
import {
|
||||
CODE_ASSISTANT_MIN_CODING_SCORE,
|
||||
CODE_ASSISTANT_BASE_ADOPTION_RATE,
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
AGENTS_PLATFORM_MIN_AGENTS_SCORE,
|
||||
AGENTS_PLATFORM_BASE_ADOPTION_RATE,
|
||||
AGENTS_PLATFORM_CHURN_RATE,
|
||||
} from '@ai-tycoon/shared';
|
||||
} from '@token-empire/shared';
|
||||
|
||||
export interface ProductLineResult {
|
||||
codeAssistant: CodeAssistantState;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SeasonalPhase } from '@ai-tycoon/shared';
|
||||
import { SEASONAL_CYCLE_TICKS, SEASONAL_MULTIPLIERS } from '@ai-tycoon/shared';
|
||||
import type { SeasonalPhase } from '@token-empire/shared';
|
||||
import { SEASONAL_CYCLE_TICKS, SEASONAL_MULTIPLIERS } from '@token-empire/shared';
|
||||
|
||||
export interface SeasonalResult {
|
||||
phase: SeasonalPhase;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user