From f66b954ba3c1ba7d1c8a3b143a20bef8288ce814 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Wed, 18 Feb 2026 14:00:31 +0100
Subject: [PATCH 01/52] Standardize postcss on 8.4.49 to address CVE in
versions < 8.4.49 (#59)
* Initial plan
* Fix postcss vulnerability by upgrading to 8.5.6
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* Update postcss override to target 8.5.6 to eliminate dual versions
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* Standardize on postcss 8.4.49 to fix CVE and eliminate version conflicts
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
---
apps/web/package.json | 2 +-
package.json | 3 +-
pnpm-lock.yaml | 401 ++++++++++++++++++++----------------------
3 files changed, 194 insertions(+), 212 deletions(-)
diff --git a/apps/web/package.json b/apps/web/package.json
index 57efca4a..4ada5cea 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -28,7 +28,7 @@
"@types/react-dom": "^18.3.1",
"autoprefixer": "^10.4.20",
"eslint-config-next": "^15.5.10",
- "postcss": "^8.4.49",
+ "postcss": "8.4.49",
"tailwindcss": "^3.4.17"
}
}
diff --git a/package.json b/package.json
index 0589d492..c18482e4 100644
--- a/package.json
+++ b/package.json
@@ -73,7 +73,8 @@
"overrides": {
"glob@>=10.2.0 <10.5.0": "10.5.0",
"glob@>=11.0.0 <11.1.0": "11.1.0",
- "lodash@>=4.0.0 <=4.17.22": "4.17.23"
+ "lodash@>=4.0.0 <=4.17.22": "4.17.23",
+ "postcss@>=8.0.0 <9.0.0": "8.4.49"
}
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6df4b660..73554902 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,7 @@ overrides:
glob@>=10.2.0 <10.5.0: 10.5.0
glob@>=11.0.0 <11.1.0: 11.1.0
lodash@>=4.0.0 <=4.17.22: 4.17.23
+ postcss@>=8.0.0 <9.0.0: 8.4.49
importers:
@@ -24,13 +25,13 @@ importers:
version: link:packages/sim
'@nestjs/common':
specifier: ^11.1.13
- version: 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ version: 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core':
specifier: ^11.1.13
- version: 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express':
specifier: ^11.1.13
- version: 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)
+ version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)
'@prisma/client':
specifier: ^6.19.2
version: 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3)
@@ -40,10 +41,10 @@ importers:
devDependencies:
'@better-auth/cli':
specifier: ^1.4.18
- version: 1.4.18(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0))
+ version: 1.4.18(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0))
'@nestjs/testing':
specifier: ^11.1.13
- version: 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13))
+ version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-express@11.1.14)
'@types/node':
specifier: ^22.19.11
version: 22.19.11
@@ -52,7 +53,7 @@ importers:
version: 6.0.3
'@typescript-eslint/eslint-plugin':
specifier: ^8.55.0
- version: 8.55.0(@typescript-eslint/parser@8.56.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
+ version: 8.56.0(@typescript-eslint/parser@8.56.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
'@typescript-eslint/parser':
specifier: ^8.56.0
version: 8.56.0(eslint@8.57.1)(typescript@5.9.3)
@@ -103,13 +104,13 @@ importers:
version: link:../../packages/sim
'@thallesp/nestjs-better-auth':
specifier: ^2.4.0
- version: 2.4.0(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2))(better-auth@1.4.18(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)))(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0)))(express@5.2.1)(typescript@5.9.3)
+ version: 2.4.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(better-auth@1.4.18(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)))(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0)))(express@5.2.1)(typescript@5.9.3)
better-auth:
specifier: ^1.4.18
version: 1.4.18(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)))(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0))
better-call:
specifier: ^1.1.8
- version: 1.1.8(zod@4.3.6)
+ version: 1.3.2(zod@4.3.6)
apps/web:
dependencies:
@@ -161,13 +162,13 @@ importers:
version: 18.3.7(@types/react@18.3.28)
autoprefixer:
specifier: ^10.4.20
- version: 10.4.24(postcss@8.5.6)
+ version: 10.4.24(postcss@8.4.49)
eslint-config-next:
specifier: ^15.5.10
version: 15.5.12(eslint@8.57.1)(typescript@5.9.3)
postcss:
- specifier: ^8.4.49
- version: 8.5.6
+ specifier: 8.4.49
+ version: 8.4.49
tailwindcss:
specifier: ^3.4.17
version: 3.4.19(tsx@4.21.0)
@@ -400,6 +401,9 @@ packages:
'@better-auth/utils@0.3.0':
resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==}
+ '@better-auth/utils@0.3.1':
+ resolution: {integrity: sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg==}
+
'@better-fetch/fetch@1.1.21':
resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==}
@@ -832,8 +836,8 @@ packages:
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
- '@nestjs/common@11.1.13':
- resolution: {integrity: sha512-ieqWtipT+VlyDWLz5Rvz0f3E5rXcVAnaAi+D53DEHLjc1kmFxCgZ62qVfTX2vwkywwqNkTNXvBgGR72hYqV//Q==}
+ '@nestjs/common@11.1.14':
+ resolution: {integrity: sha512-IN/tlqd7Nl9gl6f0jsWEuOrQDaCI9vHzxv0fisHysfBQzfQIkqlv5A7w4Qge02BUQyczXT9HHPgHtWHCxhjRng==}
peerDependencies:
class-transformer: '>=0.4.1'
class-validator: '>=0.13.2'
@@ -845,8 +849,8 @@ packages:
class-validator:
optional: true
- '@nestjs/core@11.1.13':
- resolution: {integrity: sha512-Tq9EIKiC30EBL8hLK93tNqaToy0hzbuVGYt29V8NhkVJUsDzlmiVf6c3hSPtzx2krIUVbTgQ2KFeaxr72rEyzQ==}
+ '@nestjs/core@11.1.14':
+ resolution: {integrity: sha512-7OXPPMoDr6z+5NkoQKu4hOhfjz/YYqM3bNilPqv1WVFWrzSmuNXxvhbX69YMmNmRYascPXiwESqf5jJdjKXEww==}
engines: {node: '>= 20'}
peerDependencies:
'@nestjs/common': ^11.0.0
@@ -863,14 +867,14 @@ packages:
'@nestjs/websockets':
optional: true
- '@nestjs/platform-express@11.1.13':
- resolution: {integrity: sha512-LYmi43BrAs1n74kLCUfXcHag7s1CmGETcFbf9IVyA/KWXAuAH95G3wEaZZiyabOLFNwq4ifnRGnIwUwW7cz3+w==}
+ '@nestjs/platform-express@11.1.14':
+ resolution: {integrity: sha512-Fs+/j+mBSBSXErOQJ/YdUn/HqJGSJ4pGfiJyYOyz04l42uNVnqEakvu1kXLbxMabR6vd6/h9d6Bi4tso9p7o4Q==}
peerDependencies:
'@nestjs/common': ^11.0.0
'@nestjs/core': ^11.0.0
- '@nestjs/testing@11.1.13':
- resolution: {integrity: sha512-bOWP8nLEZAOEEX8jAZGBCc1yU0+nv4g2ipc+QEzkVUe3eEEUKHKaeGafJ3GtDuGavlZKfkXEqflZuICdavu5dQ==}
+ '@nestjs/testing@11.1.14':
+ resolution: {integrity: sha512-cQxX0ronsTbpfHz8/LYOVWXxoTxv6VoxrnuZoQaVX7QV2PSMqxWE7/9jSQR0GcqAFUEmFP34c6EJqfkjfX/k4Q==}
peerDependencies:
'@nestjs/common': ^11.0.0
'@nestjs/core': ^11.0.0
@@ -1192,6 +1196,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-primitive@2.1.4':
+ resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-select@2.2.6':
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
peerDependencies:
@@ -1521,12 +1538,12 @@ packages:
'@types/validator@13.15.10':
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
- '@typescript-eslint/eslint-plugin@8.55.0':
- resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==}
+ '@typescript-eslint/eslint-plugin@8.56.0':
+ resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- '@typescript-eslint/parser': ^8.55.0
- eslint: ^8.57.0 || ^9.0.0
+ '@typescript-eslint/parser': ^8.56.0
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/parser@8.56.0':
@@ -1536,76 +1553,46 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/project-service@8.55.0':
- resolution: {integrity: sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- peerDependencies:
- typescript: '>=4.8.4 <6.0.0'
-
'@typescript-eslint/project-service@8.56.0':
resolution: {integrity: sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/scope-manager@8.55.0':
- resolution: {integrity: sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
'@typescript-eslint/scope-manager@8.56.0':
resolution: {integrity: sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/tsconfig-utils@8.55.0':
- resolution: {integrity: sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- peerDependencies:
- typescript: '>=4.8.4 <6.0.0'
-
'@typescript-eslint/tsconfig-utils@8.56.0':
resolution: {integrity: sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/type-utils@8.55.0':
- resolution: {integrity: sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==}
+ '@typescript-eslint/type-utils@8.56.0':
+ resolution: {integrity: sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- eslint: ^8.57.0 || ^9.0.0
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/types@8.55.0':
- resolution: {integrity: sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
'@typescript-eslint/types@8.56.0':
resolution: {integrity: sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/typescript-estree@8.55.0':
- resolution: {integrity: sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- peerDependencies:
- typescript: '>=4.8.4 <6.0.0'
-
'@typescript-eslint/typescript-estree@8.56.0':
resolution: {integrity: sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/utils@8.55.0':
- resolution: {integrity: sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==}
+ '@typescript-eslint/utils@8.56.0':
+ resolution: {integrity: sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- eslint: ^8.57.0 || ^9.0.0
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/visitor-keys@8.55.0':
- resolution: {integrity: sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
'@typescript-eslint/visitor-keys@8.56.0':
resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1840,7 +1827,7 @@ packages:
engines: {node: ^10 || ^12 || >=14}
hasBin: true
peerDependencies:
- postcss: ^8.1.0
+ postcss: 8.4.49
available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
@@ -1934,6 +1921,14 @@ packages:
zod:
optional: true
+ better-call@1.3.2:
+ resolution: {integrity: sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw==}
+ peerDependencies:
+ zod: ^4.0.0
+ peerDependenciesMeta:
+ zod:
+ optional: true
+
better-sqlite3@12.6.2:
resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==}
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x}
@@ -2028,8 +2023,8 @@ packages:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'}
- caniuse-lite@1.0.30001769:
- resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==}
+ caniuse-lite@1.0.30001770:
+ resolution: {integrity: sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==}
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
@@ -2553,10 +2548,6 @@ packages:
resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
- eslint-visitor-keys@4.2.1:
- resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
-
eslint-visitor-keys@5.0.0:
resolution: {integrity: sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
@@ -2984,8 +2975,8 @@ packages:
resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
engines: {node: '>= 0.4'}
- is-wsl@3.1.0:
- resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==}
+ is-wsl@3.1.1:
+ resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
engines: {node: '>=16'}
isarray@2.0.5:
@@ -3072,8 +3063,8 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
- libphonenumber-js@1.12.36:
- resolution: {integrity: sha512-woWhKMAVx1fzzUnMCyOzglgSgf6/AFHLASdOBcchYCyvWSGWt12imw3iu2hdI5d4dGZRsNWAmWiz37sDKUPaRQ==}
+ libphonenumber-js@1.12.37:
+ resolution: {integrity: sha512-rDU6bkpuMs8YRt/UpkuYEAsYSoNuDEbrE41I3KNvmXREGH6DGBJ8Wbak4by29wNOQ27zk4g4HL82zf0OGhwRuw==}
lilconfig@2.1.0:
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
@@ -3267,6 +3258,10 @@ packages:
node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
+ node-exports-info@1.6.0:
+ resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==}
+ engines: {node: '>= 0.4'}
+
node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
@@ -3455,20 +3450,20 @@ packages:
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
engines: {node: '>=14.0.0'}
peerDependencies:
- postcss: ^8.0.0
+ postcss: 8.4.49
postcss-js@4.1.0:
resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==}
engines: {node: ^12 || ^14 || >= 16}
peerDependencies:
- postcss: ^8.4.21
+ postcss: 8.4.49
postcss-load-config@6.0.1:
resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
engines: {node: '>= 18'}
peerDependencies:
jiti: '>=1.21.0'
- postcss: '>=8.0.9'
+ postcss: 8.4.49
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
@@ -3485,7 +3480,7 @@ packages:
resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
engines: {node: '>=12.0'}
peerDependencies:
- postcss: ^8.2.14
+ postcss: 8.4.49
postcss-selector-parser@6.1.2:
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
@@ -3494,12 +3489,8 @@ packages:
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
- postcss@8.4.31:
- resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
- engines: {node: ^10 || ^12 || >=14}
-
- postcss@8.5.6:
- resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
+ postcss@8.4.49:
+ resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==}
engines: {node: ^10 || ^12 || >=14}
postgres-array@2.0.0:
@@ -3563,8 +3554,8 @@ packages:
pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
- qs@6.14.2:
- resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==}
+ qs@6.15.0:
+ resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
engines: {node: '>=0.6'}
queue-microtask@1.2.3:
@@ -3680,8 +3671,9 @@ packages:
engines: {node: '>= 0.4'}
hasBin: true
- resolve@2.0.0-next.5:
- resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==}
+ resolve@2.0.0-next.6:
+ resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==}
+ engines: {node: '>= 0.4'}
hasBin: true
reusify@1.1.0:
@@ -3756,6 +3748,9 @@ packages:
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+ set-cookie-parser@3.0.1:
+ resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==}
+
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -4481,12 +4476,12 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
- '@better-auth/cli@1.4.18(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0))':
+ '@better-auth/cli@1.4.18(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0))':
dependencies:
'@babel/core': 7.29.0
'@babel/preset-react': 7.28.5(@babel/core@7.29.0)
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.0)
- '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
+ '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
'@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
'@better-auth/utils': 0.3.0
'@clack/prompts': 0.11.0
@@ -4564,14 +4559,27 @@ snapshots:
nanostores: 1.1.0
zod: 4.3.6
+ '@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)':
+ dependencies:
+ '@better-auth/utils': 0.3.0
+ '@better-fetch/fetch': 1.1.21
+ '@standard-schema/spec': 1.1.0
+ better-call: 1.3.2(zod@4.3.6)
+ jose: 6.1.3
+ kysely: 0.28.11
+ nanostores: 1.1.0
+ zod: 4.3.6
+
'@better-auth/telemetry@1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))':
dependencies:
- '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
+ '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
'@better-auth/utils': 0.3.0
'@better-fetch/fetch': 1.1.21
'@better-auth/utils@0.3.0': {}
+ '@better-auth/utils@0.3.1': {}
+
'@better-fetch/fetch@1.1.21': {}
'@borewit/text-codec@0.2.1': {}
@@ -4898,7 +4906,7 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
- '@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
+ '@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
file-type: 21.3.0
iterare: 1.2.1
@@ -4913,9 +4921,9 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@nestjs/core@11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
+ '@nestjs/core@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
- '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nuxt/opencollective': 0.4.1
fast-safe-stringify: 2.1.1
iterare: 1.2.1
@@ -4925,12 +4933,12 @@ snapshots:
tslib: 2.8.1
uid: 2.0.2
optionalDependencies:
- '@nestjs/platform-express': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)
+ '@nestjs/platform-express': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)
- '@nestjs/platform-express@11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)':
+ '@nestjs/platform-express@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)':
dependencies:
- '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
- '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2)
cors: 2.8.6
express: 5.2.1
multer: 2.0.2
@@ -4939,13 +4947,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@nestjs/testing@11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13))':
+ '@nestjs/testing@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-express@11.1.14)':
dependencies:
- '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
- '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2)
tslib: 2.8.1
optionalDependencies:
- '@nestjs/platform-express': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)
+ '@nestjs/platform-express': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)
'@next/env@15.5.12': {}
@@ -5216,6 +5224,15 @@ snapshots:
'@types/react': 18.3.28
'@types/react-dom': 18.3.7(@types/react@18.3.28)
+ '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-slot': 1.2.4(@types/react@18.3.28)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.28
+ '@types/react-dom': 18.3.7(@types/react@18.3.28)
+
'@radix-ui/react-select@2.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/number': 1.1.1
@@ -5409,10 +5426,10 @@ snapshots:
dependencies:
tslib: 2.8.1
- '@thallesp/nestjs-better-auth@2.4.0(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2))(better-auth@1.4.18(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)))(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0)))(express@5.2.1)(typescript@5.9.3)':
+ '@thallesp/nestjs-better-auth@2.4.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(better-auth@1.4.18(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)))(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0)))(express@5.2.1)(typescript@5.9.3)':
dependencies:
- '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
- '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2)
better-auth: 1.4.18(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)))(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0))
express: 5.2.1
typescript: 5.9.3
@@ -5481,14 +5498,14 @@ snapshots:
'@types/validator@13.15.10': {}
- '@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.56.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)':
+ '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.56.0(eslint@8.57.1)(typescript@5.9.3)
- '@typescript-eslint/scope-manager': 8.55.0
- '@typescript-eslint/type-utils': 8.55.0(eslint@8.57.1)(typescript@5.9.3)
- '@typescript-eslint/utils': 8.55.0(eslint@8.57.1)(typescript@5.9.3)
- '@typescript-eslint/visitor-keys': 8.55.0
+ '@typescript-eslint/scope-manager': 8.56.0
+ '@typescript-eslint/type-utils': 8.56.0(eslint@8.57.1)(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.56.0(eslint@8.57.1)(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.56.0
eslint: 8.57.1
ignore: 7.0.5
natural-compare: 1.4.0
@@ -5509,15 +5526,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/project-service@8.55.0(typescript@5.9.3)':
- dependencies:
- '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3)
- '@typescript-eslint/types': 8.55.0
- debug: 4.4.3
- typescript: 5.9.3
- transitivePeerDependencies:
- - supports-color
-
'@typescript-eslint/project-service@8.56.0(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.56.0(typescript@5.9.3)
@@ -5527,29 +5535,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/scope-manager@8.55.0':
- dependencies:
- '@typescript-eslint/types': 8.55.0
- '@typescript-eslint/visitor-keys': 8.55.0
-
'@typescript-eslint/scope-manager@8.56.0':
dependencies:
'@typescript-eslint/types': 8.56.0
'@typescript-eslint/visitor-keys': 8.56.0
- '@typescript-eslint/tsconfig-utils@8.55.0(typescript@5.9.3)':
- dependencies:
- typescript: 5.9.3
-
'@typescript-eslint/tsconfig-utils@8.56.0(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
- '@typescript-eslint/type-utils@8.55.0(eslint@8.57.1)(typescript@5.9.3)':
+ '@typescript-eslint/type-utils@8.56.0(eslint@8.57.1)(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/types': 8.55.0
- '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3)
- '@typescript-eslint/utils': 8.55.0(eslint@8.57.1)(typescript@5.9.3)
+ '@typescript-eslint/types': 8.56.0
+ '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.56.0(eslint@8.57.1)(typescript@5.9.3)
debug: 4.4.3
eslint: 8.57.1
ts-api-utils: 2.4.0(typescript@5.9.3)
@@ -5557,25 +5556,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/types@8.55.0': {}
-
'@typescript-eslint/types@8.56.0': {}
- '@typescript-eslint/typescript-estree@8.55.0(typescript@5.9.3)':
- dependencies:
- '@typescript-eslint/project-service': 8.55.0(typescript@5.9.3)
- '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3)
- '@typescript-eslint/types': 8.55.0
- '@typescript-eslint/visitor-keys': 8.55.0
- debug: 4.4.3
- minimatch: 9.0.5
- semver: 7.7.4
- tinyglobby: 0.2.15
- ts-api-utils: 2.4.0(typescript@5.9.3)
- typescript: 5.9.3
- transitivePeerDependencies:
- - supports-color
-
'@typescript-eslint/typescript-estree@8.56.0(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.56.0(typescript@5.9.3)
@@ -5591,22 +5573,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.55.0(eslint@8.57.1)(typescript@5.9.3)':
+ '@typescript-eslint/utils@8.56.0(eslint@8.57.1)(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1)
- '@typescript-eslint/scope-manager': 8.55.0
- '@typescript-eslint/types': 8.55.0
- '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.56.0
+ '@typescript-eslint/types': 8.56.0
+ '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3)
eslint: 8.57.1
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/visitor-keys@8.55.0':
- dependencies:
- '@typescript-eslint/types': 8.55.0
- eslint-visitor-keys: 4.2.1
-
'@typescript-eslint/visitor-keys@8.56.0':
dependencies:
'@typescript-eslint/types': 8.56.0
@@ -5681,15 +5658,6 @@ snapshots:
chai: 5.3.3
tinyrainbow: 2.0.0
- '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.11)(jiti@1.21.7)(tsx@4.21.0))':
- dependencies:
- '@vitest/spy': 3.2.4
- estree-walker: 3.0.3
- magic-string: 0.30.21
- optionalDependencies:
- vite: 7.3.1(@types/node@22.19.11)(jiti@1.21.7)(tsx@4.21.0)
- optional: true
-
'@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0))':
dependencies:
'@vitest/spy': 3.2.4
@@ -5844,13 +5812,13 @@ snapshots:
asynckit@0.4.0: {}
- autoprefixer@10.4.24(postcss@8.5.6):
+ autoprefixer@10.4.24(postcss@8.4.49):
dependencies:
browserslist: 4.28.1
- caniuse-lite: 1.0.30001769
+ caniuse-lite: 1.0.30001770
fraction.js: 5.3.4
picocolors: 1.1.1
- postcss: 8.5.6
+ postcss: 8.4.49
postcss-value-parser: 4.2.0
available-typed-arrays@1.0.7:
@@ -5919,7 +5887,7 @@ snapshots:
better-auth@1.4.18(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)))(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0)):
dependencies:
- '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
+ '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
'@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
'@better-auth/utils': 0.3.0
'@better-fetch/fetch': 1.1.21
@@ -5951,6 +5919,15 @@ snapshots:
optionalDependencies:
zod: 4.3.6
+ better-call@1.3.2(zod@4.3.6):
+ dependencies:
+ '@better-auth/utils': 0.3.1
+ '@better-fetch/fetch': 1.1.21
+ rou3: 0.7.12
+ set-cookie-parser: 3.0.1
+ optionalDependencies:
+ zod: 4.3.6
+
better-sqlite3@12.6.2:
dependencies:
bindings: 1.5.0
@@ -5976,7 +5953,7 @@ snapshots:
http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
- qs: 6.14.2
+ qs: 6.15.0
raw-body: 3.0.2
type-is: 2.0.1
transitivePeerDependencies:
@@ -5998,7 +5975,7 @@ snapshots:
browserslist@4.28.1:
dependencies:
baseline-browser-mapping: 2.9.19
- caniuse-lite: 1.0.30001769
+ caniuse-lite: 1.0.30001770
electron-to-chromium: 1.5.286
node-releases: 2.0.27
update-browserslist-db: 1.2.3(browserslist@4.28.1)
@@ -6085,7 +6062,7 @@ snapshots:
camelcase-css@2.0.1: {}
- caniuse-lite@1.0.30001769: {}
+ caniuse-lite@1.0.30001770: {}
chai@5.3.3:
dependencies:
@@ -6146,7 +6123,7 @@ snapshots:
class-validator@0.14.3:
dependencies:
'@types/validator': 13.15.10
- libphonenumber-js: 1.12.36
+ libphonenumber-js: 1.12.37
validator: 13.15.26
class-variance-authority@0.7.1:
@@ -6164,7 +6141,7 @@ snapshots:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1)
- '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
transitivePeerDependencies:
@@ -6512,7 +6489,7 @@ snapshots:
dependencies:
'@next/eslint-plugin-next': 15.5.12
'@rushstack/eslint-patch': 1.15.0
- '@typescript-eslint/eslint-plugin': 8.55.0(@typescript-eslint/parser@8.56.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
+ '@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.56.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
'@typescript-eslint/parser': 8.56.0(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
@@ -6551,7 +6528,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1))(eslint@8.57.1):
+ eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
dependencies:
debug: 3.2.7
optionalDependencies:
@@ -6573,7 +6550,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1))(eslint@8.57.1)
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -6631,7 +6608,7 @@ snapshots:
object.fromentries: 2.0.8
object.values: 1.2.1
prop-types: 15.8.1
- resolve: 2.0.0-next.5
+ resolve: 2.0.0-next.6
semver: 6.3.1
string.prototype.matchall: 4.0.12
string.prototype.repeat: 1.0.0
@@ -6643,8 +6620,6 @@ snapshots:
eslint-visitor-keys@3.4.3: {}
- eslint-visitor-keys@4.2.1: {}
-
eslint-visitor-keys@5.0.0: {}
eslint@8.57.1:
@@ -6740,7 +6715,7 @@ snapshots:
once: 1.4.0
parseurl: 1.3.3
proxy-addr: 2.0.7
- qs: 6.14.2
+ qs: 6.15.0
range-parser: 1.2.1
router: 2.2.0
send: 1.2.1
@@ -7169,7 +7144,7 @@ snapshots:
call-bound: 1.0.4
get-intrinsic: 1.3.0
- is-wsl@3.1.0:
+ is-wsl@3.1.1:
dependencies:
is-inside-container: 1.0.0
@@ -7242,7 +7217,7 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
- libphonenumber-js@1.12.36: {}
+ libphonenumber-js@1.12.37: {}
lilconfig@2.1.0: {}
@@ -7383,8 +7358,8 @@ snapshots:
dependencies:
'@next/env': 15.5.12
'@swc/helpers': 0.5.15
- caniuse-lite: 1.0.30001769
- postcss: 8.4.31
+ caniuse-lite: 1.0.30001770
+ postcss: 8.4.49
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
styled-jsx: 5.1.6(@babel/core@7.29.0)(react@18.3.1)
@@ -7408,6 +7383,13 @@ snapshots:
node-abort-controller@3.1.1: {}
+ node-exports-info@1.6.0:
+ dependencies:
+ array.prototype.flatmap: 1.3.3
+ es-errors: 1.3.0
+ object.entries: 1.1.9
+ semver: 6.3.1
+
node-fetch-native@1.6.7: {}
node-gyp-build-optional-packages@5.2.2:
@@ -7586,29 +7568,29 @@ snapshots:
possible-typed-array-names@1.1.0: {}
- postcss-import@15.1.0(postcss@8.5.6):
+ postcss-import@15.1.0(postcss@8.4.49):
dependencies:
- postcss: 8.5.6
+ postcss: 8.4.49
postcss-value-parser: 4.2.0
read-cache: 1.0.0
resolve: 1.22.11
- postcss-js@4.1.0(postcss@8.5.6):
+ postcss-js@4.1.0(postcss@8.4.49):
dependencies:
camelcase-css: 2.0.1
- postcss: 8.5.6
+ postcss: 8.4.49
- postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0):
+ postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.4.49)(tsx@4.21.0):
dependencies:
lilconfig: 3.1.3
optionalDependencies:
jiti: 1.21.7
- postcss: 8.5.6
+ postcss: 8.4.49
tsx: 4.21.0
- postcss-nested@6.2.0(postcss@8.5.6):
+ postcss-nested@6.2.0(postcss@8.4.49):
dependencies:
- postcss: 8.5.6
+ postcss: 8.4.49
postcss-selector-parser: 6.1.2
postcss-selector-parser@6.1.2:
@@ -7618,13 +7600,7 @@ snapshots:
postcss-value-parser@4.2.0: {}
- postcss@8.4.31:
- dependencies:
- nanoid: 3.3.11
- picocolors: 1.1.1
- source-map-js: 1.2.1
-
- postcss@8.5.6:
+ postcss@8.4.49:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
@@ -7693,7 +7669,7 @@ snapshots:
pure-rand@6.1.0: {}
- qs@6.14.2:
+ qs@6.15.0:
dependencies:
side-channel: 1.1.0
@@ -7817,9 +7793,12 @@ snapshots:
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
- resolve@2.0.0-next.5:
+ resolve@2.0.0-next.6:
dependencies:
+ es-errors: 1.3.0
is-core-module: 2.16.1
+ node-exports-info: 1.6.0
+ object-keys: 1.1.1
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
@@ -7940,6 +7919,8 @@ snapshots:
set-cookie-parser@2.7.2: {}
+ set-cookie-parser@3.0.1: {}
+
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -8162,7 +8143,7 @@ snapshots:
formidable: 3.5.4
methods: 1.1.2
mime: 2.6.0
- qs: 6.14.2
+ qs: 6.15.0
transitivePeerDependencies:
- supports-color
@@ -8198,11 +8179,11 @@ snapshots:
normalize-path: 3.0.0
object-hash: 3.0.0
picocolors: 1.1.1
- postcss: 8.5.6
- postcss-import: 15.1.0(postcss@8.5.6)
- postcss-js: 4.1.0(postcss@8.5.6)
- postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)
- postcss-nested: 6.2.0(postcss@8.5.6)
+ postcss: 8.4.49
+ postcss-import: 15.1.0(postcss@8.4.49)
+ postcss-js: 4.1.0(postcss@8.4.49)
+ postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.4.49)(tsx@4.21.0)
+ postcss-nested: 6.2.0(postcss@8.4.49)
postcss-selector-parser: 6.1.2
resolve: 1.22.11
sucrase: 3.35.1
@@ -8466,7 +8447,7 @@ snapshots:
esbuild: 0.27.3
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
- postcss: 8.5.6
+ postcss: 8.4.49
rollup: 4.57.1
tinyglobby: 0.2.15
optionalDependencies:
@@ -8481,7 +8462,7 @@ snapshots:
esbuild: 0.27.3
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
- postcss: 8.5.6
+ postcss: 8.4.49
rollup: 4.57.1
tinyglobby: 0.2.15
optionalDependencies:
@@ -8494,7 +8475,7 @@ snapshots:
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
- '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.11)(jiti@1.21.7)(tsx@4.21.0))
+ '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@@ -8629,7 +8610,7 @@ snapshots:
wsl-utils@0.1.0:
dependencies:
- is-wsl: 3.1.0
+ is-wsl: 3.1.1
xtend@4.0.2: {}
From 102df74dbc1e0d09104ba3fbb18779f8121b0e5b Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Wed, 18 Feb 2026 16:45:10 +0100
Subject: [PATCH 02/52] Fix early-game UX issues: form placeholders, time
visibility, completion feedback (#73)
* Initial plan
* Fix prefilled form fields, add tick countdown, improve workforce UI explanations
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* Add toast notifications for research and production completion
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* Fix TypeScript errors in toast notifications
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* Add release entry for UX improvements
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* Remove prefilled allocation percentages from workforce page initial state
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* Fix capacity delta input reset to empty string after submission
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* Apply code review feedback: improve accessibility, fix countdown boundary, deduplicate toast recipes
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* Update design guidelines documentation with new UX patterns
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
---
...218131646-fix-ux-data-visibility-issues.md | 12 ++
.../src/components/layout/tick-countdown.tsx | 53 +++++++++
apps/web/src/components/layout/top-bar.tsx | 6 +-
.../market/order-placement-card.tsx | 8 +-
.../components/production/production-page.tsx | 33 ++++--
.../src/components/research/research-page.tsx | 43 ++++++-
.../components/workforce/workforce-page.tsx | 110 +++++++++++-------
apps/web/src/lib/api-client.ts | 2 +-
docs/design/DESIGN_GUIDELINES.md | 24 ++++
9 files changed, 230 insertions(+), 61 deletions(-)
create mode 100644 .releases/unreleased/20260218131646-fix-ux-data-visibility-issues.md
create mode 100644 apps/web/src/components/layout/tick-countdown.tsx
diff --git a/.releases/unreleased/20260218131646-fix-ux-data-visibility-issues.md b/.releases/unreleased/20260218131646-fix-ux-data-visibility-issues.md
new file mode 100644
index 00000000..877f0f77
--- /dev/null
+++ b/.releases/unreleased/20260218131646-fix-ux-data-visibility-issues.md
@@ -0,0 +1,12 @@
+---
+type: minor
+area: web
+summary: Fix early UX and data visibility issues (forms, time display, workforce clarity, completion feedback)
+---
+
+- Replace prefilled form values with descriptive placeholders in market orders, production jobs, and workforce
+- Add tick countdown timer showing "Next week in Xs" with tooltip explaining time progression
+- Reduce health polling from 3s to 15s to minimize UI refresh indicator blinking
+- Add comprehensive workforce explanations (capacity impact, allocation labels, help text)
+- Add toast notifications for research and production job completions with unlocked recipe details
+- Improve overall system transparency and onboarding clarity
diff --git a/apps/web/src/components/layout/tick-countdown.tsx b/apps/web/src/components/layout/tick-countdown.tsx
new file mode 100644
index 00000000..da99c26d
--- /dev/null
+++ b/apps/web/src/components/layout/tick-countdown.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Clock } from "lucide-react";
+import { InlineHelp } from "@/components/ui/inline-help";
+import { useWorldHealth } from "./world-health-provider";
+
+// Default tick interval (60 seconds) - matches worker default configuration
+// TODO: Get this from API configuration endpoint
+const DEFAULT_TICK_INTERVAL_MS = 60_000;
+
+export function TickCountdown() {
+ const { health } = useWorldHealth();
+ const [secondsRemaining, setSecondsRemaining] = useState(null);
+
+ useEffect(() => {
+ if (!health?.lastAdvancedAt) {
+ setSecondsRemaining(null);
+ return;
+ }
+
+ const updateCountdown = () => {
+ const lastAdvancedTime = new Date(health.lastAdvancedAt!).getTime();
+ const now = Date.now();
+ const elapsed = now - lastAdvancedTime;
+ const remaining =
+ (DEFAULT_TICK_INTERVAL_MS - (elapsed % DEFAULT_TICK_INTERVAL_MS)) %
+ DEFAULT_TICK_INTERVAL_MS;
+ setSecondsRemaining(Math.ceil(remaining / 1000));
+ };
+
+ updateCountdown();
+ const interval = setInterval(updateCountdown, 1000);
+
+ return () => clearInterval(interval);
+ }, [health?.lastAdvancedAt, health?.currentTick]);
+
+ if (secondsRemaining === null) {
+ return null;
+ }
+
+ const helpText = `Time progression: The simulation advances in discrete weeks (ticks). Each week represents ${DEFAULT_TICK_INTERVAL_MS / 1000} seconds of real time. Production jobs, research, and shipments complete when the required number of weeks pass.`;
+
+ return (
+
+
+
+ Next week in {secondsRemaining}s
+
+
+
+ );
+}
diff --git a/apps/web/src/components/layout/top-bar.tsx b/apps/web/src/components/layout/top-bar.tsx
index 695cd3d3..d86e9101 100644
--- a/apps/web/src/components/layout/top-bar.tsx
+++ b/apps/web/src/components/layout/top-bar.tsx
@@ -15,6 +15,7 @@ import { useControlManager } from "./control-manager";
import { PROFILE_PANEL_ID } from "./profile-panel";
import { StatusIndicator } from "./status-indicator";
import { UiSfxSettings } from "./ui-sfx-settings";
+import { TickCountdown } from "./tick-countdown";
import { useWorldHealth } from "./world-health-provider";
export function TopBar() {
@@ -38,7 +39,10 @@ export function TopBar() {
{TOP_BAR_TITLES[pathname] ?? "CorpSim"}
-
{formatCadencePoint(health?.currentTick)}
+
+
{formatCadencePoint(health?.currentTick)}
+
+
diff --git a/apps/web/src/components/market/order-placement-card.tsx b/apps/web/src/components/market/order-placement-card.tsx
index 1fe6053d..8f74b7a8 100644
--- a/apps/web/src/components/market/order-placement-card.tsx
+++ b/apps/web/src/components/market/order-placement-card.tsx
@@ -30,8 +30,8 @@ export function OrderPlacementCard({
const [side, setSide] = useState<"BUY" | "SELL">("BUY");
const [selectedItemId, setSelectedItemId] = useState
("");
const [itemSearch, setItemSearch] = useState("");
- const [priceInput, setPriceInput] = useState("1.00");
- const [quantityInput, setQuantityInput] = useState("1");
+ const [priceInput, setPriceInput] = useState("");
+ const [quantityInput, setQuantityInput] = useState("");
const [error, setError] = useState(null);
const deferredItemSearch = useDeferredValue(itemSearch);
@@ -144,7 +144,7 @@ export function OrderPlacementCard({
setQuantityInput(event.target.value)}
- placeholder="1"
+ placeholder="Enter quantity (e.g., 100)"
/>
@@ -181,7 +181,7 @@ export function OrderPlacementCard({
setPriceInput(event.target.value)}
- placeholder="1.00"
+ placeholder="Enter price (e.g., 1.50)"
/>
Enter dollars (for example, 0.80). The order is stored in cents.
diff --git a/apps/web/src/components/production/production-page.tsx b/apps/web/src/components/production/production-page.tsx
index 943033fd..9e5e6c38 100644
--- a/apps/web/src/components/production/production-page.tsx
+++ b/apps/web/src/components/production/production-page.tsx
@@ -78,7 +78,7 @@ export function ProductionPage() {
const [recipePage, setRecipePage] = useState(1);
const [recipePageSize, setRecipePageSize] =
useState<(typeof PRODUCTION_RECIPE_PAGE_SIZE_OPTIONS)[number]>(10);
- const [quantityInput, setQuantityInput] = useState("1");
+ const [quantityInput, setQuantityInput] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [isLoadingRecipes, setIsLoadingRecipes] = useState(true);
const [isLoadingJobs, setIsLoadingJobs] = useState(true);
@@ -331,18 +331,33 @@ export function ProductionPage() {
return;
}
- let hasNewCompletion = false;
- for (const jobId of nextIds) {
- if (!completedJobIdsRef.current.has(jobId)) {
- hasNewCompletion = true;
- break;
+ const newlyCompleted: ProductionJob[] = [];
+ for (const job of completedJobs) {
+ if (!completedJobIdsRef.current.has(job.id)) {
+ newlyCompleted.push(job);
}
}
- if (hasNewCompletion) {
+ if (newlyCompleted.length > 0) {
play("event_production_completed");
+
+ // Show toast notification for completed jobs
+ if (newlyCompleted.length === 1) {
+ const job = newlyCompleted[0];
+ showToast({
+ title: "Production Complete",
+ description: `Produced ${job.quantity} × ${job.recipe.outputItem.name}`,
+ variant: "success"
+ });
+ } else {
+ showToast({
+ title: "Production Complete",
+ description: `${newlyCompleted.length} production jobs completed`,
+ variant: "success"
+ });
+ }
}
completedJobIdsRef.current = nextIds;
- }, [completedJobs, play]);
+ }, [completedJobs, play, showToast]);
const showInitialRecipesSkeleton = isLoadingRecipes && !hasLoadedRecipes;
const showInitialJobsSkeleton = isLoadingJobs && !hasLoadedJobs;
@@ -614,7 +629,7 @@ export function ProductionPage() {
setQuantityInput(event.target.value)}
- placeholder="1"
+ placeholder="Enter number of runs (e.g., 10)"
/>
diff --git a/apps/web/src/components/research/research-page.tsx b/apps/web/src/components/research/research-page.tsx
index 056a3481..5db85ee5 100644
--- a/apps/web/src/components/research/research-page.tsx
+++ b/apps/web/src/components/research/research-page.tsx
@@ -195,6 +195,8 @@ export function ResearchPage() {
didPrimeStatusesRef.current = false;
}, [activeCompanyId]);
+ const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node] as const)), [nodes]);
+
useEffect(() => {
const nextStatusById = new Map(nodes.map((node) => [node.id, node.status] as const));
if (!didPrimeStatusesRef.current) {
@@ -203,21 +205,50 @@ export function ResearchPage() {
return;
}
- let hasNewCompletion = false;
+ const completedNodes: ResearchNode[] = [];
for (const [nodeId, nextStatus] of nextStatusById.entries()) {
const previousStatus = statusByNodeIdRef.current.get(nodeId);
if (previousStatus !== "COMPLETED" && nextStatus === "COMPLETED") {
- hasNewCompletion = true;
- break;
+ const node = nodeById.get(nodeId);
+ if (node) {
+ completedNodes.push(node);
+ }
}
}
- if (hasNewCompletion) {
+ if (completedNodes.length > 0) {
play("event_research_completed");
+
+ // Show toast notification for completed research
+ const unlockedRecipes = completedNodes.flatMap((node) => node.unlockRecipes);
+ const uniqueRecipeNamesSet = new Set(
+ unlockedRecipes
+ .map((recipe) => recipe.recipeName)
+ .filter((name): name is string => Boolean(name && name.trim()))
+ );
+ const uniqueRecipeNames = Array.from(uniqueRecipeNamesSet);
+ const MAX_RECIPES_IN_TOAST = 3;
+ let message: string;
+
+ if (uniqueRecipeNames.length > 0) {
+ const displayedNames = uniqueRecipeNames.slice(0, MAX_RECIPES_IN_TOAST);
+ const remainingCount = uniqueRecipeNames.length - displayedNames.length;
+ const baseList = displayedNames.join(", ");
+ const summary =
+ remainingCount > 0 ? `${baseList} + ${remainingCount} more` : baseList;
+ message = `Research complete! Unlocked recipes: ${summary}`;
+ } else {
+ message = "Research complete!";
+ }
+
+ showToast({
+ title: completedNodes.length === 1 ? completedNodes[0].name : "Research Complete",
+ description: message,
+ variant: "success"
+ });
}
statusByNodeIdRef.current = nextStatusById;
- }, [nodes, play]);
+ }, [nodes, play, showToast, nodeById]);
- const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node] as const)), [nodes]);
const selectedNode = selectedNodeId ? nodeById.get(selectedNodeId) ?? null : null;
const filteredNodes = useMemo(() => {
diff --git a/apps/web/src/components/workforce/workforce-page.tsx b/apps/web/src/components/workforce/workforce-page.tsx
index ffe3dce0..cf3e6596 100644
--- a/apps/web/src/components/workforce/workforce-page.tsx
+++ b/apps/web/src/components/workforce/workforce-page.tsx
@@ -52,12 +52,12 @@ export function WorkforcePage() {
const { health } = useWorldHealth();
const [workforce, setWorkforce] = useState(null);
const [allocationDraft, setAllocationDraft] = useState({
- operationsPct: "40",
- researchPct: "20",
- logisticsPct: "20",
- corporatePct: "20"
+ operationsPct: "",
+ researchPct: "",
+ logisticsPct: "",
+ corporatePct: ""
});
- const [capacityDeltaInput, setCapacityDeltaInput] = useState("0");
+ const [capacityDeltaInput, setCapacityDeltaInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [hasLoadedWorkforce, setHasLoadedWorkforce] = useState(false);
const [isSavingAllocation, setIsSavingAllocation] = useState(false);
@@ -207,7 +207,7 @@ export function WorkforcePage() {
deltaCapacity
});
await loadWorkforce();
- setCapacityDeltaInput("0");
+ setCapacityDeltaInput("");
setError(null);
showToast({
title: "Capacity request submitted",
@@ -246,6 +246,9 @@ export function WorkforcePage() {
Organizational Capacity
+
+ Workforce capacity determines production speed and research efficiency. Higher capacity allows faster operations, but increases weekly salary costs. Allocation percentages control which departments receive speed bonuses.
+
@@ -278,42 +281,69 @@ export function WorkforcePage() {
Allocation Controls
+
+ Distribute your workforce across departments. Higher allocation in each area provides speed bonuses. Total must equal 100%.
+
@@ -345,7 +375,7 @@ export function WorkforcePage() {
setCapacityDeltaInput(event.target.value)}
- placeholder="Delta capacity"
+ placeholder="Enter change (e.g., +50 or -20)"
inputMode="numeric"
/>
diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts
index f477d874..6b337dea 100644
--- a/apps/web/src/lib/api-client.ts
+++ b/apps/web/src/lib/api-client.ts
@@ -1,6 +1,6 @@
import { isLocalhostHostname, isLocalhostUrl } from "./localhost-utils";
-export const HEALTH_POLL_INTERVAL_MS = 3_000;
+export const HEALTH_POLL_INTERVAL_MS = 15_000;
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL?.trim() ?? "";
diff --git a/docs/design/DESIGN_GUIDELINES.md b/docs/design/DESIGN_GUIDELINES.md
index 71069470..f8cb1d43 100644
--- a/docs/design/DESIGN_GUIDELINES.md
+++ b/docs/design/DESIGN_GUIDELINES.md
@@ -143,6 +143,30 @@ Preferred components:
Avoid custom components unless necessary.
+### Form Input Guidelines
+
+**Placeholder Text:**
+* Use descriptive placeholders with examples instead of prefilled values
+* Example: `"Enter price (e.g., 1.50)"` not a prefilled `"1.00"`
+* Empty initial state helps users understand field purpose
+* Placeholders should explain what value is expected
+
+**Labels:**
+* Add explicit labels above inputs for clarity
+* Use `htmlFor`/`id` attributes for accessibility (screen readers)
+* Labels should be independent of default values
+
+**Toast Notifications:**
+* Use for completion feedback (research complete, production finished)
+* Show actionable information (e.g., "Unlocked recipes: X, Y, Z")
+* Truncate long lists with `"+ N more"` pattern
+* Deduplicate repeated items in multi-item toasts
+
+**Time Display:**
+* Show countdown timers for time-based systems ("Next week in 42s")
+* Include tooltips explaining time units and progression
+* Update in real-time for dynamic feedback
+
---
## Data Visualization
From e6472655b3fc3b84bcf6277f95bd6bb77737ee65 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Thu, 19 Feb 2026 09:58:44 +0100
Subject: [PATCH 03/52] feat: Add infrastructure-based production foundation
(Phase 1-2) (#75)
* Initial plan
* feat(sim): add building infrastructure domain layer with Prisma schema and tests
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* chore: add release entry for building infrastructure phase 1
* feat(sim): integrate building operating costs into tick pipeline
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* fix(sim): fix stale cash bug and reserved cash invariant in building operating costs
- Fetch fresh company data for each building to avoid stale cash values when processing multiple buildings
- Use availableCash() to respect reservedCashCents when checking affordability
- Update AGENTS.md to reflect new 11-stage tick pipeline
- Add tests for reserved cash respect and multi-building scenarios
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* refactor(sim): improve building service types and documentation clarity
- Remove `any` return types, let TypeScript infer from Prisma
- Clarify which invariants are enforced vs planned in module docs
- Add note in tick-engine that production validation doesn't check building status yet (Phase 2)
- Update building service docs to separate current vs future features
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* fix(shared): add new building ledger entry types to FinanceLedgerEntryType
- Add BUILDING_OPERATING_COST and BUILDING_ACQUISITION to shared API types
- Fixes TypeScript compilation errors in finance controller and service
- All 53 tests passing
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* fix(sim): remove explicit any type in building tests to pass linter
- Replace `{} as any` with `{} as Prisma.TransactionClient`
- Fixes ESLint error: @typescript-eslint/no-explicit-any
- All tests passing, TypeScript compilation successful
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
---
...700-add-building-infrastructure-phase-1.md | 14 +
docs/agents/AGENTS.md | 19 +-
.../migration.sql | 66 +++
packages/db/prisma/schema.prisma | 531 ++++++++++--------
packages/shared/src/api-types.ts | 2 +
packages/sim/src/index.ts | 1 +
packages/sim/src/services/buildings.ts | 514 +++++++++++++++++
packages/sim/src/services/tick-engine.ts | 43 +-
packages/sim/tests/buildings.test.ts | 526 +++++++++++++++++
9 files changed, 1447 insertions(+), 269 deletions(-)
create mode 100644 .releases/unreleased/20260218155700-add-building-infrastructure-phase-1.md
create mode 100644 packages/db/prisma/migrations/20260218155056_add_building_infrastructure/migration.sql
create mode 100644 packages/sim/src/services/buildings.ts
create mode 100644 packages/sim/tests/buildings.test.ts
diff --git a/.releases/unreleased/20260218155700-add-building-infrastructure-phase-1.md b/.releases/unreleased/20260218155700-add-building-infrastructure-phase-1.md
new file mode 100644
index 00000000..590cf386
--- /dev/null
+++ b/.releases/unreleased/20260218155700-add-building-infrastructure-phase-1.md
@@ -0,0 +1,14 @@
+---
+type: minor
+area: sim
+summary: Add building infrastructure domain layer for capital-based production system
+---
+
+- Add Building model with BuildingType and BuildingStatus enums to Prisma schema
+- Add BUILDING_OPERATING_COST and BUILDING_ACQUISITION ledger entry types
+- Implement building acquisition, operating cost application, and reactivation services
+- Add production capacity tracking based on active buildings
+- Buildings have weekly operating costs (7 ticks interval)
+- Buildings deactivate when company cannot afford operating costs
+- Create comprehensive test suite (12 passing tests)
+- Prepare foundation for infrastructure-based production requirements
diff --git a/docs/agents/AGENTS.md b/docs/agents/AGENTS.md
index 800a209a..d3469db9 100644
--- a/docs/agents/AGENTS.md
+++ b/docs/agents/AGENTS.md
@@ -298,15 +298,16 @@ availableInventory = quantity - reservedQuantity
### Tick Pipeline Order (NEVER REORDER)
1. Bot actions
-2. Production completions
-3. Research completions
-4. Market matching
-5. Shipment deliveries
-6. Workforce updates
-7. Demand sink
-8. Contract lifecycle
-9. Market candles
-10. World state update
+2. Building operating costs
+3. Production completions
+4. Research completions
+5. Market matching
+6. Shipment deliveries
+7. Workforce updates
+8. Demand sink
+9. Contract lifecycle
+10. Market candles
+11. World state update
Changing this order is a **breaking change**.
diff --git a/packages/db/prisma/migrations/20260218155056_add_building_infrastructure/migration.sql b/packages/db/prisma/migrations/20260218155056_add_building_infrastructure/migration.sql
new file mode 100644
index 00000000..432d6f51
--- /dev/null
+++ b/packages/db/prisma/migrations/20260218155056_add_building_infrastructure/migration.sql
@@ -0,0 +1,66 @@
+-- CreateEnum
+CREATE TYPE "BuildingType" AS ENUM ('MINE', 'FARM', 'FACTORY', 'MEGA_FACTORY', 'WAREHOUSE', 'HEADQUARTERS', 'RND_CENTER');
+
+-- CreateEnum
+CREATE TYPE "BuildingStatus" AS ENUM ('ACTIVE', 'INACTIVE', 'CONSTRUCTION');
+
+-- AlterEnum
+-- This migration adds more than one value to an enum.
+-- With PostgreSQL versions 11 and earlier, this is not possible
+-- in a single migration. This can be worked around by creating
+-- multiple migrations, each migration adding only one value to
+-- the enum.
+
+
+ALTER TYPE "LedgerEntryType" ADD VALUE 'BUILDING_OPERATING_COST';
+ALTER TYPE "LedgerEntryType" ADD VALUE 'BUILDING_ACQUISITION';
+
+-- AlterTable
+ALTER TABLE "ProductionJob" ADD COLUMN "buildingId" TEXT;
+
+-- AlterTable
+ALTER TABLE "SimulationControlState" ALTER COLUMN "updatedAt" DROP DEFAULT;
+
+-- AlterTable
+ALTER TABLE "SimulationLease" ALTER COLUMN "updatedAt" DROP DEFAULT;
+
+-- CreateTable
+CREATE TABLE "Building" (
+ "id" TEXT NOT NULL,
+ "companyId" TEXT NOT NULL,
+ "regionId" TEXT NOT NULL,
+ "buildingType" "BuildingType" NOT NULL,
+ "status" "BuildingStatus" NOT NULL DEFAULT 'ACTIVE',
+ "name" TEXT,
+ "acquisitionCostCents" BIGINT NOT NULL,
+ "weeklyOperatingCostCents" BIGINT NOT NULL,
+ "capacitySlots" INTEGER NOT NULL DEFAULT 1,
+ "tickAcquired" INTEGER NOT NULL,
+ "tickConstructionCompletes" INTEGER,
+ "lastOperatingCostTick" INTEGER,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Building_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "Building_companyId_status_idx" ON "Building"("companyId", "status");
+
+-- CreateIndex
+CREATE INDEX "Building_regionId_buildingType_idx" ON "Building"("regionId", "buildingType");
+
+-- CreateIndex
+CREATE INDEX "Building_status_lastOperatingCostTick_idx" ON "Building"("status", "lastOperatingCostTick");
+
+-- CreateIndex
+CREATE INDEX "ProductionJob_buildingId_status_idx" ON "ProductionJob"("buildingId", "status");
+
+-- AddForeignKey
+ALTER TABLE "ProductionJob" ADD CONSTRAINT "ProductionJob_buildingId_fkey" FOREIGN KEY ("buildingId") REFERENCES "Building"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Building" ADD CONSTRAINT "Building_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Building" ADD CONSTRAINT "Building_regionId_fkey" FOREIGN KEY ("regionId") REFERENCES "Region"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index e312bea2..b640fe77 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -71,56 +71,75 @@ enum LedgerEntryType {
PRODUCTION_COST
WORKFORCE_SALARY_EXPENSE
WORKFORCE_RECRUITMENT_EXPENSE
+ BUILDING_OPERATING_COST
+ BUILDING_ACQUISITION
MANUAL_ADJUSTMENT
}
+enum BuildingType {
+ MINE
+ FARM
+ FACTORY
+ MEGA_FACTORY
+ WAREHOUSE
+ HEADQUARTERS
+ RND_CENTER
+}
+
+enum BuildingStatus {
+ ACTIVE
+ INACTIVE
+ CONSTRUCTION
+}
+
model Company {
- id String @id @default(cuid())
- code String @unique
- name String
- isPlayer Boolean @default(false)
- specialization CompanySpecialization @default(UNASSIGNED)
- specializationChangedAt DateTime?
- ownerPlayerId String?
- regionId String
- cashCents BigInt
- reservedCashCents BigInt @default(0)
- workforceCapacity Int @default(0)
- workforceAllocationOpsPct Int @default(40)
- workforceAllocationRngPct Int @default(20)
- workforceAllocationLogPct Int @default(20)
- workforceAllocationCorpPct Int @default(20)
- orgEfficiencyBps Int @default(7000)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- ownerPlayer Player? @relation(fields: [ownerPlayerId], references: [id], onDelete: SetNull)
- region Region @relation(fields: [regionId], references: [id], onDelete: Restrict)
- inventories Inventory[]
- orders MarketOrder[]
- productionJobs ProductionJob[]
- companyRecipes CompanyRecipe[]
- companyResearches CompanyResearch[]
- researchJobs ResearchJob[]
- shipments Shipment[]
- ledgerEntries LedgerEntry[]
- workforceCapacityDeltas WorkforceCapacityDelta[]
- buyContracts Contract[] @relation("ContractBuyerCompany")
- sellContracts Contract[] @relation("ContractSellerCompany")
- contractFulfillments ContractFulfillment[] @relation("ContractFulfillmentSellerCompany")
- buyTrades Trade[] @relation("TradeBuyerCompany")
- sellTrades Trade[] @relation("TradeSellerCompany")
+ id String @id @default(cuid())
+ code String @unique
+ name String
+ isPlayer Boolean @default(false)
+ specialization CompanySpecialization @default(UNASSIGNED)
+ specializationChangedAt DateTime?
+ ownerPlayerId String?
+ regionId String
+ cashCents BigInt
+ reservedCashCents BigInt @default(0)
+ workforceCapacity Int @default(0)
+ workforceAllocationOpsPct Int @default(40)
+ workforceAllocationRngPct Int @default(20)
+ workforceAllocationLogPct Int @default(20)
+ workforceAllocationCorpPct Int @default(20)
+ orgEfficiencyBps Int @default(7000)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ ownerPlayer Player? @relation(fields: [ownerPlayerId], references: [id], onDelete: SetNull)
+ region Region @relation(fields: [regionId], references: [id], onDelete: Restrict)
+ inventories Inventory[]
+ orders MarketOrder[]
+ productionJobs ProductionJob[]
+ companyRecipes CompanyRecipe[]
+ companyResearches CompanyResearch[]
+ researchJobs ResearchJob[]
+ shipments Shipment[]
+ ledgerEntries LedgerEntry[]
+ workforceCapacityDeltas WorkforceCapacityDelta[]
+ buyContracts Contract[] @relation("ContractBuyerCompany")
+ sellContracts Contract[] @relation("ContractSellerCompany")
+ contractFulfillments ContractFulfillment[] @relation("ContractFulfillmentSellerCompany")
+ buyTrades Trade[] @relation("TradeBuyerCompany")
+ sellTrades Trade[] @relation("TradeSellerCompany")
+ buildings Building[]
@@index([ownerPlayerId])
@@index([regionId])
}
model Player {
- id String @id @default(cuid())
- handle String @unique
+ id String @id @default(cuid())
+ handle String @unique
tutorialCompletedAt DateTime?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- companies Company[]
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ companies Company[]
}
model User {
@@ -148,16 +167,16 @@ model User {
}
model Session {
- id String @id
- expiresAt DateTime
- token String
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- ipAddress String?
- userAgent String?
+ id String @id
+ expiresAt DateTime
+ token String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ ipAddress String?
+ userAgent String?
impersonatedBy String?
- userId String
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ userId String
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([token])
@@index([userId])
@@ -209,48 +228,49 @@ model TwoFactor {
}
model Item {
- id String @id @default(cuid())
- code String @unique
- name String
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- inventories Inventory[]
- orders MarketOrder[]
- trades Trade[]
- candles ItemTickCandle[]
- shipments Shipment[]
- outputRecipes Recipe[] @relation("RecipeOutput")
- recipeInputs RecipeInput[]
- contracts Contract[]
+ id String @id @default(cuid())
+ code String @unique
+ name String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ inventories Inventory[]
+ orders MarketOrder[]
+ trades Trade[]
+ candles ItemTickCandle[]
+ shipments Shipment[]
+ outputRecipes Recipe[] @relation("RecipeOutput")
+ recipeInputs RecipeInput[]
+ contracts Contract[]
contractFulfillments ContractFulfillment[]
}
model Region {
- id String @id @default(cuid())
- code String @unique
- name String
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- companies Company[]
- inventories Inventory[]
- marketOrders MarketOrder[]
- trades Trade[]
- itemTickCandles ItemTickCandle[]
- shipmentsFrom Shipment[] @relation("ShipmentFromRegion")
- shipmentsTo Shipment[] @relation("ShipmentToRegion")
+ id String @id @default(cuid())
+ code String @unique
+ name String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ companies Company[]
+ inventories Inventory[]
+ marketOrders MarketOrder[]
+ trades Trade[]
+ itemTickCandles ItemTickCandle[]
+ shipmentsFrom Shipment[] @relation("ShipmentFromRegion")
+ shipmentsTo Shipment[] @relation("ShipmentToRegion")
+ buildings Building[]
}
model Inventory {
- companyId String
- itemId String
- regionId String
- quantity Int @default(0)
- reservedQuantity Int @default(0)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
- item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
- region Region @relation(fields: [regionId], references: [id], onDelete: Restrict)
+ companyId String
+ itemId String
+ regionId String
+ quantity Int @default(0)
+ reservedQuantity Int @default(0)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
+ item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
+ region Region @relation(fields: [regionId], references: [id], onDelete: Restrict)
@@id([companyId, itemId, regionId])
@@index([companyId])
@@ -259,22 +279,22 @@ model Inventory {
}
model Shipment {
- id String @id @default(cuid())
- companyId String
- fromRegionId String
- toRegionId String
- itemId String
- quantity Int
- status ShipmentStatus @default(IN_TRANSIT)
- tickCreated Int
- tickArrives Int
- tickClosed Int?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- company Company @relation(fields: [companyId], references: [id], onDelete: Restrict)
- fromRegion Region @relation("ShipmentFromRegion", fields: [fromRegionId], references: [id], onDelete: Restrict)
- toRegion Region @relation("ShipmentToRegion", fields: [toRegionId], references: [id], onDelete: Restrict)
- item Item @relation(fields: [itemId], references: [id], onDelete: Restrict)
+ id String @id @default(cuid())
+ companyId String
+ fromRegionId String
+ toRegionId String
+ itemId String
+ quantity Int
+ status ShipmentStatus @default(IN_TRANSIT)
+ tickCreated Int
+ tickArrives Int
+ tickClosed Int?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ company Company @relation(fields: [companyId], references: [id], onDelete: Restrict)
+ fromRegion Region @relation("ShipmentFromRegion", fields: [fromRegionId], references: [id], onDelete: Restrict)
+ toRegion Region @relation("ShipmentToRegion", fields: [toRegionId], references: [id], onDelete: Restrict)
+ item Item @relation(fields: [itemId], references: [id], onDelete: Restrict)
@@index([companyId, status, tickCreated])
@@index([status, tickArrives])
@@ -282,27 +302,27 @@ model Shipment {
}
model MarketOrder {
- id String @id @default(cuid())
+ id String @id @default(cuid())
companyId String
itemId String
regionId String
side OrderSide
- status OrderStatus @default(OPEN)
+ status OrderStatus @default(OPEN)
quantity Int
remainingQuantity Int
unitPriceCents BigInt
- reservedCashCents BigInt @default(0)
- reservedQuantity Int @default(0)
+ reservedCashCents BigInt @default(0)
+ reservedQuantity Int @default(0)
tickPlaced Int
tickClosed Int?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
closedAt DateTime?
- company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
- item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
- region Region @relation(fields: [regionId], references: [id], onDelete: Restrict)
- buyTrades Trade[] @relation("TradeBuyOrder")
- sellTrades Trade[] @relation("TradeSellOrder")
+ company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
+ item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
+ region Region @relation(fields: [regionId], references: [id], onDelete: Restrict)
+ buyTrades Trade[] @relation("TradeBuyOrder")
+ sellTrades Trade[] @relation("TradeSellOrder")
@@index([regionId, itemId, side, status, unitPriceCents, createdAt])
@@index([companyId, status])
@@ -310,24 +330,24 @@ model MarketOrder {
}
model Trade {
- id String @id @default(cuid())
- buyOrderId String
- sellOrderId String
- buyerCompanyId String
- sellerCompanyId String
- itemId String
- regionId String
- quantity Int
- unitPriceCents BigInt
- totalPriceCents BigInt
- tick Int
- createdAt DateTime @default(now())
- buyOrder MarketOrder @relation("TradeBuyOrder", fields: [buyOrderId], references: [id], onDelete: Cascade)
- sellOrder MarketOrder @relation("TradeSellOrder", fields: [sellOrderId], references: [id], onDelete: Cascade)
- buyerCompany Company @relation("TradeBuyerCompany", fields: [buyerCompanyId], references: [id], onDelete: Cascade)
- sellerCompany Company @relation("TradeSellerCompany", fields: [sellerCompanyId], references: [id], onDelete: Cascade)
- item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
- region Region @relation(fields: [regionId], references: [id], onDelete: Restrict)
+ id String @id @default(cuid())
+ buyOrderId String
+ sellOrderId String
+ buyerCompanyId String
+ sellerCompanyId String
+ itemId String
+ regionId String
+ quantity Int
+ unitPriceCents BigInt
+ totalPriceCents BigInt
+ tick Int
+ createdAt DateTime @default(now())
+ buyOrder MarketOrder @relation("TradeBuyOrder", fields: [buyOrderId], references: [id], onDelete: Cascade)
+ sellOrder MarketOrder @relation("TradeSellOrder", fields: [sellOrderId], references: [id], onDelete: Cascade)
+ buyerCompany Company @relation("TradeBuyerCompany", fields: [buyerCompanyId], references: [id], onDelete: Cascade)
+ sellerCompany Company @relation("TradeSellerCompany", fields: [sellerCompanyId], references: [id], onDelete: Cascade)
+ item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
+ region Region @relation(fields: [regionId], references: [id], onDelete: Restrict)
@@index([regionId, itemId, tick])
@@index([buyerCompanyId, tick])
@@ -336,110 +356,137 @@ model Trade {
}
model Recipe {
- id String @id @default(cuid())
- code String @unique
- name String
- durationTicks Int
- outputItemId String
- outputQuantity Int
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- outputItem Item @relation("RecipeOutput", fields: [outputItemId], references: [id], onDelete: Restrict)
- inputs RecipeInput[]
- jobs ProductionJob[]
- companyRecipes CompanyRecipe[]
- unlockedByNodes ResearchNodeUnlockRecipe[]
+ id String @id @default(cuid())
+ code String @unique
+ name String
+ durationTicks Int
+ outputItemId String
+ outputQuantity Int
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ outputItem Item @relation("RecipeOutput", fields: [outputItemId], references: [id], onDelete: Restrict)
+ inputs RecipeInput[]
+ jobs ProductionJob[]
+ companyRecipes CompanyRecipe[]
+ unlockedByNodes ResearchNodeUnlockRecipe[]
}
model RecipeInput {
- recipeId String
- itemId String
- quantity Int
- recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
- item Item @relation(fields: [itemId], references: [id], onDelete: Restrict)
+ recipeId String
+ itemId String
+ quantity Int
+ recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
+ item Item @relation(fields: [itemId], references: [id], onDelete: Restrict)
@@id([recipeId, itemId])
}
model ProductionJob {
- id String @id @default(cuid())
- companyId String
- recipeId String
- status ProductionJobStatus @default(QUEUED)
- runs Int @default(1)
- startedTick Int
- dueTick Int
- completedTick Int?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
- recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Restrict)
+ id String @id @default(cuid())
+ companyId String
+ recipeId String
+ buildingId String?
+ status ProductionJobStatus @default(QUEUED)
+ runs Int @default(1)
+ startedTick Int
+ dueTick Int
+ completedTick Int?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
+ recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Restrict)
+ building Building? @relation(fields: [buildingId], references: [id], onDelete: Restrict)
@@index([status, dueTick])
@@index([companyId, status])
+ @@index([buildingId, status])
+}
+
+model Building {
+ id String @id @default(cuid())
+ companyId String
+ regionId String
+ buildingType BuildingType
+ status BuildingStatus @default(ACTIVE)
+ name String?
+ acquisitionCostCents BigInt
+ weeklyOperatingCostCents BigInt
+ capacitySlots Int @default(1)
+ tickAcquired Int
+ tickConstructionCompletes Int?
+ lastOperatingCostTick Int?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
+ region Region @relation(fields: [regionId], references: [id], onDelete: Restrict)
+ productionJobs ProductionJob[]
+
+ @@index([companyId, status])
+ @@index([regionId, buildingType])
+ @@index([status, lastOperatingCostTick])
}
model CompanyRecipe {
- companyId String
- recipeId String
- isUnlocked Boolean @default(false)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
- recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
+ companyId String
+ recipeId String
+ isUnlocked Boolean @default(false)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
+ recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
@@id([companyId, recipeId])
@@index([companyId, isUnlocked])
}
model ResearchNode {
- id String @id @default(cuid())
- code String @unique
+ id String @id @default(cuid())
+ code String @unique
name String
description String
costCashCents BigInt
durationTicks Int
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
unlockRecipes ResearchNodeUnlockRecipe[]
- prerequisites ResearchPrerequisite[] @relation("ResearchNodePrerequisites")
- requiredBy ResearchPrerequisite[] @relation("ResearchNodeRequiredBy")
+ prerequisites ResearchPrerequisite[] @relation("ResearchNodePrerequisites")
+ requiredBy ResearchPrerequisite[] @relation("ResearchNodeRequiredBy")
companyResearches CompanyResearch[]
researchJobs ResearchJob[]
}
model ResearchNodeUnlockRecipe {
- nodeId String
- recipeId String
- node ResearchNode @relation(fields: [nodeId], references: [id], onDelete: Cascade)
- recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
+ nodeId String
+ recipeId String
+ node ResearchNode @relation(fields: [nodeId], references: [id], onDelete: Cascade)
+ recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
@@id([nodeId, recipeId])
@@index([recipeId])
}
model ResearchPrerequisite {
- id String @id @default(cuid())
- nodeId String
- prerequisiteNodeId String
- node ResearchNode @relation("ResearchNodePrerequisites", fields: [nodeId], references: [id], onDelete: Cascade)
- prerequisiteNode ResearchNode @relation("ResearchNodeRequiredBy", fields: [prerequisiteNodeId], references: [id], onDelete: Restrict)
+ id String @id @default(cuid())
+ nodeId String
+ prerequisiteNodeId String
+ node ResearchNode @relation("ResearchNodePrerequisites", fields: [nodeId], references: [id], onDelete: Cascade)
+ prerequisiteNode ResearchNode @relation("ResearchNodeRequiredBy", fields: [prerequisiteNodeId], references: [id], onDelete: Restrict)
@@unique([nodeId, prerequisiteNodeId])
@@index([prerequisiteNodeId])
}
model CompanyResearch {
- id String @id @default(cuid())
+ id String @id @default(cuid())
companyId String
nodeId String
status CompanyResearchStatus @default(LOCKED)
tickStarted Int?
tickCompletes Int?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
- node ResearchNode @relation(fields: [nodeId], references: [id], onDelete: Cascade)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
+ node ResearchNode @relation(fields: [nodeId], references: [id], onDelete: Cascade)
@@unique([companyId, nodeId])
@@index([companyId, status])
@@ -464,32 +511,32 @@ model ResearchJob {
}
model WorldTickState {
- id Int @id @default(1)
- currentTick Int @default(0)
- lockVersion Int @default(0)
- lastAdvancedAt DateTime?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ id Int @id @default(1)
+ currentTick Int @default(0)
+ lockVersion Int @default(0)
+ lastAdvancedAt DateTime?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
}
model Contract {
- id String @id @default(cuid())
+ id String @id @default(cuid())
buyerCompanyId String
sellerCompanyId String?
itemId String
quantity Int
remainingQuantity Int
priceCents BigInt
- status ContractStatus @default(OPEN)
+ status ContractStatus @default(OPEN)
tickCreated Int
tickExpires Int
tickAccepted Int?
tickClosed Int?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- buyerCompany Company @relation("ContractBuyerCompany", fields: [buyerCompanyId], references: [id], onDelete: Restrict)
- sellerCompany Company? @relation("ContractSellerCompany", fields: [sellerCompanyId], references: [id], onDelete: SetNull)
- item Item @relation(fields: [itemId], references: [id], onDelete: Restrict)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ buyerCompany Company @relation("ContractBuyerCompany", fields: [buyerCompanyId], references: [id], onDelete: Restrict)
+ sellerCompany Company? @relation("ContractSellerCompany", fields: [sellerCompanyId], references: [id], onDelete: SetNull)
+ item Item @relation(fields: [itemId], references: [id], onDelete: Restrict)
fulfillments ContractFulfillment[]
@@index([status, tickExpires])
@@ -498,17 +545,17 @@ model Contract {
}
model ContractFulfillment {
- id String @id @default(cuid())
+ id String @id @default(cuid())
contractId String
sellerCompanyId String
itemId String
quantity Int
priceCents BigInt
tick Int
- createdAt DateTime @default(now())
- contract Contract @relation(fields: [contractId], references: [id], onDelete: Restrict)
- sellerCompany Company @relation("ContractFulfillmentSellerCompany", fields: [sellerCompanyId], references: [id], onDelete: Restrict)
- item Item @relation(fields: [itemId], references: [id], onDelete: Restrict)
+ createdAt DateTime @default(now())
+ contract Contract @relation(fields: [contractId], references: [id], onDelete: Restrict)
+ sellerCompany Company @relation("ContractFulfillmentSellerCompany", fields: [sellerCompanyId], references: [id], onDelete: Restrict)
+ item Item @relation(fields: [itemId], references: [id], onDelete: Restrict)
@@index([contractId, tick])
@@index([sellerCompanyId, tick])
@@ -516,40 +563,40 @@ model ContractFulfillment {
}
model LedgerEntry {
- id String @id @default(cuid())
- companyId String
- tick Int
- entryType LedgerEntryType
+ id String @id @default(cuid())
+ companyId String
+ tick Int
+ entryType LedgerEntryType
/// Delta on total cashCents.
- deltaCashCents BigInt
+ deltaCashCents BigInt
/// Delta on reservedCashCents; positive reserves cash, negative releases it.
- deltaReservedCashCents BigInt @default(0)
+ deltaReservedCashCents BigInt @default(0)
/// cashCents balance after applying this entry.
- balanceAfterCents BigInt
- referenceType String
- referenceId String
- createdAt DateTime @default(now())
- company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
+ balanceAfterCents BigInt
+ referenceType String
+ referenceId String
+ createdAt DateTime @default(now())
+ company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
@@index([companyId, tick])
}
model ItemTickCandle {
- id String @id @default(cuid())
- itemId String
- regionId String
- tick Int
- openCents BigInt
- highCents BigInt
- lowCents BigInt
- closeCents BigInt
- volumeQty Int
- tradeCount Int
- vwapCents BigInt?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
- region Region @relation(fields: [regionId], references: [id], onDelete: Restrict)
+ id String @id @default(cuid())
+ itemId String
+ regionId String
+ tick Int
+ openCents BigInt
+ highCents BigInt
+ lowCents BigInt
+ closeCents BigInt
+ volumeQty Int
+ tradeCount Int
+ vwapCents BigInt?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
+ region Region @relation(fields: [regionId], references: [id], onDelete: Restrict)
@@unique([itemId, regionId, tick])
@@index([itemId, regionId, tick])
@@ -599,23 +646,23 @@ model SimulationTickExecution {
}
model SimulationControlState {
- id Int @id @default(1)
- botsPaused Boolean @default(false)
- processingStopped Boolean @default(false)
+ id Int @id @default(1)
+ botsPaused Boolean @default(false)
+ processingStopped Boolean @default(false)
lastInvariantViolationTick Int?
- lastInvariantViolationAt DateTime?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ lastInvariantViolationAt DateTime?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
}
model MissingItemLog {
- id String @id @default(cuid())
- itemCode String?
- itemName String
- context String
- source String
- metadata String?
- createdAt DateTime @default(now())
+ id String @id @default(cuid())
+ itemCode String?
+ itemName String
+ context String
+ source String
+ metadata String?
+ createdAt DateTime @default(now())
@@index([source, createdAt])
@@index([itemCode])
diff --git a/packages/shared/src/api-types.ts b/packages/shared/src/api-types.ts
index 788b1c3c..519d3227 100644
--- a/packages/shared/src/api-types.ts
+++ b/packages/shared/src/api-types.ts
@@ -453,6 +453,8 @@ export type FinanceLedgerEntryType =
| "PRODUCTION_COST"
| "WORKFORCE_SALARY_EXPENSE"
| "WORKFORCE_RECRUITMENT_EXPENSE"
+ | "BUILDING_OPERATING_COST"
+ | "BUILDING_ACQUISITION"
| "MANUAL_ADJUSTMENT";
export interface FinanceLedgerEntry {
diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts
index 5baea787..caa6c2a8 100644
--- a/packages/sim/src/index.ts
+++ b/packages/sim/src/index.ts
@@ -13,6 +13,7 @@ export * from "./services/research";
export * from "./services/contracts";
export * from "./services/regions";
export * from "./services/shipments";
+export * from "./services/buildings";
export * from "./services/reset-simulation";
export * from "./services/tick-engine";
export * from "./bots/bot-runner";
diff --git a/packages/sim/src/services/buildings.ts b/packages/sim/src/services/buildings.ts
new file mode 100644
index 00000000..09e3e679
--- /dev/null
+++ b/packages/sim/src/services/buildings.ts
@@ -0,0 +1,514 @@
+/**
+ * Building Infrastructure Service
+ *
+ * @module buildings
+ *
+ * ## Purpose
+ * Manages the lifecycle of buildings in the infrastructure-based production system.
+ * Buildings provide production capacity, storage, and corporate capabilities with
+ * mandatory operating costs. This enforces capital investment requirements and
+ * introduces fixed-cost financial risk.
+ *
+ * ## Building Lifecycle
+ * 1. **Acquisition**: Company purchases building, pays acquisition cost, building created in ACTIVE state
+ * 2. **Operation**: Weekly operating costs deducted from company cash during tick processing
+ * 3. **Deactivation**: If operating costs cannot be paid, building becomes INACTIVE, production paused
+ * 4. **Reactivation**: When cash is available, building can be manually or automatically reactivated
+ *
+ * ## Building Types and Categories
+ * - **PRODUCTION**: MINE, FARM, FACTORY, MEGA_FACTORY
+ * - Provide production job capacity
+ * - Required for production jobs
+ * - Have capacity slots limiting concurrent jobs
+ * - **STORAGE**: WAREHOUSE
+ * - Increase regional storage capacity
+ * - Prevent infinite stock scaling
+ * - Have weekly operating costs
+ * - **CORPORATE**: HEADQUARTERS, RND_CENTER
+ * - Unlock corporate-level capabilities
+ * - May provide strategic bonuses (future)
+ * - Required for advanced automation (future)
+ *
+ * ## Invariants Enforced
+ * - **No Negative Cash**: Operating costs cannot create negative balance
+ * - **Mandatory Ledger Entries**: All financial mutations write ledger entries
+ * - **Reserved Cash Respect**: Operating costs check available cash (after reservations)
+ * - **Regional Association**: Buildings tied to specific region
+ * - **Operating Cost Tracking**: lastOperatingCostTick prevents duplicate charges
+ *
+ * ## Invariants Planned (Not Yet Enforced)
+ * - **Capacity Limits**: Production jobs cannot exceed building capacity (Phase 2)
+ * - **Active Building Requirement**: Production requires ACTIVE building (Phase 2)
+ * - **Storage Limits**: Warehouse capacity limits inventory (Phase 3)
+ *
+ * ## Financial Rules
+ * - Acquisition cost paid upfront (immediate ledger entry)
+ * - Operating costs charged weekly (7 ticks)
+ * - If cash insufficient:
+ * - Building status set to INACTIVE
+ * - Production paused (no new jobs, existing jobs continue)
+ * - No silent balance mutation allowed
+ *
+ * ## Side Effects
+ * All operations are transactional:
+ * - Building creation: Deducts acquisition cost, creates building record, creates ledger entry
+ * - Operating cost application: Deducts cost OR deactivates building, creates ledger entry
+ * - Building reactivation: Updates status to ACTIVE (no cost)
+ *
+ * ## Transaction Boundaries
+ * - Each operation (acquire, deactivate, reactivate) is atomic
+ * - Operating costs applied in batch during tick processing
+ * - Rollback on any validation failure or constraint violation
+ *
+ * ## Determinism
+ * - Operating costs apply every 7 ticks deterministically
+ * - Processing order: by building ID (lexicographic)
+ * - Deactivation deterministic based on cash availability
+ *
+ * ## Error Handling
+ * - NotFoundError: Building or company doesn't exist
+ * - InsufficientFundsError: Cannot afford acquisition cost
+ * - DomainInvariantError: Validation failures (negative costs, invalid type)
+ * - All state changes are transactional; failures leave no partial state
+ */
+
+import {
+ BuildingStatus,
+ BuildingType,
+ LedgerEntryType,
+ Prisma,
+ PrismaClient
+} from "@prisma/client";
+import {
+ DomainInvariantError,
+ InsufficientFundsError,
+ NotFoundError
+} from "../domain/errors";
+import { availableCash } from "../domain/reservations";
+
+/**
+ * Input for acquiring a new building
+ */
+export interface AcquireBuildingInput {
+ companyId: string;
+ regionId: string;
+ buildingType: BuildingType;
+ acquisitionCostCents: bigint;
+ weeklyOperatingCostCents: bigint;
+ capacitySlots?: number;
+ tick: number;
+ name?: string;
+}
+
+/**
+ * Input for applying operating costs to all active buildings
+ */
+export interface ApplyBuildingOperatingCostsInput {
+ tick: number;
+}
+
+/**
+ * Result of applying operating costs
+ */
+export interface ApplyBuildingOperatingCostsResult {
+ processedCount: number;
+ deactivatedCount: number;
+ totalCostCents: bigint;
+}
+
+/**
+ * Input for reactivating an inactive building
+ */
+export interface ReactivateBuildingInput {
+ buildingId: string;
+ tick: number;
+}
+
+/**
+ * Constants
+ */
+export const BUILDING_OPERATING_COST_INTERVAL_TICKS = 7; // Weekly
+
+/**
+ * Validates building acquisition input
+ */
+function validateAcquireBuildingInput(input: AcquireBuildingInput): void {
+ if (!input.companyId) {
+ throw new DomainInvariantError("companyId is required");
+ }
+
+ if (!input.regionId) {
+ throw new DomainInvariantError("regionId is required");
+ }
+
+ if (!input.buildingType) {
+ throw new DomainInvariantError("buildingType is required");
+ }
+
+ if (input.acquisitionCostCents < 0n) {
+ throw new DomainInvariantError("acquisitionCostCents cannot be negative");
+ }
+
+ if (input.weeklyOperatingCostCents < 0n) {
+ throw new DomainInvariantError("weeklyOperatingCostCents cannot be negative");
+ }
+
+ if (input.capacitySlots !== undefined && input.capacitySlots < 1) {
+ throw new DomainInvariantError("capacitySlots must be at least 1");
+ }
+
+ if (input.tick < 0) {
+ throw new DomainInvariantError("tick must be non-negative");
+ }
+}
+
+/**
+ * Acquires a new building for a company
+ *
+ * @param tx - Prisma transaction client
+ * @param input - Building acquisition parameters
+ * @returns Created building
+ *
+ * @throws {NotFoundError} If company or region doesn't exist
+ * @throws {InsufficientFundsError} If company cannot afford acquisition cost
+ * @throws {DomainInvariantError} If validation fails
+ */
+export async function acquireBuildingWithTx(
+ tx: Prisma.TransactionClient,
+ input: AcquireBuildingInput
+) {
+ validateAcquireBuildingInput(input);
+
+ const company = await tx.company.findUnique({
+ where: { id: input.companyId },
+ select: {
+ id: true,
+ cashCents: true,
+ reservedCashCents: true
+ }
+ });
+
+ if (!company) {
+ throw new NotFoundError(`company ${input.companyId} not found`);
+ }
+
+ const region = await tx.region.findUnique({
+ where: { id: input.regionId },
+ select: { id: true }
+ });
+
+ if (!region) {
+ throw new NotFoundError(`region ${input.regionId} not found`);
+ }
+
+ const available = availableCash({
+ cashCents: company.cashCents,
+ reservedCashCents: company.reservedCashCents
+ });
+
+ if (available < input.acquisitionCostCents) {
+ throw new InsufficientFundsError(
+ `insufficient cash to acquire building: need ${input.acquisitionCostCents}, have ${available}`
+ );
+ }
+
+ const newCashCents = company.cashCents - input.acquisitionCostCents;
+
+ await tx.company.update({
+ where: { id: input.companyId },
+ data: {
+ cashCents: newCashCents
+ }
+ });
+
+ const building = await tx.building.create({
+ data: {
+ companyId: input.companyId,
+ regionId: input.regionId,
+ buildingType: input.buildingType,
+ status: BuildingStatus.ACTIVE,
+ name: input.name ?? null,
+ acquisitionCostCents: input.acquisitionCostCents,
+ weeklyOperatingCostCents: input.weeklyOperatingCostCents,
+ capacitySlots: input.capacitySlots ?? 1,
+ tickAcquired: input.tick,
+ lastOperatingCostTick: input.tick
+ }
+ });
+
+ await tx.ledgerEntry.create({
+ data: {
+ companyId: input.companyId,
+ tick: input.tick,
+ entryType: LedgerEntryType.BUILDING_ACQUISITION,
+ deltaCashCents: -input.acquisitionCostCents,
+ deltaReservedCashCents: 0n,
+ balanceAfterCents: newCashCents,
+ referenceType: "BUILDING",
+ referenceId: building.id
+ }
+ });
+
+ return building;
+}
+
+/**
+ * Applies operating costs to all active buildings for the current tick
+ *
+ * @param tx - Prisma transaction client
+ * @param input - Tick information
+ * @returns Result summary
+ *
+ * @remarks
+ * - Operating costs charged every BUILDING_OPERATING_COST_INTERVAL_TICKS (weekly)
+ * - Buildings with insufficient available cash (after reservations) are deactivated
+ * - Ledger entries created for each cost application
+ * - Processing order is deterministic (by building ID)
+ * - Fresh company data fetched for each building to avoid stale cash values
+ */
+export async function applyBuildingOperatingCostsWithTx(
+ tx: Prisma.TransactionClient,
+ input: ApplyBuildingOperatingCostsInput
+): Promise {
+ const { tick } = input;
+
+ if (tick < 0) {
+ throw new DomainInvariantError("tick must be non-negative");
+ }
+
+ // Find buildings due for operating cost (without company data to avoid stale values)
+ const dueBuildings = await tx.building.findMany({
+ where: {
+ status: BuildingStatus.ACTIVE,
+ OR: [
+ {
+ lastOperatingCostTick: null
+ },
+ {
+ lastOperatingCostTick: {
+ lte: tick - BUILDING_OPERATING_COST_INTERVAL_TICKS
+ }
+ }
+ ]
+ },
+ orderBy: {
+ id: "asc" // Deterministic processing order
+ },
+ select: {
+ id: true,
+ companyId: true,
+ weeklyOperatingCostCents: true
+ }
+ });
+
+ let processedCount = 0;
+ let deactivatedCount = 0;
+ let totalCostCents = 0n;
+
+ for (const building of dueBuildings) {
+ const operatingCost = building.weeklyOperatingCostCents;
+
+ // Fetch fresh company data to avoid stale cash values when processing multiple buildings
+ const company = await tx.company.findUniqueOrThrow({
+ where: { id: building.companyId },
+ select: {
+ id: true,
+ cashCents: true,
+ reservedCashCents: true
+ }
+ });
+
+ // Check if company has sufficient available cash (respecting reservations)
+ const available = availableCash({
+ cashCents: company.cashCents,
+ reservedCashCents: company.reservedCashCents
+ });
+
+ if (available >= operatingCost) {
+ // Company can afford operating cost
+ const newCashCents = company.cashCents - operatingCost;
+
+ await tx.company.update({
+ where: { id: building.companyId },
+ data: {
+ cashCents: newCashCents
+ }
+ });
+
+ await tx.building.update({
+ where: { id: building.id },
+ data: {
+ lastOperatingCostTick: tick
+ }
+ });
+
+ await tx.ledgerEntry.create({
+ data: {
+ companyId: building.companyId,
+ tick,
+ entryType: LedgerEntryType.BUILDING_OPERATING_COST,
+ deltaCashCents: -operatingCost,
+ deltaReservedCashCents: 0n,
+ balanceAfterCents: newCashCents,
+ referenceType: "BUILDING",
+ referenceId: building.id
+ }
+ });
+
+ totalCostCents += operatingCost;
+ processedCount++;
+ } else {
+ // Company cannot afford operating cost - deactivate building
+ await tx.building.update({
+ where: { id: building.id },
+ data: {
+ status: BuildingStatus.INACTIVE,
+ lastOperatingCostTick: tick
+ }
+ });
+
+ deactivatedCount++;
+ }
+ }
+
+ return {
+ processedCount,
+ deactivatedCount,
+ totalCostCents
+ };
+}
+
+/**
+ * Reactivates an inactive building
+ *
+ * @param tx - Prisma transaction client
+ * @param input - Building and tick information
+ * @returns Updated building
+ *
+ * @throws {NotFoundError} If building doesn't exist
+ * @throws {DomainInvariantError} If building is not inactive
+ *
+ * @remarks
+ * - No cost to reactivate
+ * - Building must be in INACTIVE status
+ * - Does not charge backdated operating costs
+ */
+export async function reactivateBuildingWithTx(
+ tx: Prisma.TransactionClient,
+ input: ReactivateBuildingInput
+) {
+ if (!input.buildingId) {
+ throw new DomainInvariantError("buildingId is required");
+ }
+
+ if (input.tick < 0) {
+ throw new DomainInvariantError("tick must be non-negative");
+ }
+
+ const building = await tx.building.findUnique({
+ where: { id: input.buildingId }
+ });
+
+ if (!building) {
+ throw new NotFoundError(`building ${input.buildingId} not found`);
+ }
+
+ if (building.status !== BuildingStatus.INACTIVE) {
+ throw new DomainInvariantError(
+ `building ${input.buildingId} is not inactive (current status: ${building.status})`
+ );
+ }
+
+ return tx.building.update({
+ where: { id: input.buildingId },
+ data: {
+ status: BuildingStatus.ACTIVE,
+ lastOperatingCostTick: input.tick
+ }
+ });
+}
+
+/**
+ * Gets buildings for a company
+ *
+ * @param tx - Prisma transaction client or client
+ * @param companyId - Company ID
+ * @returns Array of buildings
+ */
+export async function getBuildingsForCompany(
+ tx: Prisma.TransactionClient | PrismaClient,
+ companyId: string
+) {
+ return tx.building.findMany({
+ where: { companyId },
+ orderBy: [{ createdAt: "asc" }, { id: "asc" }]
+ });
+}
+
+/**
+ * Checks if a company has an active building of a specific type
+ *
+ * @param tx - Prisma transaction client or client
+ * @param companyId - Company ID
+ * @param buildingType - Building type to check
+ * @returns True if company has active building of that type
+ */
+export async function hasActiveBuildingOfType(
+ tx: Prisma.TransactionClient | PrismaClient,
+ companyId: string,
+ buildingType: BuildingType
+): Promise {
+ const count = await tx.building.count({
+ where: {
+ companyId,
+ buildingType,
+ status: BuildingStatus.ACTIVE
+ }
+ });
+
+ return count > 0;
+}
+
+/**
+ * Gets available production capacity for a company
+ *
+ * @param tx - Prisma transaction client or client
+ * @param companyId - Company ID
+ * @returns Object with total capacity and used capacity
+ */
+export async function getProductionCapacityForCompany(
+ tx: Prisma.TransactionClient | PrismaClient,
+ companyId: string
+): Promise<{ totalCapacity: number; usedCapacity: number }> {
+ const productionBuildingTypes = [
+ BuildingType.MINE,
+ BuildingType.FARM,
+ BuildingType.FACTORY,
+ BuildingType.MEGA_FACTORY
+ ];
+
+ const buildings = await tx.building.findMany({
+ where: {
+ companyId,
+ buildingType: { in: productionBuildingTypes },
+ status: BuildingStatus.ACTIVE
+ },
+ select: {
+ capacitySlots: true
+ }
+ });
+
+ const totalCapacity = buildings.reduce(
+ (sum, building) => sum + building.capacitySlots,
+ 0
+ );
+
+ const usedCapacity = await tx.productionJob.count({
+ where: {
+ companyId,
+ status: "IN_PROGRESS"
+ }
+ });
+
+ return { totalCapacity, usedCapacity };
+}
diff --git a/packages/sim/src/services/tick-engine.ts b/packages/sim/src/services/tick-engine.ts
index 7bf399d9..c10ea494 100644
--- a/packages/sim/src/services/tick-engine.ts
+++ b/packages/sim/src/services/tick-engine.ts
@@ -23,6 +23,7 @@
* ## Side Effects
* All state mutations occur within a single transaction boundary:
* - Bot actions (market orders, production starts)
+ * - Building operating costs (deduct costs, deactivate buildings)
* - Production/research completions
* - Market trades and settlements
* - Shipment deliveries
@@ -39,7 +40,7 @@
*
* ## Data Flow
* ```
- * Tick N → [Bots] → [Production] → [Research] → [Market Matching]
+ * Tick N → [Bots] → [Building Operating Costs] → [Production] → [Research] → [Market Matching]
* → [Shipments] → [Workforce] → [Demand] → [Contracts]
* → [Candles] → [World State Update] → Tick N+1
* ```
@@ -66,6 +67,7 @@ import { completeDueProductionJobs } from "./production";
import { completeDueResearchJobs } from "./research";
import { deliverDueShipmentsForTick } from "./shipments";
import { runWorkforceForTick, WorkforceRuntimeConfig } from "./workforce";
+import { applyBuildingOperatingCostsWithTx } from "./buildings";
/**
* Internal representation of world tick state for optimistic locking.
@@ -236,14 +238,16 @@ function isTickExecutionConflict(error: unknown, executionKey: string | undefine
* @remarks
* ## Pipeline Stages (Executed Sequentially)
* 1. Bot actions (market orders, production starts)
- * 2. Production job completions
- * 3. Research completions and recipe unlocks
- * 4. Market matching and settlement
- * 5. Shipment deliveries
- * 6. Workforce updates (arrivals, salaries, efficiency)
- * 7. Demand sink consumption (baseline market activity)
- * 8. Contract lifecycle (expiration and generation)
- * 9. Market candle aggregation (OHLC/VWAP/volume)
+ * 2. Building operating costs (deduct costs, deactivate unpaid buildings)
+ * - Note: Production validation doesn't yet check building status (Phase 2)
+ * 3. Production job completions
+ * 4. Research completions and recipe unlocks
+ * 5. Market matching and settlement
+ * 6. Shipment deliveries
+ * 7. Workforce updates (arrivals, salaries, efficiency)
+ * 8. Demand sink consumption (baseline market activity)
+ * 9. Contract lifecycle (expiration and generation)
+ * 10. Market candle aggregation (OHLC/VWAP/volume)
*
* ## Determinism
* - Order is fixed and must not change (breaking change if reordered)
@@ -261,19 +265,22 @@ async function runTickPipeline(
): Promise {
// Tick pipeline order:
// 1) bot actions (orders / production starts)
- // 2) production completions
- // 3) research completions and recipe unlocks
- // 4) market matching and settlement
- // 5) shipment deliveries
- // 6) workforce update (scheduled arrivals, salary ledger, efficiency)
- // 7) baseline demand sink consumption
- // 8) contract lifecycle (expire and generate)
- // 9) market candle aggregation (OHLC/VWAP/volume)
- // 10) finalize world tick state
+ // 2) building operating costs (deactivate unpaid buildings)
+ // Note: Production validation doesn't yet check building status (Phase 2)
+ // 3) production completions
+ // 4) research completions and recipe unlocks
+ // 5) market matching and settlement
+ // 6) shipment deliveries
+ // 7) workforce update (scheduled arrivals, salary ledger, efficiency)
+ // 8) baseline demand sink consumption
+ // 9) contract lifecycle (expire and generate)
+ // 10) market candle aggregation (OHLC/VWAP/volume)
+ // 11) finalize world tick state
if (options.runBots) {
await runBotsForTick(tx, nextTick, options.botConfig);
}
+ await applyBuildingOperatingCostsWithTx(tx, { tick: nextTick });
await completeDueProductionJobs(tx, nextTick);
await completeDueResearchJobs(tx, nextTick);
// Matching runs in tick processing, not in request path.
diff --git a/packages/sim/tests/buildings.test.ts b/packages/sim/tests/buildings.test.ts
new file mode 100644
index 00000000..1c05a2f1
--- /dev/null
+++ b/packages/sim/tests/buildings.test.ts
@@ -0,0 +1,526 @@
+import { BuildingStatus, BuildingType, Prisma } from "@prisma/client";
+import { describe, expect, it, vi } from "vitest";
+import {
+ DomainInvariantError,
+ InsufficientFundsError,
+ NotFoundError,
+ acquireBuildingWithTx,
+ applyBuildingOperatingCostsWithTx,
+ reactivateBuildingWithTx,
+ getProductionCapacityForCompany,
+ BUILDING_OPERATING_COST_INTERVAL_TICKS
+} from "../src";
+
+describe("building service", () => {
+ describe("acquireBuildingWithTx", () => {
+ it("validates input parameters", async () => {
+ const tx = {} as Prisma.TransactionClient;
+
+ await expect(
+ acquireBuildingWithTx(tx, {
+ companyId: "",
+ regionId: "region-1",
+ buildingType: BuildingType.FACTORY,
+ acquisitionCostCents: 10000n,
+ weeklyOperatingCostCents: 500n,
+ tick: 10
+ })
+ ).rejects.toThrow(DomainInvariantError);
+
+ await expect(
+ acquireBuildingWithTx(tx, {
+ companyId: "company-1",
+ regionId: "region-1",
+ buildingType: BuildingType.FACTORY,
+ acquisitionCostCents: -100n,
+ weeklyOperatingCostCents: 500n,
+ tick: 10
+ })
+ ).rejects.toThrow(DomainInvariantError);
+
+ await expect(
+ acquireBuildingWithTx(tx, {
+ companyId: "company-1",
+ regionId: "region-1",
+ buildingType: BuildingType.FACTORY,
+ acquisitionCostCents: 10000n,
+ weeklyOperatingCostCents: -500n,
+ tick: 10
+ })
+ ).rejects.toThrow(DomainInvariantError);
+
+ await expect(
+ acquireBuildingWithTx(tx, {
+ companyId: "company-1",
+ regionId: "region-1",
+ buildingType: BuildingType.FACTORY,
+ acquisitionCostCents: 10000n,
+ weeklyOperatingCostCents: 500n,
+ capacitySlots: 0,
+ tick: 10
+ })
+ ).rejects.toThrow(DomainInvariantError);
+ });
+
+ it("throws NotFoundError if company doesn't exist", async () => {
+ const tx = {
+ company: {
+ findUnique: vi.fn().mockResolvedValue(null)
+ },
+ building: {},
+ region: {}
+ } as unknown as Prisma.TransactionClient;
+
+ await expect(
+ acquireBuildingWithTx(tx, {
+ companyId: "nonexistent",
+ regionId: "region-1",
+ buildingType: BuildingType.FACTORY,
+ acquisitionCostCents: 10000n,
+ weeklyOperatingCostCents: 500n,
+ tick: 10
+ })
+ ).rejects.toThrow(NotFoundError);
+ });
+
+ it("throws InsufficientFundsError if company cannot afford acquisition cost", async () => {
+ const tx = {
+ company: {
+ findUnique: vi.fn().mockResolvedValue({
+ id: "company-1",
+ cashCents: 5000n,
+ reservedCashCents: 1000n
+ })
+ },
+ region: {
+ findUnique: vi.fn().mockResolvedValue({ id: "region-1" })
+ }
+ } as unknown as Prisma.TransactionClient;
+
+ await expect(
+ acquireBuildingWithTx(tx, {
+ companyId: "company-1",
+ regionId: "region-1",
+ buildingType: BuildingType.FACTORY,
+ acquisitionCostCents: 10000n,
+ weeklyOperatingCostCents: 500n,
+ tick: 10
+ })
+ ).rejects.toThrow(InsufficientFundsError);
+ });
+
+ it("creates building and deducts acquisition cost with ledger entry", async () => {
+ const companyUpdate = vi.fn().mockResolvedValue(null);
+ const buildingCreate = vi.fn().mockResolvedValue({
+ id: "building-1",
+ companyId: "company-1",
+ regionId: "region-1",
+ buildingType: BuildingType.FACTORY,
+ status: BuildingStatus.ACTIVE,
+ acquisitionCostCents: 10000n,
+ weeklyOperatingCostCents: 500n,
+ capacitySlots: 5,
+ tickAcquired: 10,
+ lastOperatingCostTick: 10
+ });
+ const ledgerCreate = vi.fn().mockResolvedValue(null);
+
+ const tx = {
+ company: {
+ findUnique: vi.fn().mockResolvedValue({
+ id: "company-1",
+ cashCents: 15000n,
+ reservedCashCents: 0n
+ }),
+ update: companyUpdate
+ },
+ region: {
+ findUnique: vi.fn().mockResolvedValue({ id: "region-1" })
+ },
+ building: {
+ create: buildingCreate
+ },
+ ledgerEntry: {
+ create: ledgerCreate
+ }
+ } as unknown as Prisma.TransactionClient;
+
+ const building = await acquireBuildingWithTx(tx, {
+ companyId: "company-1",
+ regionId: "region-1",
+ buildingType: BuildingType.FACTORY,
+ acquisitionCostCents: 10000n,
+ weeklyOperatingCostCents: 500n,
+ capacitySlots: 5,
+ tick: 10
+ });
+
+ expect(building.id).toBe("building-1");
+ expect(companyUpdate).toHaveBeenCalledWith({
+ where: { id: "company-1" },
+ data: { cashCents: 5000n }
+ });
+
+ expect(buildingCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ companyId: "company-1",
+ regionId: "region-1",
+ buildingType: BuildingType.FACTORY,
+ status: BuildingStatus.ACTIVE,
+ acquisitionCostCents: 10000n,
+ weeklyOperatingCostCents: 500n,
+ capacitySlots: 5,
+ tickAcquired: 10,
+ lastOperatingCostTick: 10
+ })
+ });
+
+ expect(ledgerCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ companyId: "company-1",
+ tick: 10,
+ entryType: "BUILDING_ACQUISITION",
+ deltaCashCents: -10000n,
+ balanceAfterCents: 5000n,
+ referenceType: "BUILDING",
+ referenceId: "building-1"
+ })
+ });
+ });
+ });
+
+ describe("applyBuildingOperatingCostsWithTx", () => {
+ it("applies operating costs to buildings due for payment", async () => {
+ const companyUpdate = vi.fn().mockResolvedValue(null);
+ const buildingUpdate = vi.fn().mockResolvedValue(null);
+ const ledgerCreate = vi.fn().mockResolvedValue(null);
+
+ const tx = {
+ building: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: "building-1",
+ companyId: "company-1",
+ weeklyOperatingCostCents: 500n
+ },
+ {
+ id: "building-2",
+ companyId: "company-2",
+ weeklyOperatingCostCents: 300n
+ }
+ ]),
+ update: buildingUpdate
+ },
+ company: {
+ findUniqueOrThrow: vi
+ .fn()
+ .mockResolvedValueOnce({
+ id: "company-1",
+ cashCents: 10000n,
+ reservedCashCents: 0n
+ })
+ .mockResolvedValueOnce({
+ id: "company-2",
+ cashCents: 5000n,
+ reservedCashCents: 0n
+ }),
+ update: companyUpdate
+ },
+ ledgerEntry: {
+ create: ledgerCreate
+ }
+ } as unknown as Prisma.TransactionClient;
+
+ const currentTick = 10 + BUILDING_OPERATING_COST_INTERVAL_TICKS;
+ const result = await applyBuildingOperatingCostsWithTx(tx, {
+ tick: currentTick
+ });
+
+ expect(result.processedCount).toBe(2);
+ expect(result.deactivatedCount).toBe(0);
+ expect(result.totalCostCents).toBe(800n);
+
+ expect(companyUpdate).toHaveBeenCalledTimes(2);
+ expect(buildingUpdate).toHaveBeenCalledTimes(2);
+ expect(ledgerCreate).toHaveBeenCalledTimes(2);
+ });
+
+ it("deactivates buildings when company cannot afford operating cost", async () => {
+ const buildingUpdate = vi.fn().mockResolvedValue(null);
+
+ const tx = {
+ building: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: "building-1",
+ companyId: "company-1",
+ weeklyOperatingCostCents: 500n
+ }
+ ]),
+ update: buildingUpdate
+ },
+ company: {
+ findUniqueOrThrow: vi.fn().mockResolvedValue({
+ id: "company-1",
+ cashCents: 100n, // Insufficient
+ reservedCashCents: 0n
+ }),
+ update: vi.fn()
+ },
+ ledgerEntry: {
+ create: vi.fn()
+ }
+ } as unknown as Prisma.TransactionClient;
+
+ const currentTick = 10 + BUILDING_OPERATING_COST_INTERVAL_TICKS;
+ const result = await applyBuildingOperatingCostsWithTx(tx, {
+ tick: currentTick
+ });
+
+ expect(result.processedCount).toBe(0);
+ expect(result.deactivatedCount).toBe(1);
+ expect(result.totalCostCents).toBe(0n);
+
+ expect(buildingUpdate).toHaveBeenCalledWith({
+ where: { id: "building-1" },
+ data: {
+ status: BuildingStatus.INACTIVE,
+ lastOperatingCostTick: currentTick
+ }
+ });
+ });
+
+ it("skips buildings not yet due for operating cost", async () => {
+ const tx = {
+ building: {
+ findMany: vi.fn().mockResolvedValue([])
+ }
+ } as unknown as Prisma.TransactionClient;
+
+ const result = await applyBuildingOperatingCostsWithTx(tx, {
+ tick: 10
+ });
+
+ expect(result.processedCount).toBe(0);
+ expect(result.deactivatedCount).toBe(0);
+ expect(result.totalCostCents).toBe(0n);
+ });
+
+ it("respects reserved cash when checking affordability", async () => {
+ const buildingUpdate = vi.fn().mockResolvedValue(null);
+
+ const tx = {
+ building: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: "building-1",
+ companyId: "company-1",
+ weeklyOperatingCostCents: 500n
+ }
+ ]),
+ update: buildingUpdate
+ },
+ company: {
+ findUniqueOrThrow: vi.fn().mockResolvedValue({
+ id: "company-1",
+ cashCents: 600n, // Enough total cash
+ reservedCashCents: 200n // But only 400 available
+ }),
+ update: vi.fn()
+ },
+ ledgerEntry: {
+ create: vi.fn()
+ }
+ } as unknown as Prisma.TransactionClient;
+
+ const currentTick = 10 + BUILDING_OPERATING_COST_INTERVAL_TICKS;
+ const result = await applyBuildingOperatingCostsWithTx(tx, {
+ tick: currentTick
+ });
+
+ // Should deactivate because available cash (400) < operating cost (500)
+ expect(result.processedCount).toBe(0);
+ expect(result.deactivatedCount).toBe(1);
+ expect(buildingUpdate).toHaveBeenCalledWith({
+ where: { id: "building-1" },
+ data: {
+ status: BuildingStatus.INACTIVE,
+ lastOperatingCostTick: currentTick
+ }
+ });
+ });
+
+ it("handles multiple buildings for same company with fresh cash values", async () => {
+ const companyUpdate = vi.fn().mockResolvedValue(null);
+ const buildingUpdate = vi.fn().mockResolvedValue(null);
+ const ledgerCreate = vi.fn().mockResolvedValue(null);
+
+ const tx = {
+ building: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: "building-1",
+ companyId: "company-1",
+ weeklyOperatingCostCents: 300n
+ },
+ {
+ id: "building-2",
+ companyId: "company-1", // Same company
+ weeklyOperatingCostCents: 200n
+ }
+ ]),
+ update: buildingUpdate
+ },
+ company: {
+ findUniqueOrThrow: vi
+ .fn()
+ // First building: company has 1000
+ .mockResolvedValueOnce({
+ id: "company-1",
+ cashCents: 1000n,
+ reservedCashCents: 0n
+ })
+ // Second building: company now has 700 (after first deduction)
+ .mockResolvedValueOnce({
+ id: "company-1",
+ cashCents: 700n,
+ reservedCashCents: 0n
+ }),
+ update: companyUpdate
+ },
+ ledgerEntry: {
+ create: ledgerCreate
+ }
+ } as unknown as Prisma.TransactionClient;
+
+ const currentTick = 10 + BUILDING_OPERATING_COST_INTERVAL_TICKS;
+ const result = await applyBuildingOperatingCostsWithTx(tx, {
+ tick: currentTick
+ });
+
+ // Both buildings should be processed with correct cash values
+ expect(result.processedCount).toBe(2);
+ expect(result.deactivatedCount).toBe(0);
+ expect(result.totalCostCents).toBe(500n);
+
+ // Verify company.findUniqueOrThrow was called twice (once per building)
+ expect(tx.company.findUniqueOrThrow).toHaveBeenCalledTimes(2);
+
+ // First update: 1000 - 300 = 700
+ expect(companyUpdate).toHaveBeenNthCalledWith(1, {
+ where: { id: "company-1" },
+ data: { cashCents: 700n }
+ });
+
+ // Second update: 700 - 200 = 500
+ expect(companyUpdate).toHaveBeenNthCalledWith(2, {
+ where: { id: "company-1" },
+ data: { cashCents: 500n }
+ });
+ });
+ });
+
+ describe("reactivateBuildingWithTx", () => {
+ it("reactivates an inactive building", async () => {
+ const buildingUpdate = vi.fn().mockResolvedValue({
+ id: "building-1",
+ status: BuildingStatus.ACTIVE
+ });
+
+ const tx = {
+ building: {
+ findUnique: vi.fn().mockResolvedValue({
+ id: "building-1",
+ status: BuildingStatus.INACTIVE
+ }),
+ update: buildingUpdate
+ }
+ } as unknown as Prisma.TransactionClient;
+
+ const result = await reactivateBuildingWithTx(tx, {
+ buildingId: "building-1",
+ tick: 20
+ });
+
+ expect(result.status).toBe(BuildingStatus.ACTIVE);
+ expect(buildingUpdate).toHaveBeenCalledWith({
+ where: { id: "building-1" },
+ data: {
+ status: BuildingStatus.ACTIVE,
+ lastOperatingCostTick: 20
+ }
+ });
+ });
+
+ it("throws error if building is not inactive", async () => {
+ const tx = {
+ building: {
+ findUnique: vi.fn().mockResolvedValue({
+ id: "building-1",
+ status: BuildingStatus.ACTIVE
+ })
+ }
+ } as unknown as Prisma.TransactionClient;
+
+ await expect(
+ reactivateBuildingWithTx(tx, {
+ buildingId: "building-1",
+ tick: 20
+ })
+ ).rejects.toThrow(DomainInvariantError);
+ });
+
+ it("throws NotFoundError if building doesn't exist", async () => {
+ const tx = {
+ building: {
+ findUnique: vi.fn().mockResolvedValue(null)
+ }
+ } as unknown as Prisma.TransactionClient;
+
+ await expect(
+ reactivateBuildingWithTx(tx, {
+ buildingId: "nonexistent",
+ tick: 20
+ })
+ ).rejects.toThrow(NotFoundError);
+ });
+ });
+
+ describe("getProductionCapacityForCompany", () => {
+ it("calculates total and used production capacity", async () => {
+ const tx = {
+ building: {
+ findMany: vi.fn().mockResolvedValue([
+ { capacitySlots: 5 },
+ { capacitySlots: 3 },
+ { capacitySlots: 10 }
+ ])
+ },
+ productionJob: {
+ count: vi.fn().mockResolvedValue(7)
+ }
+ } as unknown as Prisma.TransactionClient;
+
+ const result = await getProductionCapacityForCompany(tx, "company-1");
+
+ expect(result.totalCapacity).toBe(18);
+ expect(result.usedCapacity).toBe(7);
+ });
+
+ it("returns zero capacity when no buildings exist", async () => {
+ const tx = {
+ building: {
+ findMany: vi.fn().mockResolvedValue([])
+ },
+ productionJob: {
+ count: vi.fn().mockResolvedValue(0)
+ }
+ } as unknown as Prisma.TransactionClient;
+
+ const result = await getProductionCapacityForCompany(tx, "company-1");
+
+ expect(result.totalCapacity).toBe(0);
+ expect(result.usedCapacity).toBe(0);
+ });
+ });
+});
From 878f01c111ce911458fab94deddca28efc4b9225 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Thu, 19 Feb 2026 10:41:22 +0100
Subject: [PATCH 04/52] Fix OAuth redirect_uri mismatch between better-auth and
provider configuration (#77)
* Initial plan
* feat: Add nginx proxy rule for OAuth callbacks on web domain
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* docs: Update deployment docs and env example for OAuth callback configuration
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* docs: Add deployment guide for OAuth callback redirect URL fix
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* fix: Address PR review feedback - update release area, nginx comments, and use RFC 5737 IP ranges
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
---
.env.example | 3 +
...219091824-fix-github-oauth-redirect-url.md | 10 ++
docs/project/DOKPLOY_DOCKERFILE.md | 24 +++-
docs/project/OAUTH_CALLBACK_FIX.md | 116 ++++++++++++++++++
docs/project/corpsim.altitude.nginx.conf | 22 +++-
5 files changed, 167 insertions(+), 8 deletions(-)
create mode 100644 .releases/unreleased/20260219091824-fix-github-oauth-redirect-url.md
create mode 100644 docs/project/OAUTH_CALLBACK_FIX.md
diff --git a/.env.example b/.env.example
index adb9dcee..7404303c 100644
--- a/.env.example
+++ b/.env.example
@@ -43,6 +43,9 @@ JWT_SECRET=change_me_dev_only
SESSION_SECRET=change_me_dev_only
BETTER_AUTH_SECRET=replace_with_at_least_32_random_chars
ADMIN_PASSWORD=
+# BETTER_AUTH_URL: Base URL for Better Auth endpoints and OAuth callbacks
+# In production, set this to the main web domain (e.g., https://corpsim.altitude-interactive.com)
+# NOT the API subdomain. The nginx proxy will forward /api/auth/* to the API server.
BETTER_AUTH_URL=http://localhost:4310
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
diff --git a/.releases/unreleased/20260219091824-fix-github-oauth-redirect-url.md b/.releases/unreleased/20260219091824-fix-github-oauth-redirect-url.md
new file mode 100644
index 00000000..62f24ac6
--- /dev/null
+++ b/.releases/unreleased/20260219091824-fix-github-oauth-redirect-url.md
@@ -0,0 +1,10 @@
+---
+type: patch
+area: web
+summary: Fix OAuth callback redirect URLs for GitHub, Microsoft, and Discord by configuring nginx proxy and BETTER_AUTH_URL
+---
+
+- Updated nginx configuration to proxy `/api/auth/*` requests from web domain to API server
+- Added documentation for configuring `BETTER_AUTH_URL` to use the main web domain in production
+- Fixed OAuth callback URL issues that caused "redirect_uri is not associated with this application" errors
+- Ensures all OAuth providers (GitHub, Microsoft, Discord) redirect to the correct domain
diff --git a/docs/project/DOKPLOY_DOCKERFILE.md b/docs/project/DOKPLOY_DOCKERFILE.md
index 936640d0..adeed8af 100644
--- a/docs/project/DOKPLOY_DOCKERFILE.md
+++ b/docs/project/DOKPLOY_DOCKERFILE.md
@@ -38,6 +38,8 @@ Create separate Dokploy apps from the same repository and Dockerfile:
- `API_PORT=4310`
- expose/public port `4310`
- `CORS_ORIGIN=https://corpsim.altitude-interactive.com`
+ - `BETTER_AUTH_URL=https://corpsim.altitude-interactive.com`
+ - ⚠️ **IMPORTANT**: Set `BETTER_AUTH_URL` to the main web domain (not the API subdomain) so OAuth callbacks redirect to the correct domain
3. `corpsim-worker`
- `APP_ROLE=worker`
@@ -89,11 +91,29 @@ pnpm sim:seed
## Nginx upstream mapping
-- `corpsim.altitude-interactive.com` -> `10.7.0.3:4311`
-- `corpsim-api.altitude-interactive.com` -> `10.7.0.3:4310`
+- `corpsim.altitude-interactive.com` -> `192.0.2.10:4311` (web app)
+- `corpsim-api.altitude-interactive.com` -> `192.0.2.10:4310` (API server)
+
+**IMPORTANT**: The nginx configuration for `corpsim.altitude-interactive.com` must proxy `/api/auth/*` requests to the API server at port 4310. This allows OAuth callbacks to work correctly on the main web domain. See `docs/project/corpsim.altitude.nginx.conf` for the complete configuration.
+
+**Note**: Replace the example IP address `192.0.2.10` (from RFC 5737 documentation range) with your actual server IP address.
## Troubleshooting
+### OAuth failing with "redirect_uri is not associated with this application"
+
+**Symptom:** When clicking "Continue with GitHub" (or other OAuth providers), you get:
+- Error from OAuth provider: "The redirect_uri is not associated with this application"
+- Failed OAuth authentication
+
+**Cause:** The `BETTER_AUTH_URL` environment variable for the API service is not configured correctly, or the nginx proxy is not set up to forward `/api/auth/*` requests from the web domain to the API server.
+
+**Solution:**
+1. Ensure `BETTER_AUTH_URL=https://corpsim.altitude-interactive.com` is set for the API service (use the main web domain, NOT the API subdomain)
+2. Verify the nginx configuration includes a proxy rule for `/api/auth/*` (see `docs/project/corpsim.altitude.nginx.conf`)
+3. Restart the API service and nginx after making changes
+4. Configure OAuth apps (GitHub, Google, Microsoft, Discord) to allow callback URLs like `https://corpsim.altitude-interactive.com/api/auth/callback/{provider}`
+
### Authentication failing with "Provider not found" or 404 errors
**Symptom:** When clicking "Continue with Google" (or other OAuth providers), you get:
diff --git a/docs/project/OAUTH_CALLBACK_FIX.md b/docs/project/OAUTH_CALLBACK_FIX.md
new file mode 100644
index 00000000..dc8f57ba
--- /dev/null
+++ b/docs/project/OAUTH_CALLBACK_FIX.md
@@ -0,0 +1,116 @@
+# OAuth Callback Redirect URL Fix - Deployment Guide
+
+## Problem
+When users try to sign in/sign up with GitHub (or other OAuth providers) on production at `https://corpsim.altitude-interactive.com`, they receive an error:
+
+> "The redirect_uri is not associated with this application."
+
+## Root Cause
+The OAuth flow in better-auth works as follows:
+1. User clicks "Continue with GitHub" on the web app
+2. Browser sends request to API server (via `NEXT_PUBLIC_API_URL`)
+3. API server redirects to GitHub with a `redirect_uri` parameter
+4. The `redirect_uri` is constructed from `BETTER_AUTH_URL` environment variable
+5. GitHub redirects back to that `redirect_uri` after user authorization
+6. API server processes the callback
+
+The issue occurs because:
+- The GitHub OAuth App is configured to allow callbacks to `https://corpsim.altitude-interactive.com/api/auth/callback/github`
+- But `BETTER_AUTH_URL` is set to `https://corpsim-api.altitude-interactive.com`
+- This causes GitHub to reject the callback URL as unauthorized
+
+## Solution
+To fix this issue, we need to:
+
+### 1. Update Nginx Configuration
+Apply the updated `docs/project/corpsim.altitude.nginx.conf` to the production nginx server:
+
+```nginx
+server {
+ listen 443 ssl http2;
+ listen [::]:443 ssl http2;
+ server_name corpsim.altitude-interactive.com;
+
+ ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
+
+ # Proxy Better Auth routes (/api/auth/*) to API server
+ location /api/auth/ {
+ proxy_pass http://192.0.2.10:4310;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ location / {
+ proxy_pass http://192.0.2.10:4311;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+```
+
+This adds a specific proxy rule for `/api/auth/*` requests that forwards them to the API server. Replace the example IP addresses and SSL certificate paths with your production values.
+
+### 2. Update API Service Environment Variable
+In Dokploy (or your deployment platform), update the `corpsim-api` service environment variables:
+
+Add:
+```
+BETTER_AUTH_URL=https://corpsim.altitude-interactive.com
+```
+
+This tells better-auth to construct OAuth callback URLs using the main web domain instead of the API subdomain.
+
+### 3. Reload/Restart Services
+1. Reload nginx configuration: `sudo nginx -s reload`
+2. Restart the API service in Dokploy (or redeploy with the new environment variable)
+
+### 4. Update OAuth Provider Configurations
+Ensure the OAuth apps on each provider's developer portal have the correct callback URL registered:
+
+**GitHub**: https://github.com/settings/developers
+- Callback URL: `https://corpsim.altitude-interactive.com/api/auth/callback/github`
+
+**Google**: https://console.cloud.google.com/apis/credentials
+- Authorized redirect URI: `https://corpsim.altitude-interactive.com/api/auth/callback/google`
+
+**Microsoft**: https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps
+- Redirect URI: `https://corpsim.altitude-interactive.com/api/auth/callback/microsoft`
+
+**Discord**: https://discord.com/developers/applications
+- Redirect URI: `https://corpsim.altitude-interactive.com/api/auth/callback/discord`
+
+## How It Works After the Fix
+1. User clicks "Continue with GitHub" on `https://corpsim.altitude-interactive.com`
+2. Browser calls `https://corpsim-api.altitude-interactive.com/api/auth/sign-in/social`
+3. API server (with `BETTER_AUTH_URL=https://corpsim.altitude-interactive.com`) redirects to GitHub with `redirect_uri=https://corpsim.altitude-interactive.com/api/auth/callback/github`
+4. User authorizes on GitHub
+5. GitHub redirects to `https://corpsim.altitude-interactive.com/api/auth/callback/github`
+6. Nginx proxies this request to the API server at port 4310
+7. API server processes the callback and redirects to success page
+
+## Testing
+After applying the fix:
+1. Go to `https://corpsim.altitude-interactive.com`
+2. Navigate to `/sign-in` or `/sign-up`
+3. Click "Continue with GitHub" (or another OAuth provider)
+4. Verify you're redirected to the provider's authorization page
+5. Authorize the application
+6. Verify you're redirected back to the app successfully
+7. Check browser network tab to confirm callbacks go to the correct domain
+
+## Alternative Solution (Not Recommended)
+Instead of updating nginx and environment variables, you could update each OAuth provider's configuration to allow callbacks to both domains:
+- `https://corpsim.altitude-interactive.com/api/auth/callback/{provider}`
+- `https://corpsim-api.altitude-interactive.com/api/auth/callback/{provider}`
+
+However, this is not recommended because:
+- It's confusing to have callbacks on two different domains
+- It's harder to manage and update OAuth provider configurations
+- Users might see inconsistent domain behavior
diff --git a/docs/project/corpsim.altitude.nginx.conf b/docs/project/corpsim.altitude.nginx.conf
index d3dd68d6..764249b2 100644
--- a/docs/project/corpsim.altitude.nginx.conf
+++ b/docs/project/corpsim.altitude.nginx.conf
@@ -17,11 +17,21 @@ server {
listen [::]:443 ssl http2;
server_name corpsim.altitude-interactive.com;
- ssl_certificate /etc/ssl/cloudflare/altitude_pubkey.pem;
- ssl_certificate_key /etc/ssl/cloudflare/altitude_privkey.key;
+ ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
+
+ # Proxy Better Auth routes (/api/auth/*) to API server
+ location /api/auth/ {
+ proxy_pass http://192.0.2.10:4310;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
location / {
- proxy_pass http://10.7.0.3:4311;
+ proxy_pass http://192.0.2.10:4311;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -35,11 +45,11 @@ server {
listen [::]:443 ssl http2;
server_name corpsim-api.altitude-interactive.com;
- ssl_certificate /etc/ssl/cloudflare/altitude_pubkey.pem;
- ssl_certificate_key /etc/ssl/cloudflare/altitude_privkey.key;
+ ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
- proxy_pass http://10.7.0.3:4310;
+ proxy_pass http://192.0.2.10:4310;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
From 360f8f34504182c85ec96d4c2407b617fd9b83e5 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Thu, 19 Feb 2026 12:15:18 +0100
Subject: [PATCH 05/52] Phase 3: Enforce storage capacity limits and production
building requirements (#78)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Initial plan
* feat: implement storage capacity system and building validation
- Add BASE_STORAGE_CAPACITY_PER_REGION and WAREHOUSE_CAPACITY_PER_SLOT constants
- Implement calculateRegionalStorageCapacity function
- Implement validateStorageCapacity function to check inventory limits
- Integrate storage validation in production completions
- Integrate storage validation in market settlement
- Integrate storage validation in shipment deliveries
- Add validateProductionBuildingAvailable function
- Integrate building validation in production job creation
- Update tick-engine JSDoc with Phase 3 validation notes
- Add comprehensive tests for storage capacity system
- Add tests for production building validation
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* fix: remove unused imports in storage.test.ts
* fix: resolve storage validation race conditions and bot production
- Move storage validation BEFORE inventory mutations in production
- Move storage validation BEFORE inventory mutations in market settlement
- Only validate production buildings for player companies (bots exempt)
- Prevents batch processing race condition where multiple operations in same tick could exceed capacity
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* fix: account for net inventory change in production storage validation
- Calculate net inventory change (outputs - inputs consumed)
- Only validate storage if net change is positive
- Update documentation to accurately reflect validation behavior
- Addresses bot reviewer feedback on production capacity logic
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* fix: skip storage validation for self-trades in market settlement
- Self-trades (same company, region, and item) have net zero inventory change
- Skip storage capacity validation when buyer and seller are the same entity
- Prevents false positive rejections when company at capacity trades with itself
- Addresses bot reviewer feedback on self-trade edge case
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* fix: add warehouse to market-candles test to ensure storage capacity
- Test was failing because buyer company lacked sufficient storage capacity
- Added warehouse to buyer company in test setup
- Warehouse increases capacity by 500 units (base 1000 + warehouse 500 = 1500 total)
- Ensures trade settlement doesn't hit storage limit validation
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* fix: add production building and warehouse to seeded player company
- Player company now gets a FACTORY (5 capacity slots) for production jobs
- Player company now gets a WAREHOUSE (500 capacity) to handle 1088 units of starting inventory
- Total capacity: 1000 (base) + 500 (warehouse) = 1500 units
- Removes need for per-test warehouse setup in market-candles test
- Fixes all integration tests that rely on player company having buildings
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* refactor: reduce starting inventory and remove free warehouse
- Reduced player starting inventory from 1088 to 885 units (fits in base 1000 capacity)
- Removed free warehouse to avoid giving players buildings with upkeep costs
- Keep FACTORY for production capability (no operating cost in seed)
- Starting inventory breakdown:
* ironOre: 240 → 200
* coal: 140 → 120
* copperOre: 180 → 150
* water: 200 → 150
* fertilizer: 150 → 120
* bioSubstrate: 160 → 130
* ironIngot: 12 → 10
* copperIngot: 6 → 5
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
---
packages/db/src/seed-world.ts | 33 +++-
packages/sim/src/services/buildings.ts | 149 +++++++++++++++++++
packages/sim/src/services/market-matching.ts | 17 +++
packages/sim/src/services/production.ts | 32 +++-
packages/sim/src/services/shipments.ts | 9 ++
packages/sim/src/services/tick-engine.ts | 16 +-
packages/sim/tests/buildings.test.ts | 39 +++++
packages/sim/tests/storage.test.ts | 94 ++++++++++++
8 files changed, 377 insertions(+), 12 deletions(-)
create mode 100644 packages/sim/tests/storage.test.ts
diff --git a/packages/db/src/seed-world.ts b/packages/db/src/seed-world.ts
index 1bf4e5dc..d7746f74 100644
--- a/packages/db/src/seed-world.ts
+++ b/packages/db/src/seed-world.ts
@@ -721,14 +721,14 @@ export async function seedWorld(
if (playerCompany) {
inventoryRows.unshift(
- { companyId: playerCompany.id, itemKey: "ironOre", quantity: 240 },
- { companyId: playerCompany.id, itemKey: "coal", quantity: 140 },
- { companyId: playerCompany.id, itemKey: "copperOre", quantity: 180 },
- { companyId: playerCompany.id, itemKey: "water", quantity: 200 },
- { companyId: playerCompany.id, itemKey: "fertilizer", quantity: 150 },
- { companyId: playerCompany.id, itemKey: "bioSubstrate", quantity: 160 },
- { companyId: playerCompany.id, itemKey: "ironIngot", quantity: 12 },
- { companyId: playerCompany.id, itemKey: "copperIngot", quantity: 6 }
+ { companyId: playerCompany.id, itemKey: "ironOre", quantity: 200 },
+ { companyId: playerCompany.id, itemKey: "coal", quantity: 120 },
+ { companyId: playerCompany.id, itemKey: "copperOre", quantity: 150 },
+ { companyId: playerCompany.id, itemKey: "water", quantity: 150 },
+ { companyId: playerCompany.id, itemKey: "fertilizer", quantity: 120 },
+ { companyId: playerCompany.id, itemKey: "bioSubstrate", quantity: 130 },
+ { companyId: playerCompany.id, itemKey: "ironIngot", quantity: 10 },
+ { companyId: playerCompany.id, itemKey: "copperIngot", quantity: 5 }
);
}
@@ -766,6 +766,23 @@ export async function seedWorld(
}))
});
+ // Create factory for player company to support production
+ if (playerCompany) {
+ await prisma.building.create({
+ data: {
+ companyId: playerCompany.id,
+ regionId: coreRegion.id,
+ buildingType: "FACTORY",
+ status: "ACTIVE",
+ acquisitionCostCents: 0n,
+ weeklyOperatingCostCents: 0n,
+ capacitySlots: 5,
+ tickAcquired: 0,
+ lastOperatingCostTick: 0
+ }
+ });
+ }
+
const recipesByKey: Record = {};
for (const definition of RECIPE_DEFINITIONS) {
const recipe = await prisma.recipe.create({
diff --git a/packages/sim/src/services/buildings.ts b/packages/sim/src/services/buildings.ts
index 09e3e679..96fa98a6 100644
--- a/packages/sim/src/services/buildings.ts
+++ b/packages/sim/src/services/buildings.ts
@@ -124,10 +124,26 @@ export interface ReactivateBuildingInput {
tick: number;
}
+/**
+ * ## Building Staffing (Future Enhancement)
+ *
+ * When employee assignment is implemented:
+ * - Buildings will have minEmployees, maxEmployees, employeesAssigned fields
+ * - Effective capacity = baseCapacity * (employeesAssigned - minEmployees) / (maxEmployees - minEmployees)
+ * - Buildings with employeesAssigned < minEmployees automatically set to INACTIVE status
+ * - Staffing changes do not affect running production jobs, only new jobs
+ *
+ * Current implementation:
+ * - All buildings use full capacity when ACTIVE
+ * - Staffing mechanics deferred to future phase
+ */
+
/**
* Constants
*/
export const BUILDING_OPERATING_COST_INTERVAL_TICKS = 7; // Weekly
+export const BASE_STORAGE_CAPACITY_PER_REGION = 1000;
+export const WAREHOUSE_CAPACITY_PER_SLOT = 500;
/**
* Validates building acquisition input
@@ -512,3 +528,136 @@ export async function getProductionCapacityForCompany(
return { totalCapacity, usedCapacity };
}
+
+/**
+ * Calculates total storage capacity for a region
+ *
+ * @param warehouseCount - Number of active warehouses in the region
+ * @param capacityPerWarehouse - Storage capacity per warehouse (default: WAREHOUSE_CAPACITY_PER_SLOT)
+ * @param baseCapacity - Base storage capacity per region (default: BASE_STORAGE_CAPACITY_PER_REGION)
+ * @returns Total storage capacity
+ *
+ * @remarks
+ * - Base capacity is the minimum storage available in any region
+ * - Each warehouse adds additional capacity based on capacitySlots or fixed amount
+ * - Formula: baseCapacity + (warehouseCount * capacityPerWarehouse)
+ */
+export function calculateRegionalStorageCapacity(
+ warehouseCount: number,
+ capacityPerWarehouse: number = WAREHOUSE_CAPACITY_PER_SLOT,
+ baseCapacity: number = BASE_STORAGE_CAPACITY_PER_REGION
+): number {
+ if (!Number.isInteger(warehouseCount) || warehouseCount < 0) {
+ throw new DomainInvariantError("warehouseCount must be a non-negative integer");
+ }
+ if (!Number.isInteger(capacityPerWarehouse) || capacityPerWarehouse < 0) {
+ throw new DomainInvariantError("capacityPerWarehouse must be a non-negative integer");
+ }
+ if (!Number.isInteger(baseCapacity) || baseCapacity < 0) {
+ throw new DomainInvariantError("baseCapacity must be a non-negative integer");
+ }
+
+ return baseCapacity + (warehouseCount * capacityPerWarehouse);
+}
+
+/**
+ * Validates that adding inventory to a region would not exceed storage capacity
+ *
+ * @param tx - Prisma transaction client
+ * @param companyId - Company ID
+ * @param regionId - Region ID
+ * @param quantityToAdd - Quantity of inventory to add
+ *
+ * @throws {DomainInvariantError} If adding inventory would exceed regional storage capacity
+ *
+ * @remarks
+ * - Calculates current total inventory in region (all items combined)
+ * - Gets warehouse count to calculate total capacity
+ * - Throws error if current + quantityToAdd exceeds capacity
+ * - Must be called before any inventory mutation (production, market, shipments)
+ */
+export async function validateStorageCapacity(
+ tx: Prisma.TransactionClient,
+ companyId: string,
+ regionId: string,
+ quantityToAdd: number
+): Promise {
+ if (!companyId) {
+ throw new DomainInvariantError("companyId is required");
+ }
+ if (!regionId) {
+ throw new DomainInvariantError("regionId is required");
+ }
+ if (!Number.isInteger(quantityToAdd) || quantityToAdd < 0) {
+ throw new DomainInvariantError("quantityToAdd must be a non-negative integer");
+ }
+
+ // Get current total inventory in region
+ const currentInventory = await tx.inventory.aggregate({
+ where: { companyId, regionId },
+ _sum: { quantity: true }
+ });
+
+ // Get warehouse count for capacity calculation
+ const warehouseCount = await tx.building.count({
+ where: {
+ companyId,
+ regionId,
+ buildingType: BuildingType.WAREHOUSE,
+ status: BuildingStatus.ACTIVE
+ }
+ });
+
+ const capacity = calculateRegionalStorageCapacity(warehouseCount);
+ const currentTotal = currentInventory._sum.quantity || 0;
+
+ if (currentTotal + quantityToAdd > capacity) {
+ throw new DomainInvariantError(
+ `storage capacity exceeded: current=${currentTotal}, adding=${quantityToAdd}, capacity=${capacity}`
+ );
+ }
+}
+
+/**
+ * Validates that a company has at least one active production building
+ *
+ * @param tx - Prisma transaction client
+ * @param companyId - Company ID
+ *
+ * @throws {DomainInvariantError} If company has no active production buildings
+ *
+ * @remarks
+ * - Production buildings include: MINE, FARM, FACTORY, MEGA_FACTORY
+ * - Only ACTIVE buildings are counted
+ * - Must be called before creating production jobs
+ */
+export async function validateProductionBuildingAvailable(
+ tx: Prisma.TransactionClient,
+ companyId: string
+): Promise {
+ if (!companyId) {
+ throw new DomainInvariantError("companyId is required");
+ }
+
+ const productionBuildingTypes = [
+ BuildingType.MINE,
+ BuildingType.FARM,
+ BuildingType.FACTORY,
+ BuildingType.MEGA_FACTORY
+ ];
+
+ const activeBuildingCount = await tx.building.count({
+ where: {
+ companyId,
+ buildingType: { in: productionBuildingTypes },
+ status: BuildingStatus.ACTIVE
+ }
+ });
+
+ if (activeBuildingCount === 0) {
+ throw new DomainInvariantError(
+ `company ${companyId} has no active production buildings`
+ );
+ }
+}
+
diff --git a/packages/sim/src/services/market-matching.ts b/packages/sim/src/services/market-matching.ts
index df5b34a6..ec117812 100644
--- a/packages/sim/src/services/market-matching.ts
+++ b/packages/sim/src/services/market-matching.ts
@@ -61,6 +61,7 @@ import {
PrismaClient
} from "@prisma/client";
import { DomainInvariantError, NotFoundError } from "../domain/errors";
+import { validateStorageCapacity } from "./buildings";
/**
* Order representation for matching purposes.
@@ -297,6 +298,22 @@ async function settleMatch(
const buyReservedCash = buyOrder.reservedCashCents - reserveReduction;
const sellReservedQuantity = sellOrder.reservedQuantity - match.quantity;
+ // Validate storage capacity BEFORE any inventory mutations
+ // Skip validation for self-trades in the same region and item (net inventory change is zero)
+ const isSelfTradeInSameRegionAndItem =
+ buyOrder.companyId === sellOrder.companyId &&
+ buyOrder.regionId === sellOrder.regionId &&
+ buyOrder.itemId === sellOrder.itemId;
+
+ if (!isSelfTradeInSameRegionAndItem) {
+ await validateStorageCapacity(
+ tx,
+ buyOrder.companyId,
+ buyOrder.regionId,
+ match.quantity
+ );
+ }
+
if (buyerIsSeller) {
await tx.company.update({
where: { id: buyerCompany.id },
diff --git a/packages/sim/src/services/production.ts b/packages/sim/src/services/production.ts
index 722a4c8f..060a584a 100644
--- a/packages/sim/src/services/production.ts
+++ b/packages/sim/src/services/production.ts
@@ -81,6 +81,10 @@ import {
applyDurationMultiplierTicks,
resolveWorkforceRuntimeModifiers
} from "./workforce";
+import {
+ validateProductionBuildingAvailable,
+ validateStorageCapacity
+} from "./buildings";
interface RecipeInputRow {
itemId: string;
@@ -488,6 +492,12 @@ export async function createProductionJobWithTx(
throw new DomainInvariantError("recipe durationTicks cannot be negative");
}
+ // Validate player company has at least one active production building
+ // (bots operate with different rules and may not have buildings)
+ if (company.isPlayer) {
+ await validateProductionBuildingAvailable(tx, input.companyId);
+ }
+
const requirements = calculateRecipeInputRequirements(recipe.inputs, input.quantity);
const requiredItemIds = requirements.map((entry) => entry.itemId);
@@ -845,6 +855,26 @@ export async function completeDueProductionJobs(
);
}
+ const outputQuantity = job.recipe.outputQuantity * job.runs;
+
+ // Calculate net inventory change (outputs added minus inputs consumed)
+ const totalInputQuantity = requirements.reduce(
+ (sum, requirement) => sum + requirement.quantity,
+ 0
+ );
+ const netInventoryChange = outputQuantity - totalInputQuantity;
+
+ // Validate storage capacity accounts for net change after consuming inputs
+ // (only validate if net change is positive, i.e., we're adding more than consuming)
+ if (netInventoryChange > 0) {
+ await validateStorageCapacity(
+ tx,
+ job.companyId,
+ job.company.regionId,
+ netInventoryChange
+ );
+ }
+
for (const requirement of requirements) {
await tx.inventory.update({
where: {
@@ -865,8 +895,6 @@ export async function completeDueProductionJobs(
});
}
- const outputQuantity = job.recipe.outputQuantity * job.runs;
-
await tx.inventory.upsert({
where: {
companyId_itemId_regionId: {
diff --git a/packages/sim/src/services/shipments.ts b/packages/sim/src/services/shipments.ts
index ffe3566e..92a0d723 100644
--- a/packages/sim/src/services/shipments.ts
+++ b/packages/sim/src/services/shipments.ts
@@ -84,6 +84,7 @@ import {
applyDurationMultiplierTicks,
resolveWorkforceRuntimeModifiers
} from "./workforce";
+import { validateStorageCapacity } from "./buildings";
export interface ShipmentRuntimeConfig {
baseFeeCents: bigint;
@@ -670,6 +671,14 @@ export async function deliverDueShipmentsForTick(
continue;
}
+ // Validate storage capacity before adding delivered inventory
+ await validateStorageCapacity(
+ tx,
+ shipment.companyId,
+ shipment.toRegionId,
+ shipment.quantity
+ );
+
await tx.inventory.upsert({
where: {
companyId_itemId_regionId: {
diff --git a/packages/sim/src/services/tick-engine.ts b/packages/sim/src/services/tick-engine.ts
index c10ea494..635a73e1 100644
--- a/packages/sim/src/services/tick-engine.ts
+++ b/packages/sim/src/services/tick-engine.ts
@@ -239,7 +239,6 @@ function isTickExecutionConflict(error: unknown, executionKey: string | undefine
* ## Pipeline Stages (Executed Sequentially)
* 1. Bot actions (market orders, production starts)
* 2. Building operating costs (deduct costs, deactivate unpaid buildings)
- * - Note: Production validation doesn't yet check building status (Phase 2)
* 3. Production job completions
* 4. Research completions and recipe unlocks
* 5. Market matching and settlement
@@ -249,6 +248,13 @@ function isTickExecutionConflict(error: unknown, executionKey: string | undefine
* 9. Contract lifecycle (expiration and generation)
* 10. Market candle aggregation (OHLC/VWAP/volume)
*
+ * ## Phase 3 Validations
+ * - Storage capacity enforced at inventory mutation points
+ * - Production: validates net inventory change (outputs - inputs consumed)
+ * - Market settlement: validates buyer's capacity before trade execution
+ * - Shipment delivery: validates destination capacity before delivery
+ * - Building availability validated for production job creation
+ *
* ## Determinism
* - Order is fixed and must not change (breaking change if reordered)
* - Each stage reads state modified by previous stages
@@ -266,7 +272,6 @@ async function runTickPipeline(
// Tick pipeline order:
// 1) bot actions (orders / production starts)
// 2) building operating costs (deactivate unpaid buildings)
- // Note: Production validation doesn't yet check building status (Phase 2)
// 3) production completions
// 4) research completions and recipe unlocks
// 5) market matching and settlement
@@ -276,6 +281,13 @@ async function runTickPipeline(
// 9) contract lifecycle (expire and generate)
// 10) market candle aggregation (OHLC/VWAP/volume)
// 11) finalize world tick state
+ //
+ // Phase 3 Validations:
+ // - Storage capacity enforced at inventory mutation points:
+ // * Production: validates net change (outputs - inputs), skips if net negative
+ // * Market: validates buyer capacity before trade execution
+ // * Shipments: validates destination capacity before delivery
+ // - Building availability validated for production job creation
if (options.runBots) {
await runBotsForTick(tx, nextTick, options.botConfig);
}
diff --git a/packages/sim/tests/buildings.test.ts b/packages/sim/tests/buildings.test.ts
index 1c05a2f1..ee14db7f 100644
--- a/packages/sim/tests/buildings.test.ts
+++ b/packages/sim/tests/buildings.test.ts
@@ -8,6 +8,7 @@ import {
applyBuildingOperatingCostsWithTx,
reactivateBuildingWithTx,
getProductionCapacityForCompany,
+ validateProductionBuildingAvailable,
BUILDING_OPERATING_COST_INTERVAL_TICKS
} from "../src";
@@ -523,4 +524,42 @@ describe("building service", () => {
expect(result.usedCapacity).toBe(0);
});
});
+
+ describe("validateProductionBuildingAvailable", () => {
+ it("validates company has active production building", async () => {
+ const tx = {
+ building: {
+ count: vi.fn().mockResolvedValue(2)
+ }
+ } as unknown as Prisma.TransactionClient;
+
+ await expect(
+ validateProductionBuildingAvailable(tx, "company-1")
+ ).resolves.not.toThrow();
+ });
+
+ it("throws when company has no active production buildings", async () => {
+ const tx = {
+ building: {
+ count: vi.fn().mockResolvedValue(0)
+ }
+ } as unknown as Prisma.TransactionClient;
+
+ await expect(
+ validateProductionBuildingAvailable(tx, "company-1")
+ ).rejects.toThrow(DomainInvariantError);
+ });
+
+ it("validates required parameters", async () => {
+ const tx = {
+ building: {
+ count: vi.fn().mockResolvedValue(1)
+ }
+ } as unknown as Prisma.TransactionClient;
+
+ await expect(
+ validateProductionBuildingAvailable(tx, "")
+ ).rejects.toThrow(DomainInvariantError);
+ });
+ });
});
diff --git a/packages/sim/tests/storage.test.ts b/packages/sim/tests/storage.test.ts
new file mode 100644
index 00000000..80de02a9
--- /dev/null
+++ b/packages/sim/tests/storage.test.ts
@@ -0,0 +1,94 @@
+import { Prisma } from "@prisma/client";
+import { describe, expect, it, vi } from "vitest";
+import {
+ DomainInvariantError,
+ calculateRegionalStorageCapacity,
+ validateStorageCapacity,
+ BASE_STORAGE_CAPACITY_PER_REGION,
+ WAREHOUSE_CAPACITY_PER_SLOT
+} from "../src";
+
+describe("storage capacity system", () => {
+ it("calculates base storage capacity correctly", () => {
+ const capacity = calculateRegionalStorageCapacity(0);
+ expect(capacity).toBe(BASE_STORAGE_CAPACITY_PER_REGION);
+ });
+
+ it("adds warehouse capacity to base", () => {
+ const capacity = calculateRegionalStorageCapacity(2);
+ expect(capacity).toBe(BASE_STORAGE_CAPACITY_PER_REGION + (2 * WAREHOUSE_CAPACITY_PER_SLOT));
+ });
+
+ it("validates storage capacity and throws on overflow", async () => {
+ const tx = {
+ inventory: {
+ aggregate: vi.fn().mockResolvedValue({
+ _sum: { quantity: 900 }
+ })
+ },
+ building: {
+ count: vi.fn().mockResolvedValue(0) // No warehouses
+ }
+ } as unknown as Prisma.TransactionClient;
+
+ // Should fail: 900 + 200 = 1100 > 1000 base capacity
+ await expect(
+ validateStorageCapacity(tx, "company-1", "region-1", 200)
+ ).rejects.toThrow(DomainInvariantError);
+ });
+
+ it("allows storage within capacity", async () => {
+ const tx = {
+ inventory: {
+ aggregate: vi.fn().mockResolvedValue({
+ _sum: { quantity: 500 }
+ })
+ },
+ building: {
+ count: vi.fn().mockResolvedValue(1) // 1 warehouse = 1500 total capacity
+ }
+ } as unknown as Prisma.TransactionClient;
+
+ // Should succeed: 500 + 800 = 1300 < 1500
+ await expect(
+ validateStorageCapacity(tx, "company-1", "region-1", 800)
+ ).resolves.not.toThrow();
+ });
+
+ it("throws on negative warehouse count", () => {
+ expect(() => calculateRegionalStorageCapacity(-1)).toThrow(DomainInvariantError);
+ });
+
+ it("throws on negative capacity per warehouse", () => {
+ expect(() => calculateRegionalStorageCapacity(1, -500)).toThrow(DomainInvariantError);
+ });
+
+ it("throws on negative base capacity", () => {
+ expect(() => calculateRegionalStorageCapacity(1, 500, -1000)).toThrow(DomainInvariantError);
+ });
+
+ it("validates required parameters in validateStorageCapacity", async () => {
+ const tx = {
+ inventory: {
+ aggregate: vi.fn().mockResolvedValue({
+ _sum: { quantity: 0 }
+ })
+ },
+ building: {
+ count: vi.fn().mockResolvedValue(0)
+ }
+ } as unknown as Prisma.TransactionClient;
+
+ await expect(
+ validateStorageCapacity(tx, "", "region-1", 100)
+ ).rejects.toThrow(DomainInvariantError);
+
+ await expect(
+ validateStorageCapacity(tx, "company-1", "", 100)
+ ).rejects.toThrow(DomainInvariantError);
+
+ await expect(
+ validateStorageCapacity(tx, "company-1", "region-1", -10)
+ ).rejects.toThrow(DomainInvariantError);
+ });
+});
From 7d00bce080babcd9b081ffc9473615c3fa65abfc Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Thu, 19 Feb 2026 15:39:56 +0100
Subject: [PATCH 06/52] Phase 4 & 5: Buildings Management UI + Preflight
Validation + Deterministic Overflow Policy + Stuck Shipment Indicator (#79)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Initial plan
* feat(api): add buildings API with preflight validation
- Add BuildingsController with endpoints for listing, acquiring, and reactivating buildings
- Add preflight validation endpoints for production jobs and buy orders
- Add storage and capacity info endpoints
- Add building type definitions with costs (Phase 5 balance pass)
- Add building-related types to shared package
- Register buildings module in app.module.ts
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* feat(web): add buildings API client functions
- Add building-related parsers to api-parsers
- Add buildings API functions to api client
- Add support for listing, acquiring, and reactivating buildings
- Add preflight validation API calls
- Add storage and capacity info API calls
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* feat(web): add Buildings page and UI components
- Add Buildings page with region/category grouping
- Add building status display with badges
- Add Acquire Building dialog with cost preview
- Add reactivate building functionality
- Add Storage Meter component for capacity visualization
- Add missing UI components (Dialog, Label, Progress)
- Install required radix-ui packages
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* chore: add release entry for Phase 4 & 5 implementation
* fix: remove unused imports in buildings service
* feat(sim): implement deterministic overflow policy for shipments
CRITICAL FIX: Shipment delivery now returns to sender when destination storage is full, preventing tick blocking.
**Overflow Strategy: Return to Sender**
When shipment arrives but destination storage is full:
- Shipment marked as DELIVERED (not failed)
- Inventory returned to origin region (fromRegionId)
- No error thrown (prevents tick advancement failures)
- Deterministic behavior - always returns, never partial
- Player consequence: wasted logistics fee
**Changes:**
- packages/sim/src/services/shipments.ts:
- deliverDueShipmentsForTick() now returns {deliveredCount, returnedCount}
- Catches storage capacity errors and returns to sender
- Updated JSDoc with comprehensive overflow policy documentation
**Prevents Soft-Lock:**
- Tick processing never fails due to player storage mismanagement
- Multi-operation storage contention handled deterministically
- Same-tick shipments processed in arrival order (ASC)
All 64 existing tests passing.
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* docs: add comprehensive overflow policy and economic analysis
Complete documentation covering:
- Deterministic overflow policy (reject at source + return to sender)
- Multi-operation storage contention handling
- Soft-lock prevention strategy
- Economic balance analysis with break-even calculations
- Early game viability assessment
- Over-expansion risk scenarios
- Integration test coverage plan
Key Findings:
- Current building costs too high for absolute beginners
- Recommend adding $25k Workshop tier
- MegaFactory dangerous if purchased too early (bankruptcy risk)
- Production failure behavior needs design decision (lose inputs vs return)
All quality gates passing:
- ✅ 64/64 tests
- ✅ TypeScript compilation
- ✅ ESLint
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* feat: add WORKSHOP building tier and improve overflow policy
**Workshop Building Tier ($25k Entry-Level):**
- Acquisition: $25,000 (vs $80k for Farm)
- Weekly cost: $1,500 (vs $4k for Farm)
- Capacity: 1 slot
- Purpose: True early-game viability
**Overflow Policy Enhancements:**
- Origin storage validation SKIPPED for returns (prevents deadlock)
- Rationale: Items were already at origin - bounded violation acceptable
- Origin may temporarily exceed capacity but self-corrects
- Deterministic: Same inputs → same outputs
- Idempotent: updateMany prevents double-processing
- Atomic: Single transaction, no partial state
- No ledger entry on return (fee already paid at creation)
**Changes:**
- packages/db/prisma/schema.prisma: Add WORKSHOP to BuildingType enum
- packages/shared/src/api-types.ts: Add WORKSHOP to type union
- packages/sim/src/services/buildings.ts: Include WORKSHOP in production building types
- packages/sim/src/services/shipments.ts: Enhanced overflow documentation
- apps/api/src/buildings/buildings.service.ts: Add WORKSHOP definition and include in preflight
**Quality:**
- All 64 tests passing
- TypeScript compilation passing
- ESLint passing
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* fix: maintain hard storage caps and use deterministic ordering
BREAKING CHANGES to overflow policy per reviewer feedback:
**1. Hard Storage Caps Maintained:**
- Origin capacity NOW VALIDATED on rollback (was bypassed)
- No bounded violation - all regions respect capacity limits
- If both origin and destination full, delivery FAILS
- This is acceptable - player made storage management error
**2. Delivery Rollback Semantics:**
- Clarified: This is a ROLLBACK, not a logistics return
- No new shipment created, no additional travel time
- Status: DELIVERED (completed, even if rolled back)
- Player penalty: wasted fee + time
**3. Deterministic Ordering:**
- Changed from createdAt (wall-clock) to tickCreated (deterministic)
- Guarantees same order on replay: ORDER BY tickArrives ASC, tickCreated ASC
- Location: packages/sim/src/services/shipments.ts:674
**Changes:**
- packages/sim/src/services/shipments.ts:
- Add origin capacity validation on rollback (line 730-736)
- Use tickCreated instead of createdAt for ordering
- Comprehensive JSDoc updates explaining rollback vs return
- Hard cap invariants maintained everywhere
- docs/overflow-policy-and-economics.md:
- Updated to reflect rollback semantics
- Clarified deterministic ordering with tickCreated
- Documented edge case (both regions full)
**Rationale:**
- Hard caps are invariants - no bypass allowed
- Edge case failure acceptable (player must manage both regions)
- Simpler than overflow bucket (no quarantine system needed)
- Deterministic ordering critical for replay
All 64 tests passing.
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* fix: ensure tick never fails - retry shipments when both regions full
CRITICAL FIX: Tick advancement can never be blocked by storage overflow.
**Problem:** Previous implementation threw error if both origin and destination were full, blocking tick advancement.
**Solution:** Three-tier delivery fallback:
1. Destination has capacity → Normal delivery
2. Destination full, origin has capacity → Rollback to origin (DELIVERED status)
3. Both regions full → Keep IN_TRANSIT, retry next tick (tick continues)
**Changes:**
- packages/sim/src/services/shipments.ts:
- Moved status update AFTER capacity validation (line 746-756)
- Only update to DELIVERED if delivery succeeds
- If both regions full: shipment stays IN_TRANSIT, no throw
- Comprehensive JSDoc explaining three-tier fallback
- Line 733: Catch origin validation error, set shouldDeliver=false
- docs/overflow-policy-and-economics.md:
- Updated to document three-tier delivery outcomes
- Clarified tick never fails guarantee
- Documented retry mechanism for double-full scenario
**Guarantees:**
✅ Tick advancement NEVER fails due to storage
✅ Hard storage caps maintained (no bypass)
✅ Deterministic retry behavior
✅ Player gets clear feedback (shipment stays IN_TRANSIT until space available)
**Edge Case Handling:**
- Both regions full → shipment retries automatically next tick
- Player must clear space in EITHER region for delivery
- No soft-lock possible
All 64 tests passing.
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* refactor: extract production building types constant and fix type safety
Address code review feedback:
1. Extract PRODUCTION_BUILDING_TYPES as shared constant (eliminates duplication)
2. Fix type safety: use `as BuildingType` instead of `as any` in acquire dialog
**Changes:**
- packages/sim/src/services/buildings.ts:
- Add PRODUCTION_BUILDING_TYPES export (line 89-97)
- Use constant in getProductionCapacityForCompany (line 513)
- Use constant in assertHasActiveProductionBuilding (line 649)
- apps/api/src/buildings/buildings.service.ts:
- Import PRODUCTION_BUILDING_TYPES (line 19)
- Use constant in preflightProductionJob (line 337)
- apps/web/src/components/buildings/acquire-building-dialog.tsx:
- Import BuildingType from api (line 25)
- Fix type assertion: as BuildingType (line 90)
**Benefits:**
- Single source of truth for production building types
- Easier maintenance (add/remove building types in one place)
- Better type safety (no `as any` bypass)
- Consistent across API and sim layers
All 64 tests passing, typecheck and lint passing.
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* fix: add database migration for WORKSHOP building type
CRITICAL FIX: Database enum missing WORKSHOP value causing 500 errors.
**Problem:** Added WORKSHOP to TypeScript code and Prisma schema, but database enum wasn't updated via migration.
**Error:**
```
invalid input value for enum "BuildingType": "WORKSHOP"
```
**Solution:** Create migration to add WORKSHOP to BuildingType enum in PostgreSQL.
**Migration:** packages/db/prisma/migrations/20260219133500_add_workshop_building_type/migration.sql
- Adds WORKSHOP value to BuildingType enum
- Required for integration tests to pass
- Safe to apply (enum extension, no data migration needed)
Integration tests will pass once migration is applied to test database.
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* refactor: apply code review suggestions
Address copilot-pull-request-reviewer feedback:
1. Remove trailing blank lines in api-parsers.ts
2. Simplify listBuildings controller - pass query DTO directly
3. Wrap building definitions response for API consistency
**Changes:**
- apps/web/src/lib/api-parsers.ts: Remove trailing whitespace
- apps/api/src/buildings/buildings.controller.ts:
- Pass query DTO directly to listBuildings (no redundant object)
- Wrap definitions in { definitions: [...] } for consistency
**Note:** Bot comment about tickCreated field is incorrect - field exists in schema at line 290.
All changes are code quality improvements with no functional impact.
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
* feat: add stuck shipment UI indicator with storage management link
Implement real-time visual feedback for stuck shipments (IN_TRANSIT past ETA).
**Changes:**
- apps/web/src/components/logistics/logistics-page.tsx:
- Added Badge and Link imports
- Detect stuck shipments: tickArrives < currentTick
- Display warning badge with tooltip on stuck shipments
- Add "Manage Storage" button linking to /buildings page
- Tooltip: "Waiting for storage capacity in destination or origin region"
**UX Improvements:**
- Clear visual indicator (amber warning badge) when shipment can't deliver
- Actionable "Manage Storage" button for direct navigation to Buildings page
- Tooltip explains retry behavior (waiting for capacity)
- Prevents player confusion about stuck shipments
**Implementation Details:**
- Uses existing Badge component with "warning" variant
- Conditionally renders badge only when isStuck = true
- Adds direct link to Buildings page for capacity management
- Maintains existing Cancel button functionality
No breaking changes. Pure UX enhancement.
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com>
---
...ings-management-ui-with-preflight-valid.md | 30 ++
apps/api/src/app.module.ts | 4 +
.../api/src/buildings/buildings.controller.ts | 115 ++++
apps/api/src/buildings/buildings.service.ts | 491 ++++++++++++++++++
.../src/buildings/dto/acquire-building.dto.ts | 19 +
.../src/buildings/dto/list-buildings.dto.ts | 16 +
.../buildings/dto/preflight-buy-order.dto.ts | 20 +
.../dto/preflight-production-job.dto.ts | 16 +
.../buildings/dto/reactivate-building.dto.ts | 7 +
apps/web/app/buildings/page.tsx | 5 +
apps/web/package.json | 3 +
.../buildings/acquire-building-dialog.tsx | 241 +++++++++
.../components/buildings/buildings-page.tsx | 249 +++++++++
.../components/buildings/storage-meter.tsx | 126 +++++
.../components/logistics/logistics-page.tsx | 80 ++-
apps/web/src/components/ui/dialog.tsx | 121 +++++
apps/web/src/components/ui/label.tsx | 20 +
apps/web/src/components/ui/progress.tsx | 29 ++
apps/web/src/lib/api-parsers.ts | 124 +++++
apps/web/src/lib/api.ts | 93 ++++
docs/overflow-policy-and-economics.md | 305 +++++++++++
.../migration.sql | 2 +
packages/db/prisma/schema.prisma | 1 +
packages/shared/src/api-types.ts | 88 ++++
packages/sim/src/services/buildings.ts | 33 +-
packages/sim/src/services/shipments.ts | 145 ++++--
pnpm-lock.yaml | 142 ++++-
27 files changed, 2447 insertions(+), 78 deletions(-)
create mode 100644 .releases/unreleased/20260219112748-add-buildings-management-ui-with-preflight-valid.md
create mode 100644 apps/api/src/buildings/buildings.controller.ts
create mode 100644 apps/api/src/buildings/buildings.service.ts
create mode 100644 apps/api/src/buildings/dto/acquire-building.dto.ts
create mode 100644 apps/api/src/buildings/dto/list-buildings.dto.ts
create mode 100644 apps/api/src/buildings/dto/preflight-buy-order.dto.ts
create mode 100644 apps/api/src/buildings/dto/preflight-production-job.dto.ts
create mode 100644 apps/api/src/buildings/dto/reactivate-building.dto.ts
create mode 100644 apps/web/app/buildings/page.tsx
create mode 100644 apps/web/src/components/buildings/acquire-building-dialog.tsx
create mode 100644 apps/web/src/components/buildings/buildings-page.tsx
create mode 100644 apps/web/src/components/buildings/storage-meter.tsx
create mode 100644 apps/web/src/components/ui/dialog.tsx
create mode 100644 apps/web/src/components/ui/label.tsx
create mode 100644 apps/web/src/components/ui/progress.tsx
create mode 100644 docs/overflow-policy-and-economics.md
create mode 100644 packages/db/prisma/migrations/20260219133500_add_workshop_building_type/migration.sql
diff --git a/.releases/unreleased/20260219112748-add-buildings-management-ui-with-preflight-valid.md b/.releases/unreleased/20260219112748-add-buildings-management-ui-with-preflight-valid.md
new file mode 100644
index 00000000..3528bfd8
--- /dev/null
+++ b/.releases/unreleased/20260219112748-add-buildings-management-ui-with-preflight-valid.md
@@ -0,0 +1,30 @@
+---
+type: minor
+area: web,api,sim
+summary: Add Buildings Management UI with preflight validation and acquisition flows (Phase 4 & 5)
+---
+
+**Phase 4 - Frontend Integration + Preflight + Operator Visibility:**
+- Add Buildings page with region/category grouping and status display
+- Add building acquisition dialog with cost preview
+- Add reusable Storage Meter component with warning thresholds (80%, 95%, 100%)
+- Add preflight validation endpoints (canCreateProductionJob, canPlaceBuyOrder)
+- Add storage and capacity info endpoints
+- Add building type definitions endpoint with balanced costs
+
+**Phase 5 - Acquisition Flows + Balance Pass:**
+- Implement transactional building acquisition with ledger entries
+- Define BuildingType dataset: Early Workshop, Factory, MegaFactory, Warehouse, HQ
+- Add building reactivation flow for INACTIVE buildings
+- Implement cost preview showing acquisition cost and weekly operating expenses
+
+**API Layer:**
+- Add BuildingsController with full CRUD operations
+- Add BuildingsService with ownership validation
+- Add buildings API client functions and parsers
+
+**UI Components:**
+- Add Dialog, Label, and Progress UI primitives
+- Install required @radix-ui packages
+
+- Add Buildings Management UI with preflight validation and acquisition flows (Phase 4 & 5)
diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts
index d3d12659..c9f92069 100644
--- a/apps/api/src/app.module.ts
+++ b/apps/api/src/app.module.ts
@@ -2,6 +2,8 @@ import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
import { AuthModule } from "@thallesp/nestjs-better-auth";
import { MaintenanceModeMiddleware } from "./common/middleware/maintenance-mode.middleware";
import { SchemaReadinessMiddleware } from "./common/middleware/schema-readiness.middleware";
+import { BuildingsController } from "./buildings/buildings.controller";
+import { BuildingsService } from "./buildings/buildings.service";
import { CompaniesController } from "./companies/companies.controller";
import { CompaniesService } from "./companies/companies.service";
import { ContractsController } from "./contracts/contracts.controller";
@@ -63,6 +65,7 @@ import { auth } from "./lib/auth";
ContractsController,
PlayersController,
CompaniesController,
+ BuildingsController,
MarketController,
ItemsController,
RegionsController,
@@ -83,6 +86,7 @@ import { auth } from "./lib/auth";
FinanceService,
ContractsService,
CompaniesService,
+ BuildingsService,
PlayersService,
MarketService,
MaintenanceService,
diff --git a/apps/api/src/buildings/buildings.controller.ts b/apps/api/src/buildings/buildings.controller.ts
new file mode 100644
index 00000000..0202157a
--- /dev/null
+++ b/apps/api/src/buildings/buildings.controller.ts
@@ -0,0 +1,115 @@
+import {
+ Body,
+ Controller,
+ Get,
+ HttpCode,
+ HttpStatus,
+ Inject,
+ Post,
+ Query
+} from "@nestjs/common";
+import { CurrentPlayerId } from "../common/decorators/current-player-id.decorator";
+import { AcquireBuildingDto } from "./dto/acquire-building.dto";
+import { ListBuildingsDto } from "./dto/list-buildings.dto";
+import { ReactivateBuildingDto } from "./dto/reactivate-building.dto";
+import { PreflightProductionJobDto } from "./dto/preflight-production-job.dto";
+import { PreflightBuyOrderDto } from "./dto/preflight-buy-order.dto";
+import { BuildingsService } from "./buildings.service";
+
+@Controller("v1/buildings")
+export class BuildingsController {
+ private readonly buildingsService: BuildingsService;
+
+ constructor(@Inject(BuildingsService) buildingsService: BuildingsService) {
+ this.buildingsService = buildingsService;
+ }
+
+ @Get()
+ async listBuildings(
+ @Query() query: ListBuildingsDto,
+ @CurrentPlayerId() playerId: string
+ ) {
+ return this.buildingsService.listBuildings(query, playerId);
+ }
+
+ @Post("acquire")
+ async acquireBuilding(
+ @Body() body: AcquireBuildingDto,
+ @CurrentPlayerId() playerId: string
+ ) {
+ return this.buildingsService.acquireBuilding(
+ {
+ companyId: body.companyId,
+ regionId: body.regionId,
+ buildingType: body.buildingType,
+ name: body.name
+ },
+ playerId
+ );
+ }
+
+ @Post("reactivate")
+ @HttpCode(HttpStatus.OK)
+ async reactivateBuilding(
+ @Body() body: ReactivateBuildingDto,
+ @CurrentPlayerId() playerId: string
+ ) {
+ return this.buildingsService.reactivateBuilding(body.buildingId, playerId);
+ }
+
+ @Get("storage-info")
+ async getStorageInfo(
+ @Query("companyId") companyId: string,
+ @Query("regionId") regionId: string,
+ @CurrentPlayerId() playerId: string
+ ) {
+ return this.buildingsService.getRegionalStorageInfo(companyId, regionId, playerId);
+ }
+
+ @Get("capacity-info")
+ async getCapacityInfo(
+ @Query("companyId") companyId: string,
+ @CurrentPlayerId() playerId: string
+ ) {
+ return this.buildingsService.getProductionCapacityInfo(companyId, playerId);
+ }
+
+ @Post("preflight/production-job")
+ @HttpCode(HttpStatus.OK)
+ async preflightProductionJob(
+ @Body() body: PreflightProductionJobDto,
+ @CurrentPlayerId() playerId: string
+ ) {
+ return this.buildingsService.preflightProductionJob(
+ {
+ companyId: body.companyId,
+ recipeId: body.recipeId,
+ quantity: body.quantity
+ },
+ playerId
+ );
+ }
+
+ @Post("preflight/buy-order")
+ @HttpCode(HttpStatus.OK)
+ async preflightBuyOrder(
+ @Body() body: PreflightBuyOrderDto,
+ @CurrentPlayerId() playerId: string
+ ) {
+ return this.buildingsService.preflightBuyOrder(
+ {
+ companyId: body.companyId,
+ regionId: body.regionId,
+ itemId: body.itemId,
+ quantity: body.quantity
+ },
+ playerId
+ );
+ }
+
+ @Get("definitions")
+ async getBuildingTypeDefinitions() {
+ const definitions = await this.buildingsService.getBuildingTypeDefinitions();
+ return { definitions };
+ }
+}
diff --git a/apps/api/src/buildings/buildings.service.ts b/apps/api/src/buildings/buildings.service.ts
new file mode 100644
index 00000000..a414cc13
--- /dev/null
+++ b/apps/api/src/buildings/buildings.service.ts
@@ -0,0 +1,491 @@
+import { Inject, Injectable } from "@nestjs/common";
+import { BuildingStatus, BuildingType } from "@prisma/client";
+import type {
+ BuildingRecord,
+ RegionalStorageInfo,
+ ProductionCapacityInfo,
+ PreflightValidationResult,
+ ValidationIssue,
+ BuildingTypeDefinition
+} from "@corpsim/shared";
+import {
+ assertCompanyOwnedByPlayer,
+ resolvePlayerById,
+ acquireBuildingWithTx,
+ reactivateBuildingWithTx,
+ getProductionCapacityForCompany,
+ calculateRegionalStorageCapacity,
+ WAREHOUSE_CAPACITY_PER_SLOT,
+ PRODUCTION_BUILDING_TYPES
+} from "@corpsim/sim";
+import { PrismaService } from "../prisma/prisma.service";
+import { WorldService } from "../world/world.service";
+
+// Building type definitions with costs (Phase 5 balance pass)
+const BUILDING_DEFINITIONS: Record> = {
+ [BuildingType.WORKSHOP]: {
+ category: "PRODUCTION",
+ name: "Workshop",
+ description: "Small-scale production facility for beginners",
+ acquisitionCostCents: "2500000", // $25,000
+ weeklyOperatingCostCents: "150000", // $1,500/week
+ capacitySlots: 1
+ },
+ [BuildingType.MINE]: {
+ category: "PRODUCTION",
+ name: "Mine",
+ description: "Extract raw materials from the earth",
+ acquisitionCostCents: "10000000", // $100,000
+ weeklyOperatingCostCents: "500000", // $5,000/week
+ capacitySlots: 2
+ },
+ [BuildingType.FARM]: {
+ category: "PRODUCTION",
+ name: "Farm",
+ description: "Grow and harvest agricultural products",
+ acquisitionCostCents: "8000000", // $80,000
+ weeklyOperatingCostCents: "400000", // $4,000/week
+ capacitySlots: 2
+ },
+ [BuildingType.FACTORY]: {
+ category: "PRODUCTION",
+ name: "Factory",
+ description: "Process materials into finished goods",
+ acquisitionCostCents: "25000000", // $250,000
+ weeklyOperatingCostCents: "1200000", // $12,000/week
+ capacitySlots: 3
+ },
+ [BuildingType.MEGA_FACTORY]: {
+ category: "PRODUCTION",
+ name: "Mega Factory",
+ description: "High-capacity industrial production facility",
+ acquisitionCostCents: "100000000", // $1,000,000
+ weeklyOperatingCostCents: "5000000", // $50,000/week
+ capacitySlots: 10
+ },
+ [BuildingType.WAREHOUSE]: {
+ category: "STORAGE",
+ name: "Warehouse",
+ description: "Store inventory beyond base capacity",
+ acquisitionCostCents: "15000000", // $150,000
+ weeklyOperatingCostCents: "800000", // $8,000/week
+ capacitySlots: 1,
+ storageCapacity: WAREHOUSE_CAPACITY_PER_SLOT
+ },
+ [BuildingType.HEADQUARTERS]: {
+ category: "CORPORATE",
+ name: "Headquarters",
+ description: "Corporate management and strategic operations",
+ acquisitionCostCents: "50000000", // $500,000
+ weeklyOperatingCostCents: "2500000", // $25,000/week
+ capacitySlots: 1
+ },
+ [BuildingType.RND_CENTER]: {
+ category: "CORPORATE",
+ name: "R&D Center",
+ description: "Research and development facility",
+ acquisitionCostCents: "30000000", // $300,000
+ weeklyOperatingCostCents: "1500000", // $15,000/week
+ capacitySlots: 1
+ }
+};
+
+function mapBuildingToDto(building: {
+ id: string;
+ companyId: string;
+ regionId: string;
+ buildingType: BuildingType;
+ status: BuildingStatus;
+ name: string | null;
+ acquisitionCostCents: bigint;
+ weeklyOperatingCostCents: bigint;
+ capacitySlots: number;
+ tickAcquired: number;
+ tickConstructionCompletes: number | null;
+ lastOperatingCostTick: number | null;
+ createdAt: Date;
+ updatedAt: Date;
+ region: {
+ id: string;
+ code: string;
+ name: string;
+ };
+}): BuildingRecord {
+ return {
+ id: building.id,
+ companyId: building.companyId,
+ regionId: building.regionId,
+ buildingType: building.buildingType as BuildingType,
+ status: building.status as BuildingStatus,
+ name: building.name,
+ acquisitionCostCents: building.acquisitionCostCents.toString(),
+ weeklyOperatingCostCents: building.weeklyOperatingCostCents.toString(),
+ capacitySlots: building.capacitySlots,
+ tickAcquired: building.tickAcquired,
+ tickConstructionCompletes: building.tickConstructionCompletes,
+ lastOperatingCostTick: building.lastOperatingCostTick,
+ createdAt: building.createdAt.toISOString(),
+ updatedAt: building.updatedAt.toISOString(),
+ region: {
+ id: building.region.id,
+ code: building.region.code,
+ name: building.region.name
+ }
+ };
+}
+
+@Injectable()
+export class BuildingsService {
+ private readonly prisma: PrismaService;
+ private readonly worldService: WorldService;
+
+ constructor(
+ @Inject(PrismaService) prisma: PrismaService,
+ @Inject(WorldService) worldService: WorldService
+ ) {
+ this.prisma = prisma;
+ this.worldService = worldService;
+ }
+
+ async listBuildings(
+ filters: { companyId: string; regionId?: string; status?: BuildingStatus },
+ playerId: string
+ ): Promise {
+ const player = await resolvePlayerById(this.prisma, playerId);
+ await assertCompanyOwnedByPlayer(this.prisma, player.id, filters.companyId);
+
+ const buildings = await this.prisma.building.findMany({
+ where: {
+ companyId: filters.companyId,
+ ...(filters.regionId && { regionId: filters.regionId }),
+ ...(filters.status && { status: filters.status })
+ },
+ include: {
+ region: {
+ select: {
+ id: true,
+ code: true,
+ name: true
+ }
+ }
+ },
+ orderBy: [
+ { regionId: "asc" },
+ { buildingType: "asc" },
+ { createdAt: "asc" }
+ ]
+ });
+
+ return buildings.map(mapBuildingToDto);
+ }
+
+ async acquireBuilding(
+ input: { companyId: string; regionId: string; buildingType: BuildingType; name?: string },
+ playerId: string
+ ): Promise {
+ const player = await resolvePlayerById(this.prisma, playerId);
+ await assertCompanyOwnedByPlayer(this.prisma, player.id, input.companyId);
+
+ const definition = BUILDING_DEFINITIONS[input.buildingType];
+ if (!definition) {
+ throw new Error(`Unknown building type: ${input.buildingType}`);
+ }
+
+ const worldState = await this.worldService.getTickState();
+ const currentTick = worldState.currentTick;
+
+ const building = await this.prisma.$transaction(async (tx) => {
+ return acquireBuildingWithTx(tx, {
+ companyId: input.companyId,
+ regionId: input.regionId,
+ buildingType: input.buildingType,
+ name: input.name,
+ acquisitionCostCents: BigInt(definition.acquisitionCostCents),
+ weeklyOperatingCostCents: BigInt(definition.weeklyOperatingCostCents),
+ capacitySlots: definition.capacitySlots,
+ tick: currentTick
+ });
+ });
+
+ const buildingWithRegion = await this.prisma.building.findUniqueOrThrow({
+ where: { id: building.id },
+ include: {
+ region: {
+ select: {
+ id: true,
+ code: true,
+ name: true
+ }
+ }
+ }
+ });
+
+ return mapBuildingToDto(buildingWithRegion);
+ }
+
+ async reactivateBuilding(
+ buildingId: string,
+ playerId: string
+ ): Promise {
+ // Verify ownership
+ const building = await this.prisma.building.findUniqueOrThrow({
+ where: { id: buildingId },
+ select: { companyId: true }
+ });
+
+ const player = await resolvePlayerById(this.prisma, playerId);
+ await assertCompanyOwnedByPlayer(this.prisma, player.id, building.companyId);
+
+ const worldState = await this.worldService.getTickState();
+ const currentTick = worldState.currentTick;
+
+ const reactivated = await this.prisma.$transaction(async (tx) => {
+ return reactivateBuildingWithTx(tx, {
+ buildingId,
+ tick: currentTick
+ });
+ });
+
+ const buildingWithRegion = await this.prisma.building.findUniqueOrThrow({
+ where: { id: reactivated.id },
+ include: {
+ region: {
+ select: {
+ id: true,
+ code: true,
+ name: true
+ }
+ }
+ }
+ });
+
+ return mapBuildingToDto(buildingWithRegion);
+ }
+
+ async getRegionalStorageInfo(
+ companyId: string,
+ regionId: string,
+ playerId: string
+ ): Promise {
+ const player = await resolvePlayerById(this.prisma, playerId);
+ await assertCompanyOwnedByPlayer(this.prisma, player.id, companyId);
+
+ const [currentInventory, warehouseCount] = await Promise.all([
+ this.prisma.inventory.aggregate({
+ where: { companyId, regionId },
+ _sum: { quantity: true }
+ }),
+ this.prisma.building.count({
+ where: {
+ companyId,
+ regionId,
+ buildingType: BuildingType.WAREHOUSE,
+ status: BuildingStatus.ACTIVE
+ }
+ })
+ ]);
+
+ const maxCapacity = calculateRegionalStorageCapacity(warehouseCount);
+ const currentUsage = currentInventory._sum.quantity || 0;
+ const usagePercentage = maxCapacity > 0 ? (currentUsage / maxCapacity) * 100 : 0;
+
+ return {
+ companyId,
+ regionId,
+ currentUsage,
+ maxCapacity,
+ usagePercentage,
+ warehouseCount
+ };
+ }
+
+ async getProductionCapacityInfo(
+ companyId: string,
+ playerId: string
+ ): Promise {
+ const player = await resolvePlayerById(this.prisma, playerId);
+ await assertCompanyOwnedByPlayer(this.prisma, player.id, companyId);
+
+ const capacityInfo = await getProductionCapacityForCompany(this.prisma, companyId);
+
+ return {
+ companyId,
+ totalCapacity: capacityInfo.totalCapacity,
+ usedCapacity: capacityInfo.usedCapacity,
+ availableCapacity: capacityInfo.totalCapacity - capacityInfo.usedCapacity,
+ usagePercentage:
+ capacityInfo.totalCapacity > 0
+ ? (capacityInfo.usedCapacity / capacityInfo.totalCapacity) * 100
+ : 0
+ };
+ }
+
+ async preflightProductionJob(
+ input: { companyId: string; recipeId: string; quantity: number },
+ playerId: string
+ ): Promise {
+ const player = await resolvePlayerById(this.prisma, playerId);
+ await assertCompanyOwnedByPlayer(this.prisma, player.id, input.companyId);
+
+ const issues: ValidationIssue[] = [];
+
+ try {
+ // Check for active production buildings
+ const activeBuildingCount = await this.prisma.building.count({
+ where: {
+ companyId: input.companyId,
+ buildingType: { in: PRODUCTION_BUILDING_TYPES },
+ status: BuildingStatus.ACTIVE
+ }
+ });
+
+ if (activeBuildingCount === 0) {
+ issues.push({
+ code: "NO_ACTIVE_BUILDING",
+ message: "No active production buildings available",
+ severity: "ERROR"
+ });
+ }
+
+ // Check production capacity
+ const capacityInfo = await getProductionCapacityForCompany(
+ this.prisma,
+ input.companyId
+ );
+
+ if (capacityInfo.usedCapacity >= capacityInfo.totalCapacity) {
+ issues.push({
+ code: "BUILDING_CAPACITY_FULL",
+ message: `Production capacity full: ${capacityInfo.usedCapacity}/${capacityInfo.totalCapacity} slots used`,
+ severity: "ERROR"
+ });
+ }
+
+ // Check storage capacity for output
+ const recipe = await this.prisma.recipe.findUnique({
+ where: { id: input.recipeId },
+ include: { outputItem: true }
+ });
+
+ if (recipe) {
+ const company = await this.prisma.company.findUnique({
+ where: { id: input.companyId },
+ select: { regionId: true }
+ });
+
+ if (company) {
+ const outputQuantity = recipe.outputQuantity * input.quantity;
+ const currentInventory = await this.prisma.inventory.aggregate({
+ where: { companyId: input.companyId, regionId: company.regionId },
+ _sum: { quantity: true }
+ });
+
+ const warehouseCount = await this.prisma.building.count({
+ where: {
+ companyId: input.companyId,
+ regionId: company.regionId,
+ buildingType: BuildingType.WAREHOUSE,
+ status: BuildingStatus.ACTIVE
+ }
+ });
+
+ const capacity = calculateRegionalStorageCapacity(warehouseCount);
+ const currentTotal = currentInventory._sum.quantity || 0;
+
+ if (currentTotal + outputQuantity > capacity) {
+ issues.push({
+ code: "INSUFFICIENT_STORAGE",
+ message: `Insufficient storage: need ${currentTotal + outputQuantity}, capacity ${capacity}`,
+ severity: "ERROR"
+ });
+ } else if (currentTotal + outputQuantity > capacity * 0.8) {
+ issues.push({
+ code: "STORAGE_WARNING",
+ message: `Storage will be ${Math.round(((currentTotal + outputQuantity) / capacity) * 100)}% full after production`,
+ severity: "WARNING"
+ });
+ }
+ }
+ }
+ } catch (error) {
+ issues.push({
+ code: "VALIDATION_ERROR",
+ message: error instanceof Error ? error.message : "Unknown validation error",
+ severity: "ERROR"
+ });
+ }
+
+ return {
+ valid: !issues.some((issue) => issue.severity === "ERROR"),
+ issues
+ };
+ }
+
+ async preflightBuyOrder(
+ input: { companyId: string; regionId: string; itemId: string; quantity: number },
+ playerId: string
+ ): Promise {
+ const player = await resolvePlayerById(this.prisma, playerId);
+ await assertCompanyOwnedByPlayer(this.prisma, player.id, input.companyId);
+
+ const issues: ValidationIssue[] = [];
+
+ try {
+ // Check storage capacity
+ const currentInventory = await this.prisma.inventory.aggregate({
+ where: { companyId: input.companyId, regionId: input.regionId },
+ _sum: { quantity: true }
+ });
+
+ const warehouseCount = await this.prisma.building.count({
+ where: {
+ companyId: input.companyId,
+ regionId: input.regionId,
+ buildingType: BuildingType.WAREHOUSE,
+ status: BuildingStatus.ACTIVE
+ }
+ });
+
+ const capacity = calculateRegionalStorageCapacity(warehouseCount);
+ const currentTotal = currentInventory._sum.quantity || 0;
+
+ if (currentTotal + input.quantity > capacity) {
+ issues.push({
+ code: "INSUFFICIENT_STORAGE",
+ message: `Insufficient storage: need ${currentTotal + input.quantity}, capacity ${capacity}`,
+ severity: "ERROR"
+ });
+ } else if (currentTotal + input.quantity > capacity * 0.95) {
+ issues.push({
+ code: "STORAGE_CRITICAL",
+ message: `Storage will be ${Math.round(((currentTotal + input.quantity) / capacity) * 100)}% full after purchase`,
+ severity: "WARNING"
+ });
+ } else if (currentTotal + input.quantity > capacity * 0.8) {
+ issues.push({
+ code: "STORAGE_WARNING",
+ message: `Storage will be ${Math.round(((currentTotal + input.quantity) / capacity) * 100)}% full after purchase`,
+ severity: "WARNING"
+ });
+ }
+ } catch (error) {
+ issues.push({
+ code: "VALIDATION_ERROR",
+ message: error instanceof Error ? error.message : "Unknown validation error",
+ severity: "ERROR"
+ });
+ }
+
+ return {
+ valid: !issues.some((issue) => issue.severity === "ERROR"),
+ issues
+ };
+ }
+
+ async getBuildingTypeDefinitions(): Promise {
+ return Object.entries(BUILDING_DEFINITIONS).map(([buildingType, definition]) => ({
+ buildingType: buildingType as BuildingType,
+ ...definition
+ }));
+ }
+}
diff --git a/apps/api/src/buildings/dto/acquire-building.dto.ts b/apps/api/src/buildings/dto/acquire-building.dto.ts
new file mode 100644
index 00000000..a93b8088
--- /dev/null
+++ b/apps/api/src/buildings/dto/acquire-building.dto.ts
@@ -0,0 +1,19 @@
+import { IsEnum, IsOptional, IsString, MinLength } from "class-validator";
+import { BuildingType } from "@prisma/client";
+
+export class AcquireBuildingDto {
+ @IsString()
+ @MinLength(1)
+ companyId!: string;
+
+ @IsString()
+ @MinLength(1)
+ regionId!: string;
+
+ @IsEnum(BuildingType)
+ buildingType!: BuildingType;
+
+ @IsOptional()
+ @IsString()
+ name?: string;
+}
diff --git a/apps/api/src/buildings/dto/list-buildings.dto.ts b/apps/api/src/buildings/dto/list-buildings.dto.ts
new file mode 100644
index 00000000..4ed0edd9
--- /dev/null
+++ b/apps/api/src/buildings/dto/list-buildings.dto.ts
@@ -0,0 +1,16 @@
+import { IsEnum, IsOptional, IsString, MinLength } from "class-validator";
+import { BuildingStatus } from "@prisma/client";
+
+export class ListBuildingsDto {
+ @IsString()
+ @MinLength(1)
+ companyId!: string;
+
+ @IsOptional()
+ @IsString()
+ regionId?: string;
+
+ @IsOptional()
+ @IsEnum(BuildingStatus)
+ status?: BuildingStatus;
+}
diff --git a/apps/api/src/buildings/dto/preflight-buy-order.dto.ts b/apps/api/src/buildings/dto/preflight-buy-order.dto.ts
new file mode 100644
index 00000000..d2972c50
--- /dev/null
+++ b/apps/api/src/buildings/dto/preflight-buy-order.dto.ts
@@ -0,0 +1,20 @@
+import { IsInt, IsNumber, IsString, Min, MinLength } from "class-validator";
+
+export class PreflightBuyOrderDto {
+ @IsString()
+ @MinLength(1)
+ companyId!: string;
+
+ @IsString()
+ @MinLength(1)
+ regionId!: string;
+
+ @IsString()
+ @MinLength(1)
+ itemId!: string;
+
+ @IsNumber({ allowInfinity: false, allowNaN: false, maxDecimalPlaces: 0 })
+ @IsInt()
+ @Min(1)
+ quantity!: number;
+}
diff --git a/apps/api/src/buildings/dto/preflight-production-job.dto.ts b/apps/api/src/buildings/dto/preflight-production-job.dto.ts
new file mode 100644
index 00000000..fc3af024
--- /dev/null
+++ b/apps/api/src/buildings/dto/preflight-production-job.dto.ts
@@ -0,0 +1,16 @@
+import { IsInt, IsNumber, IsString, Min, MinLength } from "class-validator";
+
+export class PreflightProductionJobDto {
+ @IsString()
+ @MinLength(1)
+ companyId!: string;
+
+ @IsString()
+ @MinLength(1)
+ recipeId!: string;
+
+ @IsNumber({ allowInfinity: false, allowNaN: false, maxDecimalPlaces: 0 })
+ @IsInt()
+ @Min(1)
+ quantity!: number;
+}
diff --git a/apps/api/src/buildings/dto/reactivate-building.dto.ts b/apps/api/src/buildings/dto/reactivate-building.dto.ts
new file mode 100644
index 00000000..803df60f
--- /dev/null
+++ b/apps/api/src/buildings/dto/reactivate-building.dto.ts
@@ -0,0 +1,7 @@
+import { IsString, MinLength } from "class-validator";
+
+export class ReactivateBuildingDto {
+ @IsString()
+ @MinLength(1)
+ buildingId!: string;
+}
diff --git a/apps/web/app/buildings/page.tsx b/apps/web/app/buildings/page.tsx
new file mode 100644
index 00000000..ce363a60
--- /dev/null
+++ b/apps/web/app/buildings/page.tsx
@@ -0,0 +1,5 @@
+import { BuildingsPage } from "@/components/buildings/buildings-page";
+
+export default function BuildingsRoute() {
+ return ;
+}
diff --git a/apps/web/package.json b/apps/web/package.json
index 4ada5cea..5df8cdca 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -10,7 +10,10 @@
},
"dependencies": {
"@corpsim/shared": "workspace:*",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.4",
+ "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.1",
"better-auth": "^1.4.18",
diff --git a/apps/web/src/components/buildings/acquire-building-dialog.tsx b/apps/web/src/components/buildings/acquire-building-dialog.tsx
new file mode 100644
index 00000000..0fec61da
--- /dev/null
+++ b/apps/web/src/components/buildings/acquire-building-dialog.tsx
@@ -0,0 +1,241 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { useActiveCompany } from "@/components/company/active-company-provider";
+import { useToastManager } from "@/components/ui/toast-manager";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle
+} from "@/components/ui/dialog";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue
+} from "@/components/ui/select";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ BuildingType,
+ BuildingTypeDefinition,
+ RegionSummary,
+ acquireBuilding,
+ getBuildingTypeDefinitions,
+ listRegions
+} from "@/lib/api";
+import { formatCents } from "@/lib/format";
+
+interface AcquireBuildingDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSuccess: () => void;
+}
+
+export function AcquireBuildingDialog({
+ open,
+ onOpenChange,
+ onSuccess
+}: AcquireBuildingDialogProps) {
+ const { activeCompany, activeCompanyId } = useActiveCompany();
+ const { showToast } = useToastManager();
+ const [definitions, setDefinitions] = useState([]);
+ const [regions, setRegions] = useState([]);
+ const [selectedBuildingType, setSelectedBuildingType] = useState("");
+ const [selectedRegionId, setSelectedRegionId] = useState("");
+ const [buildingName, setBuildingName] = useState("");
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const loadData = useCallback(async () => {
+ try {
+ const [defs, regs] = await Promise.all([
+ getBuildingTypeDefinitions(),
+ listRegions()
+ ]);
+ setDefinitions(defs);
+ setRegions(regs);
+ } catch (caught) {
+ console.error("Failed to load building data:", caught);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (open) {
+ void loadData();
+ setSelectedRegionId(activeCompany?.regionId ?? "");
+ setSelectedBuildingType("");
+ setBuildingName("");
+ }
+ }, [open, loadData, activeCompany?.regionId]);
+
+ const selectedDefinition = definitions.find((d) => d.buildingType === selectedBuildingType);
+
+ const handleSubmit = useCallback(
+ async (event: React.FormEvent) => {
+ event.preventDefault();
+
+ if (!activeCompanyId || !selectedBuildingType || !selectedRegionId) {
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ await acquireBuilding({
+ companyId: activeCompanyId,
+ regionId: selectedRegionId,
+ buildingType: selectedBuildingType as BuildingType,
+ name: buildingName || undefined
+ });
+
+ showToast({
+ title: "Building Acquired",
+ description: `Successfully acquired ${selectedDefinition?.name ?? selectedBuildingType}`,
+ variant: "success"
+ });
+
+ onSuccess();
+ } catch (caught) {
+ showToast({
+ title: "Acquisition Failed",
+ description: caught instanceof Error ? caught.message : "Failed to acquire building",
+ variant: "error"
+ });
+ } finally {
+ setIsSubmitting(false);
+ }
+ },
+ [
+ activeCompanyId,
+ selectedBuildingType,
+ selectedRegionId,
+ buildingName,
+ selectedDefinition,
+ onSuccess,
+ showToast
+ ]
+ );
+
+ const currentCash = activeCompany?.cashCents
+ ? BigInt(activeCompany.cashCents)
+ : BigInt(0);
+ const acquisitionCost = selectedDefinition
+ ? BigInt(selectedDefinition.acquisitionCostCents)
+ : BigInt(0);
+ const canAfford = currentCash >= acquisitionCost;
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/buildings/buildings-page.tsx b/apps/web/src/components/buildings/buildings-page.tsx
new file mode 100644
index 00000000..371a594c
--- /dev/null
+++ b/apps/web/src/components/buildings/buildings-page.tsx
@@ -0,0 +1,249 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useActiveCompany } from "@/components/company/active-company-provider";
+import { useWorldHealth } from "@/components/layout/world-health-provider";
+import { useToastManager } from "@/components/ui/toast-manager";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { Badge } from "@/components/ui/badge";
+import {
+ BuildingRecord,
+ BuildingStatus,
+ BuildingTypeDefinition,
+ listBuildings,
+ getBuildingTypeDefinitions,
+ reactivateBuilding
+} from "@/lib/api";
+import { formatCents } from "@/lib/format";
+import { UI_COPY } from "@/lib/ui-copy";
+import { AcquireBuildingDialog } from "./acquire-building-dialog";
+
+const STATUS_BADGE_VARIANTS: Record = {
+ ACTIVE: "default",
+ INACTIVE: "danger",
+ CONSTRUCTION: "muted"
+};
+
+export function BuildingsPage() {
+ const { activeCompany, activeCompanyId } = useActiveCompany();
+ const { health } = useWorldHealth();
+ const { showToast } = useToastManager();
+ const [buildings, setBuildings] = useState([]);
+ const [definitions, setDefinitions] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [acquireDialogOpen, setAcquireDialogOpen] = useState(false);
+
+ const loadBuildings = useCallback(async () => {
+ if (!activeCompanyId) {
+ setBuildings([]);
+ return;
+ }
+
+ setIsLoading(true);
+ try {
+ const buildingRecords = await listBuildings({ companyId: activeCompanyId });
+ setBuildings(buildingRecords);
+ setError(null);
+ } catch (caught) {
+ setError(caught instanceof Error ? caught.message : "Failed to load buildings");
+ } finally {
+ setIsLoading(false);
+ }
+ }, [activeCompanyId]);
+
+ const loadDefinitions = useCallback(async () => {
+ try {
+ const defs = await getBuildingTypeDefinitions();
+ setDefinitions(defs);
+ } catch (caught) {
+ console.error("Failed to load building definitions:", caught);
+ }
+ }, []);
+
+ useEffect(() => {
+ void loadBuildings();
+ }, [loadBuildings]);
+
+ useEffect(() => {
+ void loadDefinitions();
+ }, [loadDefinitions]);
+
+ useEffect(() => {
+ const tick = health?.currentTick;
+ if (tick === undefined || !activeCompanyId) {
+ return;
+ }
+
+ const timeout = setTimeout(() => {
+ void loadBuildings();
+ }, 500);
+
+ return () => clearTimeout(timeout);
+ }, [health?.currentTick, loadBuildings, activeCompanyId]);
+
+ const groupedBuildings = useMemo(() => {
+ const byRegion: Record = {};
+
+ for (const building of buildings) {
+ const key = building.region.name;
+ if (!byRegion[key]) {
+ byRegion[key] = [];
+ }
+ byRegion[key].push(building);
+ }
+
+ return byRegion;
+ }, [buildings]);
+
+ const handleReactivate = useCallback(
+ async (buildingId: string) => {
+ try {
+ await reactivateBuilding(buildingId);
+ showToast({
+ title: "Building Reactivated",
+ variant: "success"
+ });
+ void loadBuildings();
+ } catch (caught) {
+ showToast({
+ title: "Reactivation Failed",
+ description: caught instanceof Error ? caught.message : "Failed to reactivate building",
+ variant: "error"
+ });
+ }
+ },
+ [loadBuildings, showToast]
+ );
+
+ const handleAcquireSuccess = useCallback(() => {
+ setAcquireDialogOpen(false);
+ void loadBuildings();
+ }, [loadBuildings]);
+
+ if (!activeCompanyId) {
+ return (
+
+
+ Buildings
+
+
+ {UI_COPY.common.noCompanySelected}
+
+
+ );
+ }
+
+ return (
+
+
+
+ Buildings
+ setAcquireDialogOpen(true)}>Acquire Building
+
+
+
+ Active company: {activeCompany?.name ?? UI_COPY.common.noCompanySelected}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {isLoading && buildings.length === 0 ? (
+ Loading buildings...
+ ) : buildings.length === 0 ? (
+
+ No buildings owned. Click "Acquire Building" to purchase your first facility.
+
+ ) : (
+
+ {Object.entries(groupedBuildings).map(([regionName, regionBuildings]) => (
+
+
{regionName}
+
+
+
+ Type
+ Name
+ Status
+ Weekly Cost
+ Capacity
+ Actions
+
+
+
+ {regionBuildings.map((building) => {
+ const definition = definitions.find(
+ (d) => d.buildingType === building.buildingType
+ );
+ return (
+
+
+ {definition?.name ?? building.buildingType}
+ {definition?.category && (
+
+ ({definition.category})
+
+ )}
+
+
+ {building.name || "—"}
+
+
+
+ {building.status}
+
+ {building.status === "INACTIVE" && (
+
+ Cannot afford operating costs
+
+ )}
+
+
+ {formatCents(building.weeklyOperatingCostCents)}
+
+
+ {building.capacitySlots}
+ {definition?.category === "STORAGE" && (
+
+ {" "}
+ (+{definition.storageCapacity} storage)
+
+ )}
+
+
+ {building.status === "INACTIVE" && (
+ handleReactivate(building.id)}
+ >
+ Reactivate
+
+ )}
+
+
+ );
+ })}
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/buildings/storage-meter.tsx b/apps/web/src/components/buildings/storage-meter.tsx
new file mode 100644
index 00000000..213ca9c1
--- /dev/null
+++ b/apps/web/src/components/buildings/storage-meter.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { RegionalStorageInfo, getRegionalStorageInfo } from "@/lib/api";
+import { Progress } from "@/components/ui/progress";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { AlertTriangle, AlertCircle } from "lucide-react";
+
+interface StorageMeterProps {
+ companyId: string;
+ regionId: string;
+ className?: string;
+ showDetails?: boolean;
+}
+
+export function StorageMeter({
+ companyId,
+ regionId,
+ className = "",
+ showDetails = true
+}: StorageMeterProps) {
+ const [info, setInfo] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let mounted = true;
+
+ const loadInfo = async () => {
+ setIsLoading(true);
+ try {
+ const storageInfo = await getRegionalStorageInfo(companyId, regionId);
+ if (mounted) {
+ setInfo(storageInfo);
+ setError(null);
+ }
+ } catch (caught) {
+ if (mounted) {
+ setError(caught instanceof Error ? caught.message : "Failed to load storage info");
+ }
+ } finally {
+ if (mounted) {
+ setIsLoading(false);
+ }
+ }
+ };
+
+ void loadInfo();
+
+ return () => {
+ mounted = false;
+ };
+ }, [companyId, regionId]);
+
+ if (isLoading) {
+ return (
+
+
Loading storage info...
+
+ );
+ }
+
+ if (error || !info) {
+ return null;
+ }
+
+ const percentage = info.usagePercentage;
+ const warningThreshold = 80;
+ const criticalThreshold = 95;
+ const fullThreshold = 100;
+
+ let progressColor = "bg-primary";
+ let showWarning = false;
+ let warningMessage = "";
+ let warningIcon = ;
+
+ if (percentage >= fullThreshold) {
+ progressColor = "bg-destructive";
+ showWarning = true;
+ warningMessage = "Storage is full! Cannot store more items.";
+ warningIcon = ;
+ } else if (percentage >= criticalThreshold) {
+ progressColor = "bg-destructive";
+ showWarning = true;
+ warningMessage = `Storage is ${percentage.toFixed(0)}% full. Critical capacity!`;
+ } else if (percentage >= warningThreshold) {
+ progressColor = "bg-yellow-500";
+ showWarning = true;
+ warningMessage = `Storage is ${percentage.toFixed(0)}% full. Consider expanding.`;
+ }
+
+ return (
+
+ {showDetails && (
+
+ Regional Storage
+
+ {info.currentUsage.toLocaleString()} / {info.maxCapacity.toLocaleString()}
+
+
+ )}
+
+
+
+ {showDetails && (
+
+ {percentage.toFixed(1)}% used
+ {info.warehouseCount > 0 && (
+
+ {info.warehouseCount} warehouse{info.warehouseCount !== 1 ? "s" : ""}
+
+ )}
+
+ )}
+
+ {showWarning && showDetails && (
+
= fullThreshold ? "destructive" : "default"} className="mt-2">
+
+ {warningIcon}
+
{warningMessage}
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/logistics/logistics-page.tsx b/apps/web/src/components/logistics/logistics-page.tsx
index 4ab5d6b8..62361e40 100644
--- a/apps/web/src/components/logistics/logistics-page.tsx
+++ b/apps/web/src/components/logistics/logistics-page.tsx
@@ -5,6 +5,7 @@ import { useActiveCompany } from "@/components/company/active-company-provider";
import { ItemLabel } from "@/components/items/item-label";
import { useWorldHealth } from "@/components/layout/world-health-provider";
import { useUiSfx } from "@/components/layout/ui-sfx-provider";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -31,6 +32,7 @@ import {
import { formatCents } from "@/lib/format";
import { UI_CADENCE_TERMS } from "@/lib/ui-terms";
import { formatCodeLabel, getRegionLabel, UI_COPY } from "@/lib/ui-copy";
+import Link from "next/link";
const SHIPMENT_REFRESH_DEBOUNCE_MS = 600;
const SHIPMENT_BASE_FEE_CENTS = Number.parseInt(
@@ -714,28 +716,62 @@ export function LogisticsPage() {
{showInitialShipmentsSkeleton && pagedInTransit.length === 0 ? : null}
- {pagedInTransit.map((shipment) => (
-
-
-
-
-
- {`${getRegionLabel({ code: shipment.fromRegion.code, name: shipment.fromRegion.name })} -> ${getRegionLabel({ code: shipment.toRegion.code, name: shipment.toRegion.name })}`}
-
- {shipment.quantity}
- {shipment.tickArrives}
-
- void handleCancelShipment(shipment.id)}
- disabled={isSubmitting}
- >
- Cancel
-
-
-
- ))}
+ {pagedInTransit.map((shipment) => {
+ const isStuck = health?.currentTick !== undefined && shipment.tickArrives < health.currentTick;
+ return (
+
+
+
+
+
+
+
+ {`${getRegionLabel({ code: shipment.fromRegion.code, name: shipment.fromRegion.name })} -> ${getRegionLabel({ code: shipment.toRegion.code, name: shipment.toRegion.name })}`}
+
+ {isStuck ? (
+
+ Stuck
+
+ ) : null}
+
+
+ {shipment.quantity}
+ {shipment.tickArrives}
+
+ {isStuck ? (
+
+
+
+ Manage Storage
+
+
+ void handleCancelShipment(shipment.id)}
+ disabled={isSubmitting}
+ >
+ Cancel
+
+
+ ) : (
+ void handleCancelShipment(shipment.id)}
+ disabled={isSubmitting}
+ >
+ Cancel
+
+ )}
+
+
+ );
+ })}
{!showInitialShipmentsSkeleton && pagedInTransit.length === 0 ? (
diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx
new file mode 100644
index 00000000..04d5163a
--- /dev/null
+++ b/apps/web/src/components/ui/dialog.tsx
@@ -0,0 +1,121 @@
+"use client";
+
+import * as React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+const Dialog = DialogPrimitive.Root;
+
+const DialogTrigger = DialogPrimitive.Trigger;
+
+const DialogPortal = DialogPrimitive.Portal;
+
+const DialogClose = DialogPrimitive.Close;
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogHeader.displayName = "DialogHeader";
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogFooter.displayName = "DialogFooter";
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogClose,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription
+};
diff --git a/apps/web/src/components/ui/label.tsx b/apps/web/src/components/ui/label.tsx
new file mode 100644
index 00000000..6ac6d6aa
--- /dev/null
+++ b/apps/web/src/components/ui/label.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import * as React from "react";
+import * as LabelPrimitive from "@radix-ui/react-label";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/lib/utils";
+
+const labelVariants = cva(
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+);
+
+const Label = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & VariantProps
+>(({ className, ...props }, ref) => (
+
+));
+Label.displayName = LabelPrimitive.Root.displayName;
+
+export { Label };
diff --git a/apps/web/src/components/ui/progress.tsx b/apps/web/src/components/ui/progress.tsx
new file mode 100644
index 00000000..12e3e72b
--- /dev/null
+++ b/apps/web/src/components/ui/progress.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import * as React from "react";
+import * as ProgressPrimitive from "@radix-ui/react-progress";
+import { cn } from "@/lib/utils";
+
+const Progress = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ indicatorClassName?: string;
+ }
+>(({ className, value, indicatorClassName, ...props }, ref) => (
+
+
+
+));
+Progress.displayName = ProgressPrimitive.Root.displayName;
+
+export { Progress };
diff --git a/apps/web/src/lib/api-parsers.ts b/apps/web/src/lib/api-parsers.ts
index 0c584d44..6fa7ed25 100644
--- a/apps/web/src/lib/api-parsers.ts
+++ b/apps/web/src/lib/api-parsers.ts
@@ -1,4 +1,9 @@
import type {
+ BuildingCategory,
+ BuildingRecord,
+ BuildingStatus,
+ BuildingType,
+ BuildingTypeDefinition,
CompanySpecialization,
CompanySpecializationOption,
DatabaseSchemaReadiness,
@@ -25,14 +30,18 @@ import type {
PlayerRegistryCompany,
PlayerRegistryEntry,
PlayerRegistryItemHolding,
+ PreflightValidationResult,
+ ProductionCapacityInfo,
ProductionJob,
ProductionJobStatus,
ProductionRecipe,
+ RegionalStorageInfo,
RegionSummary,
ResearchJob,
ResearchNode,
ResearchNodeStatus,
ShipmentRecord,
+ ValidationIssue,
WorkforceCapacityChangeResult,
WorldHealth,
WorldTickState
@@ -905,3 +914,118 @@ export function parseWorkforceCapacityChangeResult(
};
}
+export function parseBuildingRecord(value: unknown): BuildingRecord {
+ if (!isRecord(value)) {
+ throw new Error("Invalid building record payload");
+ }
+
+ if (!isRecord(value.region)) {
+ throw new Error("Invalid building region payload");
+ }
+
+ return {
+ id: readString(value.id, "id"),
+ companyId: readString(value.companyId, "companyId"),
+ regionId: readString(value.regionId, "regionId"),
+ buildingType: readString(value.buildingType, "buildingType") as BuildingType,
+ status: readString(value.status, "status") as BuildingStatus,
+ name: readNullableString(value.name, "name"),
+ acquisitionCostCents: readString(value.acquisitionCostCents, "acquisitionCostCents"),
+ weeklyOperatingCostCents: readString(
+ value.weeklyOperatingCostCents,
+ "weeklyOperatingCostCents"
+ ),
+ capacitySlots: readNumber(value.capacitySlots, "capacitySlots"),
+ tickAcquired: readNumber(value.tickAcquired, "tickAcquired"),
+ tickConstructionCompletes:
+ value.tickConstructionCompletes === null
+ ? null
+ : readNumber(value.tickConstructionCompletes, "tickConstructionCompletes"),
+ lastOperatingCostTick:
+ value.lastOperatingCostTick === null
+ ? null
+ : readNumber(value.lastOperatingCostTick, "lastOperatingCostTick"),
+ createdAt: readString(value.createdAt, "createdAt"),
+ updatedAt: readString(value.updatedAt, "updatedAt"),
+ region: {
+ id: readString(value.region.id, "region.id"),
+ code: readString(value.region.code, "region.code"),
+ name: readString(value.region.name, "region.name")
+ }
+ };
+}
+
+export function parseRegionalStorageInfo(value: unknown): RegionalStorageInfo {
+ if (!isRecord(value)) {
+ throw new Error("Invalid regional storage info payload");
+ }
+
+ return {
+ companyId: readString(value.companyId, "companyId"),
+ regionId: readString(value.regionId, "regionId"),
+ currentUsage: readNumber(value.currentUsage, "currentUsage"),
+ maxCapacity: readNumber(value.maxCapacity, "maxCapacity"),
+ usagePercentage: readNumber(value.usagePercentage, "usagePercentage"),
+ warehouseCount: readNumber(value.warehouseCount, "warehouseCount")
+ };
+}
+
+export function parseProductionCapacityInfo(value: unknown): ProductionCapacityInfo {
+ if (!isRecord(value)) {
+ throw new Error("Invalid production capacity info payload");
+ }
+
+ return {
+ companyId: readString(value.companyId, "companyId"),
+ totalCapacity: readNumber(value.totalCapacity, "totalCapacity"),
+ usedCapacity: readNumber(value.usedCapacity, "usedCapacity"),
+ availableCapacity: readNumber(value.availableCapacity, "availableCapacity"),
+ usagePercentage: readNumber(value.usagePercentage, "usagePercentage")
+ };
+}
+
+export function parseValidationIssue(value: unknown): ValidationIssue {
+ if (!isRecord(value)) {
+ throw new Error("Invalid validation issue payload");
+ }
+
+ return {
+ code: readString(value.code, "code"),
+ message: readString(value.message, "message"),
+ severity: readString(value.severity, "severity") as "ERROR" | "WARNING"
+ };
+}
+
+export function parsePreflightValidationResult(value: unknown): PreflightValidationResult {
+ if (!isRecord(value)) {
+ throw new Error("Invalid preflight validation result payload");
+ }
+
+ return {
+ valid: readBoolean(value.valid, "valid"),
+ issues: readArray(value.issues, "issues").map(parseValidationIssue)
+ };
+}
+
+export function parseBuildingTypeDefinition(value: unknown): BuildingTypeDefinition {
+ if (!isRecord(value)) {
+ throw new Error("Invalid building type definition payload");
+ }
+
+ return {
+ buildingType: readString(value.buildingType, "buildingType") as BuildingType,
+ category: readString(value.category, "category") as BuildingCategory,
+ name: readString(value.name, "name"),
+ description: readString(value.description, "description"),
+ acquisitionCostCents: readString(value.acquisitionCostCents, "acquisitionCostCents"),
+ weeklyOperatingCostCents: readString(
+ value.weeklyOperatingCostCents,
+ "weeklyOperatingCostCents"
+ ),
+ capacitySlots: readNumber(value.capacitySlots, "capacitySlots"),
+ storageCapacity:
+ value.storageCapacity === undefined
+ ? undefined
+ : readNumber(value.storageCapacity, "storageCapacity")
+ };
+}
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index d09e5a78..00a6f343 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -1,4 +1,8 @@
import type {
+ AcquireBuildingInput,
+ BuildingListFilters,
+ BuildingRecord,
+ BuildingTypeDefinition,
CompanySpecialization,
CompanySpecializationOption,
CompanyWorkforce,
@@ -27,9 +31,12 @@ import type {
PlaceMarketOrderInput,
PlayerIdentity,
PlayerRegistryEntry,
+ PreflightValidationResult,
+ ProductionCapacityInfo,
ProductionJob,
ProductionJobFilters,
ProductionRecipe,
+ RegionalStorageInfo,
RegionSummary,
ResearchJob,
ResearchNode,
@@ -55,6 +62,8 @@ import {
invalidateCachedRequestPrefix
} from "./request-cache";
import {
+ parseBuildingRecord,
+ parseBuildingTypeDefinition,
parseCompanyDetails,
parseCompanySpecializationOption,
parseCompanyWorkforce,
@@ -74,8 +83,11 @@ import {
parseOnboardingStatus,
parsePlayerIdentity,
parsePlayerRegistryEntry,
+ parsePreflightValidationResult,
+ parseProductionCapacityInfo,
parseProductionJob,
parseProductionRecipe,
+ parseRegionalStorageInfo,
parseRegionSummary,
parseResearchJob,
parseResearchNode,
@@ -836,3 +848,84 @@ export async function resetWorld(reseed = true): Promise {
invalidateResearchCaches();
return result;
}
+
+// Buildings API
+
+export async function listBuildings(filters: BuildingListFilters): Promise {
+ const params = new URLSearchParams();
+ params.set("companyId", filters.companyId);
+ if (filters.regionId) {
+ params.set("regionId", filters.regionId);
+ }
+ if (filters.status) {
+ params.set("status", filters.status);
+ }
+
+ return fetchJson(`/v1/buildings?${params.toString()}`, (value) =>
+ readArray(value, "buildings").map(parseBuildingRecord)
+ );
+}
+
+export async function acquireBuilding(input: AcquireBuildingInput): Promise {
+ return fetchJson("/v1/buildings/acquire", parseBuildingRecord, {
+ method: "POST",
+ body: JSON.stringify(input)
+ });
+}
+
+export async function reactivateBuilding(buildingId: string): Promise {
+ return fetchJson("/v1/buildings/reactivate", parseBuildingRecord, {
+ method: "POST",
+ body: JSON.stringify({ buildingId })
+ });
+}
+
+export async function getRegionalStorageInfo(
+ companyId: string,
+ regionId: string
+): Promise {
+ const params = new URLSearchParams();
+ params.set("companyId", companyId);
+ params.set("regionId", regionId);
+
+ return fetchJson(`/v1/buildings/storage-info?${params.toString()}`, parseRegionalStorageInfo);
+}
+
+export async function getProductionCapacityInfo(
+ companyId: string
+): Promise {
+ const params = new URLSearchParams();
+ params.set("companyId", companyId);
+
+ return fetchJson(`/v1/buildings/capacity-info?${params.toString()}`, parseProductionCapacityInfo);
+}
+
+export async function preflightProductionJob(input: {
+ companyId: string;
+ recipeId: string;
+ quantity: number;
+}): Promise {
+ return fetchJson("/v1/buildings/preflight/production-job", parsePreflightValidationResult, {
+ method: "POST",
+ body: JSON.stringify(input)
+ });
+}
+
+export async function preflightBuyOrder(input: {
+ companyId: string;
+ regionId: string;
+ itemId: string;
+ quantity: number;
+}): Promise {
+ return fetchJson("/v1/buildings/preflight/buy-order", parsePreflightValidationResult, {
+ method: "POST",
+ body: JSON.stringify(input)
+ });
+}
+
+export async function getBuildingTypeDefinitions(): Promise {
+ return fetchJson("/v1/buildings/definitions", (value) =>
+ readArray(value, "definitions").map(parseBuildingTypeDefinition)
+ );
+}
+
diff --git a/docs/overflow-policy-and-economics.md b/docs/overflow-policy-and-economics.md
new file mode 100644
index 00000000..bb2daa22
--- /dev/null
+++ b/docs/overflow-policy-and-economics.md
@@ -0,0 +1,305 @@
+# Phase 4 & 5 - Overflow Policy & Economic Design
+
+## Deterministic Overflow Policy
+
+### Strategy: Hybrid Approach
+
+**1. Player-Initiated Actions → REJECT AT SOURCE**
+- Production job creation
+- Market buy orders
+- Building acquisition
+
+**Behavior:** Validation occurs BEFORE transaction. Operation fails with actionable error message.
+
+**Implementation:**
+```typescript
+// Production
+await validateStorageCapacity(tx, companyId, regionId, netInventoryChange);
+// Location: packages/sim/src/services/production.ts:247-252
+
+// Market Buy
+await validateStorageCapacity(tx, buyOrder.companyId, buyOrder.regionId, match.quantity);
+// Location: packages/sim/src/services/market-matching.ts:356-361
+```
+
+**2. System-Driven Operations → DELIVERY ROLLBACK**
+- Shipment deliveries
+
+**Behavior:** When destination storage full, delivery rolls back to origin (NOT a new shipment with travel time).
+
+**Implementation:**
+```typescript
+try {
+ await validateStorageCapacity(tx, shipment.companyId, shipment.toRegionId, shipment.quantity);
+ destinationRegionId = shipment.toRegionId; // Normal delivery
+} catch (error) {
+ if (error instanceof DomainInvariantError && error.message.includes("storage capacity exceeded")) {
+ destinationRegionId = shipment.fromRegionId; // Rollback to origin
+ returnedCount += 1;
+
+ // CRITICAL: Validate origin capacity (maintains hard cap invariants)
+ await validateStorageCapacity(tx, shipment.companyId, shipment.fromRegionId, shipment.quantity);
+ // If origin also full, delivery fails - player must manage storage better
+ }
+}
+// Location: packages/sim/src/services/shipments.ts:713-734
+```
+
+**Key Differences from "Return to Sender":**
+- This is a ROLLBACK, not a logistics return
+- No new shipment record created
+- No additional travel time
+- Origin capacity IS validated (hard caps maintained)
+- May fail if both regions full (acceptable - player error)
+
+---
+
+## Multi-Operation Storage Contention
+
+### Same-Tick Processing Order
+
+When multiple operations target the same storage in a single tick:
+
+1. **Deterministic Order:** All operations processed in deterministic order: `ORDER BY tickArrives ASC, tickCreated ASC`
+ - Uses tickCreated (deterministic) instead of createdAt (wall-clock time)
+ - Guarantees same order on replay
+2. **Sequential Validation:** Each operation validates against current state
+3. **First-Come-First-Served:** Early operations succeed, later ones may fail/rollback
+
+### Example Scenario
+
+```
+Tick 100 at Region A (capacity: 1000, current: 900):
+ 1. Shipment 1 (tickCreated=50) arrives (quantity: 50) → ✅ Delivered (950/1000)
+ 2. Shipment 2 (tickCreated=51) arrives (quantity: 100) → ❌ Rollback to origin
+ - Destination full, attempts rollback
+ - Origin capacity validated - succeeds or fails based on origin state
+ 3. Production completes (net +80) → ❌ Fails validation (950/1000)
+```
+
+**Key Insight:** No race conditions possible - everything is sequential within transaction, ordered by deterministic fields.
+
+---
+
+## Soft-Lock Prevention
+
+### Problem Statement
+Without overflow policy, player could be stuck:
+- Ship 1000 items to Region B
+- Region B only has 500 capacity
+- Shipment arrival fails tick processing
+- **Game broken - tick cannot advance**
+
+### Solution
+Shipment delivery has three-tier fallback to ensure tick NEVER fails:
+
+**Tier 1: Normal Delivery (Destination Has Space)**
+- ✅ Tick advances
+- ✅ Items delivered to destination
+- ✅ Shipment status: DELIVERED
+
+**Tier 2: Rollback (Destination Full, Origin Has Space)**
+- ✅ Tick advances
+- ✅ Items returned to origin
+- ❌ Logistics fee wasted ($250 + $15/unit)
+- ❌ Travel time wasted
+- ✅ Shipment status: DELIVERED (with rollback)
+
+**Tier 3: Retry (Both Regions Full)**
+- ✅ Tick advances (CRITICAL - never fails)
+- ⏸️ Shipment stays IN_TRANSIT
+- 🔄 Automatic retry next tick
+- ℹ️ Player must clear space in either region
+- ✅ Shipment status: IN_TRANSIT (unchanged)
+
+**Player learns:**
+- Check destination capacity before shipping
+- Don't overfill origin during transit
+- Manage storage proactively to avoid retry loops
+
+**Hard Cap Invariants Maintained:**
+- All regions respect capacity limits
+- No storage bypass in any scenario
+- Retry mechanism prevents soft-lock without violating invariants
+
+---
+
+## Economic Balance Analysis
+
+### Building Cost Structure
+
+| Building | Acquisition | Weekly Cost | Capacity | Category |
+|----------|-------------|-------------|----------|----------|
+| Workshop | $25,000 | $1,500 | 1 slot | Early |
+| Mine | $100,000 | $5,000 | 2 slots | Early |
+| Farm | $80,000 | $4,000 | 2 slots | Early |
+| Factory | $250,000 | $12,000 | 3 slots | Mid |
+| MegaFactory | $1,000,000 | $50,000 | 10 slots | Late |
+| Warehouse | $150,000 | $8,000 | +500 items | Storage |
+| HQ | $500,000 | $25,000 | 1 slot | Corporate |
+| R&D Center | $300,000 | $15,000 | 1 slot | Corporate |
+
+### Break-Even Analysis
+
+#### Assumptions
+- Player starts with $500,000 seed capital (default)
+- Production recipes yield 20-40% margin
+- Weekly operating costs must be covered by revenue
+
+#### Workshop (Recommended Addition)
+**Cost:** $25k acquisition + $1.5k/week
+**Break-Even:** $1.5k weekly profit = $214/day @ 7 ticks/week
+**Use Case:** First building for absolute beginners
+**Status:** NOT YET IMPLEMENTED - RECOMMENDED
+
+#### Mine
+**Cost:** $100k acquisition + $5k/week
+**Break-Even:** $5k weekly profit = $714/day
+**Feasible:** ✅ With basic ore → metal recipe
+**Risk:** Moderate - needs consistent production
+
+#### Farm
+**Cost:** $80k acquisition + $4k/week
+**Break-Even:** $4k weekly profit = $571/day
+**Feasible:** ✅ With agricultural products
+**Risk:** Low-moderate
+
+#### Factory
+**Cost:** $250k acquisition + $12k/week
+**Break-Even:** $12k weekly profit = $1,714/day
+**Feasible:** ⚠️ Requires established operation
+**Risk:** High for early game
+
+#### MegaFactory
+**Cost:** $1M acquisition + $50k/week
+**Break-Even:** $50k weekly profit = $7,142/day
+**Feasible:** ❌ Late game only
+**Risk:** **DANGEROUS** if purchased too early - can bankrupt company
+
+### Early Game Viability Assessment
+
+**Current State:** ⚠️ **MARGINAL**
+- Cheapest building: Mine at $100k (20% of starting capital)
+- Must produce $5k profit/week immediately
+- No forgiveness for operational errors
+
+**Recommendation:** Add Workshop tier
+```typescript
+WORKSHOP: {
+ category: "PRODUCTION",
+ name: "Workshop",
+ description: "Small-scale production facility for beginners",
+ acquisitionCostCents: "2500000", // $25,000
+ weeklyOperatingCostCents: "150000", // $1,500/week
+ capacitySlots: 1
+}
+```
+
+### Over-Expansion Risk
+
+**Scenario:** Player buys MegaFactory too early
+- Acquisition: -$1M (leaves $0 if starting capital)
+- Week 1 cost: -$50k → **BANKRUPT** (can't pay)
+- Building → INACTIVE
+- Production halts
+- Soft-lock: Can't earn money without production
+
+**Prevention:**
+1. ✅ Preflight validation warns about costs
+2. ✅ Building deactivation preserves company (doesn't delete)
+3. ✅ Reactivation possible when cash available
+4. ⏳ UI warning for "danger zone" acquisitions
+
+### Storage Lock Scenarios
+
+**Scenario:** Player fills all storage, can't receive shipments/production
+- Current storage: 1000/1000 (no warehouses)
+- Shipment arrives: 500 units → Returns to sender
+- Production completes: 100 units → **FAILS** (loses inputs)
+
+**Prevention:**
+1. ✅ Storage meter shows capacity in UI
+2. ✅ Preflight validation warns before production
+3. ✅ Shipments return safely (no loss)
+4. ⚠️ Production failure **LOSES INPUTS** - this is harsh
+5. **TODO:** Consider returning production inputs to inventory on storage failure
+
+---
+
+## Integration Test Coverage Plan
+
+### Required Tests (Deferred - Need Database)
+
+#### 1. Shipment Overflow Return
+```typescript
+it("returns shipment to sender when destination storage full")
+it("delivers shipment successfully when capacity available")
+it("handles multiple shipments with mixed outcomes")
+```
+
+#### 2. Same-Tick Storage Contention
+```typescript
+it("processes operations in deterministic order")
+it("allows first operation, rejects second when total exceeds capacity")
+```
+
+#### 3. Building Deactivation During Production
+```typescript
+it("allows running jobs to complete after building deactivates")
+it("prevents new jobs when all buildings inactive")
+it("reactivates building and allows new jobs")
+```
+
+#### 4. Acquisition at Cash Boundary
+```typescript
+it("allows acquisition when exactly at acquisition cost")
+it("rejects acquisition when $1 short")
+it("respects reserved cash during acquisition")
+```
+
+#### 5. Bankruptcy Risk
+```typescript
+it("deactivates buildings when operating cost exceeds available cash")
+it("preserves company when all buildings deactivated")
+it("allows recovery via market sales or contracts")
+```
+
+---
+
+## Production-Grade Checklist
+
+- [x] Deterministic overflow policy defined
+- [x] Shipment return-to-sender implemented
+- [x] Documentation comprehensive
+- [x] All unit tests passing (64/64)
+- [ ] Integration tests implemented (requires DB setup)
+- [ ] Workshop tier added to building definitions
+- [ ] Economic balance validated with real gameplay
+- [ ] Production failure input recovery (design decision needed)
+- [ ] Player-facing overflow documentation
+- [ ] UI warnings for danger zone operations
+
+---
+
+## Open Design Questions
+
+### 1. Production Failure Behavior
+**Current:** Inputs consumed, no output (storage full)
+**Alternative:** Return inputs to inventory, cancel job
+**Decision:** **Reviewer input needed**
+
+### 2. Workshop Tier Priority
+**Recommendation:** High priority
+**Rationale:** Early game currently too harsh
+**Decision:** **Approve for implementation?**
+
+### 3. Automatic Building Reactivation
+**Current:** Manual only
+**Alternative:** Auto-reactivate when cash available
+**Decision:** **Keep manual for player control**
+
+---
+
+**Document Version:** 1.0
+**Last Updated:** 2026-02-19
+**Status:** Awaiting Reviewer Feedback
diff --git a/packages/db/prisma/migrations/20260219133500_add_workshop_building_type/migration.sql b/packages/db/prisma/migrations/20260219133500_add_workshop_building_type/migration.sql
new file mode 100644
index 00000000..48b2265d
--- /dev/null
+++ b/packages/db/prisma/migrations/20260219133500_add_workshop_building_type/migration.sql
@@ -0,0 +1,2 @@
+-- AlterEnum
+ALTER TYPE "BuildingType" ADD VALUE 'WORKSHOP';
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index b640fe77..6902eef1 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -77,6 +77,7 @@ enum LedgerEntryType {
}
enum BuildingType {
+ WORKSHOP
MINE
FARM
FACTORY
diff --git a/packages/shared/src/api-types.ts b/packages/shared/src/api-types.ts
index 519d3227..260b9048 100644
--- a/packages/shared/src/api-types.ts
+++ b/packages/shared/src/api-types.ts
@@ -557,3 +557,91 @@ export interface WorkforceCapacityChangeResult {
workforceCapacity: number;
orgEfficiencyBps: number;
}
+
+export type BuildingType =
+ | "WORKSHOP"
+ | "MINE"
+ | "FARM"
+ | "FACTORY"
+ | "MEGA_FACTORY"
+ | "WAREHOUSE"
+ | "HEADQUARTERS"
+ | "RND_CENTER";
+
+export type BuildingStatus = "ACTIVE" | "INACTIVE" | "CONSTRUCTION";
+
+export type BuildingCategory = "PRODUCTION" | "STORAGE" | "CORPORATE";
+
+export interface BuildingRecord {
+ id: string;
+ companyId: string;
+ regionId: string;
+ buildingType: BuildingType;
+ status: BuildingStatus;
+ name: string | null;
+ acquisitionCostCents: string;
+ weeklyOperatingCostCents: string;
+ capacitySlots: number;
+ tickAcquired: number;
+ tickConstructionCompletes: number | null;
+ lastOperatingCostTick: number | null;
+ createdAt: string;
+ updatedAt: string;
+ region: {
+ id: string;
+ code: string;
+ name: string;
+ };
+}
+
+export interface BuildingListFilters {
+ companyId: string;
+ regionId?: string;
+ status?: BuildingStatus;
+}
+
+export interface AcquireBuildingInput {
+ companyId: string;
+ regionId: string;
+ buildingType: BuildingType;
+ name?: string;
+}
+
+export interface RegionalStorageInfo {
+ companyId: string;
+ regionId: string;
+ currentUsage: number;
+ maxCapacity: number;
+ usagePercentage: number;
+ warehouseCount: number;
+}
+
+export interface ProductionCapacityInfo {
+ companyId: string;
+ totalCapacity: number;
+ usedCapacity: number;
+ availableCapacity: number;
+ usagePercentage: number;
+}
+
+export interface ValidationIssue {
+ code: string;
+ message: string;
+ severity: "ERROR" | "WARNING";
+}
+
+export interface PreflightValidationResult {
+ valid: boolean;
+ issues: ValidationIssue[];
+}
+
+export interface BuildingTypeDefinition {
+ buildingType: BuildingType;
+ category: BuildingCategory;
+ name: string;
+ description: string;
+ acquisitionCostCents: string;
+ weeklyOperatingCostCents: string;
+ capacitySlots: number;
+ storageCapacity?: number;
+}
diff --git a/packages/sim/src/services/buildings.ts b/packages/sim/src/services/buildings.ts
index 96fa98a6..03ad47e3 100644
--- a/packages/sim/src/services/buildings.ts
+++ b/packages/sim/src/services/buildings.ts
@@ -16,7 +16,7 @@
* 4. **Reactivation**: When cash is available, building can be manually or automatically reactivated
*
* ## Building Types and Categories
- * - **PRODUCTION**: MINE, FARM, FACTORY, MEGA_FACTORY
+ * - **PRODUCTION**: WORKSHOP, MINE, FARM, FACTORY, MEGA_FACTORY
* - Provide production job capacity
* - Required for production jobs
* - Have capacity slots limiting concurrent jobs
@@ -86,6 +86,17 @@ import {
} from "../domain/errors";
import { availableCash } from "../domain/reservations";
+/**
+ * Production building types that provide manufacturing capacity
+ */
+export const PRODUCTION_BUILDING_TYPES: BuildingType[] = [
+ BuildingType.WORKSHOP,
+ BuildingType.MINE,
+ BuildingType.FARM,
+ BuildingType.FACTORY,
+ BuildingType.MEGA_FACTORY
+];
+
/**
* Input for acquiring a new building
*/
@@ -496,17 +507,10 @@ export async function getProductionCapacityForCompany(
tx: Prisma.TransactionClient | PrismaClient,
companyId: string
): Promise<{ totalCapacity: number; usedCapacity: number }> {
- const productionBuildingTypes = [
- BuildingType.MINE,
- BuildingType.FARM,
- BuildingType.FACTORY,
- BuildingType.MEGA_FACTORY
- ];
-
const buildings = await tx.building.findMany({
where: {
companyId,
- buildingType: { in: productionBuildingTypes },
+ buildingType: { in: PRODUCTION_BUILDING_TYPES },
status: BuildingStatus.ACTIVE
},
select: {
@@ -627,7 +631,7 @@ export async function validateStorageCapacity(
* @throws {DomainInvariantError} If company has no active production buildings
*
* @remarks
- * - Production buildings include: MINE, FARM, FACTORY, MEGA_FACTORY
+ * - Production buildings include: WORKSHOP, MINE, FARM, FACTORY, MEGA_FACTORY
* - Only ACTIVE buildings are counted
* - Must be called before creating production jobs
*/
@@ -639,17 +643,10 @@ export async function validateProductionBuildingAvailable(
throw new DomainInvariantError("companyId is required");
}
- const productionBuildingTypes = [
- BuildingType.MINE,
- BuildingType.FARM,
- BuildingType.FACTORY,
- BuildingType.MEGA_FACTORY
- ];
-
const activeBuildingCount = await tx.building.count({
where: {
companyId,
- buildingType: { in: productionBuildingTypes },
+ buildingType: { in: PRODUCTION_BUILDING_TYPES },
status: BuildingStatus.ACTIVE
}
});
diff --git a/packages/sim/src/services/shipments.ts b/packages/sim/src/services/shipments.ts
index 92a0d723..b1bd4300 100644
--- a/packages/sim/src/services/shipments.ts
+++ b/packages/sim/src/services/shipments.ts
@@ -22,6 +22,40 @@
* 3. **Delivery**: At arrival tick, `deliverDueShipmentsForTick()` transfers inventory to destination
* 4. **Cancellation** (optional): Player can cancel in-transit shipments to recover inventory
*
+ * ## DETERMINISTIC OVERFLOW POLICY
+ * **Storage Full at Destination → Delivery Rollback or Retry**
+ *
+ * When a shipment arrives but destination storage is full:
+ *
+ * **Case 1: Origin has capacity (typical)**
+ * - Shipment status: DELIVERED (completed with rollback)
+ * - Inventory action: ROLLBACK - items returned to origin region (fromRegionId)
+ * - This is NOT a new shipment with transit time - it's an immediate rollback
+ * - Origin capacity validation: YES - maintains hard cap invariants
+ * - Player consequence: Wasted logistics fee, items back at origin
+ *
+ * **Case 2: Both regions full (edge case)**
+ * - Shipment status: REMAINS IN_TRANSIT (not updated)
+ * - Inventory action: NONE - no delivery attempted
+ * - Next tick: Shipment will be retried automatically
+ * - Tick advancement: NEVER FAILS - continues deterministically
+ * - Player must: Clear space in either region for delivery to succeed
+ *
+ * **Critical Guarantee: Tick Never Fails**
+ * If both destination and origin are full, the shipment remains IN_TRANSIT and will
+ * retry next tick. This ensures tick advancement NEVER fails due to storage overflow,
+ * preventing game-breaking soft-locks. The shipment will automatically deliver once
+ * the player clears space in either region.
+ *
+ * **Rationale for retry vs throw:**
+ * Shipment delivery happens during tick processing (system-driven, not player action).
+ * Throwing an error would block tick advancement, creating a soft-lock. By keeping
+ * the shipment IN_TRANSIT and retrying next tick, we maintain determinism while
+ * giving the player time to manage storage.
+ *
+ * **Atomicity:** Rollback happens in same transaction as delivery attempt
+ * **Idempotency:** Shipment update uses optimistic locking (updateMany with status condition)
+ *
* ## Invariants Enforced
* - Item must be unlocked by research (player companies) or specialization constraints
* - Cannot ship to same region (source != destination)
@@ -29,12 +63,17 @@
* - Must have available inventory (quantity - reserved) and cash for fees
* - Cancellation only allowed for IN_TRANSIT status
* - Destination inventory created or incremented atomically on delivery
+ * - **Overflow invariant**: Delivery never exceeds destination capacity (rollback or retry)
+ * - **Hard capacity caps**: All regions respect capacity limits (no bypass)
+ * - **Tick safety**: Tick advancement never fails due to storage (retry on double-full)
*
* ## Side Effects and Transaction Boundaries
* All operations are atomic (prisma.$transaction):
* - **createShipment**: Decrements inventory + cash (two optimistic locks), creates shipment record,
* logs fee ledger entry
- * - **deliverDueShipmentsForTick**: Updates shipment status, upserts destination inventory
+ * - **deliverDueShipmentsForTick**: Updates shipment status to DELIVERED (if delivery succeeds),
+ * upserts destination inventory OR rolls back to origin (if origin has capacity) OR leaves
+ * IN_TRANSIT for retry (if both regions full - ensures tick never fails)
* - **cancelShipment**: Returns inventory to source, updates shipment status to CANCELLED
*
* ## Deterministic Travel Time
@@ -45,20 +84,25 @@
* - **Arrival Tick**: `currentTick + adjustedDuration`
*
* ## Delivery Processing
- * - Deterministic order: `ORDER BY tickArrives ASC, createdAt ASC`
+ * - Deterministic order: `ORDER BY tickArrives ASC, tickCreated ASC`
* - Batch processing for efficiency (all due shipments in single tick)
* - Atomic inventory transfer (no partial deliveries)
+ * - Overflow handling: Rollback to origin if destination full (validates origin capacity)
+ * - Retry mechanism: If both regions full, shipment stays IN_TRANSIT (tick never fails)
+ * - Idempotency: updateMany prevents double-processing on retry
*
* ## Fee Structure
* - **Base Fee**: Fixed cost per shipment (configured)
* - **Unit Fee**: Cost per item shipped (configured)
* - **Total**: `baseFee + (quantity * feePerUnit)`
- * - Fees are non-refundable even on cancellation
+ * - Fees are non-refundable even on cancellation or overflow return
+ * - **NO additional ledger entry on return** - fee already recorded at shipment creation
*
* ## Error Handling
* - NotFoundError: Shipment, company, region, or item doesn't exist
* - DomainInvariantError: Same-region shipment, insufficient inventory/cash, item locked
* - OptimisticLockConflictError: Concurrent modification detected (retry required)
+ * - **No errors on overflow**: Returns to sender deterministically
*/
import {
LedgerEntryType,
@@ -632,7 +676,7 @@ export async function cancelShipmentWithTx(
export async function deliverDueShipmentsForTick(
tx: Prisma.TransactionClient,
tick: number
-): Promise {
+): Promise<{ deliveredCount: number; returnedCount: number }> {
ensureTick(tick, "tick");
const dueShipments = await tx.shipment.findMany({
@@ -642,55 +686,92 @@ export async function deliverDueShipmentsForTick(
lte: tick
}
},
- orderBy: [{ tickArrives: "asc" }, { createdAt: "asc" }],
+ orderBy: [{ tickArrives: "asc" }, { tickCreated: "asc" }],
select: {
id: true,
companyId: true,
fromRegionId: true,
toRegionId: true,
itemId: true,
- quantity: true
+ quantity: true,
+ tickCreated: true
}
});
let deliveredCount = 0;
+ let returnedCount = 0;
for (const shipment of dueShipments) {
- const updated = await tx.shipment.updateMany({
- where: {
- id: shipment.id,
- status: ShipmentStatus.IN_TRANSIT
- },
- data: {
- status: ShipmentStatus.DELIVERED,
- tickClosed: tick
+ // Check if destination has storage capacity
+ // If not, attempt rollback delivery to origin (overflow policy: immediate rollback)
+ let destinationRegionId = shipment.toRegionId;
+ let deliveredToDestination = true;
+ let shouldDeliver = true;
+
+ try {
+ await validateStorageCapacity(
+ tx,
+ shipment.companyId,
+ shipment.toRegionId,
+ shipment.quantity
+ );
+ } catch (error) {
+ // Storage full at destination - attempt rollback to origin
+ if (error instanceof DomainInvariantError && error.message.includes("storage capacity exceeded")) {
+ // Try to rollback to origin
+ try {
+ await validateStorageCapacity(
+ tx,
+ shipment.companyId,
+ shipment.fromRegionId,
+ shipment.quantity
+ );
+ // Origin has capacity - rollback succeeds
+ destinationRegionId = shipment.fromRegionId;
+ deliveredToDestination = false;
+ returnedCount += 1;
+ } catch {
+ // Both destination AND origin are full - cannot deliver
+ // Keep shipment IN_TRANSIT, do not update status, will retry next tick
+ // This ensures tick advancement never fails due to storage overflow
+ shouldDeliver = false;
+ }
+ } else {
+ throw error;
}
- });
-
- if (updated.count !== 1) {
- continue;
}
- // Validate storage capacity before adding delivered inventory
- await validateStorageCapacity(
- tx,
- shipment.companyId,
- shipment.toRegionId,
- shipment.quantity
- );
+ // Only proceed with delivery if we have capacity somewhere
+ if (shouldDeliver) {
+ // Update shipment status to DELIVERED (with optimistic locking)
+ const updated = await tx.shipment.updateMany({
+ where: {
+ id: shipment.id,
+ status: ShipmentStatus.IN_TRANSIT
+ },
+ data: {
+ status: ShipmentStatus.DELIVERED,
+ tickClosed: tick
+ }
+ });
- await tx.inventory.upsert({
+ if (updated.count !== 1) {
+ // Another process already updated this shipment, skip
+ continue;
+ }
+
+ await tx.inventory.upsert({
where: {
companyId_itemId_regionId: {
companyId: shipment.companyId,
itemId: shipment.itemId,
- regionId: shipment.toRegionId
+ regionId: destinationRegionId
}
},
create: {
companyId: shipment.companyId,
itemId: shipment.itemId,
- regionId: shipment.toRegionId,
+ regionId: destinationRegionId,
quantity: shipment.quantity,
reservedQuantity: 0
},
@@ -701,8 +782,12 @@ export async function deliverDueShipmentsForTick(
}
});
- deliveredCount += 1;
+ if (deliveredToDestination) {
+ deliveredCount += 1;
+ }
+ }
+ // If shouldDeliver is false, shipment remains IN_TRANSIT and will be retried next tick
}
- return deliveredCount;
+ return { deliveredCount, returnedCount };
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 73554902..b1800120 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -117,9 +117,18 @@ importers:
'@corpsim/shared':
specifier: workspace:*
version: link:../../packages/shared
+ '@radix-ui/react-dialog':
+ specifier: ^1.1.15
+ version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-label':
+ specifier: ^2.1.8
+ version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-popover':
specifier: ^1.1.4
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-progress':
+ specifier: ^1.1.8
+ version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-select':
specifier: ^2.1.2
version: 2.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -669,89 +678,105 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -909,24 +934,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@next/swc-linux-arm64-musl@15.5.12':
resolution: {integrity: sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@next/swc-linux-x64-gnu@15.5.12':
resolution: {integrity: sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@next/swc-linux-x64-musl@15.5.12':
resolution: {integrity: sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@next/swc-win32-arm64-msvc@15.5.12':
resolution: {integrity: sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==}
@@ -1065,6 +1094,15 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-context@1.1.3':
+ resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@radix-ui/react-dialog@1.1.15':
resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==}
peerDependencies:
@@ -1131,6 +1169,19 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-label@2.1.8':
+ resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-popover@1.1.15':
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
peerDependencies:
@@ -1209,6 +1260,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-progress@1.1.8':
+ resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-select@2.2.6':
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
peerDependencies:
@@ -1362,66 +1426,79 @@ packages:
resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.57.1':
resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==}
cpu: [arm]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.57.1':
resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.57.1':
resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.57.1':
resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==}
cpu: [loong64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.57.1':
resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==}
cpu: [loong64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.57.1':
resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.57.1':
resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==}
cpu: [ppc64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.57.1':
resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.57.1':
resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.57.1':
resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.57.1':
resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.57.1':
resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-openbsd-x64@4.57.1':
resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==}
@@ -1639,41 +1716,49 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -4482,19 +4567,19 @@ snapshots:
'@babel/preset-react': 7.28.5(@babel/core@7.29.0)
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.0)
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
- '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
+ '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
'@better-auth/utils': 0.3.0
'@clack/prompts': 0.11.0
'@mrleebo/prisma-ast': 0.13.1
'@prisma/client': 5.22.0(prisma@6.19.2(typescript@5.9.3))
'@types/pg': 8.16.0
- better-auth: 1.4.18(@prisma/client@5.22.0(prisma@6.19.2(typescript@5.9.3)))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.2(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)))(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0))
+ better-auth: 1.4.18(@prisma/client@5.22.0(prisma@6.19.2(typescript@5.9.3)))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)))(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0))
better-sqlite3: 12.6.2
c12: 3.3.3
chalk: 5.6.2
commander: 12.1.0
dotenv: 17.3.1
- drizzle-orm: 0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))
+ drizzle-orm: 0.41.0(@prisma/client@5.22.0(prisma@6.19.2(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))
open: 10.2.0
pg: 8.18.0
prettier: 3.8.1
@@ -4571,6 +4656,12 @@ snapshots:
zod: 4.3.6
'@better-auth/telemetry@1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))':
+ dependencies:
+ '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
+ '@better-auth/utils': 0.3.0
+ '@better-fetch/fetch': 1.1.21
+
+ '@better-auth/telemetry@1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))':
dependencies:
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
'@better-auth/utils': 0.3.0
@@ -5089,6 +5180,12 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.28
+ '@radix-ui/react-context@1.1.3(@types/react@18.3.28)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.28
+
'@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -5154,6 +5251,15 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.28
+ '@radix-ui/react-label@2.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.28
+ '@types/react-dom': 18.3.7(@types/react@18.3.28)
+
'@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -5233,6 +5339,16 @@ snapshots:
'@types/react': 18.3.28
'@types/react-dom': 18.3.7(@types/react@18.3.28)
+ '@radix-ui/react-progress@1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-context': 1.1.3(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.28
+ '@types/react-dom': 18.3.7(@types/react@18.3.28)
+
'@radix-ui/react-select@2.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/number': 1.1.1
@@ -5835,10 +5951,10 @@ snapshots:
baseline-browser-mapping@2.9.19: {}
- better-auth@1.4.18(@prisma/client@5.22.0(prisma@6.19.2(typescript@5.9.3)))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.2(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)))(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0)):
+ better-auth@1.4.18(@prisma/client@5.22.0(prisma@6.19.2(typescript@5.9.3)))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)))(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0)):
dependencies:
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
- '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
+ '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
'@better-auth/utils': 0.3.0
'@better-fetch/fetch': 1.1.21
'@noble/ciphers': 2.1.1
@@ -5852,7 +5968,7 @@ snapshots:
optionalDependencies:
'@prisma/client': 5.22.0(prisma@6.19.2(typescript@5.9.3))
better-sqlite3: 12.6.2
- drizzle-orm: 0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))
+ drizzle-orm: 0.41.0(@prisma/client@5.22.0(prisma@6.19.2(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))
next: 15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
pg: 8.18.0
prisma: 6.19.2(typescript@5.9.3)
@@ -5887,8 +6003,8 @@ snapshots:
better-auth@1.4.18(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)))(next@15.5.12(@babel/core@7.29.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(tsx@4.21.0)):
dependencies:
- '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
- '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
+ '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
+ '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
'@better-auth/utils': 0.3.0
'@better-fetch/fetch': 1.1.21
'@noble/ciphers': 2.1.1
@@ -6313,6 +6429,15 @@ snapshots:
dotenv@17.3.1: {}
+ drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.2(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)):
+ optionalDependencies:
+ '@prisma/client': 5.22.0(prisma@6.19.2(typescript@5.9.3))
+ '@types/pg': 8.16.0
+ better-sqlite3: 12.6.2
+ kysely: 0.28.11
+ pg: 8.18.0
+ prisma: 6.19.2(typescript@5.9.3)
+
drizzle-orm@0.41.0(@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0)(prisma@6.19.2(typescript@5.9.3)):
optionalDependencies:
'@prisma/client': 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3)
@@ -6321,6 +6446,7 @@ snapshots:
kysely: 0.28.11
pg: 8.18.0
prisma: 6.19.2(typescript@5.9.3)
+ optional: true
dunder-proto@1.0.1:
dependencies:
From 37800c6163f27c457c4f23892fd5b0af869cc926 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 16:28:39 +0100
Subject: [PATCH 07/52] fix(auth): support single-origin sso routing
---
.env.example | 4 +
...22153300-single-origin-sso-auth-routing.md | 10 ++
apps/api/src/lib/auth.ts | 23 +++-
apps/web/app/api/auth/[[...path]]/route.ts | 119 ++++++++++++++++++
apps/web/app/v1/[...path]/route.ts | 5 +-
apps/web/src/lib/api-client.ts | 22 +++-
apps/web/src/lib/auth-client.ts | 40 ++++--
docs/project/DOKPLOY_DOCKERFILE.md | 39 ++++--
docs/project/DOKPLOY_PREVIEW.md | 24 ++--
docs/project/corpsim.altitude.nginx.conf | 11 +-
10 files changed, 241 insertions(+), 56 deletions(-)
create mode 100644 .releases/unreleased/20260222153300-single-origin-sso-auth-routing.md
create mode 100644 apps/web/app/api/auth/[[...path]]/route.ts
diff --git a/.env.example b/.env.example
index adb9dcee..92c0ea06 100644
--- a/.env.example
+++ b/.env.example
@@ -97,6 +97,10 @@ COMPANY_SPECIALIZATION_CHANGE_COOLDOWN_HOURS=4
########################################
# Frontend (Next.js public vars)
########################################
+NEXT_PUBLIC_APP_URL=http://localhost:4311
+# Optional explicit auth origin for Better Auth client requests.
+# Leave blank to use same-origin routing.
+NEXT_PUBLIC_AUTH_URL=
NEXT_PUBLIC_API_URL=http://localhost:4310
NEXT_PUBLIC_APP_NAME=CorpSim
NEXT_PUBLIC_COMPANY_SPECIALIZATION_CHANGE_COOLDOWN_HOURS=4
diff --git a/.releases/unreleased/20260222153300-single-origin-sso-auth-routing.md b/.releases/unreleased/20260222153300-single-origin-sso-auth-routing.md
new file mode 100644
index 00000000..62ee7836
--- /dev/null
+++ b/.releases/unreleased/20260222153300-single-origin-sso-auth-routing.md
@@ -0,0 +1,10 @@
+---
+type: patch
+area: web, api
+summary: Support single-origin SSO by proxying auth routes and preferring web origin for auth base URL
+---
+
+- Added Next.js proxy routing for `/api/auth/*` to the API upstream so auth can run on the web origin.
+- Updated Better Auth base URL precedence to prefer explicit/web origins before internal API URLs.
+- Hardened web API upstream resolution to avoid accidental proxy loops when public URLs point to the web origin.
+- Updated deployment docs and env examples for single-domain SSO setup.
diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts
index 06fc5bcb..657ab634 100644
--- a/apps/api/src/lib/auth.ts
+++ b/apps/api/src/lib/auth.ts
@@ -476,6 +476,7 @@ async function ensurePlayerExistsForAuthUser(user: {
function resolveTrustedOrigins(): string[] {
const sources = [
+ process.env.BETTER_AUTH_URL,
process.env.CORS_ORIGIN,
process.env.APP_URL,
process.env.WEB_URL,
@@ -497,14 +498,24 @@ function resolveTrustedOrigins(): string[] {
return Array.from(resolved);
}
+function trimTrailingSlash(value: string): string {
+ return value.endsWith("/") ? value.slice(0, -1) : value;
+}
+
function resolveAuthBaseUrl(): string {
- const explicit =
- process.env.BETTER_AUTH_URL?.trim() ||
- process.env.API_URL?.trim() ||
- process.env.APP_URL?.trim();
+ const sources = [
+ process.env.BETTER_AUTH_URL,
+ process.env.APP_URL,
+ process.env.WEB_URL,
+ process.env.NEXT_PUBLIC_APP_URL,
+ process.env.API_URL
+ ];
- if (explicit) {
- return explicit;
+ for (const source of sources) {
+ const explicit = source?.trim();
+ if (explicit) {
+ return trimTrailingSlash(explicit);
+ }
}
const apiPort = process.env.API_PORT?.trim() || process.env.PORT?.trim() || "4310";
diff --git a/apps/web/app/api/auth/[[...path]]/route.ts b/apps/web/app/api/auth/[[...path]]/route.ts
new file mode 100644
index 00000000..dc5db440
--- /dev/null
+++ b/apps/web/app/api/auth/[[...path]]/route.ts
@@ -0,0 +1,119 @@
+import { NextRequest, NextResponse } from "next/server";
+
+type RouteContext = {
+ params: Promise<{
+ path?: string[];
+ }>;
+};
+
+const REQUEST_BLOCKED_HEADERS = new Set([
+ "connection",
+ "content-length",
+ "host",
+ "keep-alive",
+ "proxy-authenticate",
+ "proxy-authorization",
+ "te",
+ "trailer",
+ "transfer-encoding",
+ "upgrade"
+]);
+
+const RESPONSE_BLOCKED_HEADERS = new Set([
+ "connection",
+ "content-encoding",
+ "content-length",
+ "keep-alive",
+ "proxy-authenticate",
+ "proxy-authorization",
+ "te",
+ "trailer",
+ "transfer-encoding",
+ "upgrade"
+]);
+
+function sanitizeHeaders(source: Headers, blocked: Set): Headers {
+ const target = new Headers();
+ source.forEach((value, key) => {
+ if (!blocked.has(key.toLowerCase())) {
+ target.set(key, value);
+ }
+ });
+ return target;
+}
+
+function resolveApiUpstreamBaseUrl(): string {
+ const explicit = process.env.API_URL?.trim() || process.env.API_INTERNAL_URL?.trim();
+
+ if (explicit) {
+ return explicit.endsWith("/") ? explicit.slice(0, -1) : explicit;
+ }
+
+ const port = process.env.API_PORT?.trim() || "4310";
+ return `http://127.0.0.1:${port}`;
+}
+
+async function proxyToApi(request: NextRequest, context: RouteContext): Promise {
+ const { path } = await context.params;
+ const suffix = path && path.length > 0 ? `/${path.join("/")}` : "";
+ const upstreamUrl = new URL(`${resolveApiUpstreamBaseUrl()}/api/auth${suffix}`);
+ upstreamUrl.search = request.nextUrl.search;
+
+ const headers = sanitizeHeaders(request.headers, REQUEST_BLOCKED_HEADERS);
+ const method = request.method.toUpperCase();
+
+ let body: ArrayBuffer | undefined;
+ if (method !== "GET" && method !== "HEAD") {
+ const buffer = await request.arrayBuffer();
+ body = buffer.byteLength > 0 ? buffer : undefined;
+ }
+
+ try {
+ const upstreamResponse = await fetch(upstreamUrl, {
+ method,
+ headers,
+ body,
+ cache: "no-store",
+ redirect: "manual"
+ });
+
+ const responseHeaders = sanitizeHeaders(upstreamResponse.headers, RESPONSE_BLOCKED_HEADERS);
+ return new NextResponse(upstreamResponse.body, {
+ status: upstreamResponse.status,
+ headers: responseHeaders
+ });
+ } catch {
+ return NextResponse.json({ message: "Auth upstream is unreachable." }, { status: 502 });
+ }
+}
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+export async function GET(request: NextRequest, context: RouteContext) {
+ return proxyToApi(request, context);
+}
+
+export async function POST(request: NextRequest, context: RouteContext) {
+ return proxyToApi(request, context);
+}
+
+export async function PUT(request: NextRequest, context: RouteContext) {
+ return proxyToApi(request, context);
+}
+
+export async function PATCH(request: NextRequest, context: RouteContext) {
+ return proxyToApi(request, context);
+}
+
+export async function DELETE(request: NextRequest, context: RouteContext) {
+ return proxyToApi(request, context);
+}
+
+export async function OPTIONS(request: NextRequest, context: RouteContext) {
+ return proxyToApi(request, context);
+}
+
+export async function HEAD(request: NextRequest, context: RouteContext) {
+ return proxyToApi(request, context);
+}
diff --git a/apps/web/app/v1/[...path]/route.ts b/apps/web/app/v1/[...path]/route.ts
index 11153032..d09d86b0 100644
--- a/apps/web/app/v1/[...path]/route.ts
+++ b/apps/web/app/v1/[...path]/route.ts
@@ -43,10 +43,7 @@ function sanitizeHeaders(source: Headers, blocked: Set): Headers {
}
function resolveApiUpstreamBaseUrl(): string {
- const explicit =
- process.env.API_URL?.trim() ||
- process.env.API_INTERNAL_URL?.trim() ||
- process.env.NEXT_PUBLIC_API_URL?.trim();
+ const explicit = process.env.API_URL?.trim() || process.env.API_INTERNAL_URL?.trim();
if (explicit) {
return explicit.endsWith("/") ? explicit.slice(0, -1) : explicit;
diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts
index f477d874..4548eb64 100644
--- a/apps/web/src/lib/api-client.ts
+++ b/apps/web/src/lib/api-client.ts
@@ -9,17 +9,27 @@ if (typeof window !== "undefined") {
const currentHost = window.location.hostname;
const isProduction = !isLocalhostHostname(currentHost);
- if (isProduction && !API_BASE_URL) {
- console.warn(
- "[CorpSim API Client] NEXT_PUBLIC_API_URL is not set. API requests may fail. " +
- "Ensure NEXT_PUBLIC_API_URL is set as a build argument when building the Docker image."
- );
- } else if (isProduction && isLocalhostUrl(API_BASE_URL)) {
+ if (isProduction && API_BASE_URL && isLocalhostUrl(API_BASE_URL)) {
console.warn(
`[CorpSim API Client] NEXT_PUBLIC_API_URL is set to "${API_BASE_URL}" but you're accessing from "${currentHost}". ` +
"This likely means the environment variable was not set as a build argument. " +
"Set NEXT_PUBLIC_API_URL as a build argument in your deployment platform and rebuild."
);
+ } else if (isProduction && API_BASE_URL) {
+ try {
+ const apiHost = new URL(API_BASE_URL).hostname;
+ if (apiHost !== currentHost) {
+ console.warn(
+ `[CorpSim API Client] API base URL host "${apiHost}" differs from current host "${currentHost}". ` +
+ "For single-domain SSO deployments, prefer same-origin API routing."
+ );
+ }
+ } catch {
+ console.warn(
+ `[CorpSim API Client] NEXT_PUBLIC_API_URL is set to "${API_BASE_URL}" but is not a valid absolute URL. ` +
+ "Use an absolute HTTPS URL, or leave it empty to use same-origin routing."
+ );
+ }
}
}
diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts
index a8b2503c..fa53e1c3 100644
--- a/apps/web/src/lib/auth-client.ts
+++ b/apps/web/src/lib/auth-client.ts
@@ -5,7 +5,11 @@ import { adminClient, twoFactorClient, usernameClient } from "better-auth/client
import { isLocalhostHostname, isLocalhostUrl } from "./localhost-utils";
function resolveAuthBaseUrl(): string {
- const raw = process.env.NEXT_PUBLIC_API_URL?.trim() ?? "";
+ const raw =
+ process.env.NEXT_PUBLIC_AUTH_URL?.trim() ||
+ process.env.NEXT_PUBLIC_APP_URL?.trim() ||
+ process.env.NEXT_PUBLIC_API_URL?.trim() ||
+ "";
if (!raw) {
return "";
}
@@ -17,7 +21,10 @@ function validateAuthConfiguration(): void {
return;
}
- const apiUrl = process.env.NEXT_PUBLIC_API_URL?.trim();
+ const authUrl =
+ process.env.NEXT_PUBLIC_AUTH_URL?.trim() ||
+ process.env.NEXT_PUBLIC_APP_URL?.trim() ||
+ process.env.NEXT_PUBLIC_API_URL?.trim();
const currentHost = window.location.hostname;
// Skip validation in development
@@ -25,21 +32,32 @@ function validateAuthConfiguration(): void {
return;
}
- // Check if API URL is missing or pointing to localhost in production
- if (!apiUrl) {
+ if (!authUrl) {
+ return;
+ }
+
+ if (isLocalhostUrl(authUrl)) {
console.warn(
- "[CorpSim Auth] NEXT_PUBLIC_API_URL is not set. Authentication will fail. " +
- "Ensure NEXT_PUBLIC_API_URL is set as a build argument when building the Docker image."
+ `[CorpSim Auth] Auth base URL is set to "${authUrl}" but you're accessing the site from "${currentHost}". ` +
+ "This likely means the environment variable was not set as a build argument. " +
+ "Authentication requests may fail. " +
+ "Set NEXT_PUBLIC_AUTH_URL (or NEXT_PUBLIC_APP_URL) to your deployed app origin and rebuild the image."
);
return;
}
- if (isLocalhostUrl(apiUrl)) {
+ try {
+ const authHost = new URL(authUrl).hostname;
+ if (authHost !== currentHost) {
+ console.warn(
+ `[CorpSim Auth] Auth base URL host "${authHost}" differs from current host "${currentHost}". ` +
+ "For SSO providers that require a single domain, point auth traffic to the same public origin."
+ );
+ }
+ } catch {
console.warn(
- `[CorpSim Auth] NEXT_PUBLIC_API_URL is set to "${apiUrl}" but you're accessing the site from "${currentHost}". ` +
- "This likely means the environment variable was not set as a build argument. " +
- "Authentication requests will fail. " +
- "Set NEXT_PUBLIC_API_URL as a build argument in your deployment platform and rebuild the image."
+ `[CorpSim Auth] Auth base URL "${authUrl}" is not a valid absolute URL. ` +
+ "Use an absolute HTTPS URL, or leave it empty to use same-origin routing."
);
}
}
diff --git a/docs/project/DOKPLOY_DOCKERFILE.md b/docs/project/DOKPLOY_DOCKERFILE.md
index 936640d0..71804d8b 100644
--- a/docs/project/DOKPLOY_DOCKERFILE.md
+++ b/docs/project/DOKPLOY_DOCKERFILE.md
@@ -15,7 +15,9 @@ Entrypoint: `scripts/start-container.sh`
**IMPORTANT:** When deploying the `web` app (or `APP_ROLE=all`), Next.js `NEXT_PUBLIC_*` environment variables are **baked into the JavaScript bundle at build time**, NOT at runtime.
In Dokploy, you MUST configure these as **build arguments**:
-- `NEXT_PUBLIC_API_URL=https://`
+- `NEXT_PUBLIC_APP_URL=https://`
+- `NEXT_PUBLIC_AUTH_URL=https://` (optional; leave empty to use same-origin)
+- `NEXT_PUBLIC_API_URL=https://` (recommended for single-domain SSO) or leave empty to use same-origin routing
Setting these only as runtime environment variables will NOT work for client-side code. The browser will get the values that were present during `docker build`, not the ones set at runtime.
@@ -48,10 +50,15 @@ Create separate Dokploy apps from the same repository and Dockerfile:
- `WEB_PORT=4311`
- expose/public port `4311`
- ⚠️ **BUILD ARGUMENTS (set before building the image):**
- - `NEXT_PUBLIC_API_URL=https://corpsim-api.altitude-interactive.com`
+ - `NEXT_PUBLIC_APP_URL=https://corpsim.altitude-interactive.com`
+ - `NEXT_PUBLIC_AUTH_URL=https://corpsim.altitude-interactive.com` (optional)
+ - `NEXT_PUBLIC_API_URL=https://corpsim.altitude-interactive.com` (or leave empty)
- **RUNTIME ENVIRONMENT VARIABLES:**
- - `NEXT_PUBLIC_API_URL=https://corpsim-api.altitude-interactive.com`
+ - `NEXT_PUBLIC_APP_URL=https://corpsim.altitude-interactive.com`
+ - `NEXT_PUBLIC_AUTH_URL=https://corpsim.altitude-interactive.com` (optional)
+ - `NEXT_PUBLIC_API_URL=https://corpsim.altitude-interactive.com` (or leave empty)
- `API_URL=http://corpsim-api:4310` (or internal Docker network address)
+ - `BETTER_AUTH_URL=https://corpsim.altitude-interactive.com`
All runtime apps also need:
@@ -64,14 +71,19 @@ All runtime apps also need:
If you insist on one container, set:
**Build Arguments (must be set before building):**
-- `NEXT_PUBLIC_API_URL=https://corpsim-api.altitude-interactive.com`
+- `NEXT_PUBLIC_APP_URL=https://corpsim.altitude-interactive.com`
+- `NEXT_PUBLIC_AUTH_URL=https://corpsim.altitude-interactive.com` (optional)
+- `NEXT_PUBLIC_API_URL=https://corpsim.altitude-interactive.com` (or leave empty)
**Runtime Environment Variables:**
- `APP_ROLE=all`
- `API_PORT=4310`
- `WEB_PORT=4311`
- `CORS_ORIGIN=https://corpsim.altitude-interactive.com`
-- `NEXT_PUBLIC_API_URL=https://corpsim-api.altitude-interactive.com`
+- `NEXT_PUBLIC_APP_URL=https://corpsim.altitude-interactive.com`
+- `NEXT_PUBLIC_AUTH_URL=https://corpsim.altitude-interactive.com` (optional)
+- `NEXT_PUBLIC_API_URL=https://corpsim.altitude-interactive.com` (or leave empty)
+- `BETTER_AUTH_URL=https://corpsim.altitude-interactive.com`
- `API_URL=http://localhost:4310` (internal server-side API access)
- `PREVIEW_DATABASE_URL=postgresql://postgres:@postgres:5432/corpsim`
- `PREVIEW_REDIS_HOST=redis`
@@ -89,8 +101,8 @@ pnpm sim:seed
## Nginx upstream mapping
-- `corpsim.altitude-interactive.com` -> `10.7.0.3:4311`
-- `corpsim-api.altitude-interactive.com` -> `10.7.0.3:4310`
+- `corpsim.altitude-interactive.com` -> `10.7.0.3:4311` (public)
+- Keep API private/internal when possible; web route handlers proxy `/v1/*` and `/api/auth/*` to the API upstream.
## Troubleshooting
@@ -101,18 +113,21 @@ pnpm sim:seed
- 404 errors in browser console for `/api/auth/sign-in/social`
- Auth requests going to wrong URL (e.g., `localhost` instead of your domain)
-**Cause:** The `NEXT_PUBLIC_API_URL` build argument was not set when building the Docker image.
+**Cause:** Public auth/API URLs were built with the wrong origin (often localhost or a separate API host), or missing entirely.
**Solution:**
1. In Dokploy, find the Build Arguments or Build Environment section for your app
-2. Add `NEXT_PUBLIC_API_URL=https://` as a build argument
-3. Trigger a **full rebuild** of the image (not just a restart)
-4. After deployment, check browser's network tab to verify requests go to the correct domain
+2. Set `NEXT_PUBLIC_APP_URL` (and optionally `NEXT_PUBLIC_AUTH_URL`) to your web domain
+3. Set `NEXT_PUBLIC_API_URL` to your web domain (or leave it empty for same-origin routing)
+4. Set runtime `BETTER_AUTH_URL` to your web domain
+5. Ensure runtime `API_URL` points to the internal API service (not the public web domain)
+6. Trigger a **full rebuild** of the image (not just a restart)
+7. After deployment, check browser's network tab to verify auth and API requests go to your web domain
### How to verify build-time values are correct
1. Open your web app in a browser
2. Open Developer Tools → Network tab
3. Trigger an API request (e.g., try to sign in)
-4. Check the request URL - it should point to your production API domain, not localhost
+4. Check request URLs - for single-domain SSO they should point to your web domain (not localhost)
5. Alternatively, check the Console tab for any warnings from CorpSim about misconfigured URLs
diff --git a/docs/project/DOKPLOY_PREVIEW.md b/docs/project/DOKPLOY_PREVIEW.md
index 4f65489f..57f1aaf6 100644
--- a/docs/project/DOKPLOY_PREVIEW.md
+++ b/docs/project/DOKPLOY_PREVIEW.md
@@ -28,7 +28,9 @@ You can use local `.env.preview` as the canonical template for these values.
1. Go to your Compose app → **Environment** tab
2. Set runtime environment variables as usual
3. **Additionally**, if Dokploy provides a "Build Arguments" section, set:
- - `NEXT_PUBLIC_API_URL=https://`
+ - `NEXT_PUBLIC_APP_URL=https://`
+ - `NEXT_PUBLIC_AUTH_URL=https://` (optional)
+ - `NEXT_PUBLIC_API_URL=https://` (recommended) or leave empty for same-origin routing
If Dokploy doesn't have a separate Build Arguments UI, note that variables set only in the **Environment** section are runtime variables and are **not** automatically available at build time. In that case, you must manually edit your `docker-compose.preview.yml` and add these as Docker build arguments (under the `args:` section of `x-app-build` or the relevant `build:` configuration for the `web` service) so that the `NEXT_PUBLIC_*` values are passed into the Next.js build.
@@ -45,7 +47,10 @@ If Dokploy doesn't have a separate Build Arguments UI, note that variables set o
- `WEB_PORT=4311`
- `WEB_PUBLIC_PORT=4311`
- `CORS_ORIGIN=https://`
-- `NEXT_PUBLIC_API_URL=https://` ⚠️ **Must also be set as build argument**
+- `NEXT_PUBLIC_APP_URL=https://` ⚠️ **Must also be set as build argument**
+- `NEXT_PUBLIC_AUTH_URL=https://` (optional, also set as build argument)
+- `NEXT_PUBLIC_API_URL=https://` (recommended, also set as build argument; can be empty for same-origin)
+- `BETTER_AUTH_URL=https://`
Release image settings (tag-first):
@@ -65,7 +70,7 @@ Recommended worker settings:
## 3) Networking / domains
- Route `web` publicly on `WEB_PUBLIC_PORT`.
-- Route `api` publicly on `API_PUBLIC_PORT`.
+- Prefer keeping `api` internal/private and letting web proxy `/v1/*` and `/api/auth/*` to the API service.
- Keep `postgres` and `redis` internal only.
## 4) First deployment init
@@ -95,13 +100,16 @@ Do not run `sim:reset` in preview unless you intentionally want to wipe/reseed s
- 404 errors in browser console for `/api/auth/sign-in/social`
- Auth requests going to wrong URL (e.g., `localhost` instead of your domain)
-**Cause:** The `NEXT_PUBLIC_API_URL` build argument was not set correctly when the Docker image was built. Next.js bakes these values into the client-side JavaScript bundle at build time.
+**Cause:** Public auth/API URLs were built with the wrong origin (often localhost or a separate API host), or missing entirely.
**Solution:**
-1. In Dokploy, ensure `NEXT_PUBLIC_API_URL` is set as a **build argument** (check Build Arguments or Build Environment section)
-2. Trigger a **full rebuild** (not just a restart)
-3. Verify the built image has the correct value by checking browser's network tab - requests should go to your API domain, not localhost
+1. In Dokploy, set `NEXT_PUBLIC_APP_URL` (and optionally `NEXT_PUBLIC_AUTH_URL`) as **build arguments**
+2. Set `NEXT_PUBLIC_API_URL` to your web domain (or leave it empty to use same-origin routing)
+3. Set runtime `BETTER_AUTH_URL` to your web domain
+4. Ensure runtime `API_URL` points to your internal API service
+5. Trigger a **full rebuild** (not just a restart)
+6. Verify in browser devtools that auth/API requests use your web domain, not localhost
### API calls going to localhost or wrong domain
-This is the same issue as above - `NEXT_PUBLIC_*` variables must be set as build arguments before building the image.
+This is the same issue as above: set correct `NEXT_PUBLIC_*` build arguments and rebuild the image.
diff --git a/docs/project/corpsim.altitude.nginx.conf b/docs/project/corpsim.altitude.nginx.conf
index d3dd68d6..6dce5404 100644
--- a/docs/project/corpsim.altitude.nginx.conf
+++ b/docs/project/corpsim.altitude.nginx.conf
@@ -9,7 +9,7 @@ server {
listen 80;
listen [::]:80;
server_name corpsim-api.altitude-interactive.com;
- return 301 https://$host$request_uri;
+ return 301 https://corpsim.altitude-interactive.com$request_uri;
}
server {
@@ -38,12 +38,5 @@ server {
ssl_certificate /etc/ssl/cloudflare/altitude_pubkey.pem;
ssl_certificate_key /etc/ssl/cloudflare/altitude_privkey.key;
- location / {
- proxy_pass http://10.7.0.3:4310;
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- }
+ return 308 https://corpsim.altitude-interactive.com$request_uri;
}
From e3313ab9303939d1dbb4e4f93c1e9a4ac4367897 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 16:31:23 +0100
Subject: [PATCH 08/52] fix(web): preserve repeated proxy headers
---
apps/web/app/api/auth/[[...path]]/route.ts | 2 +-
apps/web/app/v1/[...path]/route.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/web/app/api/auth/[[...path]]/route.ts b/apps/web/app/api/auth/[[...path]]/route.ts
index dc5db440..56acf40f 100644
--- a/apps/web/app/api/auth/[[...path]]/route.ts
+++ b/apps/web/app/api/auth/[[...path]]/route.ts
@@ -36,7 +36,7 @@ function sanitizeHeaders(source: Headers, blocked: Set): Headers {
const target = new Headers();
source.forEach((value, key) => {
if (!blocked.has(key.toLowerCase())) {
- target.set(key, value);
+ target.append(key, value);
}
});
return target;
diff --git a/apps/web/app/v1/[...path]/route.ts b/apps/web/app/v1/[...path]/route.ts
index d09d86b0..d024104e 100644
--- a/apps/web/app/v1/[...path]/route.ts
+++ b/apps/web/app/v1/[...path]/route.ts
@@ -36,7 +36,7 @@ function sanitizeHeaders(source: Headers, blocked: Set): Headers {
const target = new Headers();
source.forEach((value, key) => {
if (!blocked.has(key.toLowerCase())) {
- target.set(key, value);
+ target.append(key, value);
}
});
return target;
From aff1f22f1c93be841be485be1d6fdf3ddae3645c Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 16:48:03 +0100
Subject: [PATCH 09/52] fix(ci): apply migrations before APP_ROLE all startup
---
.../20260222154900-run-migrations-in-all-role-startup.md | 8 ++++++++
scripts/start-container.sh | 7 +++++++
2 files changed, 15 insertions(+)
create mode 100644 .releases/unreleased/20260222154900-run-migrations-in-all-role-startup.md
diff --git a/.releases/unreleased/20260222154900-run-migrations-in-all-role-startup.md b/.releases/unreleased/20260222154900-run-migrations-in-all-role-startup.md
new file mode 100644
index 00000000..73a77a5d
--- /dev/null
+++ b/.releases/unreleased/20260222154900-run-migrations-in-all-role-startup.md
@@ -0,0 +1,8 @@
+---
+type: patch
+area: ci
+summary: Run Prisma migrations before launching services in APP_ROLE=all mode
+---
+
+- Updated `scripts/start-container.sh` so `APP_ROLE=all` applies `prisma migrate deploy` before starting API, worker, and web.
+- Prevents runtime schema-readiness pauses when single-container deployments start without a dedicated migrate step.
diff --git a/scripts/start-container.sh b/scripts/start-container.sh
index 44ebc5c4..05b58288 100644
--- a/scripts/start-container.sh
+++ b/scripts/start-container.sh
@@ -32,7 +32,14 @@ run_migrate() {
exec pnpm exec prisma migrate deploy --schema packages/db/prisma/schema.prisma
}
+apply_migrations() {
+ pnpm exec prisma migrate deploy --schema packages/db/prisma/schema.prisma
+}
+
run_all() {
+ # Ensure schema is current before starting long-running processes in single-container mode.
+ apply_migrations
+
pnpm --filter @corpsim/api start &
api_pid=$!
From 5cba719401e7fa04f53af4a994f9be8925654fb3 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 16:51:48 +0100
Subject: [PATCH 10/52] fix(web): hide seeded example accounts in admin list
---
...0260222165400-hide-seeded-example-accounts-in-admin.md | 8 ++++++++
apps/web/src/components/admin/admin-page.tsx | 8 +++++++-
2 files changed, 15 insertions(+), 1 deletion(-)
create mode 100644 .releases/unreleased/20260222165400-hide-seeded-example-accounts-in-admin.md
diff --git a/.releases/unreleased/20260222165400-hide-seeded-example-accounts-in-admin.md b/.releases/unreleased/20260222165400-hide-seeded-example-accounts-in-admin.md
new file mode 100644
index 00000000..feb74fc8
--- /dev/null
+++ b/.releases/unreleased/20260222165400-hide-seeded-example-accounts-in-admin.md
@@ -0,0 +1,8 @@
+---
+type: patch
+area: web
+summary: Hide seeded example.com accounts from admin user listing
+---
+
+- Filtered admin dashboard user rows to exclude seeded accounts with emails ending in `@example.com`.
+- Keeps production/real user account management focused and uncluttered.
diff --git a/apps/web/src/components/admin/admin-page.tsx b/apps/web/src/components/admin/admin-page.tsx
index f5564426..a0c9b4bb 100644
--- a/apps/web/src/components/admin/admin-page.tsx
+++ b/apps/web/src/components/admin/admin-page.tsx
@@ -46,6 +46,11 @@ interface SupportAccount {
const MAIN_ADMIN_EMAIL = "admin@corpsim.local";
const STALE_IMPORT_TICK_THRESHOLD = 5;
+
+function isSeededExampleAccount(email: string): boolean {
+ return email.trim().toLowerCase().endsWith("@example.com");
+}
+
export function AdminPage() {
const [users, setUsers] = useState([]);
const [isLoading, setIsLoading] = useState(true);
@@ -81,7 +86,8 @@ export function AdminPage() {
});
if (result.data) {
- setUsers(result.data.users as unknown as UserData[]);
+ const loadedUsers = result.data.users as unknown as UserData[];
+ setUsers(loadedUsers.filter((user) => !isSeededExampleAccount(user.email)));
} else if (result.error) {
showToast({
title: "Failed to load users",
From de183944485848a01cde25b025a13a2188efda0c Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 17:00:16 +0100
Subject: [PATCH 11/52] fix(api): allow admins on developer catalog read
endpoints
---
...00-allow-admin-developer-read-endpoints.md | 8 +++++
.../decorators/current-player-id.decorator.ts | 33 +++++++++++++++++
.../test/current-player-id.decorator.test.ts | 36 +++++++++++++++++++
3 files changed, 77 insertions(+)
create mode 100644 .releases/unreleased/20260222170600-allow-admin-developer-read-endpoints.md
create mode 100644 apps/api/test/current-player-id.decorator.test.ts
diff --git a/.releases/unreleased/20260222170600-allow-admin-developer-read-endpoints.md b/.releases/unreleased/20260222170600-allow-admin-developer-read-endpoints.md
new file mode 100644
index 00000000..7f71f9e9
--- /dev/null
+++ b/.releases/unreleased/20260222170600-allow-admin-developer-read-endpoints.md
@@ -0,0 +1,8 @@
+---
+type: patch
+area: api, web
+summary: Allow admin accounts to access developer page read endpoints
+---
+
+- Updated player-id guard logic to permit admin `GET` access on the specific catalog endpoints used by `/developer`.
+- Kept admin restrictions in place for write operations and non-allowlisted gameplay endpoints.
diff --git a/apps/api/src/common/decorators/current-player-id.decorator.ts b/apps/api/src/common/decorators/current-player-id.decorator.ts
index e59f162e..a6b1cb2b 100644
--- a/apps/api/src/common/decorators/current-player-id.decorator.ts
+++ b/apps/api/src/common/decorators/current-player-id.decorator.ts
@@ -8,6 +8,10 @@ import type { UserSession } from "@thallesp/nestjs-better-auth";
interface RequestWithSession {
session?: UserSession | null;
+ method?: string;
+ url?: string;
+ originalUrl?: string;
+ path?: string;
}
function isAdminRole(role: string | string[] | null | undefined): boolean {
@@ -20,6 +24,32 @@ function isAdminRole(role: string | string[] | null | undefined): boolean {
.some((entry) => entry === "admin");
}
+const ADMIN_ALLOWED_READ_PATH_PREFIXES = [
+ "/v1/companies",
+ "/v1/items",
+ "/v1/production/recipes",
+ "/v1/research"
+] as const;
+
+function resolveRequestPath(request: RequestWithSession): string {
+ const rawPath = request.originalUrl ?? request.path ?? request.url ?? "";
+ const pathWithoutQuery = rawPath.split("?", 1)[0] ?? "";
+ const normalized = pathWithoutQuery.trim();
+ return normalized.length > 0 ? normalized : "/";
+}
+
+export function canAdminAccessPlayerGameplayEndpoint(request: RequestWithSession): boolean {
+ const method = request.method?.trim().toUpperCase();
+ if (method !== "GET") {
+ return false;
+ }
+
+ const path = resolveRequestPath(request);
+ return ADMIN_ALLOWED_READ_PATH_PREFIXES.some(
+ (prefix) => path === prefix || path.startsWith(`${prefix}/`)
+ );
+}
+
function resolveTestFallbackPlayerId(): string | null {
if (process.env.NODE_ENV !== "test") {
return null;
@@ -43,6 +73,9 @@ export const CurrentPlayerId = createParamDecorator(
}
if (isAdminRole(request.session?.user?.role)) {
+ if (canAdminAccessPlayerGameplayEndpoint(request)) {
+ return playerId;
+ }
throw new ForbiddenException("Admin accounts cannot access player gameplay endpoints.");
}
return playerId;
diff --git a/apps/api/test/current-player-id.decorator.test.ts b/apps/api/test/current-player-id.decorator.test.ts
new file mode 100644
index 00000000..70be25ed
--- /dev/null
+++ b/apps/api/test/current-player-id.decorator.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+import { canAdminAccessPlayerGameplayEndpoint } from "../src/common/decorators/current-player-id.decorator";
+
+describe("canAdminAccessPlayerGameplayEndpoint", () => {
+ it("allows admin GET requests to developer catalog read endpoints", () => {
+ expect(canAdminAccessPlayerGameplayEndpoint({ method: "GET", url: "/v1/companies" })).toBe(true);
+ expect(canAdminAccessPlayerGameplayEndpoint({ method: "GET", url: "/v1/items" })).toBe(true);
+ expect(canAdminAccessPlayerGameplayEndpoint({ method: "GET", url: "/v1/production/recipes" })).toBe(
+ true
+ );
+ expect(
+ canAdminAccessPlayerGameplayEndpoint({
+ method: "GET",
+ url: "/v1/research?companyId=company_seed"
+ })
+ ).toBe(true);
+ });
+
+ it("denies admin access for non-allowlisted paths", () => {
+ expect(
+ canAdminAccessPlayerGameplayEndpoint({
+ method: "GET",
+ url: "/v1/market/orders"
+ })
+ ).toBe(false);
+ });
+
+ it("denies admin access for write methods even on allowlisted paths", () => {
+ expect(
+ canAdminAccessPlayerGameplayEndpoint({
+ method: "POST",
+ url: "/v1/production/recipes"
+ })
+ ).toBe(false);
+ });
+});
From 1429d86ee0001f9d202d62349cd8b91752b60943 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 17:03:46 +0100
Subject: [PATCH 12/52] fix(web): accept redacted company cash in parsers
---
...1500-handle-redacted-company-cash-in-web-parsers.md | 8 ++++++++
apps/web/src/lib/api-parsers.ts | 10 ++++++++--
2 files changed, 16 insertions(+), 2 deletions(-)
create mode 100644 .releases/unreleased/20260222171500-handle-redacted-company-cash-in-web-parsers.md
diff --git a/.releases/unreleased/20260222171500-handle-redacted-company-cash-in-web-parsers.md b/.releases/unreleased/20260222171500-handle-redacted-company-cash-in-web-parsers.md
new file mode 100644
index 00000000..062742c2
--- /dev/null
+++ b/.releases/unreleased/20260222171500-handle-redacted-company-cash-in-web-parsers.md
@@ -0,0 +1,8 @@
+---
+type: patch
+area: web
+summary: Accept optional redacted company cash fields in API parsers
+---
+
+- Updated web API parsers to treat `cashCents` as optional in company summary and player registry company payloads.
+- Prevents admin developer page failures when backend redacts non-owned company cash values.
diff --git a/apps/web/src/lib/api-parsers.ts b/apps/web/src/lib/api-parsers.ts
index 0c584d44..a8ca63fc 100644
--- a/apps/web/src/lib/api-parsers.ts
+++ b/apps/web/src/lib/api-parsers.ts
@@ -125,13 +125,16 @@ export function parseCompanySummary(value: unknown): CompanySummary {
throw new Error("Invalid company item");
}
+ const cashCents =
+ value.cashCents === undefined ? undefined : readString(value.cashCents, "cashCents");
+
return {
id: readString(value.id, "id"),
code: readString(value.code, "code"),
name: readString(value.name, "name"),
isBot: readBoolean(value.isBot, "isBot"),
specialization: parseCompanySpecialization(value.specialization),
- cashCents: readString(value.cashCents, "cashCents"),
+ cashCents,
regionId: readString(value.regionId, "regionId"),
regionCode: readString(value.regionCode, "regionCode"),
regionName: readString(value.regionName, "regionName")
@@ -236,12 +239,15 @@ export function parsePlayerRegistryCompany(value: unknown): PlayerRegistryCompan
throw new Error("Invalid player registry company");
}
+ const cashCents =
+ value.cashCents === undefined ? undefined : readString(value.cashCents, "cashCents");
+
return {
id: readString(value.id, "id"),
code: readString(value.code, "code"),
name: readString(value.name, "name"),
isBot: readBoolean(value.isBot, "isBot"),
- cashCents: readString(value.cashCents, "cashCents"),
+ cashCents,
regionId: readString(value.regionId, "regionId"),
regionCode: readString(value.regionCode, "regionCode"),
regionName: readString(value.regionName, "regionName"),
From 685091618591bd435bcb1c15b9cecda373d526b7 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 17:06:57 +0100
Subject: [PATCH 13/52] fix(api): support admin research catalog on developer
page
---
...enable-admin-developer-research-catalog.md | 8 +++
apps/api/src/research/research.controller.ts | 22 ++++++-
apps/api/src/research/research.service.ts | 39 +++++++++++
...research-admin-catalog.integration.test.ts | 64 +++++++++++++++++++
4 files changed, 132 insertions(+), 1 deletion(-)
create mode 100644 .releases/unreleased/20260222172500-enable-admin-developer-research-catalog.md
create mode 100644 apps/api/test/research-admin-catalog.integration.test.ts
diff --git a/.releases/unreleased/20260222172500-enable-admin-developer-research-catalog.md b/.releases/unreleased/20260222172500-enable-admin-developer-research-catalog.md
new file mode 100644
index 00000000..f2fe5ccd
--- /dev/null
+++ b/.releases/unreleased/20260222172500-enable-admin-developer-research-catalog.md
@@ -0,0 +1,8 @@
+---
+type: patch
+area: api, web
+summary: Enable admin access to developer research catalog without player ownership
+---
+
+- Added admin-only research catalog read path that selects a player-owned company when no company ID is provided.
+- Kept existing player ownership enforcement for non-admin research access and all research mutations.
diff --git a/apps/api/src/research/research.controller.ts b/apps/api/src/research/research.controller.ts
index ce87b435..2f1bd171 100644
--- a/apps/api/src/research/research.controller.ts
+++ b/apps/api/src/research/research.controller.ts
@@ -1,10 +1,25 @@
-import { Body, Controller, Get, Inject, Param, Post, Query } from "@nestjs/common";
+import { Body, Controller, Get, Inject, Param, Post, Query, Req } from "@nestjs/common";
+import type { UserSession } from "@thallesp/nestjs-better-auth";
import { CurrentPlayerId } from "../common/decorators/current-player-id.decorator";
import { ListResearchDto } from "./dto/list-research.dto";
import { MutateResearchDto } from "./dto/mutate-research.dto";
import { ResearchNodeParamDto } from "./dto/research-node-param.dto";
import { ResearchService } from "./research.service";
+interface RequestWithSession {
+ session?: UserSession | null;
+}
+
+function isAdminRole(role: string | string[] | null | undefined): boolean {
+ if (!role) {
+ return false;
+ }
+ const roleValues = Array.isArray(role) ? role : role.split(",");
+ return roleValues
+ .map((entry) => entry.trim().toLowerCase())
+ .some((entry) => entry === "admin");
+}
+
@Controller("v1/research")
export class ResearchController {
private readonly researchService: ResearchService;
@@ -16,8 +31,13 @@ export class ResearchController {
@Get()
async list(
@Query() query: ListResearchDto,
+ @Req() request: RequestWithSession,
@CurrentPlayerId() playerId: string
) {
+ if (isAdminRole(request.session?.user?.role)) {
+ return this.researchService.listResearchForAdminCatalog(query.companyId);
+ }
+
return this.researchService.listResearch(query.companyId, playerId);
}
diff --git a/apps/api/src/research/research.service.ts b/apps/api/src/research/research.service.ts
index 4e74ee32..6c932c4c 100644
--- a/apps/api/src/research/research.service.ts
+++ b/apps/api/src/research/research.service.ts
@@ -54,6 +54,31 @@ export class ResearchService {
this.prisma = prisma;
}
+ private async resolveAdminCatalogCompanyId(companyId?: string): Promise {
+ if (companyId) {
+ return companyId;
+ }
+
+ const firstPlayerCompany = await this.prisma.company.findFirst({
+ where: {
+ isPlayer: true,
+ ownerPlayerId: { not: null }
+ },
+ orderBy: {
+ createdAt: "asc"
+ },
+ select: {
+ id: true
+ }
+ });
+
+ if (!firstPlayerCompany) {
+ throw new DomainInvariantError("no player-owned companies available for research catalog");
+ }
+
+ return firstPlayerCompany.id;
+ }
+
private async resolveOwnedCompanyId(playerId: string, companyId?: string): Promise {
if (companyId) {
await assertCompanyOwnedByPlayer(this.prisma, playerId, companyId);
@@ -84,6 +109,20 @@ export class ResearchService {
};
}
+ async listResearchForAdminCatalog(
+ companyId?: string
+ ): Promise<{ companyId: string; nodes: ResearchNode[] }> {
+ const resolvedCompanyId = await this.resolveAdminCatalogCompanyId(companyId);
+ const nodes = await listResearchForCompany(this.prisma, {
+ companyId: resolvedCompanyId
+ });
+
+ return {
+ companyId: resolvedCompanyId,
+ nodes: nodes.map(mapNodeToDto)
+ };
+ }
+
async startNode(
nodeId: string,
companyId: string | undefined,
diff --git a/apps/api/test/research-admin-catalog.integration.test.ts b/apps/api/test/research-admin-catalog.integration.test.ts
new file mode 100644
index 00000000..7a69abdf
--- /dev/null
+++ b/apps/api/test/research-admin-catalog.integration.test.ts
@@ -0,0 +1,64 @@
+import "reflect-metadata";
+import { INestApplication, ValidationPipe } from "@nestjs/common";
+import { Test } from "@nestjs/testing";
+import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
+import { seedWorld } from "@corpsim/db";
+import { HttpErrorFilter } from "../src/common/filters/http-error.filter";
+import { AppModule } from "../src/app.module";
+import { PrismaService } from "../src/prisma/prisma.service";
+import { ResearchService } from "../src/research/research.service";
+
+describe("research admin catalog", () => {
+ let app: INestApplication;
+ let prisma: PrismaService;
+ let researchService: ResearchService;
+
+ beforeAll(async () => {
+ const moduleRef = await Test.createTestingModule({
+ imports: [AppModule]
+ }).compile();
+
+ app = moduleRef.createNestApplication();
+ app.useGlobalPipes(
+ new ValidationPipe({
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ forbidUnknownValues: true,
+ transform: false,
+ stopAtFirstError: true
+ })
+ );
+ app.useGlobalFilters(new HttpErrorFilter());
+ await app.init();
+
+ prisma = app.get(PrismaService);
+ researchService = app.get(ResearchService);
+ });
+
+ beforeEach(async () => {
+ await seedWorld(prisma, { reset: true });
+ });
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ it("returns a research catalog for admins without requiring player ownership", async () => {
+ const result = await researchService.listResearchForAdminCatalog();
+
+ expect(typeof result.companyId).toBe("string");
+ expect(result.companyId.length).toBeGreaterThan(0);
+ expect(result.nodes.length).toBeGreaterThan(0);
+
+ const company = await prisma.company.findUniqueOrThrow({
+ where: { id: result.companyId },
+ select: {
+ isPlayer: true,
+ ownerPlayerId: true
+ }
+ });
+
+ expect(company.isPlayer).toBe(true);
+ expect(company.ownerPlayerId).not.toBeNull();
+ });
+});
From 048339158406c22725020e6150ceee53642d1606 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 17:11:04 +0100
Subject: [PATCH 14/52] fix(web): separate recipe input items across catalog
views
---
...73000-separate-recipe-input-items-in-ui.md | 8 ++++
.../src/components/dev/dev-catalog-page.tsx | 27 +++++------
.../components/items/item-quantity-list.tsx | 46 +++++++++++++++++++
.../components/production/production-page.tsx | 38 +++++++--------
4 files changed, 88 insertions(+), 31 deletions(-)
create mode 100644 .releases/unreleased/20260222173000-separate-recipe-input-items-in-ui.md
create mode 100644 apps/web/src/components/items/item-quantity-list.tsx
diff --git a/.releases/unreleased/20260222173000-separate-recipe-input-items-in-ui.md b/.releases/unreleased/20260222173000-separate-recipe-input-items-in-ui.md
new file mode 100644
index 00000000..b4e76f68
--- /dev/null
+++ b/.releases/unreleased/20260222173000-separate-recipe-input-items-in-ui.md
@@ -0,0 +1,8 @@
+---
+type: patch
+area: web
+summary: Improve recipe input readability with explicit separators and quantity labels
+---
+
+- Added a reusable `ItemQuantityList` UI component that renders item inputs with clear separators and `xN` quantities.
+- Updated developer and production recipe sections to use the shared list component, preventing concatenated input labels.
diff --git a/apps/web/src/components/dev/dev-catalog-page.tsx b/apps/web/src/components/dev/dev-catalog-page.tsx
index 3d2130bd..c61f5173 100644
--- a/apps/web/src/components/dev/dev-catalog-page.tsx
+++ b/apps/web/src/components/dev/dev-catalog-page.tsx
@@ -3,6 +3,7 @@
import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react";
import { getIconCatalogItemByCode } from "@corpsim/shared";
import { ItemLabel } from "@/components/items/item-label";
+import { ItemQuantityList } from "@/components/items/item-quantity-list";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -948,19 +949,19 @@ export function DevCatalogPage() {
- {recipe.durationTicks}
-
-
- {recipe.inputs.map((input) => (
-
- {input.quantityPerRun}
-
-
- ))}
-
-
-
- ))}
+ {recipe.durationTicks}
+
+ ({
+ key: `${recipe.id}-${input.itemId}`,
+ quantity: input.quantityPerRun,
+ itemCode: input.item.code,
+ itemName: input.item.name
+ }))}
+ />
+
+
+ ))}
{pagedRecipes.length === 0 ? (
diff --git a/apps/web/src/components/items/item-quantity-list.tsx b/apps/web/src/components/items/item-quantity-list.tsx
new file mode 100644
index 00000000..3fbcad29
--- /dev/null
+++ b/apps/web/src/components/items/item-quantity-list.tsx
@@ -0,0 +1,46 @@
+import { Fragment } from "react";
+import { ItemLabel } from "@/components/items/item-label";
+import { cn } from "@/lib/utils";
+
+export interface ItemQuantityListEntry {
+ key: string;
+ quantity: number;
+ itemCode?: string | null;
+ itemName: string;
+}
+
+interface ItemQuantityListProps {
+ items: ItemQuantityListEntry[];
+ className?: string;
+ itemClassName?: string;
+ separator?: string;
+}
+
+export function ItemQuantityList({
+ items,
+ className,
+ itemClassName,
+ separator = ","
+}: ItemQuantityListProps) {
+ if (items.length === 0) {
+ return -- ;
+ }
+
+ return (
+
+ {items.map((entry, index) => (
+
+ {index > 0 ? (
+
+ {separator}
+
+ ) : null}
+
+ x{entry.quantity}
+
+
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/components/production/production-page.tsx b/apps/web/src/components/production/production-page.tsx
index 943033fd..c350ec76 100644
--- a/apps/web/src/components/production/production-page.tsx
+++ b/apps/web/src/components/production/production-page.tsx
@@ -5,6 +5,7 @@ import { resolveCompanySpecializationCooldownHours } from "@corpsim/shared";
import { Check, ChevronsUpDown } from "lucide-react";
import { useActiveCompany } from "@/components/company/active-company-provider";
import { ItemLabel } from "@/components/items/item-label";
+import { ItemQuantityList } from "@/components/items/item-quantity-list";
import { useWorldHealth } from "@/components/layout/world-health-provider";
import { useUiSfx } from "@/components/layout/ui-sfx-provider";
import { Badge } from "@/components/ui/badge";
@@ -632,16 +633,17 @@ export function ProductionPage() {
className="inline-flex"
/>
- Inputs:
-
- {selectedRecipe.inputs.map((input) => (
-
- {input.quantityPerRun}
-
- / run
-
- ))}
-
+
+
Inputs / run:
+
({
+ key: input.itemId,
+ quantity: input.quantityPerRun,
+ itemCode: input.item.code,
+ itemName: input.item.name
+ }))}
+ />
+
) : null}
@@ -755,14 +757,14 @@ export function ProductionPage() {
{formatCadenceCount(row.recipe.durationTicks)}
-
- {row.recipe.inputs.map((input) => (
-
- {input.quantityPerRun}
-
-
- ))}
-
+ ({
+ key: input.itemId,
+ quantity: input.quantityPerRun,
+ itemCode: input.item.code,
+ itemName: input.item.name
+ }))}
+ />
))}
From bd41de8e4e1799f4ce85baaefbcc2290493d9d96 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 17:18:35 +0100
Subject: [PATCH 15/52] fix(web): centralize item quantity labels for recipe
outputs
---
...e-item-quantity-rendering-and-apply-qua.md | 7 ++++
.../src/components/dev/dev-catalog-page.tsx | 36 ++++++++++---------
.../components/items/item-quantity-label.tsx | 30 ++++++++++++++++
.../components/items/item-quantity-list.tsx | 12 ++++---
.../components/production/production-page.tsx | 19 +++++-----
apps/web/src/lib/quantity-controller.ts | 28 +++++++++++++++
6 files changed, 100 insertions(+), 32 deletions(-)
create mode 100644 .releases/unreleased/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md
create mode 100644 apps/web/src/components/items/item-quantity-label.tsx
create mode 100644 apps/web/src/lib/quantity-controller.ts
diff --git a/.releases/unreleased/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md b/.releases/unreleased/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md
new file mode 100644
index 00000000..f8998a0d
--- /dev/null
+++ b/.releases/unreleased/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md
@@ -0,0 +1,7 @@
+---
+type: patch
+area: web
+summary: Centralize item quantity rendering and apply quantifier labels to recipe outputs
+---
+
+- Centralize item quantity rendering and apply quantifier labels to recipe outputs
diff --git a/apps/web/src/components/dev/dev-catalog-page.tsx b/apps/web/src/components/dev/dev-catalog-page.tsx
index c61f5173..be56bd31 100644
--- a/apps/web/src/components/dev/dev-catalog-page.tsx
+++ b/apps/web/src/components/dev/dev-catalog-page.tsx
@@ -3,6 +3,7 @@
import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react";
import { getIconCatalogItemByCode } from "@corpsim/shared";
import { ItemLabel } from "@/components/items/item-label";
+import { ItemQuantityLabel } from "@/components/items/item-quantity-label";
import { ItemQuantityList } from "@/components/items/item-quantity-list";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -944,24 +945,25 @@ export function DevCatalogPage() {
-
- {recipe.outputQuantity}
-
-
+
- {recipe.durationTicks}
-
- ({
- key: `${recipe.id}-${input.itemId}`,
- quantity: input.quantityPerRun,
- itemCode: input.item.code,
- itemName: input.item.name
- }))}
- />
-
-
- ))}
+ {recipe.durationTicks}
+
+ ({
+ key: `${recipe.id}-${input.itemId}`,
+ quantity: input.quantityPerRun,
+ itemCode: input.item.code,
+ itemName: input.item.name
+ }))}
+ />
+
+
+ ))}
{pagedRecipes.length === 0 ? (
diff --git a/apps/web/src/components/items/item-quantity-label.tsx b/apps/web/src/components/items/item-quantity-label.tsx
new file mode 100644
index 00000000..2895f810
--- /dev/null
+++ b/apps/web/src/components/items/item-quantity-label.tsx
@@ -0,0 +1,30 @@
+import { ItemLabel } from "@/components/items/item-label";
+import { formatQuantityToken } from "@/lib/quantity-controller";
+import { cn } from "@/lib/utils";
+
+interface ItemQuantityLabelProps {
+ quantity: number;
+ itemCode?: string | null;
+ itemName: string;
+ className?: string;
+ quantityClassName?: string;
+ itemClassName?: string;
+}
+
+export function ItemQuantityLabel({
+ quantity,
+ itemCode,
+ itemName,
+ className,
+ quantityClassName,
+ itemClassName
+}: ItemQuantityLabelProps) {
+ return (
+
+
+ {formatQuantityToken(quantity)}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/items/item-quantity-list.tsx b/apps/web/src/components/items/item-quantity-list.tsx
index 3fbcad29..d2c0fe78 100644
--- a/apps/web/src/components/items/item-quantity-list.tsx
+++ b/apps/web/src/components/items/item-quantity-list.tsx
@@ -1,5 +1,5 @@
import { Fragment } from "react";
-import { ItemLabel } from "@/components/items/item-label";
+import { ItemQuantityLabel } from "@/components/items/item-quantity-label";
import { cn } from "@/lib/utils";
export interface ItemQuantityListEntry {
@@ -35,10 +35,12 @@ export function ItemQuantityList({
{separator}
) : null}
-
- x{entry.quantity}
-
-
+
))}
diff --git a/apps/web/src/components/production/production-page.tsx b/apps/web/src/components/production/production-page.tsx
index c350ec76..c0ca7f82 100644
--- a/apps/web/src/components/production/production-page.tsx
+++ b/apps/web/src/components/production/production-page.tsx
@@ -4,7 +4,7 @@ import { FormEvent, useCallback, useDeferredValue, useEffect, useMemo, useRef, u
import { resolveCompanySpecializationCooldownHours } from "@corpsim/shared";
import { Check, ChevronsUpDown } from "lucide-react";
import { useActiveCompany } from "@/components/company/active-company-provider";
-import { ItemLabel } from "@/components/items/item-label";
+import { ItemQuantityLabel } from "@/components/items/item-quantity-label";
import { ItemQuantityList } from "@/components/items/item-quantity-list";
import { useWorldHealth } from "@/components/layout/world-health-provider";
import { useUiSfx } from "@/components/layout/ui-sfx-provider";
@@ -626,8 +626,9 @@ export function ProductionPage() {
Duration: {formatCadenceCount(selectedRecipe.durationTicks)} / run
- Output: {selectedRecipe.outputQuantity}{" "}
- {row.recipe.name}
-
- {row.recipe.outputQuantity}
-
-
+
{formatCadenceCount(row.recipe.durationTicks)}
diff --git a/apps/web/src/lib/quantity-controller.ts b/apps/web/src/lib/quantity-controller.ts
new file mode 100644
index 00000000..8c0dce96
--- /dev/null
+++ b/apps/web/src/lib/quantity-controller.ts
@@ -0,0 +1,28 @@
+export interface QuantityFormatOptions {
+ prefix?: string;
+ fallback?: string;
+ locale?: string;
+}
+
+export class QuantityController {
+ format(quantity: number, options?: QuantityFormatOptions): string {
+ const prefix = options?.prefix ?? "x";
+ const fallback = options?.fallback ?? "--";
+
+ if (!Number.isFinite(quantity)) {
+ return fallback;
+ }
+
+ return `${prefix}${quantity.toLocaleString(options?.locale)}`;
+ }
+}
+
+const quantityController = new QuantityController();
+
+export function getQuantityController(): QuantityController {
+ return quantityController;
+}
+
+export function formatQuantityToken(quantity: number, options?: QuantityFormatOptions): string {
+ return quantityController.format(quantity, options);
+}
From 0aba554800869606d878abf5918fcf014bc5bc28 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 17:25:23 +0100
Subject: [PATCH 16/52] fix(web): resolve unknown item labels in market lists
---
...market-unknown-item-labels-by-using-global-i.md | 7 +++++++
apps/web/src/components/market/market-page.tsx | 14 ++++++++++----
2 files changed, 17 insertions(+), 4 deletions(-)
create mode 100644 .releases/unreleased/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md
diff --git a/.releases/unreleased/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md b/.releases/unreleased/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md
new file mode 100644
index 00000000..363a1e2a
--- /dev/null
+++ b/.releases/unreleased/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md
@@ -0,0 +1,7 @@
+---
+type: patch
+area: web
+summary: Fix market unknown item labels by using global item metadata
+---
+
+- Fix market unknown item labels by using global item metadata
diff --git a/apps/web/src/components/market/market-page.tsx b/apps/web/src/components/market/market-page.tsx
index ec949927..0ea09418 100644
--- a/apps/web/src/components/market/market-page.tsx
+++ b/apps/web/src/components/market/market-page.tsx
@@ -89,6 +89,7 @@ export function MarketPage() {
const { activeCompanyId, activeCompany } = useActiveCompany();
const { health, refresh: refreshHealth } = useWorldHealth();
+ const [catalogItems, setCatalogItems] = useState([]);
const [items, setItems] = useState([]);
const [companies, setCompanies] = useState([]);
const [regions, setRegions] = useState([]);
@@ -115,7 +116,8 @@ export function MarketPage() {
const [error, setError] = useState(null);
const loadCatalog = useCallback(async (): Promise => {
- const [itemRows, regionRows, companyRows] = await Promise.all([
+ const [catalogItemRows, selectableItemRows, regionRows, companyRows] = await Promise.all([
+ listItems(),
listItems(activeCompanyId ?? undefined),
listRegions(),
listCompanies()
@@ -140,7 +142,8 @@ export function MarketPage() {
}
setUnlockedItemIds(Array.from(unlockedIds));
- setItems(itemRows);
+ setCatalogItems(catalogItemRows);
+ setItems(selectableItemRows);
setRegions(regionRows);
setCompanies(companyRows);
let resolvedRegionId = "";
@@ -456,8 +459,11 @@ export function MarketPage() {
[regions]
);
const itemMetaById = useMemo(
- () => Object.fromEntries(items.map((item) => [item.id, { code: item.code, name: item.name }])),
- [items]
+ () =>
+ Object.fromEntries(
+ catalogItems.map((item) => [item.id, { code: item.code, name: item.name }])
+ ),
+ [catalogItems]
);
const companyNameById = useMemo(
() => Object.fromEntries(companies.map((company) => [company.id, company.name])),
From 26315cb2a2c7cc88e2b60f71658134427b549a4c Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 17:28:00 +0100
Subject: [PATCH 17/52] fix(web): scope market listings to company tradable
items
---
...market-views-to-active-company-tradable.md | 7 ++++
.../web/src/components/market/market-page.tsx | 33 ++++++++++++++++---
2 files changed, 35 insertions(+), 5 deletions(-)
create mode 100644 .releases/unreleased/20260222162747-restrict-market-views-to-active-company-tradable.md
diff --git a/.releases/unreleased/20260222162747-restrict-market-views-to-active-company-tradable.md b/.releases/unreleased/20260222162747-restrict-market-views-to-active-company-tradable.md
new file mode 100644
index 00000000..4f8cfc8a
--- /dev/null
+++ b/.releases/unreleased/20260222162747-restrict-market-views-to-active-company-tradable.md
@@ -0,0 +1,7 @@
+---
+type: patch
+area: web
+summary: Restrict market views to active company tradable items
+---
+
+- Restrict market views to active company tradable items
diff --git a/apps/web/src/components/market/market-page.tsx b/apps/web/src/components/market/market-page.tsx
index 0ea09418..58d46f3c 100644
--- a/apps/web/src/components/market/market-page.tsx
+++ b/apps/web/src/components/market/market-page.tsx
@@ -396,11 +396,11 @@ export function MarketPage() {
);
const unlockedItemIdSet = useMemo(() => new Set(unlockedItemIds), [unlockedItemIds]);
const sortedSelectableItems = useMemo(() => {
- if (unlockedItemIdSet.size === 0) {
+ if (!activeCompanyId) {
return sortedItems;
}
return sortedItems.filter((item) => unlockedItemIdSet.has(item.id));
- }, [sortedItems, unlockedItemIdSet]);
+ }, [activeCompanyId, sortedItems, unlockedItemIdSet]);
const filteredOrderSelectableItems = useMemo(() => {
const needle = deferredOrderItemSearch.trim().toLowerCase();
if (!needle) {
@@ -475,6 +475,24 @@ export function MarketPage() {
name: activeCompany.regionName
})
: null;
+ const visibleOrderBook = useMemo(() => {
+ if (!activeCompanyId) {
+ return orderBook;
+ }
+ return orderBook.filter((order) => unlockedItemIdSet.has(order.itemId));
+ }, [activeCompanyId, orderBook, unlockedItemIdSet]);
+ const visibleMyOrders = useMemo(() => {
+ if (!activeCompanyId) {
+ return myOrders;
+ }
+ return myOrders.filter((order) => unlockedItemIdSet.has(order.itemId));
+ }, [activeCompanyId, myOrders, unlockedItemIdSet]);
+ const visibleTrades = useMemo(() => {
+ if (!activeCompanyId) {
+ return trades;
+ }
+ return trades.filter((trade) => unlockedItemIdSet.has(trade.itemId));
+ }, [activeCompanyId, trades, unlockedItemIdSet]);
useEffect(() => {
if (orderFilters.itemId && !sortedSelectableItems.some((item) => item.id === orderFilters.itemId)) {
@@ -610,13 +628,18 @@ export function MarketPage() {
Showing first {visibleOrderSelectableItems.length} matching items in order-item dropdown.
) : null}
+ {activeCompanyId && sortedSelectableItems.length === 0 ? (
+
+ No tradable items are currently unlocked for this company.
+
+ ) : null}
{error ? {error}
: null}
Date: Sun, 22 Feb 2026 16:28:39 +0100
Subject: [PATCH 18/52] fix(auth): support single-origin sso routing
---
.env.example | 4 +
...22153300-single-origin-sso-auth-routing.md | 10 ++
apps/api/src/lib/auth.ts | 23 +++-
apps/web/app/api/auth/[[...path]]/route.ts | 119 ++++++++++++++++++
apps/web/app/v1/[...path]/route.ts | 5 +-
apps/web/src/lib/api-client.ts | 22 +++-
apps/web/src/lib/auth-client.ts | 40 ++++--
docs/project/DOKPLOY_DOCKERFILE.md | 44 ++++---
docs/project/DOKPLOY_PREVIEW.md | 24 ++--
docs/project/corpsim.altitude.nginx.conf | 11 +-
10 files changed, 242 insertions(+), 60 deletions(-)
create mode 100644 .releases/unreleased/20260222153300-single-origin-sso-auth-routing.md
create mode 100644 apps/web/app/api/auth/[[...path]]/route.ts
diff --git a/.env.example b/.env.example
index 7404303c..fc1b1aa7 100644
--- a/.env.example
+++ b/.env.example
@@ -100,6 +100,10 @@ COMPANY_SPECIALIZATION_CHANGE_COOLDOWN_HOURS=4
########################################
# Frontend (Next.js public vars)
########################################
+NEXT_PUBLIC_APP_URL=http://localhost:4311
+# Optional explicit auth origin for Better Auth client requests.
+# Leave blank to use same-origin routing.
+NEXT_PUBLIC_AUTH_URL=
NEXT_PUBLIC_API_URL=http://localhost:4310
NEXT_PUBLIC_APP_NAME=CorpSim
NEXT_PUBLIC_COMPANY_SPECIALIZATION_CHANGE_COOLDOWN_HOURS=4
diff --git a/.releases/unreleased/20260222153300-single-origin-sso-auth-routing.md b/.releases/unreleased/20260222153300-single-origin-sso-auth-routing.md
new file mode 100644
index 00000000..62ee7836
--- /dev/null
+++ b/.releases/unreleased/20260222153300-single-origin-sso-auth-routing.md
@@ -0,0 +1,10 @@
+---
+type: patch
+area: web, api
+summary: Support single-origin SSO by proxying auth routes and preferring web origin for auth base URL
+---
+
+- Added Next.js proxy routing for `/api/auth/*` to the API upstream so auth can run on the web origin.
+- Updated Better Auth base URL precedence to prefer explicit/web origins before internal API URLs.
+- Hardened web API upstream resolution to avoid accidental proxy loops when public URLs point to the web origin.
+- Updated deployment docs and env examples for single-domain SSO setup.
diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts
index 06fc5bcb..657ab634 100644
--- a/apps/api/src/lib/auth.ts
+++ b/apps/api/src/lib/auth.ts
@@ -476,6 +476,7 @@ async function ensurePlayerExistsForAuthUser(user: {
function resolveTrustedOrigins(): string[] {
const sources = [
+ process.env.BETTER_AUTH_URL,
process.env.CORS_ORIGIN,
process.env.APP_URL,
process.env.WEB_URL,
@@ -497,14 +498,24 @@ function resolveTrustedOrigins(): string[] {
return Array.from(resolved);
}
+function trimTrailingSlash(value: string): string {
+ return value.endsWith("/") ? value.slice(0, -1) : value;
+}
+
function resolveAuthBaseUrl(): string {
- const explicit =
- process.env.BETTER_AUTH_URL?.trim() ||
- process.env.API_URL?.trim() ||
- process.env.APP_URL?.trim();
+ const sources = [
+ process.env.BETTER_AUTH_URL,
+ process.env.APP_URL,
+ process.env.WEB_URL,
+ process.env.NEXT_PUBLIC_APP_URL,
+ process.env.API_URL
+ ];
- if (explicit) {
- return explicit;
+ for (const source of sources) {
+ const explicit = source?.trim();
+ if (explicit) {
+ return trimTrailingSlash(explicit);
+ }
}
const apiPort = process.env.API_PORT?.trim() || process.env.PORT?.trim() || "4310";
diff --git a/apps/web/app/api/auth/[[...path]]/route.ts b/apps/web/app/api/auth/[[...path]]/route.ts
new file mode 100644
index 00000000..dc5db440
--- /dev/null
+++ b/apps/web/app/api/auth/[[...path]]/route.ts
@@ -0,0 +1,119 @@
+import { NextRequest, NextResponse } from "next/server";
+
+type RouteContext = {
+ params: Promise<{
+ path?: string[];
+ }>;
+};
+
+const REQUEST_BLOCKED_HEADERS = new Set([
+ "connection",
+ "content-length",
+ "host",
+ "keep-alive",
+ "proxy-authenticate",
+ "proxy-authorization",
+ "te",
+ "trailer",
+ "transfer-encoding",
+ "upgrade"
+]);
+
+const RESPONSE_BLOCKED_HEADERS = new Set([
+ "connection",
+ "content-encoding",
+ "content-length",
+ "keep-alive",
+ "proxy-authenticate",
+ "proxy-authorization",
+ "te",
+ "trailer",
+ "transfer-encoding",
+ "upgrade"
+]);
+
+function sanitizeHeaders(source: Headers, blocked: Set): Headers {
+ const target = new Headers();
+ source.forEach((value, key) => {
+ if (!blocked.has(key.toLowerCase())) {
+ target.set(key, value);
+ }
+ });
+ return target;
+}
+
+function resolveApiUpstreamBaseUrl(): string {
+ const explicit = process.env.API_URL?.trim() || process.env.API_INTERNAL_URL?.trim();
+
+ if (explicit) {
+ return explicit.endsWith("/") ? explicit.slice(0, -1) : explicit;
+ }
+
+ const port = process.env.API_PORT?.trim() || "4310";
+ return `http://127.0.0.1:${port}`;
+}
+
+async function proxyToApi(request: NextRequest, context: RouteContext): Promise {
+ const { path } = await context.params;
+ const suffix = path && path.length > 0 ? `/${path.join("/")}` : "";
+ const upstreamUrl = new URL(`${resolveApiUpstreamBaseUrl()}/api/auth${suffix}`);
+ upstreamUrl.search = request.nextUrl.search;
+
+ const headers = sanitizeHeaders(request.headers, REQUEST_BLOCKED_HEADERS);
+ const method = request.method.toUpperCase();
+
+ let body: ArrayBuffer | undefined;
+ if (method !== "GET" && method !== "HEAD") {
+ const buffer = await request.arrayBuffer();
+ body = buffer.byteLength > 0 ? buffer : undefined;
+ }
+
+ try {
+ const upstreamResponse = await fetch(upstreamUrl, {
+ method,
+ headers,
+ body,
+ cache: "no-store",
+ redirect: "manual"
+ });
+
+ const responseHeaders = sanitizeHeaders(upstreamResponse.headers, RESPONSE_BLOCKED_HEADERS);
+ return new NextResponse(upstreamResponse.body, {
+ status: upstreamResponse.status,
+ headers: responseHeaders
+ });
+ } catch {
+ return NextResponse.json({ message: "Auth upstream is unreachable." }, { status: 502 });
+ }
+}
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+export async function GET(request: NextRequest, context: RouteContext) {
+ return proxyToApi(request, context);
+}
+
+export async function POST(request: NextRequest, context: RouteContext) {
+ return proxyToApi(request, context);
+}
+
+export async function PUT(request: NextRequest, context: RouteContext) {
+ return proxyToApi(request, context);
+}
+
+export async function PATCH(request: NextRequest, context: RouteContext) {
+ return proxyToApi(request, context);
+}
+
+export async function DELETE(request: NextRequest, context: RouteContext) {
+ return proxyToApi(request, context);
+}
+
+export async function OPTIONS(request: NextRequest, context: RouteContext) {
+ return proxyToApi(request, context);
+}
+
+export async function HEAD(request: NextRequest, context: RouteContext) {
+ return proxyToApi(request, context);
+}
diff --git a/apps/web/app/v1/[...path]/route.ts b/apps/web/app/v1/[...path]/route.ts
index 11153032..d09d86b0 100644
--- a/apps/web/app/v1/[...path]/route.ts
+++ b/apps/web/app/v1/[...path]/route.ts
@@ -43,10 +43,7 @@ function sanitizeHeaders(source: Headers, blocked: Set): Headers {
}
function resolveApiUpstreamBaseUrl(): string {
- const explicit =
- process.env.API_URL?.trim() ||
- process.env.API_INTERNAL_URL?.trim() ||
- process.env.NEXT_PUBLIC_API_URL?.trim();
+ const explicit = process.env.API_URL?.trim() || process.env.API_INTERNAL_URL?.trim();
if (explicit) {
return explicit.endsWith("/") ? explicit.slice(0, -1) : explicit;
diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts
index 6b337dea..f6ae8908 100644
--- a/apps/web/src/lib/api-client.ts
+++ b/apps/web/src/lib/api-client.ts
@@ -9,17 +9,27 @@ if (typeof window !== "undefined") {
const currentHost = window.location.hostname;
const isProduction = !isLocalhostHostname(currentHost);
- if (isProduction && !API_BASE_URL) {
- console.warn(
- "[CorpSim API Client] NEXT_PUBLIC_API_URL is not set. API requests may fail. " +
- "Ensure NEXT_PUBLIC_API_URL is set as a build argument when building the Docker image."
- );
- } else if (isProduction && isLocalhostUrl(API_BASE_URL)) {
+ if (isProduction && API_BASE_URL && isLocalhostUrl(API_BASE_URL)) {
console.warn(
`[CorpSim API Client] NEXT_PUBLIC_API_URL is set to "${API_BASE_URL}" but you're accessing from "${currentHost}". ` +
"This likely means the environment variable was not set as a build argument. " +
"Set NEXT_PUBLIC_API_URL as a build argument in your deployment platform and rebuild."
);
+ } else if (isProduction && API_BASE_URL) {
+ try {
+ const apiHost = new URL(API_BASE_URL).hostname;
+ if (apiHost !== currentHost) {
+ console.warn(
+ `[CorpSim API Client] API base URL host "${apiHost}" differs from current host "${currentHost}". ` +
+ "For single-domain SSO deployments, prefer same-origin API routing."
+ );
+ }
+ } catch {
+ console.warn(
+ `[CorpSim API Client] NEXT_PUBLIC_API_URL is set to "${API_BASE_URL}" but is not a valid absolute URL. ` +
+ "Use an absolute HTTPS URL, or leave it empty to use same-origin routing."
+ );
+ }
}
}
diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts
index a8b2503c..fa53e1c3 100644
--- a/apps/web/src/lib/auth-client.ts
+++ b/apps/web/src/lib/auth-client.ts
@@ -5,7 +5,11 @@ import { adminClient, twoFactorClient, usernameClient } from "better-auth/client
import { isLocalhostHostname, isLocalhostUrl } from "./localhost-utils";
function resolveAuthBaseUrl(): string {
- const raw = process.env.NEXT_PUBLIC_API_URL?.trim() ?? "";
+ const raw =
+ process.env.NEXT_PUBLIC_AUTH_URL?.trim() ||
+ process.env.NEXT_PUBLIC_APP_URL?.trim() ||
+ process.env.NEXT_PUBLIC_API_URL?.trim() ||
+ "";
if (!raw) {
return "";
}
@@ -17,7 +21,10 @@ function validateAuthConfiguration(): void {
return;
}
- const apiUrl = process.env.NEXT_PUBLIC_API_URL?.trim();
+ const authUrl =
+ process.env.NEXT_PUBLIC_AUTH_URL?.trim() ||
+ process.env.NEXT_PUBLIC_APP_URL?.trim() ||
+ process.env.NEXT_PUBLIC_API_URL?.trim();
const currentHost = window.location.hostname;
// Skip validation in development
@@ -25,21 +32,32 @@ function validateAuthConfiguration(): void {
return;
}
- // Check if API URL is missing or pointing to localhost in production
- if (!apiUrl) {
+ if (!authUrl) {
+ return;
+ }
+
+ if (isLocalhostUrl(authUrl)) {
console.warn(
- "[CorpSim Auth] NEXT_PUBLIC_API_URL is not set. Authentication will fail. " +
- "Ensure NEXT_PUBLIC_API_URL is set as a build argument when building the Docker image."
+ `[CorpSim Auth] Auth base URL is set to "${authUrl}" but you're accessing the site from "${currentHost}". ` +
+ "This likely means the environment variable was not set as a build argument. " +
+ "Authentication requests may fail. " +
+ "Set NEXT_PUBLIC_AUTH_URL (or NEXT_PUBLIC_APP_URL) to your deployed app origin and rebuild the image."
);
return;
}
- if (isLocalhostUrl(apiUrl)) {
+ try {
+ const authHost = new URL(authUrl).hostname;
+ if (authHost !== currentHost) {
+ console.warn(
+ `[CorpSim Auth] Auth base URL host "${authHost}" differs from current host "${currentHost}". ` +
+ "For SSO providers that require a single domain, point auth traffic to the same public origin."
+ );
+ }
+ } catch {
console.warn(
- `[CorpSim Auth] NEXT_PUBLIC_API_URL is set to "${apiUrl}" but you're accessing the site from "${currentHost}". ` +
- "This likely means the environment variable was not set as a build argument. " +
- "Authentication requests will fail. " +
- "Set NEXT_PUBLIC_API_URL as a build argument in your deployment platform and rebuild the image."
+ `[CorpSim Auth] Auth base URL "${authUrl}" is not a valid absolute URL. ` +
+ "Use an absolute HTTPS URL, or leave it empty to use same-origin routing."
);
}
}
diff --git a/docs/project/DOKPLOY_DOCKERFILE.md b/docs/project/DOKPLOY_DOCKERFILE.md
index adeed8af..a6d39352 100644
--- a/docs/project/DOKPLOY_DOCKERFILE.md
+++ b/docs/project/DOKPLOY_DOCKERFILE.md
@@ -15,7 +15,9 @@ Entrypoint: `scripts/start-container.sh`
**IMPORTANT:** When deploying the `web` app (or `APP_ROLE=all`), Next.js `NEXT_PUBLIC_*` environment variables are **baked into the JavaScript bundle at build time**, NOT at runtime.
In Dokploy, you MUST configure these as **build arguments**:
-- `NEXT_PUBLIC_API_URL=https://`
+- `NEXT_PUBLIC_APP_URL=https://`
+- `NEXT_PUBLIC_AUTH_URL=https://` (optional; leave empty to use same-origin)
+- `NEXT_PUBLIC_API_URL=https://` (recommended for single-domain SSO) or leave empty to use same-origin routing
Setting these only as runtime environment variables will NOT work for client-side code. The browser will get the values that were present during `docker build`, not the ones set at runtime.
@@ -50,10 +52,15 @@ Create separate Dokploy apps from the same repository and Dockerfile:
- `WEB_PORT=4311`
- expose/public port `4311`
- ⚠️ **BUILD ARGUMENTS (set before building the image):**
- - `NEXT_PUBLIC_API_URL=https://corpsim-api.altitude-interactive.com`
+ - `NEXT_PUBLIC_APP_URL=https://corpsim.altitude-interactive.com`
+ - `NEXT_PUBLIC_AUTH_URL=https://corpsim.altitude-interactive.com` (optional)
+ - `NEXT_PUBLIC_API_URL=https://corpsim.altitude-interactive.com` (or leave empty)
- **RUNTIME ENVIRONMENT VARIABLES:**
- - `NEXT_PUBLIC_API_URL=https://corpsim-api.altitude-interactive.com`
+ - `NEXT_PUBLIC_APP_URL=https://corpsim.altitude-interactive.com`
+ - `NEXT_PUBLIC_AUTH_URL=https://corpsim.altitude-interactive.com` (optional)
+ - `NEXT_PUBLIC_API_URL=https://corpsim.altitude-interactive.com` (or leave empty)
- `API_URL=http://corpsim-api:4310` (or internal Docker network address)
+ - `BETTER_AUTH_URL=https://corpsim.altitude-interactive.com`
All runtime apps also need:
@@ -66,14 +73,19 @@ All runtime apps also need:
If you insist on one container, set:
**Build Arguments (must be set before building):**
-- `NEXT_PUBLIC_API_URL=https://corpsim-api.altitude-interactive.com`
+- `NEXT_PUBLIC_APP_URL=https://corpsim.altitude-interactive.com`
+- `NEXT_PUBLIC_AUTH_URL=https://corpsim.altitude-interactive.com` (optional)
+- `NEXT_PUBLIC_API_URL=https://corpsim.altitude-interactive.com` (or leave empty)
**Runtime Environment Variables:**
- `APP_ROLE=all`
- `API_PORT=4310`
- `WEB_PORT=4311`
- `CORS_ORIGIN=https://corpsim.altitude-interactive.com`
-- `NEXT_PUBLIC_API_URL=https://corpsim-api.altitude-interactive.com`
+- `NEXT_PUBLIC_APP_URL=https://corpsim.altitude-interactive.com`
+- `NEXT_PUBLIC_AUTH_URL=https://corpsim.altitude-interactive.com` (optional)
+- `NEXT_PUBLIC_API_URL=https://corpsim.altitude-interactive.com` (or leave empty)
+- `BETTER_AUTH_URL=https://corpsim.altitude-interactive.com`
- `API_URL=http://localhost:4310` (internal server-side API access)
- `PREVIEW_DATABASE_URL=postgresql://postgres:@postgres:5432/corpsim`
- `PREVIEW_REDIS_HOST=redis`
@@ -91,12 +103,9 @@ pnpm sim:seed
## Nginx upstream mapping
-- `corpsim.altitude-interactive.com` -> `192.0.2.10:4311` (web app)
-- `corpsim-api.altitude-interactive.com` -> `192.0.2.10:4310` (API server)
-
-**IMPORTANT**: The nginx configuration for `corpsim.altitude-interactive.com` must proxy `/api/auth/*` requests to the API server at port 4310. This allows OAuth callbacks to work correctly on the main web domain. See `docs/project/corpsim.altitude.nginx.conf` for the complete configuration.
-
-**Note**: Replace the example IP address `192.0.2.10` (from RFC 5737 documentation range) with your actual server IP address.
+- `corpsim.altitude-interactive.com` -> `:4311` (public)
+- Keep API private/internal when possible; web route handlers proxy `/v1/*` and `/api/auth/*` to the API upstream.
+- If `corpsim-api.altitude-interactive.com` is still configured, redirect it to `corpsim.altitude-interactive.com`.
## Troubleshooting
@@ -121,18 +130,21 @@ pnpm sim:seed
- 404 errors in browser console for `/api/auth/sign-in/social`
- Auth requests going to wrong URL (e.g., `localhost` instead of your domain)
-**Cause:** The `NEXT_PUBLIC_API_URL` build argument was not set when building the Docker image.
+**Cause:** Public auth/API URLs were built with the wrong origin (often localhost or a separate API host), or missing entirely.
**Solution:**
1. In Dokploy, find the Build Arguments or Build Environment section for your app
-2. Add `NEXT_PUBLIC_API_URL=https://` as a build argument
-3. Trigger a **full rebuild** of the image (not just a restart)
-4. After deployment, check browser's network tab to verify requests go to the correct domain
+2. Set `NEXT_PUBLIC_APP_URL` (and optionally `NEXT_PUBLIC_AUTH_URL`) to your web domain
+3. Set `NEXT_PUBLIC_API_URL` to your web domain (or leave it empty for same-origin routing)
+4. Set runtime `BETTER_AUTH_URL` to your web domain
+5. Ensure runtime `API_URL` points to the internal API service (not the public web domain)
+6. Trigger a **full rebuild** of the image (not just a restart)
+7. After deployment, check browser's network tab to verify auth and API requests go to your web domain
### How to verify build-time values are correct
1. Open your web app in a browser
2. Open Developer Tools → Network tab
3. Trigger an API request (e.g., try to sign in)
-4. Check the request URL - it should point to your production API domain, not localhost
+4. Check request URLs - for single-domain SSO they should point to your web domain (not localhost)
5. Alternatively, check the Console tab for any warnings from CorpSim about misconfigured URLs
diff --git a/docs/project/DOKPLOY_PREVIEW.md b/docs/project/DOKPLOY_PREVIEW.md
index 4f65489f..57f1aaf6 100644
--- a/docs/project/DOKPLOY_PREVIEW.md
+++ b/docs/project/DOKPLOY_PREVIEW.md
@@ -28,7 +28,9 @@ You can use local `.env.preview` as the canonical template for these values.
1. Go to your Compose app → **Environment** tab
2. Set runtime environment variables as usual
3. **Additionally**, if Dokploy provides a "Build Arguments" section, set:
- - `NEXT_PUBLIC_API_URL=https://`
+ - `NEXT_PUBLIC_APP_URL=https://`
+ - `NEXT_PUBLIC_AUTH_URL=https://` (optional)
+ - `NEXT_PUBLIC_API_URL=https://` (recommended) or leave empty for same-origin routing
If Dokploy doesn't have a separate Build Arguments UI, note that variables set only in the **Environment** section are runtime variables and are **not** automatically available at build time. In that case, you must manually edit your `docker-compose.preview.yml` and add these as Docker build arguments (under the `args:` section of `x-app-build` or the relevant `build:` configuration for the `web` service) so that the `NEXT_PUBLIC_*` values are passed into the Next.js build.
@@ -45,7 +47,10 @@ If Dokploy doesn't have a separate Build Arguments UI, note that variables set o
- `WEB_PORT=4311`
- `WEB_PUBLIC_PORT=4311`
- `CORS_ORIGIN=https://`
-- `NEXT_PUBLIC_API_URL=https://` ⚠️ **Must also be set as build argument**
+- `NEXT_PUBLIC_APP_URL=https://` ⚠️ **Must also be set as build argument**
+- `NEXT_PUBLIC_AUTH_URL=https://` (optional, also set as build argument)
+- `NEXT_PUBLIC_API_URL=https://` (recommended, also set as build argument; can be empty for same-origin)
+- `BETTER_AUTH_URL=https://`
Release image settings (tag-first):
@@ -65,7 +70,7 @@ Recommended worker settings:
## 3) Networking / domains
- Route `web` publicly on `WEB_PUBLIC_PORT`.
-- Route `api` publicly on `API_PUBLIC_PORT`.
+- Prefer keeping `api` internal/private and letting web proxy `/v1/*` and `/api/auth/*` to the API service.
- Keep `postgres` and `redis` internal only.
## 4) First deployment init
@@ -95,13 +100,16 @@ Do not run `sim:reset` in preview unless you intentionally want to wipe/reseed s
- 404 errors in browser console for `/api/auth/sign-in/social`
- Auth requests going to wrong URL (e.g., `localhost` instead of your domain)
-**Cause:** The `NEXT_PUBLIC_API_URL` build argument was not set correctly when the Docker image was built. Next.js bakes these values into the client-side JavaScript bundle at build time.
+**Cause:** Public auth/API URLs were built with the wrong origin (often localhost or a separate API host), or missing entirely.
**Solution:**
-1. In Dokploy, ensure `NEXT_PUBLIC_API_URL` is set as a **build argument** (check Build Arguments or Build Environment section)
-2. Trigger a **full rebuild** (not just a restart)
-3. Verify the built image has the correct value by checking browser's network tab - requests should go to your API domain, not localhost
+1. In Dokploy, set `NEXT_PUBLIC_APP_URL` (and optionally `NEXT_PUBLIC_AUTH_URL`) as **build arguments**
+2. Set `NEXT_PUBLIC_API_URL` to your web domain (or leave it empty to use same-origin routing)
+3. Set runtime `BETTER_AUTH_URL` to your web domain
+4. Ensure runtime `API_URL` points to your internal API service
+5. Trigger a **full rebuild** (not just a restart)
+6. Verify in browser devtools that auth/API requests use your web domain, not localhost
### API calls going to localhost or wrong domain
-This is the same issue as above - `NEXT_PUBLIC_*` variables must be set as build arguments before building the image.
+This is the same issue as above: set correct `NEXT_PUBLIC_*` build arguments and rebuild the image.
diff --git a/docs/project/corpsim.altitude.nginx.conf b/docs/project/corpsim.altitude.nginx.conf
index 764249b2..6d308243 100644
--- a/docs/project/corpsim.altitude.nginx.conf
+++ b/docs/project/corpsim.altitude.nginx.conf
@@ -9,7 +9,7 @@ server {
listen 80;
listen [::]:80;
server_name corpsim-api.altitude-interactive.com;
- return 301 https://$host$request_uri;
+ return 301 https://corpsim.altitude-interactive.com$request_uri;
}
server {
@@ -48,12 +48,5 @@ server {
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
- location / {
- proxy_pass http://192.0.2.10:4310;
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- }
+ return 308 https://corpsim.altitude-interactive.com$request_uri;
}
From e0f236b378e59f55445686b9a5de53798173bfb3 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 16:31:23 +0100
Subject: [PATCH 19/52] fix(web): preserve repeated proxy headers
---
apps/web/app/api/auth/[[...path]]/route.ts | 2 +-
apps/web/app/v1/[...path]/route.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/web/app/api/auth/[[...path]]/route.ts b/apps/web/app/api/auth/[[...path]]/route.ts
index dc5db440..56acf40f 100644
--- a/apps/web/app/api/auth/[[...path]]/route.ts
+++ b/apps/web/app/api/auth/[[...path]]/route.ts
@@ -36,7 +36,7 @@ function sanitizeHeaders(source: Headers, blocked: Set): Headers {
const target = new Headers();
source.forEach((value, key) => {
if (!blocked.has(key.toLowerCase())) {
- target.set(key, value);
+ target.append(key, value);
}
});
return target;
diff --git a/apps/web/app/v1/[...path]/route.ts b/apps/web/app/v1/[...path]/route.ts
index d09d86b0..d024104e 100644
--- a/apps/web/app/v1/[...path]/route.ts
+++ b/apps/web/app/v1/[...path]/route.ts
@@ -36,7 +36,7 @@ function sanitizeHeaders(source: Headers, blocked: Set): Headers {
const target = new Headers();
source.forEach((value, key) => {
if (!blocked.has(key.toLowerCase())) {
- target.set(key, value);
+ target.append(key, value);
}
});
return target;
From b9ac090daacd0574419e0e64a3ce52aa85864bab Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 16:48:03 +0100
Subject: [PATCH 20/52] fix(ci): apply migrations before APP_ROLE all startup
---
.../20260222154900-run-migrations-in-all-role-startup.md | 8 ++++++++
scripts/start-container.sh | 7 +++++++
2 files changed, 15 insertions(+)
create mode 100644 .releases/unreleased/20260222154900-run-migrations-in-all-role-startup.md
diff --git a/.releases/unreleased/20260222154900-run-migrations-in-all-role-startup.md b/.releases/unreleased/20260222154900-run-migrations-in-all-role-startup.md
new file mode 100644
index 00000000..73a77a5d
--- /dev/null
+++ b/.releases/unreleased/20260222154900-run-migrations-in-all-role-startup.md
@@ -0,0 +1,8 @@
+---
+type: patch
+area: ci
+summary: Run Prisma migrations before launching services in APP_ROLE=all mode
+---
+
+- Updated `scripts/start-container.sh` so `APP_ROLE=all` applies `prisma migrate deploy` before starting API, worker, and web.
+- Prevents runtime schema-readiness pauses when single-container deployments start without a dedicated migrate step.
diff --git a/scripts/start-container.sh b/scripts/start-container.sh
index 44ebc5c4..05b58288 100644
--- a/scripts/start-container.sh
+++ b/scripts/start-container.sh
@@ -32,7 +32,14 @@ run_migrate() {
exec pnpm exec prisma migrate deploy --schema packages/db/prisma/schema.prisma
}
+apply_migrations() {
+ pnpm exec prisma migrate deploy --schema packages/db/prisma/schema.prisma
+}
+
run_all() {
+ # Ensure schema is current before starting long-running processes in single-container mode.
+ apply_migrations
+
pnpm --filter @corpsim/api start &
api_pid=$!
From 7b63a1dbead5eb6420e0da6d4a0844a0597f3994 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 16:51:48 +0100
Subject: [PATCH 21/52] fix(web): hide seeded example accounts in admin list
---
...0260222165400-hide-seeded-example-accounts-in-admin.md | 8 ++++++++
apps/web/src/components/admin/admin-page.tsx | 8 +++++++-
2 files changed, 15 insertions(+), 1 deletion(-)
create mode 100644 .releases/unreleased/20260222165400-hide-seeded-example-accounts-in-admin.md
diff --git a/.releases/unreleased/20260222165400-hide-seeded-example-accounts-in-admin.md b/.releases/unreleased/20260222165400-hide-seeded-example-accounts-in-admin.md
new file mode 100644
index 00000000..feb74fc8
--- /dev/null
+++ b/.releases/unreleased/20260222165400-hide-seeded-example-accounts-in-admin.md
@@ -0,0 +1,8 @@
+---
+type: patch
+area: web
+summary: Hide seeded example.com accounts from admin user listing
+---
+
+- Filtered admin dashboard user rows to exclude seeded accounts with emails ending in `@example.com`.
+- Keeps production/real user account management focused and uncluttered.
diff --git a/apps/web/src/components/admin/admin-page.tsx b/apps/web/src/components/admin/admin-page.tsx
index f5564426..a0c9b4bb 100644
--- a/apps/web/src/components/admin/admin-page.tsx
+++ b/apps/web/src/components/admin/admin-page.tsx
@@ -46,6 +46,11 @@ interface SupportAccount {
const MAIN_ADMIN_EMAIL = "admin@corpsim.local";
const STALE_IMPORT_TICK_THRESHOLD = 5;
+
+function isSeededExampleAccount(email: string): boolean {
+ return email.trim().toLowerCase().endsWith("@example.com");
+}
+
export function AdminPage() {
const [users, setUsers] = useState([]);
const [isLoading, setIsLoading] = useState(true);
@@ -81,7 +86,8 @@ export function AdminPage() {
});
if (result.data) {
- setUsers(result.data.users as unknown as UserData[]);
+ const loadedUsers = result.data.users as unknown as UserData[];
+ setUsers(loadedUsers.filter((user) => !isSeededExampleAccount(user.email)));
} else if (result.error) {
showToast({
title: "Failed to load users",
From f09d929cef8e7a9b2980f2088d6d63e2e574c4fc Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 17:00:16 +0100
Subject: [PATCH 22/52] fix(api): allow admins on developer catalog read
endpoints
---
...00-allow-admin-developer-read-endpoints.md | 8 +++++
.../decorators/current-player-id.decorator.ts | 33 +++++++++++++++++
.../test/current-player-id.decorator.test.ts | 36 +++++++++++++++++++
3 files changed, 77 insertions(+)
create mode 100644 .releases/unreleased/20260222170600-allow-admin-developer-read-endpoints.md
create mode 100644 apps/api/test/current-player-id.decorator.test.ts
diff --git a/.releases/unreleased/20260222170600-allow-admin-developer-read-endpoints.md b/.releases/unreleased/20260222170600-allow-admin-developer-read-endpoints.md
new file mode 100644
index 00000000..7f71f9e9
--- /dev/null
+++ b/.releases/unreleased/20260222170600-allow-admin-developer-read-endpoints.md
@@ -0,0 +1,8 @@
+---
+type: patch
+area: api, web
+summary: Allow admin accounts to access developer page read endpoints
+---
+
+- Updated player-id guard logic to permit admin `GET` access on the specific catalog endpoints used by `/developer`.
+- Kept admin restrictions in place for write operations and non-allowlisted gameplay endpoints.
diff --git a/apps/api/src/common/decorators/current-player-id.decorator.ts b/apps/api/src/common/decorators/current-player-id.decorator.ts
index e59f162e..a6b1cb2b 100644
--- a/apps/api/src/common/decorators/current-player-id.decorator.ts
+++ b/apps/api/src/common/decorators/current-player-id.decorator.ts
@@ -8,6 +8,10 @@ import type { UserSession } from "@thallesp/nestjs-better-auth";
interface RequestWithSession {
session?: UserSession | null;
+ method?: string;
+ url?: string;
+ originalUrl?: string;
+ path?: string;
}
function isAdminRole(role: string | string[] | null | undefined): boolean {
@@ -20,6 +24,32 @@ function isAdminRole(role: string | string[] | null | undefined): boolean {
.some((entry) => entry === "admin");
}
+const ADMIN_ALLOWED_READ_PATH_PREFIXES = [
+ "/v1/companies",
+ "/v1/items",
+ "/v1/production/recipes",
+ "/v1/research"
+] as const;
+
+function resolveRequestPath(request: RequestWithSession): string {
+ const rawPath = request.originalUrl ?? request.path ?? request.url ?? "";
+ const pathWithoutQuery = rawPath.split("?", 1)[0] ?? "";
+ const normalized = pathWithoutQuery.trim();
+ return normalized.length > 0 ? normalized : "/";
+}
+
+export function canAdminAccessPlayerGameplayEndpoint(request: RequestWithSession): boolean {
+ const method = request.method?.trim().toUpperCase();
+ if (method !== "GET") {
+ return false;
+ }
+
+ const path = resolveRequestPath(request);
+ return ADMIN_ALLOWED_READ_PATH_PREFIXES.some(
+ (prefix) => path === prefix || path.startsWith(`${prefix}/`)
+ );
+}
+
function resolveTestFallbackPlayerId(): string | null {
if (process.env.NODE_ENV !== "test") {
return null;
@@ -43,6 +73,9 @@ export const CurrentPlayerId = createParamDecorator(
}
if (isAdminRole(request.session?.user?.role)) {
+ if (canAdminAccessPlayerGameplayEndpoint(request)) {
+ return playerId;
+ }
throw new ForbiddenException("Admin accounts cannot access player gameplay endpoints.");
}
return playerId;
diff --git a/apps/api/test/current-player-id.decorator.test.ts b/apps/api/test/current-player-id.decorator.test.ts
new file mode 100644
index 00000000..70be25ed
--- /dev/null
+++ b/apps/api/test/current-player-id.decorator.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+import { canAdminAccessPlayerGameplayEndpoint } from "../src/common/decorators/current-player-id.decorator";
+
+describe("canAdminAccessPlayerGameplayEndpoint", () => {
+ it("allows admin GET requests to developer catalog read endpoints", () => {
+ expect(canAdminAccessPlayerGameplayEndpoint({ method: "GET", url: "/v1/companies" })).toBe(true);
+ expect(canAdminAccessPlayerGameplayEndpoint({ method: "GET", url: "/v1/items" })).toBe(true);
+ expect(canAdminAccessPlayerGameplayEndpoint({ method: "GET", url: "/v1/production/recipes" })).toBe(
+ true
+ );
+ expect(
+ canAdminAccessPlayerGameplayEndpoint({
+ method: "GET",
+ url: "/v1/research?companyId=company_seed"
+ })
+ ).toBe(true);
+ });
+
+ it("denies admin access for non-allowlisted paths", () => {
+ expect(
+ canAdminAccessPlayerGameplayEndpoint({
+ method: "GET",
+ url: "/v1/market/orders"
+ })
+ ).toBe(false);
+ });
+
+ it("denies admin access for write methods even on allowlisted paths", () => {
+ expect(
+ canAdminAccessPlayerGameplayEndpoint({
+ method: "POST",
+ url: "/v1/production/recipes"
+ })
+ ).toBe(false);
+ });
+});
From b5f1e939dfc47b237d92d34dc295b58c73c3461e Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 17:03:46 +0100
Subject: [PATCH 23/52] fix(web): accept redacted company cash in parsers
---
...1500-handle-redacted-company-cash-in-web-parsers.md | 8 ++++++++
apps/web/src/lib/api-parsers.ts | 10 ++++++++--
2 files changed, 16 insertions(+), 2 deletions(-)
create mode 100644 .releases/unreleased/20260222171500-handle-redacted-company-cash-in-web-parsers.md
diff --git a/.releases/unreleased/20260222171500-handle-redacted-company-cash-in-web-parsers.md b/.releases/unreleased/20260222171500-handle-redacted-company-cash-in-web-parsers.md
new file mode 100644
index 00000000..062742c2
--- /dev/null
+++ b/.releases/unreleased/20260222171500-handle-redacted-company-cash-in-web-parsers.md
@@ -0,0 +1,8 @@
+---
+type: patch
+area: web
+summary: Accept optional redacted company cash fields in API parsers
+---
+
+- Updated web API parsers to treat `cashCents` as optional in company summary and player registry company payloads.
+- Prevents admin developer page failures when backend redacts non-owned company cash values.
diff --git a/apps/web/src/lib/api-parsers.ts b/apps/web/src/lib/api-parsers.ts
index 6fa7ed25..aed0cd62 100644
--- a/apps/web/src/lib/api-parsers.ts
+++ b/apps/web/src/lib/api-parsers.ts
@@ -134,13 +134,16 @@ export function parseCompanySummary(value: unknown): CompanySummary {
throw new Error("Invalid company item");
}
+ const cashCents =
+ value.cashCents === undefined ? undefined : readString(value.cashCents, "cashCents");
+
return {
id: readString(value.id, "id"),
code: readString(value.code, "code"),
name: readString(value.name, "name"),
isBot: readBoolean(value.isBot, "isBot"),
specialization: parseCompanySpecialization(value.specialization),
- cashCents: readString(value.cashCents, "cashCents"),
+ cashCents,
regionId: readString(value.regionId, "regionId"),
regionCode: readString(value.regionCode, "regionCode"),
regionName: readString(value.regionName, "regionName")
@@ -245,12 +248,15 @@ export function parsePlayerRegistryCompany(value: unknown): PlayerRegistryCompan
throw new Error("Invalid player registry company");
}
+ const cashCents =
+ value.cashCents === undefined ? undefined : readString(value.cashCents, "cashCents");
+
return {
id: readString(value.id, "id"),
code: readString(value.code, "code"),
name: readString(value.name, "name"),
isBot: readBoolean(value.isBot, "isBot"),
- cashCents: readString(value.cashCents, "cashCents"),
+ cashCents,
regionId: readString(value.regionId, "regionId"),
regionCode: readString(value.regionCode, "regionCode"),
regionName: readString(value.regionName, "regionName"),
From d462c173dc810051fd9b26af5151c3a7ba032e39 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 17:06:57 +0100
Subject: [PATCH 24/52] fix(api): support admin research catalog on developer
page
---
...enable-admin-developer-research-catalog.md | 8 +++
apps/api/src/research/research.controller.ts | 22 ++++++-
apps/api/src/research/research.service.ts | 39 +++++++++++
...research-admin-catalog.integration.test.ts | 64 +++++++++++++++++++
4 files changed, 132 insertions(+), 1 deletion(-)
create mode 100644 .releases/unreleased/20260222172500-enable-admin-developer-research-catalog.md
create mode 100644 apps/api/test/research-admin-catalog.integration.test.ts
diff --git a/.releases/unreleased/20260222172500-enable-admin-developer-research-catalog.md b/.releases/unreleased/20260222172500-enable-admin-developer-research-catalog.md
new file mode 100644
index 00000000..f2fe5ccd
--- /dev/null
+++ b/.releases/unreleased/20260222172500-enable-admin-developer-research-catalog.md
@@ -0,0 +1,8 @@
+---
+type: patch
+area: api, web
+summary: Enable admin access to developer research catalog without player ownership
+---
+
+- Added admin-only research catalog read path that selects a player-owned company when no company ID is provided.
+- Kept existing player ownership enforcement for non-admin research access and all research mutations.
diff --git a/apps/api/src/research/research.controller.ts b/apps/api/src/research/research.controller.ts
index ce87b435..2f1bd171 100644
--- a/apps/api/src/research/research.controller.ts
+++ b/apps/api/src/research/research.controller.ts
@@ -1,10 +1,25 @@
-import { Body, Controller, Get, Inject, Param, Post, Query } from "@nestjs/common";
+import { Body, Controller, Get, Inject, Param, Post, Query, Req } from "@nestjs/common";
+import type { UserSession } from "@thallesp/nestjs-better-auth";
import { CurrentPlayerId } from "../common/decorators/current-player-id.decorator";
import { ListResearchDto } from "./dto/list-research.dto";
import { MutateResearchDto } from "./dto/mutate-research.dto";
import { ResearchNodeParamDto } from "./dto/research-node-param.dto";
import { ResearchService } from "./research.service";
+interface RequestWithSession {
+ session?: UserSession | null;
+}
+
+function isAdminRole(role: string | string[] | null | undefined): boolean {
+ if (!role) {
+ return false;
+ }
+ const roleValues = Array.isArray(role) ? role : role.split(",");
+ return roleValues
+ .map((entry) => entry.trim().toLowerCase())
+ .some((entry) => entry === "admin");
+}
+
@Controller("v1/research")
export class ResearchController {
private readonly researchService: ResearchService;
@@ -16,8 +31,13 @@ export class ResearchController {
@Get()
async list(
@Query() query: ListResearchDto,
+ @Req() request: RequestWithSession,
@CurrentPlayerId() playerId: string
) {
+ if (isAdminRole(request.session?.user?.role)) {
+ return this.researchService.listResearchForAdminCatalog(query.companyId);
+ }
+
return this.researchService.listResearch(query.companyId, playerId);
}
diff --git a/apps/api/src/research/research.service.ts b/apps/api/src/research/research.service.ts
index 4e74ee32..6c932c4c 100644
--- a/apps/api/src/research/research.service.ts
+++ b/apps/api/src/research/research.service.ts
@@ -54,6 +54,31 @@ export class ResearchService {
this.prisma = prisma;
}
+ private async resolveAdminCatalogCompanyId(companyId?: string): Promise {
+ if (companyId) {
+ return companyId;
+ }
+
+ const firstPlayerCompany = await this.prisma.company.findFirst({
+ where: {
+ isPlayer: true,
+ ownerPlayerId: { not: null }
+ },
+ orderBy: {
+ createdAt: "asc"
+ },
+ select: {
+ id: true
+ }
+ });
+
+ if (!firstPlayerCompany) {
+ throw new DomainInvariantError("no player-owned companies available for research catalog");
+ }
+
+ return firstPlayerCompany.id;
+ }
+
private async resolveOwnedCompanyId(playerId: string, companyId?: string): Promise {
if (companyId) {
await assertCompanyOwnedByPlayer(this.prisma, playerId, companyId);
@@ -84,6 +109,20 @@ export class ResearchService {
};
}
+ async listResearchForAdminCatalog(
+ companyId?: string
+ ): Promise<{ companyId: string; nodes: ResearchNode[] }> {
+ const resolvedCompanyId = await this.resolveAdminCatalogCompanyId(companyId);
+ const nodes = await listResearchForCompany(this.prisma, {
+ companyId: resolvedCompanyId
+ });
+
+ return {
+ companyId: resolvedCompanyId,
+ nodes: nodes.map(mapNodeToDto)
+ };
+ }
+
async startNode(
nodeId: string,
companyId: string | undefined,
diff --git a/apps/api/test/research-admin-catalog.integration.test.ts b/apps/api/test/research-admin-catalog.integration.test.ts
new file mode 100644
index 00000000..7a69abdf
--- /dev/null
+++ b/apps/api/test/research-admin-catalog.integration.test.ts
@@ -0,0 +1,64 @@
+import "reflect-metadata";
+import { INestApplication, ValidationPipe } from "@nestjs/common";
+import { Test } from "@nestjs/testing";
+import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
+import { seedWorld } from "@corpsim/db";
+import { HttpErrorFilter } from "../src/common/filters/http-error.filter";
+import { AppModule } from "../src/app.module";
+import { PrismaService } from "../src/prisma/prisma.service";
+import { ResearchService } from "../src/research/research.service";
+
+describe("research admin catalog", () => {
+ let app: INestApplication;
+ let prisma: PrismaService;
+ let researchService: ResearchService;
+
+ beforeAll(async () => {
+ const moduleRef = await Test.createTestingModule({
+ imports: [AppModule]
+ }).compile();
+
+ app = moduleRef.createNestApplication();
+ app.useGlobalPipes(
+ new ValidationPipe({
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ forbidUnknownValues: true,
+ transform: false,
+ stopAtFirstError: true
+ })
+ );
+ app.useGlobalFilters(new HttpErrorFilter());
+ await app.init();
+
+ prisma = app.get(PrismaService);
+ researchService = app.get(ResearchService);
+ });
+
+ beforeEach(async () => {
+ await seedWorld(prisma, { reset: true });
+ });
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ it("returns a research catalog for admins without requiring player ownership", async () => {
+ const result = await researchService.listResearchForAdminCatalog();
+
+ expect(typeof result.companyId).toBe("string");
+ expect(result.companyId.length).toBeGreaterThan(0);
+ expect(result.nodes.length).toBeGreaterThan(0);
+
+ const company = await prisma.company.findUniqueOrThrow({
+ where: { id: result.companyId },
+ select: {
+ isPlayer: true,
+ ownerPlayerId: true
+ }
+ });
+
+ expect(company.isPlayer).toBe(true);
+ expect(company.ownerPlayerId).not.toBeNull();
+ });
+});
From 16d598b0eb6ee4294d9a6035310076563f2beb94 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 17:11:04 +0100
Subject: [PATCH 25/52] fix(web): separate recipe input items across catalog
views
---
...73000-separate-recipe-input-items-in-ui.md | 8 ++++
.../src/components/dev/dev-catalog-page.tsx | 27 +++++------
.../components/items/item-quantity-list.tsx | 46 +++++++++++++++++++
.../components/production/production-page.tsx | 38 +++++++--------
4 files changed, 88 insertions(+), 31 deletions(-)
create mode 100644 .releases/unreleased/20260222173000-separate-recipe-input-items-in-ui.md
create mode 100644 apps/web/src/components/items/item-quantity-list.tsx
diff --git a/.releases/unreleased/20260222173000-separate-recipe-input-items-in-ui.md b/.releases/unreleased/20260222173000-separate-recipe-input-items-in-ui.md
new file mode 100644
index 00000000..b4e76f68
--- /dev/null
+++ b/.releases/unreleased/20260222173000-separate-recipe-input-items-in-ui.md
@@ -0,0 +1,8 @@
+---
+type: patch
+area: web
+summary: Improve recipe input readability with explicit separators and quantity labels
+---
+
+- Added a reusable `ItemQuantityList` UI component that renders item inputs with clear separators and `xN` quantities.
+- Updated developer and production recipe sections to use the shared list component, preventing concatenated input labels.
diff --git a/apps/web/src/components/dev/dev-catalog-page.tsx b/apps/web/src/components/dev/dev-catalog-page.tsx
index 3d2130bd..c61f5173 100644
--- a/apps/web/src/components/dev/dev-catalog-page.tsx
+++ b/apps/web/src/components/dev/dev-catalog-page.tsx
@@ -3,6 +3,7 @@
import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react";
import { getIconCatalogItemByCode } from "@corpsim/shared";
import { ItemLabel } from "@/components/items/item-label";
+import { ItemQuantityList } from "@/components/items/item-quantity-list";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -948,19 +949,19 @@ export function DevCatalogPage() {
- {recipe.durationTicks}
-
-
- {recipe.inputs.map((input) => (
-
- {input.quantityPerRun}
-
-
- ))}
-
-
-
- ))}
+ {recipe.durationTicks}
+
+ ({
+ key: `${recipe.id}-${input.itemId}`,
+ quantity: input.quantityPerRun,
+ itemCode: input.item.code,
+ itemName: input.item.name
+ }))}
+ />
+
+
+ ))}
{pagedRecipes.length === 0 ? (
diff --git a/apps/web/src/components/items/item-quantity-list.tsx b/apps/web/src/components/items/item-quantity-list.tsx
new file mode 100644
index 00000000..3fbcad29
--- /dev/null
+++ b/apps/web/src/components/items/item-quantity-list.tsx
@@ -0,0 +1,46 @@
+import { Fragment } from "react";
+import { ItemLabel } from "@/components/items/item-label";
+import { cn } from "@/lib/utils";
+
+export interface ItemQuantityListEntry {
+ key: string;
+ quantity: number;
+ itemCode?: string | null;
+ itemName: string;
+}
+
+interface ItemQuantityListProps {
+ items: ItemQuantityListEntry[];
+ className?: string;
+ itemClassName?: string;
+ separator?: string;
+}
+
+export function ItemQuantityList({
+ items,
+ className,
+ itemClassName,
+ separator = ","
+}: ItemQuantityListProps) {
+ if (items.length === 0) {
+ return -- ;
+ }
+
+ return (
+
+ {items.map((entry, index) => (
+
+ {index > 0 ? (
+
+ {separator}
+
+ ) : null}
+
+ x{entry.quantity}
+
+
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/components/production/production-page.tsx b/apps/web/src/components/production/production-page.tsx
index 9e5e6c38..fe486221 100644
--- a/apps/web/src/components/production/production-page.tsx
+++ b/apps/web/src/components/production/production-page.tsx
@@ -5,6 +5,7 @@ import { resolveCompanySpecializationCooldownHours } from "@corpsim/shared";
import { Check, ChevronsUpDown } from "lucide-react";
import { useActiveCompany } from "@/components/company/active-company-provider";
import { ItemLabel } from "@/components/items/item-label";
+import { ItemQuantityList } from "@/components/items/item-quantity-list";
import { useWorldHealth } from "@/components/layout/world-health-provider";
import { useUiSfx } from "@/components/layout/ui-sfx-provider";
import { Badge } from "@/components/ui/badge";
@@ -647,16 +648,17 @@ export function ProductionPage() {
className="inline-flex"
/>
- Inputs:
-
- {selectedRecipe.inputs.map((input) => (
-
- {input.quantityPerRun}
-
- / run
-
- ))}
-
+
+
Inputs / run:
+
({
+ key: input.itemId,
+ quantity: input.quantityPerRun,
+ itemCode: input.item.code,
+ itemName: input.item.name
+ }))}
+ />
+
) : null}
@@ -770,14 +772,14 @@ export function ProductionPage() {
{formatCadenceCount(row.recipe.durationTicks)}
-
- {row.recipe.inputs.map((input) => (
-
- {input.quantityPerRun}
-
-
- ))}
-
+ ({
+ key: input.itemId,
+ quantity: input.quantityPerRun,
+ itemCode: input.item.code,
+ itemName: input.item.name
+ }))}
+ />
))}
From 2eb934da9a3de1b800c7d62deb66d03938595078 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 17:18:35 +0100
Subject: [PATCH 26/52] fix(web): centralize item quantity labels for recipe
outputs
---
...e-item-quantity-rendering-and-apply-qua.md | 7 ++++
.../src/components/dev/dev-catalog-page.tsx | 36 ++++++++++---------
.../components/items/item-quantity-label.tsx | 30 ++++++++++++++++
.../components/items/item-quantity-list.tsx | 12 ++++---
.../components/production/production-page.tsx | 19 +++++-----
apps/web/src/lib/quantity-controller.ts | 28 +++++++++++++++
6 files changed, 100 insertions(+), 32 deletions(-)
create mode 100644 .releases/unreleased/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md
create mode 100644 apps/web/src/components/items/item-quantity-label.tsx
create mode 100644 apps/web/src/lib/quantity-controller.ts
diff --git a/.releases/unreleased/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md b/.releases/unreleased/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md
new file mode 100644
index 00000000..f8998a0d
--- /dev/null
+++ b/.releases/unreleased/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md
@@ -0,0 +1,7 @@
+---
+type: patch
+area: web
+summary: Centralize item quantity rendering and apply quantifier labels to recipe outputs
+---
+
+- Centralize item quantity rendering and apply quantifier labels to recipe outputs
diff --git a/apps/web/src/components/dev/dev-catalog-page.tsx b/apps/web/src/components/dev/dev-catalog-page.tsx
index c61f5173..be56bd31 100644
--- a/apps/web/src/components/dev/dev-catalog-page.tsx
+++ b/apps/web/src/components/dev/dev-catalog-page.tsx
@@ -3,6 +3,7 @@
import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react";
import { getIconCatalogItemByCode } from "@corpsim/shared";
import { ItemLabel } from "@/components/items/item-label";
+import { ItemQuantityLabel } from "@/components/items/item-quantity-label";
import { ItemQuantityList } from "@/components/items/item-quantity-list";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -944,24 +945,25 @@ export function DevCatalogPage() {
-
- {recipe.outputQuantity}
-
-
+
- {recipe.durationTicks}
-
- ({
- key: `${recipe.id}-${input.itemId}`,
- quantity: input.quantityPerRun,
- itemCode: input.item.code,
- itemName: input.item.name
- }))}
- />
-
-
- ))}
+ {recipe.durationTicks}
+
+ ({
+ key: `${recipe.id}-${input.itemId}`,
+ quantity: input.quantityPerRun,
+ itemCode: input.item.code,
+ itemName: input.item.name
+ }))}
+ />
+
+
+ ))}
{pagedRecipes.length === 0 ? (
diff --git a/apps/web/src/components/items/item-quantity-label.tsx b/apps/web/src/components/items/item-quantity-label.tsx
new file mode 100644
index 00000000..2895f810
--- /dev/null
+++ b/apps/web/src/components/items/item-quantity-label.tsx
@@ -0,0 +1,30 @@
+import { ItemLabel } from "@/components/items/item-label";
+import { formatQuantityToken } from "@/lib/quantity-controller";
+import { cn } from "@/lib/utils";
+
+interface ItemQuantityLabelProps {
+ quantity: number;
+ itemCode?: string | null;
+ itemName: string;
+ className?: string;
+ quantityClassName?: string;
+ itemClassName?: string;
+}
+
+export function ItemQuantityLabel({
+ quantity,
+ itemCode,
+ itemName,
+ className,
+ quantityClassName,
+ itemClassName
+}: ItemQuantityLabelProps) {
+ return (
+
+
+ {formatQuantityToken(quantity)}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/items/item-quantity-list.tsx b/apps/web/src/components/items/item-quantity-list.tsx
index 3fbcad29..d2c0fe78 100644
--- a/apps/web/src/components/items/item-quantity-list.tsx
+++ b/apps/web/src/components/items/item-quantity-list.tsx
@@ -1,5 +1,5 @@
import { Fragment } from "react";
-import { ItemLabel } from "@/components/items/item-label";
+import { ItemQuantityLabel } from "@/components/items/item-quantity-label";
import { cn } from "@/lib/utils";
export interface ItemQuantityListEntry {
@@ -35,10 +35,12 @@ export function ItemQuantityList({
{separator}
) : null}
-
- x{entry.quantity}
-
-
+
))}
diff --git a/apps/web/src/components/production/production-page.tsx b/apps/web/src/components/production/production-page.tsx
index fe486221..75a3b78e 100644
--- a/apps/web/src/components/production/production-page.tsx
+++ b/apps/web/src/components/production/production-page.tsx
@@ -4,7 +4,7 @@ import { FormEvent, useCallback, useDeferredValue, useEffect, useMemo, useRef, u
import { resolveCompanySpecializationCooldownHours } from "@corpsim/shared";
import { Check, ChevronsUpDown } from "lucide-react";
import { useActiveCompany } from "@/components/company/active-company-provider";
-import { ItemLabel } from "@/components/items/item-label";
+import { ItemQuantityLabel } from "@/components/items/item-quantity-label";
import { ItemQuantityList } from "@/components/items/item-quantity-list";
import { useWorldHealth } from "@/components/layout/world-health-provider";
import { useUiSfx } from "@/components/layout/ui-sfx-provider";
@@ -641,8 +641,9 @@ export function ProductionPage() {
Duration: {formatCadenceCount(selectedRecipe.durationTicks)} / run
- Output: {selectedRecipe.outputQuantity}{" "}
- {row.recipe.name}
-
- {row.recipe.outputQuantity}
-
-
+
{formatCadenceCount(row.recipe.durationTicks)}
diff --git a/apps/web/src/lib/quantity-controller.ts b/apps/web/src/lib/quantity-controller.ts
new file mode 100644
index 00000000..8c0dce96
--- /dev/null
+++ b/apps/web/src/lib/quantity-controller.ts
@@ -0,0 +1,28 @@
+export interface QuantityFormatOptions {
+ prefix?: string;
+ fallback?: string;
+ locale?: string;
+}
+
+export class QuantityController {
+ format(quantity: number, options?: QuantityFormatOptions): string {
+ const prefix = options?.prefix ?? "x";
+ const fallback = options?.fallback ?? "--";
+
+ if (!Number.isFinite(quantity)) {
+ return fallback;
+ }
+
+ return `${prefix}${quantity.toLocaleString(options?.locale)}`;
+ }
+}
+
+const quantityController = new QuantityController();
+
+export function getQuantityController(): QuantityController {
+ return quantityController;
+}
+
+export function formatQuantityToken(quantity: number, options?: QuantityFormatOptions): string {
+ return quantityController.format(quantity, options);
+}
From f251dc092f3acb52f1fdf43d3547ec9fe0c2d90a Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 17:25:23 +0100
Subject: [PATCH 27/52] fix(web): resolve unknown item labels in market lists
---
...market-unknown-item-labels-by-using-global-i.md | 7 +++++++
apps/web/src/components/market/market-page.tsx | 14 ++++++++++----
2 files changed, 17 insertions(+), 4 deletions(-)
create mode 100644 .releases/unreleased/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md
diff --git a/.releases/unreleased/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md b/.releases/unreleased/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md
new file mode 100644
index 00000000..363a1e2a
--- /dev/null
+++ b/.releases/unreleased/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md
@@ -0,0 +1,7 @@
+---
+type: patch
+area: web
+summary: Fix market unknown item labels by using global item metadata
+---
+
+- Fix market unknown item labels by using global item metadata
diff --git a/apps/web/src/components/market/market-page.tsx b/apps/web/src/components/market/market-page.tsx
index ec949927..0ea09418 100644
--- a/apps/web/src/components/market/market-page.tsx
+++ b/apps/web/src/components/market/market-page.tsx
@@ -89,6 +89,7 @@ export function MarketPage() {
const { activeCompanyId, activeCompany } = useActiveCompany();
const { health, refresh: refreshHealth } = useWorldHealth();
+ const [catalogItems, setCatalogItems] = useState([]);
const [items, setItems] = useState([]);
const [companies, setCompanies] = useState([]);
const [regions, setRegions] = useState([]);
@@ -115,7 +116,8 @@ export function MarketPage() {
const [error, setError] = useState(null);
const loadCatalog = useCallback(async (): Promise => {
- const [itemRows, regionRows, companyRows] = await Promise.all([
+ const [catalogItemRows, selectableItemRows, regionRows, companyRows] = await Promise.all([
+ listItems(),
listItems(activeCompanyId ?? undefined),
listRegions(),
listCompanies()
@@ -140,7 +142,8 @@ export function MarketPage() {
}
setUnlockedItemIds(Array.from(unlockedIds));
- setItems(itemRows);
+ setCatalogItems(catalogItemRows);
+ setItems(selectableItemRows);
setRegions(regionRows);
setCompanies(companyRows);
let resolvedRegionId = "";
@@ -456,8 +459,11 @@ export function MarketPage() {
[regions]
);
const itemMetaById = useMemo(
- () => Object.fromEntries(items.map((item) => [item.id, { code: item.code, name: item.name }])),
- [items]
+ () =>
+ Object.fromEntries(
+ catalogItems.map((item) => [item.id, { code: item.code, name: item.name }])
+ ),
+ [catalogItems]
);
const companyNameById = useMemo(
() => Object.fromEntries(companies.map((company) => [company.id, company.name])),
From a2057be1fd1a24f32710eee2f05d92a7b885a471 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 17:28:00 +0100
Subject: [PATCH 28/52] fix(web): scope market listings to company tradable
items
---
...market-views-to-active-company-tradable.md | 7 ++++
.../web/src/components/market/market-page.tsx | 33 ++++++++++++++++---
2 files changed, 35 insertions(+), 5 deletions(-)
create mode 100644 .releases/unreleased/20260222162747-restrict-market-views-to-active-company-tradable.md
diff --git a/.releases/unreleased/20260222162747-restrict-market-views-to-active-company-tradable.md b/.releases/unreleased/20260222162747-restrict-market-views-to-active-company-tradable.md
new file mode 100644
index 00000000..4f8cfc8a
--- /dev/null
+++ b/.releases/unreleased/20260222162747-restrict-market-views-to-active-company-tradable.md
@@ -0,0 +1,7 @@
+---
+type: patch
+area: web
+summary: Restrict market views to active company tradable items
+---
+
+- Restrict market views to active company tradable items
diff --git a/apps/web/src/components/market/market-page.tsx b/apps/web/src/components/market/market-page.tsx
index 0ea09418..58d46f3c 100644
--- a/apps/web/src/components/market/market-page.tsx
+++ b/apps/web/src/components/market/market-page.tsx
@@ -396,11 +396,11 @@ export function MarketPage() {
);
const unlockedItemIdSet = useMemo(() => new Set(unlockedItemIds), [unlockedItemIds]);
const sortedSelectableItems = useMemo(() => {
- if (unlockedItemIdSet.size === 0) {
+ if (!activeCompanyId) {
return sortedItems;
}
return sortedItems.filter((item) => unlockedItemIdSet.has(item.id));
- }, [sortedItems, unlockedItemIdSet]);
+ }, [activeCompanyId, sortedItems, unlockedItemIdSet]);
const filteredOrderSelectableItems = useMemo(() => {
const needle = deferredOrderItemSearch.trim().toLowerCase();
if (!needle) {
@@ -475,6 +475,24 @@ export function MarketPage() {
name: activeCompany.regionName
})
: null;
+ const visibleOrderBook = useMemo(() => {
+ if (!activeCompanyId) {
+ return orderBook;
+ }
+ return orderBook.filter((order) => unlockedItemIdSet.has(order.itemId));
+ }, [activeCompanyId, orderBook, unlockedItemIdSet]);
+ const visibleMyOrders = useMemo(() => {
+ if (!activeCompanyId) {
+ return myOrders;
+ }
+ return myOrders.filter((order) => unlockedItemIdSet.has(order.itemId));
+ }, [activeCompanyId, myOrders, unlockedItemIdSet]);
+ const visibleTrades = useMemo(() => {
+ if (!activeCompanyId) {
+ return trades;
+ }
+ return trades.filter((trade) => unlockedItemIdSet.has(trade.itemId));
+ }, [activeCompanyId, trades, unlockedItemIdSet]);
useEffect(() => {
if (orderFilters.itemId && !sortedSelectableItems.some((item) => item.id === orderFilters.itemId)) {
@@ -610,13 +628,18 @@ export function MarketPage() {
Showing first {visibleOrderSelectableItems.length} matching items in order-item dropdown.
) : null}
+ {activeCompanyId && sortedSelectableItems.length === 0 ? (
+
+ No tradable items are currently unlocked for this company.
+
+ ) : null}
{error ? {error}
: null}
Date: Sun, 22 Feb 2026 17:38:58 +0100
Subject: [PATCH 29/52] docs(ops): use example domains and RFC5737 IPs in nginx
docs
---
...dokploy-nginx-docs-to-example-domains-a.md | 7 +++
docs/project/DOKPLOY_DOCKERFILE.md | 50 ++++++++++---------
docs/project/corpsim.altitude.nginx.conf | 12 ++---
3 files changed, 39 insertions(+), 30 deletions(-)
create mode 100644 .releases/unreleased/20260222163851-sanitize-dokploy-nginx-docs-to-example-domains-a.md
diff --git a/.releases/unreleased/20260222163851-sanitize-dokploy-nginx-docs-to-example-domains-a.md b/.releases/unreleased/20260222163851-sanitize-dokploy-nginx-docs-to-example-domains-a.md
new file mode 100644
index 00000000..bebdcd9b
--- /dev/null
+++ b/.releases/unreleased/20260222163851-sanitize-dokploy-nginx-docs-to-example-domains-a.md
@@ -0,0 +1,7 @@
+---
+type: patch
+area: ops
+summary: Sanitize Dokploy/nginx docs to example domains and RFC 5737 IPs
+---
+
+- Sanitize Dokploy/nginx docs to example domains and RFC 5737 IPs
diff --git a/docs/project/DOKPLOY_DOCKERFILE.md b/docs/project/DOKPLOY_DOCKERFILE.md
index a6d39352..c62d1d15 100644
--- a/docs/project/DOKPLOY_DOCKERFILE.md
+++ b/docs/project/DOKPLOY_DOCKERFILE.md
@@ -10,7 +10,7 @@ This project supports Dockerfile-only runtime modes via:
Entrypoint: `scripts/start-container.sh`
-## ⚠️ Critical: Next.js Build Arguments
+## Critical: Next.js Build Arguments
**IMPORTANT:** When deploying the `web` app (or `APP_ROLE=all`), Next.js `NEXT_PUBLIC_*` environment variables are **baked into the JavaScript bundle at build time**, NOT at runtime.
@@ -39,9 +39,9 @@ Create separate Dokploy apps from the same repository and Dockerfile:
- `APP_ROLE=api`
- `API_PORT=4310`
- expose/public port `4310`
- - `CORS_ORIGIN=https://corpsim.altitude-interactive.com`
- - `BETTER_AUTH_URL=https://corpsim.altitude-interactive.com`
- - ⚠️ **IMPORTANT**: Set `BETTER_AUTH_URL` to the main web domain (not the API subdomain) so OAuth callbacks redirect to the correct domain
+ - `CORS_ORIGIN=https://app.example.com`
+ - `BETTER_AUTH_URL=https://app.example.com`
+ - **IMPORTANT**: Set `BETTER_AUTH_URL` to the main web domain (not the API subdomain) so OAuth callbacks redirect to the correct domain
3. `corpsim-worker`
- `APP_ROLE=worker`
@@ -51,16 +51,16 @@ Create separate Dokploy apps from the same repository and Dockerfile:
- `APP_ROLE=web`
- `WEB_PORT=4311`
- expose/public port `4311`
- - ⚠️ **BUILD ARGUMENTS (set before building the image):**
- - `NEXT_PUBLIC_APP_URL=https://corpsim.altitude-interactive.com`
- - `NEXT_PUBLIC_AUTH_URL=https://corpsim.altitude-interactive.com` (optional)
- - `NEXT_PUBLIC_API_URL=https://corpsim.altitude-interactive.com` (or leave empty)
+ - **BUILD ARGUMENTS (set before building the image):**
+ - `NEXT_PUBLIC_APP_URL=https://app.example.com`
+ - `NEXT_PUBLIC_AUTH_URL=https://app.example.com` (optional)
+ - `NEXT_PUBLIC_API_URL=https://app.example.com` (or leave empty)
- **RUNTIME ENVIRONMENT VARIABLES:**
- - `NEXT_PUBLIC_APP_URL=https://corpsim.altitude-interactive.com`
- - `NEXT_PUBLIC_AUTH_URL=https://corpsim.altitude-interactive.com` (optional)
- - `NEXT_PUBLIC_API_URL=https://corpsim.altitude-interactive.com` (or leave empty)
+ - `NEXT_PUBLIC_APP_URL=https://app.example.com`
+ - `NEXT_PUBLIC_AUTH_URL=https://app.example.com` (optional)
+ - `NEXT_PUBLIC_API_URL=https://app.example.com` (or leave empty)
- `API_URL=http://corpsim-api:4310` (or internal Docker network address)
- - `BETTER_AUTH_URL=https://corpsim.altitude-interactive.com`
+ - `BETTER_AUTH_URL=https://app.example.com`
All runtime apps also need:
@@ -73,19 +73,19 @@ All runtime apps also need:
If you insist on one container, set:
**Build Arguments (must be set before building):**
-- `NEXT_PUBLIC_APP_URL=https://corpsim.altitude-interactive.com`
-- `NEXT_PUBLIC_AUTH_URL=https://corpsim.altitude-interactive.com` (optional)
-- `NEXT_PUBLIC_API_URL=https://corpsim.altitude-interactive.com` (or leave empty)
+- `NEXT_PUBLIC_APP_URL=https://app.example.com`
+- `NEXT_PUBLIC_AUTH_URL=https://app.example.com` (optional)
+- `NEXT_PUBLIC_API_URL=https://app.example.com` (or leave empty)
**Runtime Environment Variables:**
- `APP_ROLE=all`
- `API_PORT=4310`
- `WEB_PORT=4311`
-- `CORS_ORIGIN=https://corpsim.altitude-interactive.com`
-- `NEXT_PUBLIC_APP_URL=https://corpsim.altitude-interactive.com`
-- `NEXT_PUBLIC_AUTH_URL=https://corpsim.altitude-interactive.com` (optional)
-- `NEXT_PUBLIC_API_URL=https://corpsim.altitude-interactive.com` (or leave empty)
-- `BETTER_AUTH_URL=https://corpsim.altitude-interactive.com`
+- `CORS_ORIGIN=https://app.example.com`
+- `NEXT_PUBLIC_APP_URL=https://app.example.com`
+- `NEXT_PUBLIC_AUTH_URL=https://app.example.com` (optional)
+- `NEXT_PUBLIC_API_URL=https://app.example.com` (or leave empty)
+- `BETTER_AUTH_URL=https://app.example.com`
- `API_URL=http://localhost:4310` (internal server-side API access)
- `PREVIEW_DATABASE_URL=postgresql://postgres:@postgres:5432/corpsim`
- `PREVIEW_REDIS_HOST=redis`
@@ -103,9 +103,9 @@ pnpm sim:seed
## Nginx upstream mapping
-- `corpsim.altitude-interactive.com` -> `:4311` (public)
+- `app.example.com` -> `192.0.2.10:4311` (public)
- Keep API private/internal when possible; web route handlers proxy `/v1/*` and `/api/auth/*` to the API upstream.
-- If `corpsim-api.altitude-interactive.com` is still configured, redirect it to `corpsim.altitude-interactive.com`.
+- If `api.example.com` is still configured, redirect it to `app.example.com`.
## Troubleshooting
@@ -118,10 +118,10 @@ pnpm sim:seed
**Cause:** The `BETTER_AUTH_URL` environment variable for the API service is not configured correctly, or the nginx proxy is not set up to forward `/api/auth/*` requests from the web domain to the API server.
**Solution:**
-1. Ensure `BETTER_AUTH_URL=https://corpsim.altitude-interactive.com` is set for the API service (use the main web domain, NOT the API subdomain)
+1. Ensure `BETTER_AUTH_URL=https://app.example.com` is set for the API service (use the main web domain, NOT the API subdomain)
2. Verify the nginx configuration includes a proxy rule for `/api/auth/*` (see `docs/project/corpsim.altitude.nginx.conf`)
3. Restart the API service and nginx after making changes
-4. Configure OAuth apps (GitHub, Google, Microsoft, Discord) to allow callback URLs like `https://corpsim.altitude-interactive.com/api/auth/callback/{provider}`
+4. Configure OAuth apps (GitHub, Google, Microsoft, Discord) to allow callback URLs like `https://app.example.com/api/auth/callback/{provider}`
### Authentication failing with "Provider not found" or 404 errors
@@ -148,3 +148,5 @@ pnpm sim:seed
3. Trigger an API request (e.g., try to sign in)
4. Check request URLs - for single-domain SSO they should point to your web domain (not localhost)
5. Alternatively, check the Console tab for any warnings from CorpSim about misconfigured URLs
+
+
diff --git a/docs/project/corpsim.altitude.nginx.conf b/docs/project/corpsim.altitude.nginx.conf
index 6d308243..3fe5e778 100644
--- a/docs/project/corpsim.altitude.nginx.conf
+++ b/docs/project/corpsim.altitude.nginx.conf
@@ -1,21 +1,21 @@
server {
listen 80;
listen [::]:80;
- server_name corpsim.altitude-interactive.com;
+ server_name app.example.com;
return 301 https://$host$request_uri;
}
server {
listen 80;
listen [::]:80;
- server_name corpsim-api.altitude-interactive.com;
- return 301 https://corpsim.altitude-interactive.com$request_uri;
+ server_name api.example.com;
+ return 301 https://app.example.com$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
- server_name corpsim.altitude-interactive.com;
+ server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
@@ -43,10 +43,10 @@ server {
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
- server_name corpsim-api.altitude-interactive.com;
+ server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
- return 308 https://corpsim.altitude-interactive.com$request_uri;
+ return 308 https://app.example.com$request_uri;
}
From e547a831ac927bccfd3cca232a47a2e7a4e59c50 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 17:40:49 +0100
Subject: [PATCH 30/52] docs(ops): drop API subdomain blocks from nginx sample
---
...egacy-api-subdomain-blocks-from-nginx-sa.md | 7 +++++++
docs/project/corpsim.altitude.nginx.conf | 18 ------------------
2 files changed, 7 insertions(+), 18 deletions(-)
create mode 100644 .releases/unreleased/20260222164042-remove-legacy-api-subdomain-blocks-from-nginx-sa.md
diff --git a/.releases/unreleased/20260222164042-remove-legacy-api-subdomain-blocks-from-nginx-sa.md b/.releases/unreleased/20260222164042-remove-legacy-api-subdomain-blocks-from-nginx-sa.md
new file mode 100644
index 00000000..e29385b4
--- /dev/null
+++ b/.releases/unreleased/20260222164042-remove-legacy-api-subdomain-blocks-from-nginx-sa.md
@@ -0,0 +1,7 @@
+---
+type: patch
+area: ops
+summary: Remove legacy API subdomain blocks from nginx sample
+---
+
+- Remove legacy API subdomain blocks from nginx sample
diff --git a/docs/project/corpsim.altitude.nginx.conf b/docs/project/corpsim.altitude.nginx.conf
index 3fe5e778..1e4fba7d 100644
--- a/docs/project/corpsim.altitude.nginx.conf
+++ b/docs/project/corpsim.altitude.nginx.conf
@@ -5,13 +5,6 @@ server {
return 301 https://$host$request_uri;
}
-server {
- listen 80;
- listen [::]:80;
- server_name api.example.com;
- return 301 https://app.example.com$request_uri;
-}
-
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
@@ -39,14 +32,3 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-
-server {
- listen 443 ssl http2;
- listen [::]:443 ssl http2;
- server_name api.example.com;
-
- ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
- ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
-
- return 308 https://app.example.com$request_uri;
-}
From 926b2727f09e1f2c62f48afb76b167d48cf007ac Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 17:45:13 +0100
Subject: [PATCH 31/52] feat(web): add ALPHA preview disclaimer to footer
version badge
---
...alpha-as-is-disclaimer-next-to-footer-versio.md | 7 +++++++
.../src/components/layout/app-version-badge.tsx | 14 +++++++++-----
2 files changed, 16 insertions(+), 5 deletions(-)
create mode 100644 .releases/unreleased/20260222164444-add-alpha-as-is-disclaimer-next-to-footer-versio.md
diff --git a/.releases/unreleased/20260222164444-add-alpha-as-is-disclaimer-next-to-footer-versio.md b/.releases/unreleased/20260222164444-add-alpha-as-is-disclaimer-next-to-footer-versio.md
new file mode 100644
index 00000000..ddf9eef7
--- /dev/null
+++ b/.releases/unreleased/20260222164444-add-alpha-as-is-disclaimer-next-to-footer-versio.md
@@ -0,0 +1,7 @@
+---
+type: patch
+area: web
+summary: Add ALPHA as-is disclaimer next to footer version badge
+---
+
+- Add ALPHA as-is disclaimer next to footer version badge
diff --git a/apps/web/src/components/layout/app-version-badge.tsx b/apps/web/src/components/layout/app-version-badge.tsx
index 1a4f18c4..0580cb7b 100644
--- a/apps/web/src/components/layout/app-version-badge.tsx
+++ b/apps/web/src/components/layout/app-version-badge.tsx
@@ -32,9 +32,13 @@ export function AppVersionBadge({ className }: { className?: string }) {
};
}, []);
- if (!version) {
- return null;
- }
-
- return CorpSim ERP v{version}
;
+ return (
+
+
{version ? `CorpSim ERP v${version}` : "CorpSim ERP"} · ALPHA
+
+ Preview build provided as-is with no player support. Data may be reset or wiped at any time
+ until beta.
+
+
+ );
}
From e5197b71035dd10a971a9d999035636f62e39766 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 17:48:17 +0100
Subject: [PATCH 32/52] feat(web): show alpha notice on version hover and
overview
---
.env.example | 1 +
...a-disclaimer-on-version-hover-and-overv.md | 7 ++++
.../components/layout/app-version-badge.tsx | 15 ++++---
.../views/overview/overview-view.tsx | 40 +++++++++++++++++++
4 files changed, 57 insertions(+), 6 deletions(-)
create mode 100644 .releases/unreleased/20260222164758-show-alpha-disclaimer-on-version-hover-and-overv.md
diff --git a/.env.example b/.env.example
index fc1b1aa7..cfa73214 100644
--- a/.env.example
+++ b/.env.example
@@ -106,6 +106,7 @@ NEXT_PUBLIC_APP_URL=http://localhost:4311
NEXT_PUBLIC_AUTH_URL=
NEXT_PUBLIC_API_URL=http://localhost:4310
NEXT_PUBLIC_APP_NAME=CorpSim
+NEXT_PUBLIC_DISCORD_SERVER_URL=
NEXT_PUBLIC_COMPANY_SPECIALIZATION_CHANGE_COOLDOWN_HOURS=4
# SSO provider visibility flags (defaults to false if not set)
# Set to 'true' to show SSO login buttons for each provider
diff --git a/.releases/unreleased/20260222164758-show-alpha-disclaimer-on-version-hover-and-overv.md b/.releases/unreleased/20260222164758-show-alpha-disclaimer-on-version-hover-and-overv.md
new file mode 100644
index 00000000..3c991827
--- /dev/null
+++ b/.releases/unreleased/20260222164758-show-alpha-disclaimer-on-version-hover-and-overv.md
@@ -0,0 +1,7 @@
+---
+type: patch
+area: web
+summary: Show ALPHA disclaimer on version hover and overview with Discord link
+---
+
+- Show ALPHA disclaimer on version hover and overview with Discord link
diff --git a/apps/web/src/components/layout/app-version-badge.tsx b/apps/web/src/components/layout/app-version-badge.tsx
index 0580cb7b..bed6db2a 100644
--- a/apps/web/src/components/layout/app-version-badge.tsx
+++ b/apps/web/src/components/layout/app-version-badge.tsx
@@ -33,12 +33,15 @@ export function AppVersionBadge({ className }: { className?: string }) {
}, []);
return (
-
-
{version ? `CorpSim ERP v${version}` : "CorpSim ERP"} · ALPHA
-
- Preview build provided as-is with no player support. Data may be reset or wiped at any time
- until beta.
-
+
+
+ {version ? `CorpSim ERP v${version}` : "CorpSim ERP"} · ALPHA
+ (hover)
+
+ Preview build provided as-is with no player support. Data may be reset or wiped at any
+ time until beta.
+
+
);
}
diff --git a/apps/web/src/components/views/overview/overview-view.tsx b/apps/web/src/components/views/overview/overview-view.tsx
index 639b11e1..90923b12 100644
--- a/apps/web/src/components/views/overview/overview-view.tsx
+++ b/apps/web/src/components/views/overview/overview-view.tsx
@@ -9,8 +9,26 @@ import { formatCents, formatInt } from "@/lib/format";
import { UI_CADENCE_TERMS } from "@/lib/ui-terms";
import { getDocumentationUrl, UI_COPY } from "@/lib/ui-copy";
+function resolveDiscordServerUrl(): string | null {
+ const raw = process.env.NEXT_PUBLIC_DISCORD_SERVER_URL?.trim();
+ if (!raw) {
+ return null;
+ }
+
+ try {
+ const parsed = new URL(raw);
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
+ return null;
+ }
+ return parsed.toString();
+ } catch {
+ return null;
+ }
+}
+
export function OverviewView() {
const { health } = useWorldHealth();
+ const discordServerUrl = resolveDiscordServerUrl();
if (!health) {
return
Loading overview metrics...
;
@@ -67,6 +85,28 @@ export function OverviewView() {
+
+
+
+ Alpha Preview Notice
+ ALPHA
+
+
+
+
+ This preview is provided as-is with no player support. Progress and economy data may be
+ reset or wiped at any time until beta.
+
+ {discordServerUrl ? (
+
+
+ Join Discord Updates
+
+
+ ) : null}
+
+
+
{UI_COPY.documentation.title}
From 1dc209d39169c0b147daf61b4335d11387334542 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 17:51:34 +0100
Subject: [PATCH 33/52] fix(web): remove hover helper text from version badge
---
...2165119-remove-hover-helper-label-from-version-badge.md | 7 +++++++
apps/web/src/components/layout/app-version-badge.tsx | 3 +--
2 files changed, 8 insertions(+), 2 deletions(-)
create mode 100644 .releases/unreleased/20260222165119-remove-hover-helper-label-from-version-badge.md
diff --git a/.releases/unreleased/20260222165119-remove-hover-helper-label-from-version-badge.md b/.releases/unreleased/20260222165119-remove-hover-helper-label-from-version-badge.md
new file mode 100644
index 00000000..97c3714c
--- /dev/null
+++ b/.releases/unreleased/20260222165119-remove-hover-helper-label-from-version-badge.md
@@ -0,0 +1,7 @@
+---
+type: patch
+area: web
+summary: Remove hover helper label from version badge
+---
+
+- Remove hover helper label from version badge
diff --git a/apps/web/src/components/layout/app-version-badge.tsx b/apps/web/src/components/layout/app-version-badge.tsx
index bed6db2a..4f22259c 100644
--- a/apps/web/src/components/layout/app-version-badge.tsx
+++ b/apps/web/src/components/layout/app-version-badge.tsx
@@ -35,8 +35,7 @@ export function AppVersionBadge({ className }: { className?: string }) {
return (
- {version ? `CorpSim ERP v${version}` : "CorpSim ERP"} · ALPHA
- (hover)
+ {version ? `CorpSim ERP v${version}` : "CorpSim ERP"} - ALPHA
Preview build provided as-is with no player support. Data may be reset or wiped at any
time until beta.
From cbb511f47d91d4cf6db896af45a8865625cae118 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 17:53:04 +0100
Subject: [PATCH 34/52] fix(web): remove focus ring box from maintenance
overlay
---
...5251-remove-maintenance-overlay-focus-ring-rectangle.md | 7 +++++++
.../web/src/components/maintenance/maintenance-overlay.tsx | 6 +++++-
2 files changed, 12 insertions(+), 1 deletion(-)
create mode 100644 .releases/unreleased/20260222165251-remove-maintenance-overlay-focus-ring-rectangle.md
diff --git a/.releases/unreleased/20260222165251-remove-maintenance-overlay-focus-ring-rectangle.md b/.releases/unreleased/20260222165251-remove-maintenance-overlay-focus-ring-rectangle.md
new file mode 100644
index 00000000..95ae19e2
--- /dev/null
+++ b/.releases/unreleased/20260222165251-remove-maintenance-overlay-focus-ring-rectangle.md
@@ -0,0 +1,7 @@
+---
+type: patch
+area: web
+summary: Remove maintenance overlay focus ring rectangle
+---
+
+- Remove maintenance overlay focus ring rectangle
diff --git a/apps/web/src/components/maintenance/maintenance-overlay.tsx b/apps/web/src/components/maintenance/maintenance-overlay.tsx
index ca5d655b..fa5c4b72 100644
--- a/apps/web/src/components/maintenance/maintenance-overlay.tsx
+++ b/apps/web/src/components/maintenance/maintenance-overlay.tsx
@@ -98,7 +98,11 @@ export function MaintenanceOverlay({ state }: { state: MaintenanceState }) {
labelledBy="maintenance-title"
describedBy="maintenance-description"
>
-
+
Operations notice
CorpSim is under maintenance
From 452b60573d8e482ec5fa244d8034b100555baa22 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 17:54:55 +0100
Subject: [PATCH 35/52] feat(web): link alpha version badge to Discord updates
---
...a-version-tag-clickable-to-discord-and-.md | 7 ++++++
.../components/layout/app-version-badge.tsx | 22 +++++++++++++++----
.../views/overview/overview-view.tsx | 20 ++---------------
apps/web/src/lib/public-links.ts | 20 +++++++++++++++++
4 files changed, 47 insertions(+), 22 deletions(-)
create mode 100644 .releases/unreleased/20260222165446-make-alpha-version-tag-clickable-to-discord-and-.md
create mode 100644 apps/web/src/lib/public-links.ts
diff --git a/.releases/unreleased/20260222165446-make-alpha-version-tag-clickable-to-discord-and-.md b/.releases/unreleased/20260222165446-make-alpha-version-tag-clickable-to-discord-and-.md
new file mode 100644
index 00000000..8de08551
--- /dev/null
+++ b/.releases/unreleased/20260222165446-make-alpha-version-tag-clickable-to-discord-and-.md
@@ -0,0 +1,7 @@
+---
+type: patch
+area: web
+summary: Make ALPHA version tag clickable to Discord and share URL resolver
+---
+
+- Make ALPHA version tag clickable to Discord and share URL resolver
diff --git a/apps/web/src/components/layout/app-version-badge.tsx b/apps/web/src/components/layout/app-version-badge.tsx
index 4f22259c..cf12b5ea 100644
--- a/apps/web/src/components/layout/app-version-badge.tsx
+++ b/apps/web/src/components/layout/app-version-badge.tsx
@@ -1,11 +1,13 @@
"use client";
import { useEffect, useState } from "react";
+import { getDiscordServerUrl } from "@/lib/public-links";
import { getDisplayVersion } from "@/lib/version";
import { cn } from "@/lib/utils";
export function AppVersionBadge({ className }: { className?: string }) {
const [version, setVersion] = useState(null);
+ const discordServerUrl = getDiscordServerUrl();
useEffect(() => {
let active = true;
@@ -34,11 +36,23 @@ export function AppVersionBadge({ className }: { className?: string }) {
return (
-
- {version ? `CorpSim ERP v${version}` : "CorpSim ERP"} - ALPHA
+
+ {discordServerUrl ? (
+
+ {version ? `CorpSim ERP v${version}` : "CorpSim ERP"} - ALPHA
+
+ ) : (
+ {version ? `CorpSim ERP v${version}` : "CorpSim ERP"} - ALPHA
+ )}
- Preview build provided as-is with no player support. Data may be reset or wiped at any
- time until beta.
+ Preview build provided as-is with no player support. Data may be reset or wiped at any time until beta.
+ {discordServerUrl ? " Click the version tag to join Discord updates." : ""}
diff --git a/apps/web/src/components/views/overview/overview-view.tsx b/apps/web/src/components/views/overview/overview-view.tsx
index 90923b12..149b63c4 100644
--- a/apps/web/src/components/views/overview/overview-view.tsx
+++ b/apps/web/src/components/views/overview/overview-view.tsx
@@ -6,29 +6,13 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useWorldHealth } from "@/components/layout/world-health-provider";
import { formatCents, formatInt } from "@/lib/format";
+import { getDiscordServerUrl } from "@/lib/public-links";
import { UI_CADENCE_TERMS } from "@/lib/ui-terms";
import { getDocumentationUrl, UI_COPY } from "@/lib/ui-copy";
-function resolveDiscordServerUrl(): string | null {
- const raw = process.env.NEXT_PUBLIC_DISCORD_SERVER_URL?.trim();
- if (!raw) {
- return null;
- }
-
- try {
- const parsed = new URL(raw);
- if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
- return null;
- }
- return parsed.toString();
- } catch {
- return null;
- }
-}
-
export function OverviewView() {
const { health } = useWorldHealth();
- const discordServerUrl = resolveDiscordServerUrl();
+ const discordServerUrl = getDiscordServerUrl();
if (!health) {
return Loading overview metrics...
;
diff --git a/apps/web/src/lib/public-links.ts b/apps/web/src/lib/public-links.ts
new file mode 100644
index 00000000..6b41e1e9
--- /dev/null
+++ b/apps/web/src/lib/public-links.ts
@@ -0,0 +1,20 @@
+export function resolveOptionalPublicUrl(value: string | undefined): string | null {
+ const raw = value?.trim();
+ if (!raw) {
+ return null;
+ }
+
+ try {
+ const parsed = new URL(raw);
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
+ return null;
+ }
+ return parsed.toString();
+ } catch {
+ return null;
+ }
+}
+
+export function getDiscordServerUrl(): string | null {
+ return resolveOptionalPublicUrl(process.env.NEXT_PUBLIC_DISCORD_SERVER_URL);
+}
From e71440631a6888182cc144a1d3ac0dd2dedbae77 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 17:59:26 +0100
Subject: [PATCH 36/52] fix(web): fetch Discord URL via runtime public-links
endpoint
---
...ord-link-from-runtime-meta-config-for-a.md | 7 +++
apps/web/app/meta/public-links/route.ts | 53 +++++++++++++++++++
.../components/layout/app-version-badge.tsx | 24 +++++++--
.../views/overview/overview-view.tsx | 25 +++++++--
apps/web/src/lib/public-links.ts | 31 +++++++++++
5 files changed, 134 insertions(+), 6 deletions(-)
create mode 100644 .releases/unreleased/20260222165916-load-discord-link-from-runtime-meta-config-for-a.md
create mode 100644 apps/web/app/meta/public-links/route.ts
diff --git a/.releases/unreleased/20260222165916-load-discord-link-from-runtime-meta-config-for-a.md b/.releases/unreleased/20260222165916-load-discord-link-from-runtime-meta-config-for-a.md
new file mode 100644
index 00000000..a0d33c4d
--- /dev/null
+++ b/.releases/unreleased/20260222165916-load-discord-link-from-runtime-meta-config-for-a.md
@@ -0,0 +1,7 @@
+---
+type: patch
+area: web
+summary: Load Discord link from runtime meta config for alpha notices
+---
+
+- Load Discord link from runtime meta config for alpha notices
diff --git a/apps/web/app/meta/public-links/route.ts b/apps/web/app/meta/public-links/route.ts
new file mode 100644
index 00000000..f0fddd25
--- /dev/null
+++ b/apps/web/app/meta/public-links/route.ts
@@ -0,0 +1,53 @@
+import { existsSync } from "node:fs";
+import { resolve } from "node:path";
+import { config } from "dotenv";
+import { NextResponse } from "next/server";
+import { getDiscordServerUrl } from "@/lib/public-links";
+
+let loadedEnv = false;
+
+function ensureEnvironmentLoaded(): void {
+ if (loadedEnv) {
+ return;
+ }
+
+ if (process.env.NEXT_PUBLIC_DISCORD_SERVER_URL) {
+ loadedEnv = true;
+ return;
+ }
+
+ const candidates = [
+ process.env.DOTENV_PATH,
+ resolve(process.cwd(), ".env"),
+ resolve(process.cwd(), "../.env"),
+ resolve(process.cwd(), "../../.env")
+ ].filter((entry): entry is string => Boolean(entry));
+
+ for (const path of candidates) {
+ if (!existsSync(path)) {
+ continue;
+ }
+ config({ path, override: false });
+ break;
+ }
+
+ loadedEnv = true;
+}
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+export async function GET() {
+ ensureEnvironmentLoaded();
+
+ return NextResponse.json(
+ {
+ discordServerUrl: getDiscordServerUrl()
+ },
+ {
+ headers: {
+ "Cache-Control": "no-store"
+ }
+ }
+ );
+}
diff --git a/apps/web/src/components/layout/app-version-badge.tsx b/apps/web/src/components/layout/app-version-badge.tsx
index cf12b5ea..e98470f4 100644
--- a/apps/web/src/components/layout/app-version-badge.tsx
+++ b/apps/web/src/components/layout/app-version-badge.tsx
@@ -1,13 +1,13 @@
"use client";
import { useEffect, useState } from "react";
-import { getDiscordServerUrl } from "@/lib/public-links";
+import { fetchDiscordServerUrlFromMeta, getDiscordServerUrl } from "@/lib/public-links";
import { getDisplayVersion } from "@/lib/version";
import { cn } from "@/lib/utils";
export function AppVersionBadge({ className }: { className?: string }) {
const [version, setVersion] = useState(null);
- const discordServerUrl = getDiscordServerUrl();
+ const [discordServerUrl, setDiscordServerUrl] = useState(() => getDiscordServerUrl());
useEffect(() => {
let active = true;
@@ -34,6 +34,24 @@ export function AppVersionBadge({ className }: { className?: string }) {
};
}, []);
+ useEffect(() => {
+ if (discordServerUrl) {
+ return;
+ }
+
+ const controller = new AbortController();
+ void fetchDiscordServerUrlFromMeta(controller.signal).then((url) => {
+ if (!url) {
+ return;
+ }
+ setDiscordServerUrl(url);
+ });
+
+ return () => {
+ controller.abort();
+ };
+ }, [discordServerUrl]);
+
return (
@@ -52,7 +70,7 @@ export function AppVersionBadge({ className }: { className?: string }) {
)}
Preview build provided as-is with no player support. Data may be reset or wiped at any time until beta.
- {discordServerUrl ? " Click the version tag to join Discord updates." : ""}
+ {discordServerUrl ? " Click the version tag to join our Discord server for updates." : ""}
diff --git a/apps/web/src/components/views/overview/overview-view.tsx b/apps/web/src/components/views/overview/overview-view.tsx
index 149b63c4..dc7291ed 100644
--- a/apps/web/src/components/views/overview/overview-view.tsx
+++ b/apps/web/src/components/views/overview/overview-view.tsx
@@ -1,18 +1,37 @@
"use client";
+import { useEffect, useState } from "react";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useWorldHealth } from "@/components/layout/world-health-provider";
import { formatCents, formatInt } from "@/lib/format";
-import { getDiscordServerUrl } from "@/lib/public-links";
+import { fetchDiscordServerUrlFromMeta, getDiscordServerUrl } from "@/lib/public-links";
import { UI_CADENCE_TERMS } from "@/lib/ui-terms";
import { getDocumentationUrl, UI_COPY } from "@/lib/ui-copy";
export function OverviewView() {
const { health } = useWorldHealth();
- const discordServerUrl = getDiscordServerUrl();
+ const [discordServerUrl, setDiscordServerUrl] = useState(() => getDiscordServerUrl());
+
+ useEffect(() => {
+ if (discordServerUrl) {
+ return;
+ }
+
+ const controller = new AbortController();
+ void fetchDiscordServerUrlFromMeta(controller.signal).then((url) => {
+ if (!url) {
+ return;
+ }
+ setDiscordServerUrl(url);
+ });
+
+ return () => {
+ controller.abort();
+ };
+ }, [discordServerUrl]);
if (!health) {
return Loading overview metrics...
;
@@ -84,7 +103,7 @@ export function OverviewView() {
{discordServerUrl ? (
- Join Discord Updates
+ Join Discord for Updates
) : null}
diff --git a/apps/web/src/lib/public-links.ts b/apps/web/src/lib/public-links.ts
index 6b41e1e9..7863dc74 100644
--- a/apps/web/src/lib/public-links.ts
+++ b/apps/web/src/lib/public-links.ts
@@ -18,3 +18,34 @@ export function resolveOptionalPublicUrl(value: string | undefined): string | nu
export function getDiscordServerUrl(): string | null {
return resolveOptionalPublicUrl(process.env.NEXT_PUBLIC_DISCORD_SERVER_URL);
}
+
+interface PublicLinksPayload {
+ discordServerUrl?: string | null;
+}
+
+export async function fetchDiscordServerUrlFromMeta(signal?: AbortSignal): Promise {
+ try {
+ const response = await fetch("/meta/public-links", {
+ cache: "no-store",
+ signal
+ });
+
+ if (!response.ok) {
+ return null;
+ }
+
+ const payload = (await response.json().catch(() => null)) as PublicLinksPayload | null;
+ if (!payload || typeof payload !== "object") {
+ return null;
+ }
+
+ const raw = payload.discordServerUrl;
+ if (typeof raw !== "string") {
+ return null;
+ }
+
+ return resolveOptionalPublicUrl(raw);
+ } catch {
+ return null;
+ }
+}
From 367c7a9fe788f99d9d8987de62d003da2c93dce5 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 18:03:04 +0100
Subject: [PATCH 37/52] fix(docs): add guideline to avoid commits to main
branch
---
docs/agents/AGENTS.md | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/docs/agents/AGENTS.md b/docs/agents/AGENTS.md
index d3469db9..c409bda8 100644
--- a/docs/agents/AGENTS.md
+++ b/docs/agents/AGENTS.md
@@ -101,6 +101,7 @@ Create one release entry in `.releases/unreleased/*.md` including:
* Do NOT bump root `package.json` version during normal commits
* Do NOT create git tags
* Do NOT bump version if remote already has the same version tag
+* NEVER COMMIT TO MAIN, suggest committing to a new dedicated branch (or canary)
* Version bump occurs only in a dedicated release-cut commit (`pnpm release:cut`)
* Exception: explicitly requested emergency hotfix release
@@ -357,15 +358,15 @@ import { useToastManager } from '@/components/ui/toast-manager';
const { showToast, confirmPopup } = useToastManager();
// Show toast
-showToast({
- title: "Order placed",
- variant: "success"
+showToast({
+ title: "Order placed",
+ variant: "success"
});
// Confirmation dialog
-const confirmed = await confirmPopup({
+const confirmed = await confirmPopup({
title: "Cancel order?",
- variant: "danger"
+ variant: "danger"
});
```
From bcced69e11950997d38dde2311d380fa02381f1e Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 18:08:54 +0100
Subject: [PATCH 38/52] feat(web): replace static onboarding tutorial with
guided walkthrough
---
...tatic-onboarding-tutorial-with-guided-c.md | 7 +
apps/web/app/tutorial/page.tsx | 106 ++-----
.../src/components/auth/auth-route-gate.tsx | 20 +-
.../components/inventory/inventory-page.tsx | 4 +-
apps/web/src/components/layout/app-shell.tsx | 2 +
.../web/src/components/market/market-page.tsx | 30 +-
.../components/production/production-page.tsx | 4 +-
.../tutorial/guided-tutorial-overlay.tsx | 297 ++++++++++++++++++
.../views/overview/overview-view.tsx | 6 +-
9 files changed, 367 insertions(+), 109 deletions(-)
create mode 100644 .releases/unreleased/20260222170810-replace-static-onboarding-tutorial-with-guided-c.md
create mode 100644 apps/web/src/components/tutorial/guided-tutorial-overlay.tsx
diff --git a/.releases/unreleased/20260222170810-replace-static-onboarding-tutorial-with-guided-c.md b/.releases/unreleased/20260222170810-replace-static-onboarding-tutorial-with-guided-c.md
new file mode 100644
index 00000000..f06b2b61
--- /dev/null
+++ b/.releases/unreleased/20260222170810-replace-static-onboarding-tutorial-with-guided-c.md
@@ -0,0 +1,7 @@
+---
+type: minor
+area: web
+summary: Replace static onboarding tutorial with guided cross-page walkthrough
+---
+
+- Replace static onboarding tutorial with guided cross-page walkthrough
diff --git a/apps/web/app/tutorial/page.tsx b/apps/web/app/tutorial/page.tsx
index ad57768a..d39fe900 100644
--- a/apps/web/app/tutorial/page.tsx
+++ b/apps/web/app/tutorial/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
@@ -8,50 +8,7 @@ import { AuthPageShell } from "@/components/auth/auth-page-shell";
import { completeOnboardingTutorial, getOnboardingStatus } from "@/lib/api";
import { getDocumentationUrl } from "@/lib/ui-copy";
-interface TutorialStep {
- title: string;
- summary: string;
- bullets: string[];
-}
-
-const TUTORIAL_STEPS: TutorialStep[] = [
- {
- title: "Welcome to CorpSim",
- summary: "You run a company in a living market. Every choice affects your growth.",
- bullets: [
- "You produce goods, trade them, and grow your company over time.",
- "Markets move as companies buy and sell, so timing matters.",
- "Plan ahead: cash, stock, and production speed all work together."
- ]
- },
- {
- title: "How the Economy Works",
- summary: "Buy low, sell smart, and keep enough stock to avoid downtime.",
- bullets: [
- "Production turns raw materials into higher-value products.",
- "If demand is high and supply is low, prices usually rise.",
- "Keep reserve cash so you can react quickly to opportunities."
- ]
- },
- {
- title: "Core Features",
- summary: "These pages are your daily tools for running the company.",
- bullets: [
- "Production: start jobs and keep lines running.",
- "Market and Contracts: buy inputs, sell outputs, and secure deals.",
- "Finance, Research, and Logistics: track cash, unlock upgrades, and move goods."
- ]
- },
- {
- title: "Documentation",
- summary: "Use the docs anytime for walkthroughs and page-by-page help.",
- bullets: [
- "Open the docs from the top bar or sidebar while playing.",
- "Follow module guides to learn efficient production loops.",
- "Use references when you need exact steps for a feature."
- ]
- }
-];
+const GUIDED_TUTORIAL_TOTAL_STEPS = 8;
function readErrorMessage(error: unknown): string {
if (error && typeof error === "object" && "message" in error && typeof error.message === "string") {
@@ -65,7 +22,6 @@ export default function TutorialPage() {
const [isLoading, setLoading] = useState(true);
const [isSubmitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
- const [stepIndex, setStepIndex] = useState(0);
useEffect(() => {
let active = true;
@@ -104,15 +60,7 @@ export default function TutorialPage() {
};
}, [router]);
- const step = useMemo(() => TUTORIAL_STEPS[stepIndex], [stepIndex]);
- const isLastStep = stepIndex >= TUTORIAL_STEPS.length - 1;
-
- async function handleContinue() {
- if (!isLastStep) {
- setStepIndex((current) => Math.min(current + 1, TUTORIAL_STEPS.length - 1));
- return;
- }
-
+ async function handleSkipTutorial() {
if (isSubmitting) {
return;
}
@@ -130,18 +78,22 @@ export default function TutorialPage() {
}
}
+ function handleStartTour() {
+ router.replace("/overview?tutorial=1&tutorialStep=0");
+ }
+
if (isLoading) {
return (
-
- Preparing your first steps in CorpSim.
+
+ Preparing your first guided steps.
);
}
return (
-
- Step {stepIndex + 1} of {TUTORIAL_STEPS.length}
-
-
{step.title}
-
{step.summary}
+
What to expect
+
+ You will be guided through Overview, Market, Production, and Inventory with focused
+ highlights on the exact sections that matter first.
+
- {step.bullets.map((bullet) => (
- - {bullet}
- ))}
+ - {GUIDED_TUTORIAL_TOTAL_STEPS} short guided steps across core pages.
+ - Each step highlights one UI section and tells you what to do there.
+ - You can skip now and still continue directly to the dashboard.
- {isLastStep ? (
-
- Ready to play? You can open the docs now or continue to the dashboard.
-
- ) : null}
{error ?
{error} : null}
-
-
setStepIndex((current) => Math.max(0, current - 1))}
- disabled={stepIndex === 0 || isSubmitting}
- >
- Back
+
+ void handleSkipTutorial()} disabled={isSubmitting}>
+ {isSubmitting ? "Skipping..." : "Skip for now"}
- void handleContinue()} disabled={isSubmitting}>
- {isLastStep ? (isSubmitting ? "Finishing..." : "Enter Dashboard") : "Next"}
+
+ Start guided tour
diff --git a/apps/web/src/components/auth/auth-route-gate.tsx b/apps/web/src/components/auth/auth-route-gate.tsx
index 95e7dc88..3dfebfa1 100644
--- a/apps/web/src/components/auth/auth-route-gate.tsx
+++ b/apps/web/src/components/auth/auth-route-gate.tsx
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
-import { usePathname, useRouter } from "next/navigation";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
import type { OnboardingStatus } from "@corpsim/shared";
import { getOnboardingStatus } from "@/lib/api";
import { authClient } from "@/lib/auth-client";
@@ -16,6 +16,8 @@ import {
} from "@/lib/auth-routes";
import { isAdminRole, isModeratorRole, isStaffRole } from "@/lib/roles";
+const GUIDED_TUTORIAL_ALLOWED_PAGES = new Set(["/overview", "/market", "/production", "/inventory"]);
+
function resolveSafeNextPath(raw: string | null): string | null {
if (!raw || !raw.startsWith("/")) {
return null;
@@ -36,13 +38,13 @@ function FullscreenMessage({ message }: { message: string }) {
export function AuthRouteGate({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
+ const searchParams = useSearchParams();
const router = useRouter();
- const nextPathFromQuery = (() => {
- if (typeof window === "undefined") {
- return null;
- }
- return resolveSafeNextPath(new URLSearchParams(window.location.search).get("next"));
- })();
+ const nextPathFromQuery = useMemo(
+ () => resolveSafeNextPath(searchParams.get("next")),
+ [searchParams]
+ );
+ const isGuidedTutorialMode = searchParams.get("tutorial") === "1";
const { data: session, isPending } = authClient.useSession();
const [onboardingStatus, setOnboardingStatus] = useState
(null);
const [onboardingStatusPathname, setOnboardingStatusPathname] = useState(null);
@@ -149,6 +151,9 @@ export function AuthRouteGate({ children }: { children: React.ReactNode }) {
if (isTutorialPage(pathname) || isProfilePage(pathname)) {
return null;
}
+ if (isGuidedTutorialMode && GUIDED_TUTORIAL_ALLOWED_PAGES.has(pathname)) {
+ return null;
+ }
return "/tutorial";
}
@@ -164,6 +169,7 @@ export function AuthRouteGate({ children }: { children: React.ReactNode }) {
onboardingStatus,
onboardingStatusPathname,
pathname,
+ isGuidedTutorialMode,
session?.user?.id
]);
diff --git a/apps/web/src/components/inventory/inventory-page.tsx b/apps/web/src/components/inventory/inventory-page.tsx
index b21f3dec..ce576f74 100644
--- a/apps/web/src/components/inventory/inventory-page.tsx
+++ b/apps/web/src/components/inventory/inventory-page.tsx
@@ -168,7 +168,7 @@ export function InventoryPage() {
return (
-
+
Inventory
@@ -265,7 +265,7 @@ export function InventoryPage() {
-
+
diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx
index adfad978..ed106b67 100644
--- a/apps/web/src/components/layout/app-shell.tsx
+++ b/apps/web/src/components/layout/app-shell.tsx
@@ -15,6 +15,7 @@ import { SidebarNav } from "./sidebar-nav";
import { TopBar } from "./top-bar";
import { useWorldHealth } from "./world-health-provider";
import { isAuthPage, isOnboardingPage, isProfilePage, isTutorialPage } from "@/lib/auth-routes";
+import { GuidedTutorialOverlay } from "@/components/tutorial/guided-tutorial-overlay";
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
@@ -95,6 +96,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
+
diff --git a/apps/web/src/components/market/market-page.tsx b/apps/web/src/components/market/market-page.tsx
index 58d46f3c..405d42a8 100644
--- a/apps/web/src/components/market/market-page.tsx
+++ b/apps/web/src/components/market/market-page.tsx
@@ -509,12 +509,14 @@ export function MarketPage() {
return (
-
+
+
+
@@ -638,13 +640,15 @@ export function MarketPage() {
-
+
+
+
-
+
Start Production
@@ -672,7 +672,7 @@ export function ProductionPage() {
-
+
Recipes
diff --git a/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx b/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx
new file mode 100644
index 00000000..1b127ccb
--- /dev/null
+++ b/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx
@@ -0,0 +1,297 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { completeOnboardingTutorial } from "@/lib/api";
+import { Button } from "@/components/ui/button";
+
+interface GuidedTutorialStep {
+ route: string;
+ targetId: string;
+ title: string;
+ description: string;
+}
+
+const GUIDED_TUTORIAL_STEPS: GuidedTutorialStep[] = [
+ {
+ route: "/overview",
+ targetId: "overview-kpis",
+ title: "Start with your company pulse",
+ description: "These metrics are your quick status check before taking any action."
+ },
+ {
+ route: "/overview",
+ targetId: "overview-integrity",
+ title: "Watch system integrity",
+ description: "If there are issues here, investigate before scaling operations."
+ },
+ {
+ route: "/market",
+ targetId: "market-order-placement",
+ title: "Place buy and sell orders",
+ description: "This is where you create market orders for the active company."
+ },
+ {
+ route: "/market",
+ targetId: "market-order-book",
+ title: "Read the order book",
+ description: "Use this table to inspect current prices, quantity, and market depth."
+ },
+ {
+ route: "/production",
+ targetId: "production-start",
+ title: "Start production runs",
+ description: "Pick a recipe and quantity, then launch jobs from this panel."
+ },
+ {
+ route: "/production",
+ targetId: "production-recipes",
+ title: "Review recipes first",
+ description: "Check output, duration, and required inputs before committing runs."
+ },
+ {
+ route: "/inventory",
+ targetId: "inventory-filters",
+ title: "Filter your inventory view",
+ description: "Search and region filters help you focus the exact stock you need."
+ },
+ {
+ route: "/inventory",
+ targetId: "inventory-table",
+ title: "Track available stock",
+ description: "Use quantity, reserved, and available values to avoid production stalls."
+ }
+];
+
+const SPOTLIGHT_PADDING_PX = 8;
+const CARD_ESTIMATED_HEIGHT_PX = 220;
+const CARD_MAX_WIDTH_PX = 360;
+
+function clampStepIndex(value: number): number {
+ if (!Number.isFinite(value)) {
+ return 0;
+ }
+ return Math.min(Math.max(Math.trunc(value), 0), GUIDED_TUTORIAL_STEPS.length - 1);
+}
+
+function buildTutorialHref(route: string, stepIndex: number): string {
+ const params = new URLSearchParams();
+ params.set("tutorial", "1");
+ params.set("tutorialStep", String(stepIndex));
+ return `${route}?${params.toString()}`;
+}
+
+function readErrorMessage(error: unknown): string {
+ if (error instanceof Error && error.message) {
+ return error.message;
+ }
+ return "Unable to finish tutorial right now. Please try again.";
+}
+
+export function GuidedTutorialOverlay() {
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const isTutorialActive = searchParams.get("tutorial") === "1";
+ const stepIndex = useMemo(
+ () => clampStepIndex(Number.parseInt(searchParams.get("tutorialStep") ?? "0", 10)),
+ [searchParams]
+ );
+ const step = GUIDED_TUTORIAL_STEPS[stepIndex];
+ const [targetRect, setTargetRect] = useState(null);
+ const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 });
+ const [isSubmitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ const isFirstStep = stepIndex === 0;
+ const isLastStep = stepIndex >= GUIDED_TUTORIAL_STEPS.length - 1;
+
+ useEffect(() => {
+ if (!isTutorialActive) {
+ return;
+ }
+
+ const updateViewport = () =>
+ setViewportSize({ width: window.innerWidth, height: window.innerHeight });
+
+ updateViewport();
+ window.addEventListener("resize", updateViewport);
+ return () => window.removeEventListener("resize", updateViewport);
+ }, [isTutorialActive]);
+
+ useEffect(() => {
+ if (!isTutorialActive) {
+ return;
+ }
+
+ if (pathname !== step.route) {
+ router.replace(buildTutorialHref(step.route, stepIndex));
+ }
+ }, [isTutorialActive, pathname, router, step.route, stepIndex]);
+
+ useEffect(() => {
+ if (!isTutorialActive || pathname !== step.route) {
+ setTargetRect(null);
+ return;
+ }
+
+ const query = `[data-tutorial-id="${step.targetId}"]`;
+ const updateRect = () => {
+ const element = document.querySelector(query);
+ if (!element) {
+ setTargetRect(null);
+ return;
+ }
+ setTargetRect(element.getBoundingClientRect());
+ };
+
+ updateRect();
+ const interval = window.setInterval(updateRect, 250);
+ window.addEventListener("scroll", updateRect, true);
+ window.addEventListener("resize", updateRect);
+
+ return () => {
+ window.clearInterval(interval);
+ window.removeEventListener("scroll", updateRect, true);
+ window.removeEventListener("resize", updateRect);
+ };
+ }, [isTutorialActive, pathname, step.route, step.targetId]);
+
+ const spotlightStyle = useMemo(() => {
+ if (!targetRect) {
+ return null;
+ }
+
+ const top = Math.max(8, targetRect.top - SPOTLIGHT_PADDING_PX);
+ const left = Math.max(8, targetRect.left - SPOTLIGHT_PADDING_PX);
+ const width = targetRect.width + SPOTLIGHT_PADDING_PX * 2;
+ const height = targetRect.height + SPOTLIGHT_PADDING_PX * 2;
+
+ return {
+ top,
+ left,
+ width,
+ height
+ };
+ }, [targetRect]);
+
+ const cardStyle = useMemo(() => {
+ if (!targetRect || viewportSize.width <= 0 || viewportSize.height <= 0) {
+ return {
+ right: 16,
+ bottom: 16,
+ width: CARD_MAX_WIDTH_PX
+ };
+ }
+
+ let top = targetRect.bottom + 12;
+ if (top + CARD_ESTIMATED_HEIGHT_PX > viewportSize.height - 16) {
+ top = targetRect.top - CARD_ESTIMATED_HEIGHT_PX - 12;
+ }
+ top = Math.max(16, top);
+
+ let left = targetRect.left;
+ const width = Math.min(CARD_MAX_WIDTH_PX, Math.max(280, viewportSize.width - 32));
+ if (left + width > viewportSize.width - 16) {
+ left = viewportSize.width - width - 16;
+ }
+ left = Math.max(16, left);
+
+ return {
+ top,
+ left,
+ width
+ };
+ }, [targetRect, viewportSize.height, viewportSize.width]);
+
+ const goToStep = useCallback(
+ (nextStepIndex: number) => {
+ const clampedIndex = clampStepIndex(nextStepIndex);
+ const nextStep = GUIDED_TUTORIAL_STEPS[clampedIndex];
+ setError(null);
+ router.replace(buildTutorialHref(nextStep.route, clampedIndex));
+ },
+ [router]
+ );
+
+ const completeTutorial = useCallback(async () => {
+ if (isSubmitting) {
+ return;
+ }
+
+ setSubmitting(true);
+ setError(null);
+ try {
+ await completeOnboardingTutorial();
+ router.replace("/overview");
+ router.refresh();
+ } catch (caught) {
+ setError(readErrorMessage(caught));
+ } finally {
+ setSubmitting(false);
+ }
+ }, [isSubmitting, router]);
+
+ if (!isTutorialActive) {
+ return null;
+ }
+
+ return (
+
+ {spotlightStyle ? (
+
+ ) : (
+
+ )}
+
+
+
+ Guided Tutorial {stepIndex + 1}/{GUIDED_TUTORIAL_STEPS.length}
+
+
{step.title}
+
{step.description}
+
+ {targetRect ? null : (
+
+ Waiting for this section to load on the page.
+
+ )}
+
+ {error ?
{error}
: null}
+
+
+
goToStep(stepIndex - 1)}
+ disabled={isFirstStep || isSubmitting}
+ >
+ Back
+
+
+
+ void completeTutorial()} disabled={isSubmitting}>
+ {isSubmitting ? "Skipping..." : "Skip"}
+
+ {isLastStep ? (
+ void completeTutorial()} disabled={isSubmitting}>
+ {isSubmitting ? "Finishing..." : "Finish"}
+
+ ) : (
+ goToStep(stepIndex + 1)} disabled={isSubmitting}>
+ Next
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/views/overview/overview-view.tsx b/apps/web/src/components/views/overview/overview-view.tsx
index dc7291ed..fedc796b 100644
--- a/apps/web/src/components/views/overview/overview-view.tsx
+++ b/apps/web/src/components/views/overview/overview-view.tsx
@@ -48,7 +48,7 @@ export function OverviewView() {
return (
-
+
{kpis.map((kpi) => (
@@ -61,7 +61,7 @@ export function OverviewView() {
))}
-
+
System Integrity
@@ -110,7 +110,7 @@ export function OverviewView() {
-
+
{UI_COPY.documentation.title}
From 459657a7e99e070cee75afe361d092ce42c78d72 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 18:15:54 +0100
Subject: [PATCH 39/52] fix(web): clarify overview metrics as world-level
---
...clarify-overview-metrics-and-tutorial-copy-as-wo.md | 7 +++++++
.../components/tutorial/guided-tutorial-overlay.tsx | 5 +++--
.../src/components/views/overview/overview-view.tsx | 10 +++++-----
3 files changed, 15 insertions(+), 7 deletions(-)
create mode 100644 .releases/unreleased/20260222171544-clarify-overview-metrics-and-tutorial-copy-as-wo.md
diff --git a/.releases/unreleased/20260222171544-clarify-overview-metrics-and-tutorial-copy-as-wo.md b/.releases/unreleased/20260222171544-clarify-overview-metrics-and-tutorial-copy-as-wo.md
new file mode 100644
index 00000000..ae78b3fb
--- /dev/null
+++ b/.releases/unreleased/20260222171544-clarify-overview-metrics-and-tutorial-copy-as-wo.md
@@ -0,0 +1,7 @@
+---
+type: patch
+area: web
+summary: Clarify overview metrics and tutorial copy as world-level values
+---
+
+- Clarify overview metrics and tutorial copy as world-level values
diff --git a/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx b/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx
index 1b127ccb..1697ce3a 100644
--- a/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx
+++ b/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx
@@ -16,8 +16,9 @@ const GUIDED_TUTORIAL_STEPS: GuidedTutorialStep[] = [
{
route: "/overview",
targetId: "overview-kpis",
- title: "Start with your company pulse",
- description: "These metrics are your quick status check before taking any action."
+ title: "Start with the world pulse",
+ description:
+ "These metrics summarize the full simulation (all companies), not just your company."
},
{
route: "/overview",
diff --git a/apps/web/src/components/views/overview/overview-view.tsx b/apps/web/src/components/views/overview/overview-view.tsx
index fedc796b..e29f7275 100644
--- a/apps/web/src/components/views/overview/overview-view.tsx
+++ b/apps/web/src/components/views/overview/overview-view.tsx
@@ -39,11 +39,11 @@ export function OverviewView() {
const kpis = [
{ label: `Current ${UI_CADENCE_TERMS.singularTitle}`, value: formatInt(health.currentTick) },
- { label: "Open Orders", value: formatInt(health.ordersOpenCount) },
- { label: "Trades (Last 100)", value: formatInt(health.tradesLast100Count) },
- { label: "Companies", value: formatInt(health.companiesCount) },
- { label: "Total Cash", value: formatCents(health.sumCashCents) },
- { label: "Reserved Cash", value: formatCents(health.sumReservedCashCents) }
+ { label: "World Open Orders", value: formatInt(health.ordersOpenCount) },
+ { label: "World Trades (Last 100)", value: formatInt(health.tradesLast100Count) },
+ { label: "World Companies", value: formatInt(health.companiesCount) },
+ { label: "World Total Cash", value: formatCents(health.sumCashCents) },
+ { label: "World Reserved Cash", value: formatCents(health.sumReservedCashCents) }
];
return (
From e806a0444f578af34ed970f39a3d061df5627983 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 18:19:10 +0100
Subject: [PATCH 40/52] feat(web): begin guided tutorial with active company
snapshot
---
...ded-tutorial-with-active-company-snapsh.md | 7 ++
apps/web/app/tutorial/page.tsx | 3 +-
.../tutorial/guided-tutorial-overlay.tsx | 60 +----------------
.../tutorial/guided-tutorial-steps.ts | 65 +++++++++++++++++++
.../views/overview/overview-view.tsx | 32 ++++++++-
5 files changed, 106 insertions(+), 61 deletions(-)
create mode 100644 .releases/unreleased/20260222171905-start-guided-tutorial-with-active-company-snapsh.md
create mode 100644 apps/web/src/components/tutorial/guided-tutorial-steps.ts
diff --git a/.releases/unreleased/20260222171905-start-guided-tutorial-with-active-company-snapsh.md b/.releases/unreleased/20260222171905-start-guided-tutorial-with-active-company-snapsh.md
new file mode 100644
index 00000000..581cb865
--- /dev/null
+++ b/.releases/unreleased/20260222171905-start-guided-tutorial-with-active-company-snapsh.md
@@ -0,0 +1,7 @@
+---
+type: patch
+area: web
+summary: Start guided tutorial with active company snapshot before world KPIs
+---
+
+- Start guided tutorial with active company snapshot before world KPIs
diff --git a/apps/web/app/tutorial/page.tsx b/apps/web/app/tutorial/page.tsx
index d39fe900..d2420d1a 100644
--- a/apps/web/app/tutorial/page.tsx
+++ b/apps/web/app/tutorial/page.tsx
@@ -7,8 +7,9 @@ import { Button } from "@/components/ui/button";
import { AuthPageShell } from "@/components/auth/auth-page-shell";
import { completeOnboardingTutorial, getOnboardingStatus } from "@/lib/api";
import { getDocumentationUrl } from "@/lib/ui-copy";
+import { GUIDED_TUTORIAL_STEPS } from "@/components/tutorial/guided-tutorial-steps";
-const GUIDED_TUTORIAL_TOTAL_STEPS = 8;
+const GUIDED_TUTORIAL_TOTAL_STEPS = GUIDED_TUTORIAL_STEPS.length;
function readErrorMessage(error: unknown): string {
if (error && typeof error === "object" && "message" in error && typeof error.message === "string") {
diff --git a/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx b/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx
index 1697ce3a..74b578b4 100644
--- a/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx
+++ b/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx
@@ -4,65 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { completeOnboardingTutorial } from "@/lib/api";
import { Button } from "@/components/ui/button";
-
-interface GuidedTutorialStep {
- route: string;
- targetId: string;
- title: string;
- description: string;
-}
-
-const GUIDED_TUTORIAL_STEPS: GuidedTutorialStep[] = [
- {
- route: "/overview",
- targetId: "overview-kpis",
- title: "Start with the world pulse",
- description:
- "These metrics summarize the full simulation (all companies), not just your company."
- },
- {
- route: "/overview",
- targetId: "overview-integrity",
- title: "Watch system integrity",
- description: "If there are issues here, investigate before scaling operations."
- },
- {
- route: "/market",
- targetId: "market-order-placement",
- title: "Place buy and sell orders",
- description: "This is where you create market orders for the active company."
- },
- {
- route: "/market",
- targetId: "market-order-book",
- title: "Read the order book",
- description: "Use this table to inspect current prices, quantity, and market depth."
- },
- {
- route: "/production",
- targetId: "production-start",
- title: "Start production runs",
- description: "Pick a recipe and quantity, then launch jobs from this panel."
- },
- {
- route: "/production",
- targetId: "production-recipes",
- title: "Review recipes first",
- description: "Check output, duration, and required inputs before committing runs."
- },
- {
- route: "/inventory",
- targetId: "inventory-filters",
- title: "Filter your inventory view",
- description: "Search and region filters help you focus the exact stock you need."
- },
- {
- route: "/inventory",
- targetId: "inventory-table",
- title: "Track available stock",
- description: "Use quantity, reserved, and available values to avoid production stalls."
- }
-];
+import { GUIDED_TUTORIAL_STEPS } from "./guided-tutorial-steps";
const SPOTLIGHT_PADDING_PX = 8;
const CARD_ESTIMATED_HEIGHT_PX = 220;
diff --git a/apps/web/src/components/tutorial/guided-tutorial-steps.ts b/apps/web/src/components/tutorial/guided-tutorial-steps.ts
new file mode 100644
index 00000000..c69d61f2
--- /dev/null
+++ b/apps/web/src/components/tutorial/guided-tutorial-steps.ts
@@ -0,0 +1,65 @@
+export interface GuidedTutorialStep {
+ route: string;
+ targetId: string;
+ title: string;
+ description: string;
+}
+
+export const GUIDED_TUTORIAL_STEPS: GuidedTutorialStep[] = [
+ {
+ route: "/overview",
+ targetId: "overview-company",
+ title: "Confirm your active company",
+ description:
+ "This card shows your current company context. These values are company-specific."
+ },
+ {
+ route: "/overview",
+ targetId: "overview-kpis",
+ title: "Read the world pulse",
+ description:
+ "These metrics summarize the full simulation (all companies), not just your company."
+ },
+ {
+ route: "/overview",
+ targetId: "overview-integrity",
+ title: "Watch system integrity",
+ description: "If there are issues here, investigate before scaling operations."
+ },
+ {
+ route: "/market",
+ targetId: "market-order-placement",
+ title: "Place buy and sell orders",
+ description: "This is where you create market orders for the active company."
+ },
+ {
+ route: "/market",
+ targetId: "market-order-book",
+ title: "Read the order book",
+ description: "Use this table to inspect current prices, quantity, and market depth."
+ },
+ {
+ route: "/production",
+ targetId: "production-start",
+ title: "Start production runs",
+ description: "Pick a recipe and quantity, then launch jobs from this panel."
+ },
+ {
+ route: "/production",
+ targetId: "production-recipes",
+ title: "Review recipes first",
+ description: "Check output, duration, and required inputs before committing runs."
+ },
+ {
+ route: "/inventory",
+ targetId: "inventory-filters",
+ title: "Filter your inventory view",
+ description: "Search and region filters help you focus the exact stock you need."
+ },
+ {
+ route: "/inventory",
+ targetId: "inventory-table",
+ title: "Track available stock",
+ description: "Use quantity, reserved, and available values to avoid production stalls."
+ }
+];
diff --git a/apps/web/src/components/views/overview/overview-view.tsx b/apps/web/src/components/views/overview/overview-view.tsx
index e29f7275..11286b25 100644
--- a/apps/web/src/components/views/overview/overview-view.tsx
+++ b/apps/web/src/components/views/overview/overview-view.tsx
@@ -5,14 +5,16 @@ import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+import { useActiveCompany } from "@/components/company/active-company-provider";
import { useWorldHealth } from "@/components/layout/world-health-provider";
import { formatCents, formatInt } from "@/lib/format";
import { fetchDiscordServerUrlFromMeta, getDiscordServerUrl } from "@/lib/public-links";
import { UI_CADENCE_TERMS } from "@/lib/ui-terms";
-import { getDocumentationUrl, UI_COPY } from "@/lib/ui-copy";
+import { formatCodeLabel, getDocumentationUrl, UI_COPY } from "@/lib/ui-copy";
export function OverviewView() {
const { health } = useWorldHealth();
+ const { activeCompany } = useActiveCompany();
const [discordServerUrl, setDiscordServerUrl] = useState(() => getDiscordServerUrl());
useEffect(() => {
@@ -48,6 +50,34 @@ export function OverviewView() {
return (
+
+
+ Active Company Snapshot
+
+
+
+
Company
+
{activeCompany?.name ?? UI_COPY.common.noCompanySelected}
+
+
+
Region
+
{activeCompany?.regionName ?? "-"}
+
+
+
Specialization
+
+ {activeCompany ? formatCodeLabel(activeCompany.specialization) : "-"}
+
+
+
+
Company Cash
+
+ {activeCompany?.cashCents ? formatCents(activeCompany.cashCents) : "Hidden/Unavailable"}
+
+
+
+
+
{kpis.map((kpi) => (
From 6353f2858a3db91753aa9c043b1bfc54b8ea408b Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 18:45:29 +0100
Subject: [PATCH 41/52] fix(sim): prevent zero-trade stalls from static bot
books
---
...iquidity-bot-orders-and-add-determinist.md | 7 +
packages/sim/src/bots/bot-runner.ts | 141 +++++++++++++++++-
.../sim/src/bots/strategies/liquidity-bot.ts | 138 ++++++++++++++---
packages/sim/tests/liquidity-bot.test.ts | 115 +++++++++++++-
4 files changed, 374 insertions(+), 27 deletions(-)
create mode 100644 .releases/unreleased/20260222174455-refresh-liquidity-bot-orders-and-add-determinist.md
diff --git a/.releases/unreleased/20260222174455-refresh-liquidity-bot-orders-and-add-determinist.md b/.releases/unreleased/20260222174455-refresh-liquidity-bot-orders-and-add-determinist.md
new file mode 100644
index 00000000..9302e396
--- /dev/null
+++ b/.releases/unreleased/20260222174455-refresh-liquidity-bot-orders-and-add-determinist.md
@@ -0,0 +1,7 @@
+---
+type: patch
+area: sim
+summary: Refresh liquidity bot orders and add deterministic crossing to prevent zero-trade stalls
+---
+
+- Refresh liquidity bot orders and add deterministic crossing to prevent zero-trade stalls
diff --git a/packages/sim/src/bots/bot-runner.ts b/packages/sim/src/bots/bot-runner.ts
index 6992af8d..4b188dc7 100644
--- a/packages/sim/src/bots/bot-runner.ts
+++ b/packages/sim/src/bots/bot-runner.ts
@@ -60,11 +60,12 @@
* - Invalid state gracefully skipped
* - DomainInvariantError for configuration validation
*/
-import { OrderSide, OrderStatus, Prisma } from "@prisma/client";
+import { BuildingStatus, BuildingType, OrderSide, OrderStatus, Prisma } from "@prisma/client";
import { resolveIconItemFallbackPriceCents } from "@corpsim/shared";
import { DomainInvariantError } from "../domain/errors";
import { availableCash } from "../domain/reservations";
-import { placeMarketOrderWithTx } from "../services/market-orders";
+import { cancelMarketOrderWithTx, placeMarketOrderWithTx } from "../services/market-orders";
+import { calculateRegionalStorageCapacity } from "../services/buildings";
import { runProducerBot } from "./strategies/producer-bot";
import {
LiquidityBotConfig,
@@ -89,6 +90,7 @@ export interface BotCompanySnapshot {
companyCode: string;
strategy: "LIQUIDITY" | "PRODUCER";
availableCashCents: bigint;
+ availableStorageUnits: number;
items: LiquidityBotState["items"];
}
@@ -208,6 +210,7 @@ export function planBotActions(
const planned = planLiquidityOrders(
{
availableCashCents: company.availableCashCents,
+ availableStorageUnits: company.availableStorageUnits,
items: company.items
},
liquidityConfig
@@ -319,8 +322,34 @@ export async function runBotsForTick(
const companyIds = companies.map((entry) => entry.id);
const itemIds = items.map((entry) => entry.id);
+ const liquidityCompanyIds = companies
+ .filter((entry) => inferStrategy(entry.code) === "LIQUIDITY")
+ .map((entry) => entry.id);
- const [inventories, openOrders, bookOrders, latestTrades] = await Promise.all([
+ if (liquidityCompanyIds.length > 0) {
+ const openLiquidityOrders = await tx.marketOrder.findMany({
+ where: {
+ companyId: { in: liquidityCompanyIds },
+ itemId: { in: itemIds },
+ status: OrderStatus.OPEN
+ },
+ orderBy: [{ id: "asc" }],
+ select: {
+ id: true,
+ companyId: true
+ }
+ });
+
+ for (const order of openLiquidityOrders) {
+ await cancelMarketOrderWithTx(tx, {
+ orderId: order.id,
+ companyId: order.companyId,
+ tick
+ });
+ }
+ }
+
+ const [inventories, openOrders, bookOrders, latestTrades, regionalInventoryTotals, regionalWarehouses] = await Promise.all([
tx.inventory.findMany({
where: {
companyId: { in: companyIds },
@@ -353,6 +382,7 @@ export async function runBotsForTick(
status: OrderStatus.OPEN
},
select: {
+ companyId: true,
regionId: true,
itemId: true,
side: true,
@@ -367,6 +397,26 @@ export async function runBotsForTick(
itemId: true,
unitPriceCents: true
}
+ }),
+ tx.inventory.groupBy({
+ by: ["companyId", "regionId"],
+ where: {
+ companyId: { in: companyIds }
+ },
+ _sum: {
+ quantity: true
+ }
+ }),
+ tx.building.groupBy({
+ by: ["companyId", "regionId"],
+ where: {
+ companyId: { in: companyIds },
+ buildingType: BuildingType.WAREHOUSE,
+ status: BuildingStatus.ACTIVE
+ },
+ _count: {
+ _all: true
+ }
})
]);
@@ -378,12 +428,40 @@ export async function runBotsForTick(
const openOrderKeySet = new Set(
openOrders.map((entry) => `${entry.companyId}:${entry.regionId}:${entry.itemId}:${entry.side}`)
);
+ const regionalInventoryTotalByCompanyRegion = new Map(
+ regionalInventoryTotals.map((entry) => [
+ `${entry.companyId}:${entry.regionId}`,
+ entry._sum.quantity ?? 0
+ ])
+ );
+ const regionalWarehouseCountByCompanyRegion = new Map(
+ regionalWarehouses.map((entry) => [
+ `${entry.companyId}:${entry.regionId}`,
+ entry._count._all
+ ])
+ );
const bestBuyPriceByRegionItem = new Map();
const bestSellPriceByRegionItem = new Map();
+ const bookOrdersByRegionItem = new Map<
+ string,
+ Array<{
+ companyId: string;
+ side: OrderSide;
+ unitPriceCents: bigint;
+ }>
+ >();
for (const order of bookOrders) {
const key = `${order.regionId}:${order.itemId}`;
+ const byRegionItem = bookOrdersByRegionItem.get(key) ?? [];
+ byRegionItem.push({
+ companyId: order.companyId,
+ side: order.side,
+ unitPriceCents: order.unitPriceCents
+ });
+ bookOrdersByRegionItem.set(key, byRegionItem);
+
if (order.side === OrderSide.BUY) {
const current = bestBuyPriceByRegionItem.get(key);
if (current === undefined || order.unitPriceCents > current) {
@@ -409,6 +487,11 @@ export async function runBotsForTick(
const referencePriceByCompanyId = new Map>();
const companySnapshots: BotCompanySnapshot[] = companies.map((company) => {
+ const regionKey = `${company.id}:${company.regionId}`;
+ const regionalInventoryTotal = regionalInventoryTotalByCompanyRegion.get(regionKey) ?? 0;
+ const regionalWarehouseCount = regionalWarehouseCountByCompanyRegion.get(regionKey) ?? 0;
+ const regionalStorageCapacity = calculateRegionalStorageCapacity(regionalWarehouseCount);
+ const availableStorageUnits = Math.max(0, regionalStorageCapacity - regionalInventoryTotal);
const referencePriceByItemId = new Map(
items.map((item) => [
item.id,
@@ -432,6 +515,51 @@ export async function runBotsForTick(
0,
(companyInventory?.quantity ?? 0) - (companyInventory?.reservedQuantity ?? 0)
);
+ const externalBookOrders =
+ bookOrdersByRegionItem.get(`${company.regionId}:${item.id}`) ?? [];
+ let bestExternalBuyPriceCents: bigint | null = null;
+ let bestExternalSellPriceCents: bigint | null = null;
+ let bestOwnBuyPriceCents: bigint | null = null;
+ let bestOwnSellPriceCents: bigint | null = null;
+
+ for (const order of externalBookOrders) {
+ if (order.companyId === company.id) {
+ if (order.side === OrderSide.BUY) {
+ if (
+ bestOwnBuyPriceCents === null ||
+ order.unitPriceCents > bestOwnBuyPriceCents
+ ) {
+ bestOwnBuyPriceCents = order.unitPriceCents;
+ }
+ continue;
+ }
+
+ if (
+ bestOwnSellPriceCents === null ||
+ order.unitPriceCents < bestOwnSellPriceCents
+ ) {
+ bestOwnSellPriceCents = order.unitPriceCents;
+ }
+ continue;
+ }
+
+ if (order.side === OrderSide.BUY) {
+ if (
+ bestExternalBuyPriceCents === null ||
+ order.unitPriceCents > bestExternalBuyPriceCents
+ ) {
+ bestExternalBuyPriceCents = order.unitPriceCents;
+ }
+ continue;
+ }
+
+ if (
+ bestExternalSellPriceCents === null ||
+ order.unitPriceCents < bestExternalSellPriceCents
+ ) {
+ bestExternalSellPriceCents = order.unitPriceCents;
+ }
+ }
return {
itemId: item.id,
@@ -443,7 +571,11 @@ export async function runBotsForTick(
),
hasOpenSellOrder: openOrderKeySet.has(
`${company.id}:${company.regionId}:${item.id}:${OrderSide.SELL}`
- )
+ ),
+ bestOwnBuyPriceCents,
+ bestOwnSellPriceCents,
+ bestExternalBuyPriceCents,
+ bestExternalSellPriceCents
};
});
@@ -455,6 +587,7 @@ export async function runBotsForTick(
cashCents: company.cashCents,
reservedCashCents: company.reservedCashCents
}),
+ availableStorageUnits,
items: itemsForCompany
};
});
diff --git a/packages/sim/src/bots/strategies/liquidity-bot.ts b/packages/sim/src/bots/strategies/liquidity-bot.ts
index 2e588e6f..c6956353 100644
--- a/packages/sim/src/bots/strategies/liquidity-bot.ts
+++ b/packages/sim/src/bots/strategies/liquidity-bot.ts
@@ -7,10 +7,15 @@ export interface LiquidityItemState {
availableInventory: number;
hasOpenBuyOrder: boolean;
hasOpenSellOrder: boolean;
+ bestOwnBuyPriceCents?: bigint | null;
+ bestOwnSellPriceCents?: bigint | null;
+ bestExternalBuyPriceCents?: bigint | null;
+ bestExternalSellPriceCents?: bigint | null;
}
export interface LiquidityBotState {
availableCashCents: bigint;
+ availableStorageUnits?: number | null;
items: LiquidityItemState[];
}
@@ -27,6 +32,8 @@ export interface PlannedLiquidityOrder {
unitPriceCents: bigint;
}
+const AGGRESSIVE_CROSS_QUANTITY = 1;
+
function calculateBuyPrice(referencePriceCents: bigint, spreadBps: number): bigint {
const price = (referencePriceCents * BigInt(10_000 - spreadBps)) / 10_000n;
return price > 0n ? price : 1n;
@@ -55,23 +62,48 @@ export function planLiquidityOrders(
}
const orders: PlannedLiquidityOrder[] = [];
- let remainingNotional = config.maxNotionalPerTickCents;
+ let remainingBuyNotional = config.maxNotionalPerTickCents;
+ let remainingSellNotional = config.maxNotionalPerTickCents;
let remainingCash = state.availableCashCents;
+ let remainingStorageUnits =
+ Number.isInteger(state.availableStorageUnits) && (state.availableStorageUnits ?? 0) >= 0
+ ? (state.availableStorageUnits as number)
+ : Number.POSITIVE_INFINITY;
const sortedItems = [...state.items].sort((left, right) => left.itemCode.localeCompare(right.itemCode));
for (const item of sortedItems) {
- if (remainingNotional <= 0n) {
+ if (remainingBuyNotional <= 0n && remainingSellNotional <= 0n) {
break;
}
const buyPrice = calculateBuyPrice(item.referencePriceCents, config.spreadBps);
const sellPrice = calculateSellPrice(item.referencePriceCents, config.spreadBps);
+ let placedBuyThisItem = false;
+ let placedSellThisItem = false;
+ let placedCrossingBuyThisItem = false;
+
+ if (remainingSellNotional > 0n && !item.hasOpenSellOrder) {
+ const desired = Math.min(config.targetQuantityPerSide, item.availableInventory);
+ const quantity = capByNotional(remainingSellNotional, sellPrice, desired);
+
+ if (quantity > 0) {
+ const notional = BigInt(quantity) * sellPrice;
+ orders.push({
+ itemId: item.itemId,
+ side: OrderSide.SELL,
+ quantity,
+ unitPriceCents: sellPrice
+ });
+ remainingSellNotional -= notional;
+ placedSellThisItem = true;
+ }
+ }
if (!item.hasOpenBuyOrder) {
const maxByBudget = buyPrice > 0n ? Number(remainingCash / buyPrice) : 0;
const desired = Math.min(config.targetQuantityPerSide, maxByBudget);
- const quantity = capByNotional(remainingNotional, buyPrice, desired);
+ const quantity = capByNotional(remainingBuyNotional, buyPrice, desired);
if (quantity > 0) {
const notional = BigInt(quantity) * buyPrice;
@@ -81,27 +113,97 @@ export function planLiquidityOrders(
quantity,
unitPriceCents: buyPrice
});
- remainingNotional -= notional;
+ remainingBuyNotional -= notional;
remainingCash -= notional;
+ placedBuyThisItem = true;
}
}
- if (remainingNotional <= 0n || item.hasOpenSellOrder) {
- continue;
+ // If only one side is currently open for this bot on this item, place a small
+ // deterministic taker order against external liquidity to unblock trade flow.
+ if (
+ remainingBuyNotional > 0n &&
+ !placedBuyThisItem &&
+ item.bestExternalSellPriceCents !== null &&
+ item.bestExternalSellPriceCents !== undefined &&
+ (
+ item.bestOwnSellPriceCents === null ||
+ item.bestOwnSellPriceCents === undefined ||
+ item.bestOwnSellPriceCents > item.bestExternalSellPriceCents
+ )
+ ) {
+ const crossBuyPrice = item.bestExternalSellPriceCents;
+ const maxByBudget = crossBuyPrice > 0n ? Number(remainingCash / crossBuyPrice) : 0;
+ const desired = Math.min(AGGRESSIVE_CROSS_QUANTITY, maxByBudget, remainingStorageUnits);
+ const quantity = capByNotional(remainingBuyNotional, crossBuyPrice, desired);
+
+ if (quantity > 0) {
+ const notional = BigInt(quantity) * crossBuyPrice;
+ orders.push({
+ itemId: item.itemId,
+ side: OrderSide.BUY,
+ quantity,
+ unitPriceCents: crossBuyPrice
+ });
+ remainingBuyNotional -= notional;
+ remainingCash -= notional;
+ remainingStorageUnits = Math.max(0, remainingStorageUnits - quantity);
+ placedBuyThisItem = true;
+ placedCrossingBuyThisItem = true;
+ }
+ }
+
+ if (
+ remainingSellNotional > 0n &&
+ !placedSellThisItem &&
+ item.bestExternalBuyPriceCents !== null &&
+ item.bestExternalBuyPriceCents !== undefined &&
+ (
+ item.bestOwnBuyPriceCents === null ||
+ item.bestOwnBuyPriceCents === undefined ||
+ item.bestOwnBuyPriceCents < item.bestExternalBuyPriceCents
+ )
+ ) {
+ const crossSellPrice = item.bestExternalBuyPriceCents;
+ const desired = Math.min(AGGRESSIVE_CROSS_QUANTITY, item.availableInventory);
+ const quantity = capByNotional(remainingSellNotional, crossSellPrice, desired);
+
+ if (quantity > 0) {
+ const notional = BigInt(quantity) * crossSellPrice;
+ orders.push({
+ itemId: item.itemId,
+ side: OrderSide.SELL,
+ quantity,
+ unitPriceCents: crossSellPrice
+ });
+ remainingSellNotional -= notional;
+ }
}
- const desired = Math.min(config.targetQuantityPerSide, item.availableInventory);
- const quantity = capByNotional(remainingNotional, sellPrice, desired);
-
- if (quantity > 0) {
- const notional = BigInt(quantity) * sellPrice;
- orders.push({
- itemId: item.itemId,
- side: OrderSide.SELL,
- quantity,
- unitPriceCents: sellPrice
- });
- remainingNotional -= notional;
+ // Fallback: if no safe external crossing buy is available, allow a tiny self-cross
+ // against this bot's resting sell liquidity to keep trade tape active.
+ if (!placedCrossingBuyThisItem && remainingBuyNotional > 0n) {
+ const ownSellPriceForSelfCross =
+ item.bestOwnSellPriceCents ?? (placedSellThisItem ? sellPrice : null);
+
+ if (ownSellPriceForSelfCross !== null && ownSellPriceForSelfCross !== undefined) {
+ const maxByBudget =
+ ownSellPriceForSelfCross > 0n ? Number(remainingCash / ownSellPriceForSelfCross) : 0;
+ const desired = Math.min(AGGRESSIVE_CROSS_QUANTITY, maxByBudget);
+ const quantity = capByNotional(remainingBuyNotional, ownSellPriceForSelfCross, desired);
+
+ if (quantity > 0) {
+ const notional = BigInt(quantity) * ownSellPriceForSelfCross;
+ orders.push({
+ itemId: item.itemId,
+ side: OrderSide.BUY,
+ quantity,
+ unitPriceCents: ownSellPriceForSelfCross
+ });
+ remainingBuyNotional -= notional;
+ remainingCash -= notional;
+ }
+ }
}
}
diff --git a/packages/sim/tests/liquidity-bot.test.ts b/packages/sim/tests/liquidity-bot.test.ts
index 643d919d..71419860 100644
--- a/packages/sim/tests/liquidity-bot.test.ts
+++ b/packages/sim/tests/liquidity-bot.test.ts
@@ -42,7 +42,7 @@ describe("liquidity bot strategy", () => {
expect(totalNotional).toBeLessThanOrEqual(2_000n);
});
- it("respects per-tick notional cap", () => {
+ it("respects per-side notional cap", () => {
const orders = planLiquidityOrders(
{
availableCashCents: 10_000n,
@@ -64,10 +64,113 @@ describe("liquidity bot strategy", () => {
}
);
- const totalNotional = orders.reduce((sum, entry) => {
- return sum + BigInt(entry.quantity) * entry.unitPriceCents;
- }, 0n);
- expect(totalNotional).toBeLessThanOrEqual(300n);
+ const buyNotional = orders
+ .filter((entry) => entry.side === OrderSide.BUY)
+ .reduce((sum, entry) => sum + BigInt(entry.quantity) * entry.unitPriceCents, 0n);
+ const sellNotional = orders
+ .filter((entry) => entry.side === OrderSide.SELL)
+ .reduce((sum, entry) => sum + BigInt(entry.quantity) * entry.unitPriceCents, 0n);
+
+ expect(buyNotional).toBeLessThanOrEqual(300n);
+ expect(sellNotional).toBeLessThanOrEqual(300n);
+ });
+
+ it("places an aggressive crossing order when only one side is open", () => {
+ const orders = planLiquidityOrders(
+ {
+ availableCashCents: 10_000n,
+ items: [
+ {
+ itemId: "item-iron",
+ itemCode: "IRON_ORE",
+ referencePriceCents: 100n,
+ availableInventory: 0,
+ hasOpenBuyOrder: true,
+ hasOpenSellOrder: false,
+ bestExternalBuyPriceCents: 92n,
+ bestExternalSellPriceCents: 104n
+ }
+ ]
+ },
+ {
+ spreadBps: 500,
+ maxNotionalPerTickCents: 10_000n,
+ targetQuantityPerSide: 5
+ }
+ );
+
+ const aggressiveBuy = orders.find(
+ (entry) => entry.side === OrderSide.BUY && entry.unitPriceCents === 104n
+ );
+
+ expect(aggressiveBuy).toBeDefined();
+ expect(aggressiveBuy?.quantity).toBe(1);
+ });
+
+ it("does not place aggressive buy orders when storage is full", () => {
+ const orders = planLiquidityOrders(
+ {
+ availableCashCents: 10_000n,
+ availableStorageUnits: 0,
+ items: [
+ {
+ itemId: "item-iron",
+ itemCode: "IRON_ORE",
+ referencePriceCents: 100n,
+ availableInventory: 0,
+ hasOpenBuyOrder: true,
+ hasOpenSellOrder: false,
+ bestExternalBuyPriceCents: 92n,
+ bestExternalSellPriceCents: 104n
+ }
+ ]
+ },
+ {
+ spreadBps: 500,
+ maxNotionalPerTickCents: 10_000n,
+ targetQuantityPerSide: 5
+ }
+ );
+
+ const aggressiveBuy = orders.find(
+ (entry) => entry.side === OrderSide.BUY && entry.unitPriceCents === 104n
+ );
+ expect(aggressiveBuy).toBeUndefined();
+ });
+
+ it("can place a safe aggressive buy even when both sides are already open", () => {
+ const orders = planLiquidityOrders(
+ {
+ availableCashCents: 10_000n,
+ availableStorageUnits: 10,
+ items: [
+ {
+ itemId: "item-iron",
+ itemCode: "IRON_ORE",
+ referencePriceCents: 100n,
+ availableInventory: 10,
+ hasOpenBuyOrder: true,
+ hasOpenSellOrder: true,
+ bestOwnBuyPriceCents: 95n,
+ bestOwnSellPriceCents: 110n,
+ bestExternalBuyPriceCents: 97n,
+ bestExternalSellPriceCents: 104n
+ }
+ ]
+ },
+ {
+ spreadBps: 500,
+ maxNotionalPerTickCents: 10_000n,
+ targetQuantityPerSide: 5
+ }
+ );
+
+ const aggressiveBuy = orders.find(
+ (entry) => entry.side === OrderSide.BUY && entry.unitPriceCents === 104n
+ );
+
+ expect(aggressiveBuy).toBeDefined();
+ expect(aggressiveBuy?.quantity).toBe(1);
});
});
@@ -87,6 +190,7 @@ describe("bot runner planning", () => {
companyCode: "BOT_TRADER_B",
strategy: "LIQUIDITY" as const,
availableCashCents: 50_000n,
+ availableStorageUnits: 500,
items: [
{
itemId: "item-iron",
@@ -103,6 +207,7 @@ describe("bot runner planning", () => {
companyCode: "BOT_MINER_A",
strategy: "PRODUCER" as const,
availableCashCents: 80_000n,
+ availableStorageUnits: 500,
items: []
}
];
From 5b4bcaaa53b8e800c7cdccf0a9a1eec1bf3ead5d Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 19:05:03 +0100
Subject: [PATCH 42/52] fix(api): harden diagnostics missing-items service
injection
---
.../20260222185800-fix-diagnostics-missing-items-di.md | 7 +++++++
apps/api/src/diagnostics/diagnostics.controller.ts | 7 ++++++-
2 files changed, 13 insertions(+), 1 deletion(-)
create mode 100644 .releases/unreleased/20260222185800-fix-diagnostics-missing-items-di.md
diff --git a/.releases/unreleased/20260222185800-fix-diagnostics-missing-items-di.md b/.releases/unreleased/20260222185800-fix-diagnostics-missing-items-di.md
new file mode 100644
index 00000000..06f904ff
--- /dev/null
+++ b/.releases/unreleased/20260222185800-fix-diagnostics-missing-items-di.md
@@ -0,0 +1,7 @@
+---
+type: patch
+area: api
+summary: Fix diagnostics missing-items endpoint DI so missing item logs can be created
+---
+
+- Harden diagnostics controller service injection with explicit @Inject assignment to avoid undefined service at runtime.
diff --git a/apps/api/src/diagnostics/diagnostics.controller.ts b/apps/api/src/diagnostics/diagnostics.controller.ts
index e720f64f..2c4ec945 100644
--- a/apps/api/src/diagnostics/diagnostics.controller.ts
+++ b/apps/api/src/diagnostics/diagnostics.controller.ts
@@ -1,5 +1,6 @@
import {
BadRequestException,
+ Inject,
Controller,
ForbiddenException,
Get,
@@ -89,7 +90,11 @@ function isStaffRole(role: string | string[] | null | undefined): boolean {
@Controller("diagnostics")
export class DiagnosticsController {
- constructor(private readonly diagnosticsService: DiagnosticsService) {}
+ private readonly diagnosticsService: DiagnosticsService;
+
+ constructor(@Inject(DiagnosticsService) diagnosticsService: DiagnosticsService) {
+ this.diagnosticsService = diagnosticsService;
+ }
private assertStaffSession(request: RequestWithSession): void {
const session = request.session;
From 0965f576fbd3d0cc169e71065703d43456d8adef Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 19:23:31 +0100
Subject: [PATCH 43/52] fix(ci): resolve root typecheck failures
---
...pecheck-for-public-links-route-and-bot-cancel-input.md | 8 ++++++++
packages/sim/src/bots/bot-runner.ts | 1 -
tsconfig.json | 3 +++
3 files changed, 11 insertions(+), 1 deletion(-)
create mode 100644 .releases/unreleased/20260222192000-fix-typecheck-for-public-links-route-and-bot-cancel-input.md
diff --git a/.releases/unreleased/20260222192000-fix-typecheck-for-public-links-route-and-bot-cancel-input.md b/.releases/unreleased/20260222192000-fix-typecheck-for-public-links-route-and-bot-cancel-input.md
new file mode 100644
index 00000000..3304cdb8
--- /dev/null
+++ b/.releases/unreleased/20260222192000-fix-typecheck-for-public-links-route-and-bot-cancel-input.md
@@ -0,0 +1,8 @@
+---
+type: patch
+area: ci
+summary: Fix typecheck failures in web public-links route import and bot order cancellation input
+---
+
+- Added a root `@/*` path mapping to `apps/web/src/*` so root `tsc` resolves web alias imports used by app route handlers.
+- Removed an invalid `companyId` property passed to `cancelMarketOrderWithTx` in bot order cleanup.
diff --git a/packages/sim/src/bots/bot-runner.ts b/packages/sim/src/bots/bot-runner.ts
index 4b188dc7..95aeb69a 100644
--- a/packages/sim/src/bots/bot-runner.ts
+++ b/packages/sim/src/bots/bot-runner.ts
@@ -343,7 +343,6 @@ export async function runBotsForTick(
for (const order of openLiquidityOrders) {
await cancelMarketOrderWithTx(tx, {
orderId: order.id,
- companyId: order.companyId,
tick
});
}
diff --git a/tsconfig.json b/tsconfig.json
index dcdd64de..35ab7ca0 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -3,6 +3,9 @@
"compilerOptions": {
"types": ["node", "vitest/globals"],
"baseUrl": ".",
+ "paths": {
+ "@/*": ["apps/web/src/*"]
+ },
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
From 4a66e9ae95a1e6b958ff969559b39e03aec133a0 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 19:49:03 +0100
Subject: [PATCH 44/52] fix(web): wrap search params hooks in suspense
---
...p-searchparams-hooks-in-suspense-for-web-build.md | 8 ++++++++
apps/web/src/components/auth/auth-route-gate.tsx | 12 ++++++++++--
.../components/tutorial/guided-tutorial-overlay.tsx | 12 ++++++++++--
3 files changed, 28 insertions(+), 4 deletions(-)
create mode 100644 .releases/unreleased/20260222195000-wrap-searchparams-hooks-in-suspense-for-web-build.md
diff --git a/.releases/unreleased/20260222195000-wrap-searchparams-hooks-in-suspense-for-web-build.md b/.releases/unreleased/20260222195000-wrap-searchparams-hooks-in-suspense-for-web-build.md
new file mode 100644
index 00000000..f0dd07a6
--- /dev/null
+++ b/.releases/unreleased/20260222195000-wrap-searchparams-hooks-in-suspense-for-web-build.md
@@ -0,0 +1,8 @@
+---
+type: patch
+area: web
+summary: Wrap search-params dependent layout clients in Suspense to fix Next build prerendering
+---
+
+- Wrapped `AuthRouteGate` and `GuidedTutorialOverlay` with React `Suspense` boundaries so `useSearchParams()` does not fail static prerender checks.
+- Preserved existing runtime behavior by keeping current loading/empty fallbacks.
diff --git a/apps/web/src/components/auth/auth-route-gate.tsx b/apps/web/src/components/auth/auth-route-gate.tsx
index 3dfebfa1..01286229 100644
--- a/apps/web/src/components/auth/auth-route-gate.tsx
+++ b/apps/web/src/components/auth/auth-route-gate.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useMemo, useState } from "react";
+import { Suspense, useEffect, useMemo, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import type { OnboardingStatus } from "@corpsim/shared";
import { getOnboardingStatus } from "@/lib/api";
@@ -36,7 +36,7 @@ function FullscreenMessage({ message }: { message: string }) {
);
}
-export function AuthRouteGate({ children }: { children: React.ReactNode }) {
+function AuthRouteGateContent({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useRouter();
@@ -198,3 +198,11 @@ export function AuthRouteGate({ children }: { children: React.ReactNode }) {
return <>{children}>;
}
+
+export function AuthRouteGate({ children }: { children: React.ReactNode }) {
+ return (
+ }>
+ {children}
+
+ );
+}
diff --git a/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx b/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx
index 74b578b4..9056b4ff 100644
--- a/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx
+++ b/apps/web/src/components/tutorial/guided-tutorial-overlay.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { completeOnboardingTutorial } from "@/lib/api";
import { Button } from "@/components/ui/button";
@@ -31,7 +31,7 @@ function readErrorMessage(error: unknown): string {
return "Unable to finish tutorial right now. Please try again.";
}
-export function GuidedTutorialOverlay() {
+function GuidedTutorialOverlayContent() {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useRouter();
@@ -238,3 +238,11 @@ export function GuidedTutorialOverlay() {
);
}
+
+export function GuidedTutorialOverlay() {
+ return (
+
+
+
+ );
+}
From ef4321c9f845e677f223b75a801cef492a46efcc Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 20:20:27 +0100
Subject: [PATCH 45/52] fix(ci): run release workflow only on main
---
.github/workflows/release.yml | 6 +++++-
...0222202100-restrict-release-workflow-to-main-branch.md | 8 ++++++++
2 files changed, 13 insertions(+), 1 deletion(-)
create mode 100644 .releases/unreleased/20260222202100-restrict-release-workflow-to-main-branch.md
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0ec082cb..237b22b7 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -4,6 +4,7 @@ on:
workflow_run:
workflows: ["Verify"]
types: [completed]
+ branches: [main]
workflow_dispatch:
permissions:
@@ -17,7 +18,10 @@ concurrency:
jobs:
release:
if: |
- github.event_name == 'workflow_dispatch' ||
+ (
+ github.event_name == 'workflow_dispatch' &&
+ github.ref == 'refs/heads/main'
+ ) ||
(
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
diff --git a/.releases/unreleased/20260222202100-restrict-release-workflow-to-main-branch.md b/.releases/unreleased/20260222202100-restrict-release-workflow-to-main-branch.md
new file mode 100644
index 00000000..2ecdaa93
--- /dev/null
+++ b/.releases/unreleased/20260222202100-restrict-release-workflow-to-main-branch.md
@@ -0,0 +1,8 @@
+---
+type: patch
+area: ci
+summary: Restrict release workflow to main branch for both automatic and manual runs
+---
+
+- Added a `main` branch filter to the release workflow's `workflow_run` trigger.
+- Added a job-level guard so manual dispatch runs only when dispatched from `refs/heads/main`.
From ab88c3d41335016dabeea97c9259a9f22fe75fe7 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 21:05:16 +0100
Subject: [PATCH 46/52] fix: stabilize prisma startup and restore phase 1-5 web
UX
---
...x-dev-prisma-lock-and-phase-feedback-ux.md | 9 +
apps/web/app/workforce/page.tsx | 4 +-
apps/web/src/components/layout/app-shell.tsx | 2 +
.../layout/research-completion-notifier.tsx | 140 +++++++++++
.../web/src/components/layout/sidebar-nav.tsx | 4 +-
.../src/components/research/research-page.tsx | 59 -----
apps/web/src/lib/page-navigation.ts | 12 +-
apps/web/src/lib/ui-copy.ts | 3 +-
scripts/prisma-generate.mjs | 222 +++++++++++++-----
9 files changed, 326 insertions(+), 129 deletions(-)
create mode 100644 .releases/unreleased/20260222211500-fix-dev-prisma-lock-and-phase-feedback-ux.md
create mode 100644 apps/web/src/components/layout/research-completion-notifier.tsx
diff --git a/.releases/unreleased/20260222211500-fix-dev-prisma-lock-and-phase-feedback-ux.md b/.releases/unreleased/20260222211500-fix-dev-prisma-lock-and-phase-feedback-ux.md
new file mode 100644
index 00000000..8f6f9d41
--- /dev/null
+++ b/.releases/unreleased/20260222211500-fix-dev-prisma-lock-and-phase-feedback-ux.md
@@ -0,0 +1,9 @@
+---
+type: patch
+area: web
+summary: Fix local Prisma lock races and restore Buildings/research feedback visibility
+---
+
+- Serialize `prisma:generate` runs with a cross-process lock to prevent Windows engine rename collisions when multiple dev scripts start at once.
+- Replace deprecated `/workforce` entry points by redirecting to `/buildings` and surfacing Buildings in shared navigation.
+- Move research completion toasts into a global app-shell notifier so completion feedback appears even when users are on other pages.
diff --git a/apps/web/app/workforce/page.tsx b/apps/web/app/workforce/page.tsx
index 54ff86fe..f66bcf24 100644
--- a/apps/web/app/workforce/page.tsx
+++ b/apps/web/app/workforce/page.tsx
@@ -1,5 +1,5 @@
-import { WorkforcePage } from "@/components/workforce/workforce-page";
+import { redirect } from "next/navigation";
export default function WorkforceRoute() {
- return ;
+ redirect("/buildings");
}
diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx
index ed106b67..1062ca5c 100644
--- a/apps/web/src/components/layout/app-shell.tsx
+++ b/apps/web/src/components/layout/app-shell.tsx
@@ -10,6 +10,7 @@ import { InventoryPreviewShortcut } from "./inventory-preview-shortcut";
import { PageSearchCommand } from "./page-search-command";
import { ProfilePanel } from "./profile-panel";
import { QuickNavigationShortcuts } from "./quick-navigation-shortcuts";
+import { ResearchCompletionNotifier } from "./research-completion-notifier";
import { ShortcutsHelpShortcut } from "./shortcuts-help-shortcut";
import { SidebarNav } from "./sidebar-nav";
import { TopBar } from "./top-bar";
@@ -95,6 +96,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
+
diff --git a/apps/web/src/components/layout/research-completion-notifier.tsx b/apps/web/src/components/layout/research-completion-notifier.tsx
new file mode 100644
index 00000000..97a74b8d
--- /dev/null
+++ b/apps/web/src/components/layout/research-completion-notifier.tsx
@@ -0,0 +1,140 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useActiveCompany } from "@/components/company/active-company-provider";
+import { useUiSfx } from "@/components/layout/ui-sfx-provider";
+import { useWorldHealth } from "@/components/layout/world-health-provider";
+import { useToast } from "@/components/ui/toast-manager";
+import { ResearchNode, listResearch } from "@/lib/api";
+
+const RESEARCH_REFRESH_DEBOUNCE_MS = 500;
+const MAX_RECIPES_IN_TOAST = 3;
+
+function buildResearchCompletionMessage(completedNodes: ResearchNode[]): string {
+ const unlockedRecipes = completedNodes.flatMap((node) => node.unlockRecipes);
+ const uniqueRecipeNames = Array.from(
+ new Set(
+ unlockedRecipes
+ .map((recipe) => recipe.recipeName)
+ .filter((name): name is string => Boolean(name && name.trim()))
+ )
+ );
+
+ if (uniqueRecipeNames.length === 0) {
+ return "Research complete!";
+ }
+
+ const displayedNames = uniqueRecipeNames.slice(0, MAX_RECIPES_IN_TOAST);
+ const remainingCount = uniqueRecipeNames.length - displayedNames.length;
+ const baseList = displayedNames.join(", ");
+ const summary = remainingCount > 0 ? `${baseList} + ${remainingCount} more` : baseList;
+ return `Research complete! Unlocked recipes: ${summary}`;
+}
+
+export function ResearchCompletionNotifier() {
+ const { activeCompanyId } = useActiveCompany();
+ const { health } = useWorldHealth();
+ const { play } = useUiSfx();
+ const { showToast } = useToast();
+
+ const [nodes, setNodes] = useState([]);
+ const statusByNodeIdRef = useRef>(new Map());
+ const didPrimeStatusesRef = useRef(false);
+
+ const loadResearch = useCallback(
+ async (options?: { force?: boolean }) => {
+ if (!activeCompanyId) {
+ setNodes([]);
+ return;
+ }
+
+ try {
+ const rows = await listResearch(activeCompanyId, { force: options?.force });
+ setNodes(rows);
+ } catch {
+ // Ignore background notifier fetch failures and retry on next tick.
+ }
+ },
+ [activeCompanyId]
+ );
+
+ useEffect(() => {
+ statusByNodeIdRef.current = new Map();
+ didPrimeStatusesRef.current = false;
+
+ if (!activeCompanyId) {
+ setNodes([]);
+ return;
+ }
+
+ void loadResearch({ force: true });
+ }, [activeCompanyId, loadResearch]);
+
+ const nextResearchCompletionTick = useMemo(() => {
+ const researching = nodes.filter(
+ (node) => node.status === "RESEARCHING" && node.tickCompletes !== null
+ );
+
+ if (researching.length === 0) {
+ return null;
+ }
+
+ return researching.reduce(
+ (minTick, node) => Math.min(minTick, node.tickCompletes ?? Number.MAX_SAFE_INTEGER),
+ Number.MAX_SAFE_INTEGER
+ );
+ }, [nodes]);
+
+ useEffect(() => {
+ if (
+ !activeCompanyId ||
+ health?.currentTick === undefined ||
+ nextResearchCompletionTick === null ||
+ health.currentTick < nextResearchCompletionTick
+ ) {
+ return;
+ }
+
+ const timeout = setTimeout(() => {
+ void loadResearch({ force: true });
+ }, RESEARCH_REFRESH_DEBOUNCE_MS);
+
+ return () => clearTimeout(timeout);
+ }, [activeCompanyId, health?.currentTick, loadResearch, nextResearchCompletionTick]);
+
+ const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node] as const)), [nodes]);
+
+ useEffect(() => {
+ const nextStatusById = new Map(nodes.map((node) => [node.id, node.status] as const));
+ if (!didPrimeStatusesRef.current) {
+ statusByNodeIdRef.current = nextStatusById;
+ didPrimeStatusesRef.current = true;
+ return;
+ }
+
+ const completedNodes: ResearchNode[] = [];
+ for (const [nodeId, nextStatus] of nextStatusById.entries()) {
+ const previousStatus = statusByNodeIdRef.current.get(nodeId);
+ if (previousStatus !== "COMPLETED" && nextStatus === "COMPLETED") {
+ const node = nodeById.get(nodeId);
+ if (node) {
+ completedNodes.push(node);
+ }
+ }
+ }
+
+ if (completedNodes.length > 0) {
+ play("event_research_completed");
+ showToast({
+ title: completedNodes.length === 1 ? completedNodes[0].name : "Research Complete",
+ description: buildResearchCompletionMessage(completedNodes),
+ variant: "success",
+ sound: "none"
+ });
+ }
+
+ statusByNodeIdRef.current = nextStatusById;
+ }, [nodeById, nodes, play, showToast]);
+
+ return null;
+}
diff --git a/apps/web/src/components/layout/sidebar-nav.tsx b/apps/web/src/components/layout/sidebar-nav.tsx
index 84a8df71..a4d92c16 100644
--- a/apps/web/src/components/layout/sidebar-nav.tsx
+++ b/apps/web/src/components/layout/sidebar-nav.tsx
@@ -3,6 +3,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
+ Building2,
BookOpenText,
Box,
CircleDollarSign,
@@ -19,7 +20,6 @@ import {
Wrench,
Truck,
TrendingUp,
- Users
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { SIDEBAR_PAGE_NAVIGATION, APP_PAGE_NAVIGATION } from "@/lib/page-navigation";
@@ -33,9 +33,9 @@ import { isAdminRole, isModeratorRole } from "@/lib/roles";
const NAV_ICON_BY_ROUTE: Record = {
"/overview": LayoutDashboard,
"/market": TrendingUp,
+ "/buildings": Building2,
"/production": Factory,
"/research": FlaskConical,
- "/workforce": Users,
"/inventory": PackageSearch,
"/logistics": Truck,
"/contracts": ClipboardList,
diff --git a/apps/web/src/components/research/research-page.tsx b/apps/web/src/components/research/research-page.tsx
index 5db85ee5..d9c870f7 100644
--- a/apps/web/src/components/research/research-page.tsx
+++ b/apps/web/src/components/research/research-page.tsx
@@ -109,8 +109,6 @@ export function ResearchPage() {
const [nodePageSize, setNodePageSize] =
useState<(typeof RESEARCH_NODE_PAGE_SIZE_OPTIONS)[number]>(20);
const deferredNodeSearch = useDeferredValue(nodeSearch);
- const statusByNodeIdRef = useRef>(new Map());
- const didPrimeStatusesRef = useRef(false);
const hasLoadedResearchRef = useRef(false);
const loadResearch = useCallback(async (options?: { force?: boolean; showLoadingState?: boolean }) => {
@@ -190,65 +188,8 @@ export function ResearchPage() {
return () => clearTimeout(timeout);
}, [activeCompanyId, health?.currentTick, loadResearch, nextResearchCompletionTick]);
- useEffect(() => {
- statusByNodeIdRef.current = new Map();
- didPrimeStatusesRef.current = false;
- }, [activeCompanyId]);
-
const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node] as const)), [nodes]);
- useEffect(() => {
- const nextStatusById = new Map(nodes.map((node) => [node.id, node.status] as const));
- if (!didPrimeStatusesRef.current) {
- statusByNodeIdRef.current = nextStatusById;
- didPrimeStatusesRef.current = true;
- return;
- }
-
- const completedNodes: ResearchNode[] = [];
- for (const [nodeId, nextStatus] of nextStatusById.entries()) {
- const previousStatus = statusByNodeIdRef.current.get(nodeId);
- if (previousStatus !== "COMPLETED" && nextStatus === "COMPLETED") {
- const node = nodeById.get(nodeId);
- if (node) {
- completedNodes.push(node);
- }
- }
- }
- if (completedNodes.length > 0) {
- play("event_research_completed");
-
- // Show toast notification for completed research
- const unlockedRecipes = completedNodes.flatMap((node) => node.unlockRecipes);
- const uniqueRecipeNamesSet = new Set(
- unlockedRecipes
- .map((recipe) => recipe.recipeName)
- .filter((name): name is string => Boolean(name && name.trim()))
- );
- const uniqueRecipeNames = Array.from(uniqueRecipeNamesSet);
- const MAX_RECIPES_IN_TOAST = 3;
- let message: string;
-
- if (uniqueRecipeNames.length > 0) {
- const displayedNames = uniqueRecipeNames.slice(0, MAX_RECIPES_IN_TOAST);
- const remainingCount = uniqueRecipeNames.length - displayedNames.length;
- const baseList = displayedNames.join(", ");
- const summary =
- remainingCount > 0 ? `${baseList} + ${remainingCount} more` : baseList;
- message = `Research complete! Unlocked recipes: ${summary}`;
- } else {
- message = "Research complete!";
- }
-
- showToast({
- title: completedNodes.length === 1 ? completedNodes[0].name : "Research Complete",
- description: message,
- variant: "success"
- });
- }
- statusByNodeIdRef.current = nextStatusById;
- }, [nodes, play, showToast, nodeById]);
-
const selectedNode = selectedNodeId ? nodeById.get(selectedNodeId) ?? null : null;
const filteredNodes = useMemo(() => {
diff --git a/apps/web/src/lib/page-navigation.ts b/apps/web/src/lib/page-navigation.ts
index 7f0c0546..1cc9a8d7 100644
--- a/apps/web/src/lib/page-navigation.ts
+++ b/apps/web/src/lib/page-navigation.ts
@@ -20,6 +20,12 @@ export const APP_PAGE_NAVIGATION: AppPageNavigationItem[] = [
keywords: ["orders", "trades", "exchange"],
showInSidebar: true
},
+ {
+ href: "/buildings",
+ label: UI_COPY.modules.buildings,
+ keywords: ["facilities", "expansion", "capacity"],
+ showInSidebar: true
+ },
{
href: "/production",
label: UI_COPY.modules.production,
@@ -32,12 +38,6 @@ export const APP_PAGE_NAVIGATION: AppPageNavigationItem[] = [
keywords: ["technology", "nodes", "unlocks"],
showInSidebar: true
},
- {
- href: "/workforce",
- label: UI_COPY.modules.workforce,
- keywords: ["staff", "allocation", "capacity"],
- showInSidebar: true
- },
{
href: "/inventory",
label: UI_COPY.modules.inventory,
diff --git a/apps/web/src/lib/ui-copy.ts b/apps/web/src/lib/ui-copy.ts
index 1dec4c82..d2c86b5c 100644
--- a/apps/web/src/lib/ui-copy.ts
+++ b/apps/web/src/lib/ui-copy.ts
@@ -4,6 +4,7 @@ export const UI_COPY = {
modules: {
overview: "Overview",
market: "Market",
+ buildings: "Buildings",
production: "Production",
research: "Research",
workforce: "Workforce",
@@ -105,9 +106,9 @@ export const UI_COPY = {
routePaths: {
"/overview": "/pages/overview",
"/market": "/pages/market",
+ "/buildings": "/pages/buildings",
"/production": "/pages/production",
"/research": "/pages/research",
- "/workforce": "/pages/workforce",
"/inventory": "/pages/inventory",
"/logistics": "/pages/logistics",
"/contracts": "/pages/contracts",
diff --git a/scripts/prisma-generate.mjs b/scripts/prisma-generate.mjs
index c28d0dad..617cca1b 100644
--- a/scripts/prisma-generate.mjs
+++ b/scripts/prisma-generate.mjs
@@ -3,12 +3,27 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
-import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const MAX_RETRIES = 6;
const BASE_DELAY_MS = 300;
+const LOCK_ACQUIRE_TIMEOUT_MS = 120_000;
+const LOCK_STALE_MS = 10 * 60 * 1_000;
+const LOCK_POLL_INTERVAL_MS = 250;
+
+class GenerateCommandFailedError extends Error {
+ constructor(exitCode) {
+ super(`[prisma:generate] generate:raw failed with exit code ${exitCode}`);
+ this.name = "GenerateCommandFailedError";
+ this.exitCode = exitCode;
+ }
+}
+
+function isNodeErrorWithCode(error) {
+ return typeof error === "object" && error !== null && "code" in error;
+}
function toPosixPath(value) {
return value.replace(/\\/g, "/");
@@ -118,6 +133,87 @@ function sleepMs(ms) {
});
}
+async function isLockFileStale(lockPath) {
+ try {
+ const details = await stat(lockPath);
+ return Date.now() - details.mtimeMs > LOCK_STALE_MS;
+ } catch (error) {
+ if (isNodeErrorWithCode(error) && error.code === "ENOENT") {
+ return false;
+ }
+ throw error;
+ }
+}
+
+async function acquireGenerateLock(repoRoot) {
+ const lockPath = resolve(repoRoot, ".corpsim/cache/prisma-generate.lock");
+ await mkdir(dirname(lockPath), { recursive: true });
+ const startedAt = Date.now();
+ let hasLoggedWaiting = false;
+
+ for (;;) {
+ try {
+ await writeFile(
+ lockPath,
+ JSON.stringify(
+ {
+ pid: process.pid,
+ lockedAt: new Date().toISOString()
+ },
+ null,
+ 2
+ ) + "\n",
+ {
+ encoding: "utf8",
+ flag: "wx"
+ }
+ );
+
+ return async () => {
+ try {
+ await unlink(lockPath);
+ } catch (error) {
+ if (!isNodeErrorWithCode(error) || error.code !== "ENOENT") {
+ console.warn("[prisma:generate] could not remove lock file", error);
+ }
+ }
+ };
+ } catch (error) {
+ if (!isNodeErrorWithCode(error) || error.code !== "EEXIST") {
+ throw error;
+ }
+
+ if (await isLockFileStale(lockPath)) {
+ try {
+ await unlink(lockPath);
+ console.warn("[prisma:generate] removed stale generate lock file");
+ continue;
+ } catch (staleCleanupError) {
+ if (
+ !isNodeErrorWithCode(staleCleanupError) ||
+ (staleCleanupError.code !== "ENOENT" && staleCleanupError.code !== "EPERM")
+ ) {
+ throw staleCleanupError;
+ }
+ }
+ }
+
+ if (!hasLoggedWaiting) {
+ console.warn("[prisma:generate] another prisma:generate process is running; waiting for lock");
+ hasLoggedWaiting = true;
+ }
+
+ if (Date.now() - startedAt > LOCK_ACQUIRE_TIMEOUT_MS) {
+ throw new Error(
+ `[prisma:generate] timed out waiting for lock after ${LOCK_ACQUIRE_TIMEOUT_MS}ms`
+ );
+ }
+
+ await sleepMs(LOCK_POLL_INTERVAL_MS);
+ }
+ }
+}
+
function normalizeNewlines(value) {
return value.replace(/\r\n/g, "\n");
}
@@ -238,7 +334,7 @@ async function runGenerateWithRetry(repoRoot, schemaContent) {
}
flushProcessOutput(result);
- process.exit(result.status ?? 1);
+ throw new GenerateCommandFailedError(result.status ?? 1);
}
const delayMs = BASE_DELAY_MS * 2 ** (attempt - 1);
@@ -252,72 +348,80 @@ async function runGenerateWithRetry(repoRoot, schemaContent) {
async function main() {
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = findRepoRoot(resolve(scriptDir, ".."));
- const forceGenerate = process.env.PRISMA_GENERATE_FORCE === "1";
- const statePath = resolve(repoRoot, ".corpsim/cache/prisma-generate-state.json");
- const { fingerprint, schemaContent } = await computeFingerprint(repoRoot);
- const sentinelPath = getGeneratedClientSentinelPath(repoRoot);
- const generatedSchemaPath = getGeneratedSchemaPath(repoRoot);
-
- if (!forceGenerate && sentinelPath && existsSync(sentinelPath) && existsSync(statePath)) {
- try {
- const stateRaw = await readFile(statePath, "utf8");
- const state = JSON.parse(stateRaw);
- if (state?.fingerprint === fingerprint && hasUsableGeneratedClient(repoRoot)) {
- console.log("[prisma:generate] schema unchanged; skipping generate");
- return;
+ const releaseLock = await acquireGenerateLock(repoRoot);
+ try {
+ const forceGenerate = process.env.PRISMA_GENERATE_FORCE === "1";
+ const statePath = resolve(repoRoot, ".corpsim/cache/prisma-generate-state.json");
+ const { fingerprint, schemaContent } = await computeFingerprint(repoRoot);
+ const sentinelPath = getGeneratedClientSentinelPath(repoRoot);
+ const generatedSchemaPath = getGeneratedSchemaPath(repoRoot);
+
+ if (!forceGenerate && sentinelPath && existsSync(sentinelPath) && existsSync(statePath)) {
+ try {
+ const stateRaw = await readFile(statePath, "utf8");
+ const state = JSON.parse(stateRaw);
+ if (state?.fingerprint === fingerprint && hasUsableGeneratedClient(repoRoot)) {
+ console.log("[prisma:generate] schema unchanged; skipping generate");
+ return;
+ }
+ } catch {
+ // Ignore state parse/read errors and regenerate.
}
- } catch {
- // Ignore state parse/read errors and regenerate.
}
- }
- if (!forceGenerate && generatedSchemaPath && existsSync(generatedSchemaPath)) {
- try {
- const generatedSchemaContent = await readFile(generatedSchemaPath, "utf8");
- if (
- canonicalizePrismaSchema(generatedSchemaContent) ===
- canonicalizePrismaSchema(schemaContent) &&
- hasUsableGeneratedClient(repoRoot)
- ) {
- console.log("[prisma:generate] generated client already matches schema; skipping generate");
- await mkdir(dirname(statePath), { recursive: true });
- await writeFile(
- statePath,
- JSON.stringify(
- {
- fingerprint,
- generatedAt: new Date().toISOString()
- },
- null,
- 2
- ) + "\n",
- "utf8"
- );
- return;
+ if (!forceGenerate && generatedSchemaPath && existsSync(generatedSchemaPath)) {
+ try {
+ const generatedSchemaContent = await readFile(generatedSchemaPath, "utf8");
+ if (
+ canonicalizePrismaSchema(generatedSchemaContent) ===
+ canonicalizePrismaSchema(schemaContent) &&
+ hasUsableGeneratedClient(repoRoot)
+ ) {
+ console.log("[prisma:generate] generated client already matches schema; skipping generate");
+ await mkdir(dirname(statePath), { recursive: true });
+ await writeFile(
+ statePath,
+ JSON.stringify(
+ {
+ fingerprint,
+ generatedAt: new Date().toISOString()
+ },
+ null,
+ 2
+ ) + "\n",
+ "utf8"
+ );
+ return;
+ }
+ } catch {
+ // If we cannot read generated schema, continue with normal generate flow.
}
- } catch {
- // If we cannot read generated schema, continue with normal generate flow.
}
- }
- await runGenerateWithRetry(repoRoot, schemaContent);
-
- await mkdir(dirname(statePath), { recursive: true });
- await writeFile(
- statePath,
- JSON.stringify(
- {
- fingerprint,
- generatedAt: new Date().toISOString()
- },
- null,
- 2
- ) + "\n",
- "utf8"
- );
+ await runGenerateWithRetry(repoRoot, schemaContent);
+
+ await mkdir(dirname(statePath), { recursive: true });
+ await writeFile(
+ statePath,
+ JSON.stringify(
+ {
+ fingerprint,
+ generatedAt: new Date().toISOString()
+ },
+ null,
+ 2
+ ) + "\n",
+ "utf8"
+ );
+ } finally {
+ await releaseLock();
+ }
}
main().catch((error) => {
+ if (error instanceof GenerateCommandFailedError) {
+ process.exit(error.exitCode);
+ }
console.error("[prisma:generate] failed", error);
process.exit(1);
});
From cf21d67503e266fd90ec24224555731963e0b3bc Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 21:43:36 +0100
Subject: [PATCH 47/52] fix(db): run Prisma generate without relying on
dotenv-cli shell binary
---
...-prisma-generate-raw-without-dotenv-cli.md | 9 ++
packages/db/package.json | 2 +-
scripts/prisma-generate-raw.mjs | 114 ++++++++++++++++++
3 files changed, 124 insertions(+), 1 deletion(-)
create mode 100644 .releases/unreleased/20260222213000-fix-prisma-generate-raw-without-dotenv-cli.md
create mode 100644 scripts/prisma-generate-raw.mjs
diff --git a/.releases/unreleased/20260222213000-fix-prisma-generate-raw-without-dotenv-cli.md b/.releases/unreleased/20260222213000-fix-prisma-generate-raw-without-dotenv-cli.md
new file mode 100644
index 00000000..b41e1b87
--- /dev/null
+++ b/.releases/unreleased/20260222213000-fix-prisma-generate-raw-without-dotenv-cli.md
@@ -0,0 +1,9 @@
+---
+type: patch
+area: db
+summary: Run Prisma generate without relying on dotenv-cli shell binary
+---
+
+- Replace `packages/db` `generate:raw` command with a Node wrapper script.
+- Load `.env` directly in the wrapper and invoke `pnpm exec prisma generate`.
+- Avoid Windows shell failures when the `dotenv` CLI binary is not linked in `node_modules/.bin`.
diff --git a/packages/db/package.json b/packages/db/package.json
index ebd30b2c..eb7b061e 100644
--- a/packages/db/package.json
+++ b/packages/db/package.json
@@ -18,7 +18,7 @@
},
"scripts": {
"generate": "node ../../scripts/prisma-generate.mjs",
- "generate:raw": "dotenv -e ../../.env -v PRISMA_GENERATE_SKIP_AUTOINSTALL=1 -- prisma generate --schema prisma/schema.prisma",
+ "generate:raw": "node ../../scripts/prisma-generate-raw.mjs",
"migrate:dev": "dotenv -e ../../.env -- prisma migrate dev --schema prisma/schema.prisma",
"migrate:deploy": "dotenv -e ../../.env -- prisma migrate deploy --schema prisma/schema.prisma",
"seed": "tsx seed.ts"
diff --git a/scripts/prisma-generate-raw.mjs b/scripts/prisma-generate-raw.mjs
new file mode 100644
index 00000000..6bca0db7
--- /dev/null
+++ b/scripts/prisma-generate-raw.mjs
@@ -0,0 +1,114 @@
+#!/usr/bin/env node
+
+import { readFileSync } from "node:fs";
+import { existsSync, readdirSync } from "node:fs";
+import { spawnSync } from "node:child_process";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+function parseDotenv(source) {
+ const output = {};
+ for (const rawLine of source.split(/\r?\n/)) {
+ const line = rawLine.trim();
+ if (!line || line.startsWith("#")) {
+ continue;
+ }
+
+ const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
+ const eqIndex = withoutExport.indexOf("=");
+ if (eqIndex <= 0) {
+ continue;
+ }
+
+ const key = withoutExport.slice(0, eqIndex).trim();
+ let value = withoutExport.slice(eqIndex + 1).trim();
+
+ if (
+ (value.startsWith('"') && value.endsWith('"')) ||
+ (value.startsWith("'") && value.endsWith("'"))
+ ) {
+ value = value.slice(1, -1);
+ }
+
+ output[key] = value;
+ }
+ return output;
+}
+
+function resolveRepoRoot() {
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
+ return resolve(scriptDir, "..");
+}
+
+function resolvePrismaCliPath(repoRoot) {
+ const directPaths = [
+ resolve(repoRoot, "node_modules/prisma/build/index.js"),
+ resolve(repoRoot, "packages/db/node_modules/prisma/build/index.js")
+ ];
+
+ for (const path of directPaths) {
+ if (existsSync(path)) {
+ return path;
+ }
+ }
+
+ const pnpmDir = resolve(repoRoot, "node_modules/.pnpm");
+ if (!existsSync(pnpmDir)) {
+ return null;
+ }
+
+ const prismaStoreDirs = readdirSync(pnpmDir).filter((entry) => entry.startsWith("prisma@"));
+ for (const entry of prismaStoreDirs) {
+ const candidate = resolve(pnpmDir, entry, "node_modules/prisma/build/index.js");
+ if (existsSync(candidate)) {
+ return candidate;
+ }
+ }
+
+ return null;
+}
+
+function main() {
+ const repoRoot = resolveRepoRoot();
+ const envPath = resolve(repoRoot, ".env");
+ let envFromFile = {};
+ try {
+ envFromFile = parseDotenv(readFileSync(envPath, "utf8"));
+ } catch {
+ envFromFile = {};
+ }
+
+ const dbDir = resolve(repoRoot, "packages/db");
+ const prismaCliPath = resolvePrismaCliPath(repoRoot);
+ if (!prismaCliPath) {
+ console.error(
+ "[prisma:generate:raw] Prisma CLI is not installed. Run `pnpm install` at the repository root."
+ );
+ process.exit(1);
+ }
+
+ const args = [
+ prismaCliPath,
+ "generate",
+ "--schema",
+ "prisma/schema.prisma"
+ ];
+
+ const result = spawnSync(process.execPath, args, {
+ cwd: dbDir,
+ stdio: "inherit",
+ env: {
+ ...process.env,
+ ...envFromFile,
+ PRISMA_GENERATE_SKIP_AUTOINSTALL: "1"
+ }
+ });
+
+ if (typeof result.status === "number") {
+ process.exit(result.status);
+ }
+
+ process.exit(1);
+}
+
+main();
From 440c28d9dcb88e8012228442e9d227d16c239311 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 22:10:23 +0100
Subject: [PATCH 48/52] fix(web): parse buildings definitions payload correctly
---
...22221000-fix-buildings-definitions-response-parsing.md | 8 ++++++++
apps/web/src/lib/api.ts | 2 +-
2 files changed, 9 insertions(+), 1 deletion(-)
create mode 100644 .releases/unreleased/20260222221000-fix-buildings-definitions-response-parsing.md
diff --git a/.releases/unreleased/20260222221000-fix-buildings-definitions-response-parsing.md b/.releases/unreleased/20260222221000-fix-buildings-definitions-response-parsing.md
new file mode 100644
index 00000000..03397a2f
--- /dev/null
+++ b/.releases/unreleased/20260222221000-fix-buildings-definitions-response-parsing.md
@@ -0,0 +1,8 @@
+---
+type: patch
+area: web
+summary: Fix buildings definitions API response parsing in web client
+---
+
+- Parse `/v1/buildings/definitions` using the `definitions` field from object payloads.
+- Prevent runtime error: `Invalid response field "definitions" (expected array)`.
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index 00a6f343..2f002be8 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -925,7 +925,7 @@ export async function preflightBuyOrder(input: {
export async function getBuildingTypeDefinitions(): Promise {
return fetchJson("/v1/buildings/definitions", (value) =>
- readArray(value, "definitions").map(parseBuildingTypeDefinition)
+ readArrayPayload(value, "definitions").map(parseBuildingTypeDefinition)
);
}
From cf4b79fe60ec705bd48ea8f991179d492449ab36 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 22:27:19 +0100
Subject: [PATCH 49/52] fix(web): support legacy buildings definitions payload
shape
---
...rt-legacy-buildings-definitions-payload.md | 9 ++++++
apps/web/src/lib/api.ts | 30 ++++++++++++++++++-
2 files changed, 38 insertions(+), 1 deletion(-)
create mode 100644 .releases/unreleased/20260222222800-support-legacy-buildings-definitions-payload.md
diff --git a/.releases/unreleased/20260222222800-support-legacy-buildings-definitions-payload.md b/.releases/unreleased/20260222222800-support-legacy-buildings-definitions-payload.md
new file mode 100644
index 00000000..90e81772
--- /dev/null
+++ b/.releases/unreleased/20260222222800-support-legacy-buildings-definitions-payload.md
@@ -0,0 +1,9 @@
+---
+type: patch
+area: web
+summary: Accept legacy object-shaped buildings definitions payloads
+---
+
+- Make `getBuildingTypeDefinitions` tolerant to both array and object-map payload shapes.
+- Automatically inject `buildingType` from object keys when legacy payload omits it.
+- Prevent runtime parsing crashes in acquire/buildings dialogs during mixed-version dev runs.
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index 2f002be8..0ae84235 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -194,6 +194,34 @@ function readArrayPayload(value: unknown, field: string): unknown[] {
return readArray(value[field], field);
}
+function readBuildingDefinitionsPayload(value: unknown): unknown[] {
+ if (Array.isArray(value)) {
+ return value;
+ }
+ if (!isRecord(value)) {
+ throw new Error('Invalid response payload for "definitions"');
+ }
+
+ const definitions = value.definitions;
+ if (Array.isArray(definitions)) {
+ return definitions;
+ }
+
+ if (isRecord(definitions)) {
+ return Object.entries(definitions).map(([buildingType, definition]) => {
+ if (!isRecord(definition)) {
+ throw new Error('Invalid response field "definitions" (expected array)');
+ }
+
+ return definition.buildingType === undefined
+ ? { ...definition, buildingType }
+ : definition;
+ });
+ }
+
+ throw new Error('Invalid response field "definitions" (expected array)');
+}
+
function parseSupportAccountSummary(value: unknown): SupportAccountSummary {
if (!isRecord(value)) {
throw new Error("Invalid support account payload");
@@ -925,7 +953,7 @@ export async function preflightBuyOrder(input: {
export async function getBuildingTypeDefinitions(): Promise {
return fetchJson("/v1/buildings/definitions", (value) =>
- readArrayPayload(value, "definitions").map(parseBuildingTypeDefinition)
+ readBuildingDefinitionsPayload(value).map(parseBuildingTypeDefinition)
);
}
From f4393bd76195342bcfa06088de022f5f9ab0b0d4 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Sun, 22 Feb 2026 23:45:45 +0100
Subject: [PATCH 50/52] chore(release): cut v0.10. 0
---
...13-fix-github-username-in-release-notes.md | 0
...218131646-fix-ux-data-visibility-issues.md | 0
...700-add-building-infrastructure-phase-1.md | 0
...219091824-fix-github-oauth-redirect-url.md | 0
...ings-management-ui-with-preflight-valid.md | 0
...22153300-single-origin-sso-auth-routing.md | 0
...4900-run-migrations-in-all-role-startup.md | 0
...e-item-quantity-rendering-and-apply-qua.md | 0
...t-unknown-item-labels-by-using-global-i.md | 0
...market-views-to-active-company-tradable.md | 0
...dokploy-nginx-docs-to-example-domains-a.md | 0
...gacy-api-subdomain-blocks-from-nginx-sa.md | 0
...-as-is-disclaimer-next-to-footer-versio.md | 0
...a-disclaimer-on-version-hover-and-overv.md | 0
...e-hover-helper-label-from-version-badge.md | 0
...aintenance-overlay-focus-ring-rectangle.md | 0
...0-hide-seeded-example-accounts-in-admin.md | 0
...a-version-tag-clickable-to-discord-and-.md | 0
...ord-link-from-runtime-meta-config-for-a.md | 0
...00-allow-admin-developer-read-endpoints.md | 0
...tatic-onboarding-tutorial-with-guided-c.md | 0
...le-redacted-company-cash-in-web-parsers.md | 0
...verview-metrics-and-tutorial-copy-as-wo.md | 0
...ded-tutorial-with-active-company-snapsh.md | 0
...enable-admin-developer-research-catalog.md | 0
...73000-separate-recipe-input-items-in-ui.md | 0
...iquidity-bot-orders-and-add-determinist.md | 0
...185800-fix-diagnostics-missing-items-di.md | 0
...public-links-route-and-bot-cancel-input.md | 0
...hparams-hooks-in-suspense-for-web-build.md | 0
...estrict-release-workflow-to-main-branch.md | 0
...x-dev-prisma-lock-and-phase-feedback-ux.md | 0
...-prisma-generate-raw-without-dotenv-cli.md | 0
...-buildings-definitions-response-parsing.md | 0
...rt-legacy-buildings-definitions-payload.md | 0
CHANGELOG.md | 40 +++++++++++++++++++
SECURITY.md | 2 +-
package.json | 2 +-
38 files changed, 42 insertions(+), 2 deletions(-)
rename .releases/{unreleased => released/v0.10.0}/20260218092513-fix-github-username-in-release-notes.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260218131646-fix-ux-data-visibility-issues.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260218155700-add-building-infrastructure-phase-1.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260219091824-fix-github-oauth-redirect-url.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260219112748-add-buildings-management-ui-with-preflight-valid.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222153300-single-origin-sso-auth-routing.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222154900-run-migrations-in-all-role-startup.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222162747-restrict-market-views-to-active-company-tradable.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222163851-sanitize-dokploy-nginx-docs-to-example-domains-a.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222164042-remove-legacy-api-subdomain-blocks-from-nginx-sa.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222164444-add-alpha-as-is-disclaimer-next-to-footer-versio.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222164758-show-alpha-disclaimer-on-version-hover-and-overv.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222165119-remove-hover-helper-label-from-version-badge.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222165251-remove-maintenance-overlay-focus-ring-rectangle.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222165400-hide-seeded-example-accounts-in-admin.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222165446-make-alpha-version-tag-clickable-to-discord-and-.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222165916-load-discord-link-from-runtime-meta-config-for-a.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222170600-allow-admin-developer-read-endpoints.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222170810-replace-static-onboarding-tutorial-with-guided-c.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222171500-handle-redacted-company-cash-in-web-parsers.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222171544-clarify-overview-metrics-and-tutorial-copy-as-wo.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222171905-start-guided-tutorial-with-active-company-snapsh.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222172500-enable-admin-developer-research-catalog.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222173000-separate-recipe-input-items-in-ui.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222174455-refresh-liquidity-bot-orders-and-add-determinist.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222185800-fix-diagnostics-missing-items-di.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222192000-fix-typecheck-for-public-links-route-and-bot-cancel-input.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222195000-wrap-searchparams-hooks-in-suspense-for-web-build.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222202100-restrict-release-workflow-to-main-branch.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222211500-fix-dev-prisma-lock-and-phase-feedback-ux.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222213000-fix-prisma-generate-raw-without-dotenv-cli.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222221000-fix-buildings-definitions-response-parsing.md (100%)
rename .releases/{unreleased => released/v0.10.0}/20260222222800-support-legacy-buildings-definitions-payload.md (100%)
diff --git a/.releases/unreleased/20260218092513-fix-github-username-in-release-notes.md b/.releases/released/v0.10.0/20260218092513-fix-github-username-in-release-notes.md
similarity index 100%
rename from .releases/unreleased/20260218092513-fix-github-username-in-release-notes.md
rename to .releases/released/v0.10.0/20260218092513-fix-github-username-in-release-notes.md
diff --git a/.releases/unreleased/20260218131646-fix-ux-data-visibility-issues.md b/.releases/released/v0.10.0/20260218131646-fix-ux-data-visibility-issues.md
similarity index 100%
rename from .releases/unreleased/20260218131646-fix-ux-data-visibility-issues.md
rename to .releases/released/v0.10.0/20260218131646-fix-ux-data-visibility-issues.md
diff --git a/.releases/unreleased/20260218155700-add-building-infrastructure-phase-1.md b/.releases/released/v0.10.0/20260218155700-add-building-infrastructure-phase-1.md
similarity index 100%
rename from .releases/unreleased/20260218155700-add-building-infrastructure-phase-1.md
rename to .releases/released/v0.10.0/20260218155700-add-building-infrastructure-phase-1.md
diff --git a/.releases/unreleased/20260219091824-fix-github-oauth-redirect-url.md b/.releases/released/v0.10.0/20260219091824-fix-github-oauth-redirect-url.md
similarity index 100%
rename from .releases/unreleased/20260219091824-fix-github-oauth-redirect-url.md
rename to .releases/released/v0.10.0/20260219091824-fix-github-oauth-redirect-url.md
diff --git a/.releases/unreleased/20260219112748-add-buildings-management-ui-with-preflight-valid.md b/.releases/released/v0.10.0/20260219112748-add-buildings-management-ui-with-preflight-valid.md
similarity index 100%
rename from .releases/unreleased/20260219112748-add-buildings-management-ui-with-preflight-valid.md
rename to .releases/released/v0.10.0/20260219112748-add-buildings-management-ui-with-preflight-valid.md
diff --git a/.releases/unreleased/20260222153300-single-origin-sso-auth-routing.md b/.releases/released/v0.10.0/20260222153300-single-origin-sso-auth-routing.md
similarity index 100%
rename from .releases/unreleased/20260222153300-single-origin-sso-auth-routing.md
rename to .releases/released/v0.10.0/20260222153300-single-origin-sso-auth-routing.md
diff --git a/.releases/unreleased/20260222154900-run-migrations-in-all-role-startup.md b/.releases/released/v0.10.0/20260222154900-run-migrations-in-all-role-startup.md
similarity index 100%
rename from .releases/unreleased/20260222154900-run-migrations-in-all-role-startup.md
rename to .releases/released/v0.10.0/20260222154900-run-migrations-in-all-role-startup.md
diff --git a/.releases/unreleased/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md b/.releases/released/v0.10.0/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md
similarity index 100%
rename from .releases/unreleased/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md
rename to .releases/released/v0.10.0/20260222161716-centralize-item-quantity-rendering-and-apply-qua.md
diff --git a/.releases/unreleased/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md b/.releases/released/v0.10.0/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md
similarity index 100%
rename from .releases/unreleased/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md
rename to .releases/released/v0.10.0/20260222162512-fix-market-unknown-item-labels-by-using-global-i.md
diff --git a/.releases/unreleased/20260222162747-restrict-market-views-to-active-company-tradable.md b/.releases/released/v0.10.0/20260222162747-restrict-market-views-to-active-company-tradable.md
similarity index 100%
rename from .releases/unreleased/20260222162747-restrict-market-views-to-active-company-tradable.md
rename to .releases/released/v0.10.0/20260222162747-restrict-market-views-to-active-company-tradable.md
diff --git a/.releases/unreleased/20260222163851-sanitize-dokploy-nginx-docs-to-example-domains-a.md b/.releases/released/v0.10.0/20260222163851-sanitize-dokploy-nginx-docs-to-example-domains-a.md
similarity index 100%
rename from .releases/unreleased/20260222163851-sanitize-dokploy-nginx-docs-to-example-domains-a.md
rename to .releases/released/v0.10.0/20260222163851-sanitize-dokploy-nginx-docs-to-example-domains-a.md
diff --git a/.releases/unreleased/20260222164042-remove-legacy-api-subdomain-blocks-from-nginx-sa.md b/.releases/released/v0.10.0/20260222164042-remove-legacy-api-subdomain-blocks-from-nginx-sa.md
similarity index 100%
rename from .releases/unreleased/20260222164042-remove-legacy-api-subdomain-blocks-from-nginx-sa.md
rename to .releases/released/v0.10.0/20260222164042-remove-legacy-api-subdomain-blocks-from-nginx-sa.md
diff --git a/.releases/unreleased/20260222164444-add-alpha-as-is-disclaimer-next-to-footer-versio.md b/.releases/released/v0.10.0/20260222164444-add-alpha-as-is-disclaimer-next-to-footer-versio.md
similarity index 100%
rename from .releases/unreleased/20260222164444-add-alpha-as-is-disclaimer-next-to-footer-versio.md
rename to .releases/released/v0.10.0/20260222164444-add-alpha-as-is-disclaimer-next-to-footer-versio.md
diff --git a/.releases/unreleased/20260222164758-show-alpha-disclaimer-on-version-hover-and-overv.md b/.releases/released/v0.10.0/20260222164758-show-alpha-disclaimer-on-version-hover-and-overv.md
similarity index 100%
rename from .releases/unreleased/20260222164758-show-alpha-disclaimer-on-version-hover-and-overv.md
rename to .releases/released/v0.10.0/20260222164758-show-alpha-disclaimer-on-version-hover-and-overv.md
diff --git a/.releases/unreleased/20260222165119-remove-hover-helper-label-from-version-badge.md b/.releases/released/v0.10.0/20260222165119-remove-hover-helper-label-from-version-badge.md
similarity index 100%
rename from .releases/unreleased/20260222165119-remove-hover-helper-label-from-version-badge.md
rename to .releases/released/v0.10.0/20260222165119-remove-hover-helper-label-from-version-badge.md
diff --git a/.releases/unreleased/20260222165251-remove-maintenance-overlay-focus-ring-rectangle.md b/.releases/released/v0.10.0/20260222165251-remove-maintenance-overlay-focus-ring-rectangle.md
similarity index 100%
rename from .releases/unreleased/20260222165251-remove-maintenance-overlay-focus-ring-rectangle.md
rename to .releases/released/v0.10.0/20260222165251-remove-maintenance-overlay-focus-ring-rectangle.md
diff --git a/.releases/unreleased/20260222165400-hide-seeded-example-accounts-in-admin.md b/.releases/released/v0.10.0/20260222165400-hide-seeded-example-accounts-in-admin.md
similarity index 100%
rename from .releases/unreleased/20260222165400-hide-seeded-example-accounts-in-admin.md
rename to .releases/released/v0.10.0/20260222165400-hide-seeded-example-accounts-in-admin.md
diff --git a/.releases/unreleased/20260222165446-make-alpha-version-tag-clickable-to-discord-and-.md b/.releases/released/v0.10.0/20260222165446-make-alpha-version-tag-clickable-to-discord-and-.md
similarity index 100%
rename from .releases/unreleased/20260222165446-make-alpha-version-tag-clickable-to-discord-and-.md
rename to .releases/released/v0.10.0/20260222165446-make-alpha-version-tag-clickable-to-discord-and-.md
diff --git a/.releases/unreleased/20260222165916-load-discord-link-from-runtime-meta-config-for-a.md b/.releases/released/v0.10.0/20260222165916-load-discord-link-from-runtime-meta-config-for-a.md
similarity index 100%
rename from .releases/unreleased/20260222165916-load-discord-link-from-runtime-meta-config-for-a.md
rename to .releases/released/v0.10.0/20260222165916-load-discord-link-from-runtime-meta-config-for-a.md
diff --git a/.releases/unreleased/20260222170600-allow-admin-developer-read-endpoints.md b/.releases/released/v0.10.0/20260222170600-allow-admin-developer-read-endpoints.md
similarity index 100%
rename from .releases/unreleased/20260222170600-allow-admin-developer-read-endpoints.md
rename to .releases/released/v0.10.0/20260222170600-allow-admin-developer-read-endpoints.md
diff --git a/.releases/unreleased/20260222170810-replace-static-onboarding-tutorial-with-guided-c.md b/.releases/released/v0.10.0/20260222170810-replace-static-onboarding-tutorial-with-guided-c.md
similarity index 100%
rename from .releases/unreleased/20260222170810-replace-static-onboarding-tutorial-with-guided-c.md
rename to .releases/released/v0.10.0/20260222170810-replace-static-onboarding-tutorial-with-guided-c.md
diff --git a/.releases/unreleased/20260222171500-handle-redacted-company-cash-in-web-parsers.md b/.releases/released/v0.10.0/20260222171500-handle-redacted-company-cash-in-web-parsers.md
similarity index 100%
rename from .releases/unreleased/20260222171500-handle-redacted-company-cash-in-web-parsers.md
rename to .releases/released/v0.10.0/20260222171500-handle-redacted-company-cash-in-web-parsers.md
diff --git a/.releases/unreleased/20260222171544-clarify-overview-metrics-and-tutorial-copy-as-wo.md b/.releases/released/v0.10.0/20260222171544-clarify-overview-metrics-and-tutorial-copy-as-wo.md
similarity index 100%
rename from .releases/unreleased/20260222171544-clarify-overview-metrics-and-tutorial-copy-as-wo.md
rename to .releases/released/v0.10.0/20260222171544-clarify-overview-metrics-and-tutorial-copy-as-wo.md
diff --git a/.releases/unreleased/20260222171905-start-guided-tutorial-with-active-company-snapsh.md b/.releases/released/v0.10.0/20260222171905-start-guided-tutorial-with-active-company-snapsh.md
similarity index 100%
rename from .releases/unreleased/20260222171905-start-guided-tutorial-with-active-company-snapsh.md
rename to .releases/released/v0.10.0/20260222171905-start-guided-tutorial-with-active-company-snapsh.md
diff --git a/.releases/unreleased/20260222172500-enable-admin-developer-research-catalog.md b/.releases/released/v0.10.0/20260222172500-enable-admin-developer-research-catalog.md
similarity index 100%
rename from .releases/unreleased/20260222172500-enable-admin-developer-research-catalog.md
rename to .releases/released/v0.10.0/20260222172500-enable-admin-developer-research-catalog.md
diff --git a/.releases/unreleased/20260222173000-separate-recipe-input-items-in-ui.md b/.releases/released/v0.10.0/20260222173000-separate-recipe-input-items-in-ui.md
similarity index 100%
rename from .releases/unreleased/20260222173000-separate-recipe-input-items-in-ui.md
rename to .releases/released/v0.10.0/20260222173000-separate-recipe-input-items-in-ui.md
diff --git a/.releases/unreleased/20260222174455-refresh-liquidity-bot-orders-and-add-determinist.md b/.releases/released/v0.10.0/20260222174455-refresh-liquidity-bot-orders-and-add-determinist.md
similarity index 100%
rename from .releases/unreleased/20260222174455-refresh-liquidity-bot-orders-and-add-determinist.md
rename to .releases/released/v0.10.0/20260222174455-refresh-liquidity-bot-orders-and-add-determinist.md
diff --git a/.releases/unreleased/20260222185800-fix-diagnostics-missing-items-di.md b/.releases/released/v0.10.0/20260222185800-fix-diagnostics-missing-items-di.md
similarity index 100%
rename from .releases/unreleased/20260222185800-fix-diagnostics-missing-items-di.md
rename to .releases/released/v0.10.0/20260222185800-fix-diagnostics-missing-items-di.md
diff --git a/.releases/unreleased/20260222192000-fix-typecheck-for-public-links-route-and-bot-cancel-input.md b/.releases/released/v0.10.0/20260222192000-fix-typecheck-for-public-links-route-and-bot-cancel-input.md
similarity index 100%
rename from .releases/unreleased/20260222192000-fix-typecheck-for-public-links-route-and-bot-cancel-input.md
rename to .releases/released/v0.10.0/20260222192000-fix-typecheck-for-public-links-route-and-bot-cancel-input.md
diff --git a/.releases/unreleased/20260222195000-wrap-searchparams-hooks-in-suspense-for-web-build.md b/.releases/released/v0.10.0/20260222195000-wrap-searchparams-hooks-in-suspense-for-web-build.md
similarity index 100%
rename from .releases/unreleased/20260222195000-wrap-searchparams-hooks-in-suspense-for-web-build.md
rename to .releases/released/v0.10.0/20260222195000-wrap-searchparams-hooks-in-suspense-for-web-build.md
diff --git a/.releases/unreleased/20260222202100-restrict-release-workflow-to-main-branch.md b/.releases/released/v0.10.0/20260222202100-restrict-release-workflow-to-main-branch.md
similarity index 100%
rename from .releases/unreleased/20260222202100-restrict-release-workflow-to-main-branch.md
rename to .releases/released/v0.10.0/20260222202100-restrict-release-workflow-to-main-branch.md
diff --git a/.releases/unreleased/20260222211500-fix-dev-prisma-lock-and-phase-feedback-ux.md b/.releases/released/v0.10.0/20260222211500-fix-dev-prisma-lock-and-phase-feedback-ux.md
similarity index 100%
rename from .releases/unreleased/20260222211500-fix-dev-prisma-lock-and-phase-feedback-ux.md
rename to .releases/released/v0.10.0/20260222211500-fix-dev-prisma-lock-and-phase-feedback-ux.md
diff --git a/.releases/unreleased/20260222213000-fix-prisma-generate-raw-without-dotenv-cli.md b/.releases/released/v0.10.0/20260222213000-fix-prisma-generate-raw-without-dotenv-cli.md
similarity index 100%
rename from .releases/unreleased/20260222213000-fix-prisma-generate-raw-without-dotenv-cli.md
rename to .releases/released/v0.10.0/20260222213000-fix-prisma-generate-raw-without-dotenv-cli.md
diff --git a/.releases/unreleased/20260222221000-fix-buildings-definitions-response-parsing.md b/.releases/released/v0.10.0/20260222221000-fix-buildings-definitions-response-parsing.md
similarity index 100%
rename from .releases/unreleased/20260222221000-fix-buildings-definitions-response-parsing.md
rename to .releases/released/v0.10.0/20260222221000-fix-buildings-definitions-response-parsing.md
diff --git a/.releases/unreleased/20260222222800-support-legacy-buildings-definitions-payload.md b/.releases/released/v0.10.0/20260222222800-support-legacy-buildings-definitions-payload.md
similarity index 100%
rename from .releases/unreleased/20260222222800-support-legacy-buildings-definitions-payload.md
rename to .releases/released/v0.10.0/20260222222800-support-legacy-buildings-definitions-payload.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5aba99d4..05a60b16 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -261,3 +261,43 @@ All notable changes to CorpSim are documented in this file.
- [ops] Add APP_IMAGE_BYPASS_TAG flag to force preview image builds from latest commit
- [ops] Fix SSO authentication in production by wiring backend runtime env vars and frontend build-time visibility flags
+
+## 0.10.0 - 2026-02-22
+
+### What's Changed
+
+- [web] Fix early UX and data visibility issues (forms, time display, workforce clarity, completion feedback)
+- [sim] Add building infrastructure domain layer for capital-based production system
+- [web,api,sim] Add Buildings Management UI with preflight validation and acquisition flows (Phase 4 & 5)
+- [web] Replace static onboarding tutorial with guided cross-page walkthrough
+- [ci] Use GitHub's built-in release notes generation
+- [web] Fix OAuth callback redirect URLs for GitHub, Microsoft, and Discord by configuring nginx proxy and BETTER_AUTH_URL
+- [web, api] Support single-origin SSO by proxying auth routes and preferring web origin for auth base URL
+- [ci] Run Prisma migrations before launching services in APP_ROLE=all mode
+- [web] Centralize item quantity rendering and apply quantifier labels to recipe outputs
+- [web] Fix market unknown item labels by using global item metadata
+- [web] Restrict market views to active company tradable items
+- [ops] Sanitize Dokploy/nginx docs to example domains and RFC 5737 IPs
+- [ops] Remove legacy API subdomain blocks from nginx sample
+- [web] Add ALPHA as-is disclaimer next to footer version badge
+- [web] Show ALPHA disclaimer on version hover and overview with Discord link
+- [web] Remove hover helper label from version badge
+- [web] Remove maintenance overlay focus ring rectangle
+- [web] Hide seeded example.com accounts from admin user listing
+- [web] Make ALPHA version tag clickable to Discord and share URL resolver
+- [web] Load Discord link from runtime meta config for alpha notices
+- [api, web] Allow admin accounts to access developer page read endpoints
+- [web] Accept optional redacted company cash fields in API parsers
+- [web] Clarify overview metrics and tutorial copy as world-level values
+- [web] Start guided tutorial with active company snapshot before world KPIs
+- [api, web] Enable admin access to developer research catalog without player ownership
+- [web] Improve recipe input readability with explicit separators and quantity labels
+- [sim] Refresh liquidity bot orders and add deterministic crossing to prevent zero-trade stalls
+- [api] Fix diagnostics missing-items endpoint DI so missing item logs can be created
+- [ci] Fix typecheck failures in web public-links route import and bot order cancellation input
+- [web] Wrap search-params dependent layout clients in Suspense to fix Next build prerendering
+- [ci] Restrict release workflow to main branch for both automatic and manual runs
+- [web] Fix local Prisma lock races and restore Buildings/research feedback visibility
+- [db] Run Prisma generate without relying on dotenv-cli shell binary
+- [web] Fix buildings definitions API response parsing in web client
+- [web] Accept legacy object-shaped buildings definitions payloads
diff --git a/SECURITY.md b/SECURITY.md
index 8bcb1bd0..fe0834ce 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -6,7 +6,7 @@ CorpSim is pre-1.0. Only the latest released version line is supported for secur
| Version | Supported |
| --- | --- |
-| `0.9.x` and newer release lines | Yes |
+| `0.10.x` and newer release lines | Yes |
| Older versions | No |
## Reporting a Vulnerability
diff --git a/package.json b/package.json
index c18482e4..92a529ae 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "corpsim",
- "version": "0.9.1",
+ "version": "0.10.0",
"private": true,
"workspaces": [
"apps/*",
From 4fc55d5ac40039476fe2d62da531a8df5f795eb8 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Mon, 23 Feb 2026 00:19:55 +0100
Subject: [PATCH 51/52] fix(db): sync static catalog for production deployments
---
...200-auto-sync-static-catalog-on-startup.md | 9 +
package.json | 1 +
packages/db/src/seed-world.ts | 239 ++++++++++++++++++
scripts/sim-sync-static.ts | 26 ++
scripts/start-container.sh | 29 +++
5 files changed, 304 insertions(+)
create mode 100644 .releases/unreleased/20260222232200-auto-sync-static-catalog-on-startup.md
create mode 100644 scripts/sim-sync-static.ts
diff --git a/.releases/unreleased/20260222232200-auto-sync-static-catalog-on-startup.md b/.releases/unreleased/20260222232200-auto-sync-static-catalog-on-startup.md
new file mode 100644
index 00000000..d614d151
--- /dev/null
+++ b/.releases/unreleased/20260222232200-auto-sync-static-catalog-on-startup.md
@@ -0,0 +1,9 @@
+---
+type: patch
+area: db
+summary: Add idempotent static catalog sync and run it on container startup
+---
+
+- Add `syncStaticCatalog` to upsert items, recipes, recipe inputs, research nodes, unlock links, and prerequisites without resetting world state.
+- Ensure missing `CompanyRecipe` links are created for existing companies so newly added recipes become available.
+- Add `pnpm sim:sync-static` and wire startup to run catalog sync automatically in `APP_ROLE=all` (or when `CORPSIM_SYNC_STATIC_DATA_ON_START=true`).
diff --git a/package.json b/package.json
index 92a529ae..af61f30a 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,7 @@
"sim:advance": "tsx scripts/sim-advance.ts",
"sim:reset": "tsx scripts/sim-reset.ts",
"sim:seed": "pnpm -C packages/db seed",
+ "sim:sync-static": "tsx scripts/sim-sync-static.ts",
"sim:stats": "tsx scripts/sim-stats.ts",
"release:entry": "node scripts/release-entry.mjs",
"release:check": "node scripts/check-release-entry.mjs",
diff --git a/packages/db/src/seed-world.ts b/packages/db/src/seed-world.ts
index d7746f74..b00b070f 100644
--- a/packages/db/src/seed-world.ts
+++ b/packages/db/src/seed-world.ts
@@ -577,6 +577,245 @@ function isRecipeAutoUnlocked(recipeCode: string): boolean {
);
}
+function chunkArray(rows: T[], chunkSize: number): T[][] {
+ if (rows.length === 0) {
+ return [];
+ }
+
+ const chunks: T[][] = [];
+ for (let index = 0; index < rows.length; index += chunkSize) {
+ chunks.push(rows.slice(index, index + chunkSize));
+ }
+ return chunks;
+}
+
+export interface SyncStaticCatalogResult {
+ itemsSynced: number;
+ recipesSynced: number;
+ researchNodesSynced: number;
+ prerequisitesSynced: number;
+ companyRecipeLinksCreated: number;
+}
+
+export async function syncStaticCatalog(prisma: PrismaClient): Promise {
+ return prisma.$transaction(async (tx) => {
+ const itemsByKey: Record = {};
+
+ for (const definition of ITEM_DEFINITIONS) {
+ const item = await tx.item.upsert({
+ where: { code: definition.code },
+ update: { name: definition.name },
+ create: {
+ code: definition.code,
+ name: definition.name
+ }
+ });
+ itemsByKey[definition.key] = item;
+ }
+
+ const recipesByKey: Record = {};
+ for (const definition of RECIPE_DEFINITIONS) {
+ const outputItem = itemsByKey[definition.outputItemKey];
+ if (!outputItem) {
+ throw new Error(`seed recipe ${definition.code} references unknown output item key ${definition.outputItemKey}`);
+ }
+
+ const recipe = await tx.recipe.upsert({
+ where: { code: definition.code },
+ update: {
+ name: definition.name,
+ durationTicks: definition.durationTicks,
+ outputItemId: outputItem.id,
+ outputQuantity: definition.outputQuantity
+ },
+ create: {
+ code: definition.code,
+ name: definition.name,
+ durationTicks: definition.durationTicks,
+ outputItemId: outputItem.id,
+ outputQuantity: definition.outputQuantity
+ }
+ });
+
+ recipesByKey[definition.key] = recipe;
+
+ const expectedInputItemIds: string[] = [];
+ for (const input of definition.inputs) {
+ const inputItem = itemsByKey[input.itemKey];
+ if (!inputItem) {
+ throw new Error(`seed recipe ${definition.code} references unknown input item key ${input.itemKey}`);
+ }
+
+ expectedInputItemIds.push(inputItem.id);
+
+ await tx.recipeInput.upsert({
+ where: {
+ recipeId_itemId: {
+ recipeId: recipe.id,
+ itemId: inputItem.id
+ }
+ },
+ update: {
+ quantity: input.quantity
+ },
+ create: {
+ recipeId: recipe.id,
+ itemId: inputItem.id,
+ quantity: input.quantity
+ }
+ });
+ }
+
+ if (expectedInputItemIds.length === 0) {
+ await tx.recipeInput.deleteMany({
+ where: {
+ recipeId: recipe.id
+ }
+ });
+ } else {
+ await tx.recipeInput.deleteMany({
+ where: {
+ recipeId: recipe.id,
+ itemId: {
+ notIn: expectedInputItemIds
+ }
+ }
+ });
+ }
+ }
+
+ const researchNodesByKey: Record = {};
+ for (const definition of RESEARCH_DEFINITIONS) {
+ const node = await tx.researchNode.upsert({
+ where: { code: definition.code },
+ update: {
+ name: definition.name,
+ description: definition.description,
+ costCashCents: definition.costCashCents,
+ durationTicks: definition.durationTicks
+ },
+ create: {
+ code: definition.code,
+ name: definition.name,
+ description: definition.description,
+ costCashCents: definition.costCashCents,
+ durationTicks: definition.durationTicks
+ }
+ });
+
+ researchNodesByKey[definition.key] = node;
+
+ const expectedUnlockRecipeIds: string[] = [];
+ for (const recipeKey of definition.unlockRecipeKeys) {
+ const recipe = recipesByKey[recipeKey];
+ if (!recipe) {
+ throw new Error(`research node ${definition.code} references unknown recipe key ${recipeKey}`);
+ }
+ expectedUnlockRecipeIds.push(recipe.id);
+ await tx.researchNodeUnlockRecipe.upsert({
+ where: {
+ nodeId_recipeId: {
+ nodeId: node.id,
+ recipeId: recipe.id
+ }
+ },
+ update: {},
+ create: {
+ nodeId: node.id,
+ recipeId: recipe.id
+ }
+ });
+ }
+
+ if (expectedUnlockRecipeIds.length === 0) {
+ await tx.researchNodeUnlockRecipe.deleteMany({
+ where: {
+ nodeId: node.id
+ }
+ });
+ } else {
+ await tx.researchNodeUnlockRecipe.deleteMany({
+ where: {
+ nodeId: node.id,
+ recipeId: {
+ notIn: expectedUnlockRecipeIds
+ }
+ }
+ });
+ }
+ }
+
+ await tx.researchPrerequisite.deleteMany();
+ await tx.researchPrerequisite.createMany({
+ data: RESEARCH_PREREQUISITES.map((entry) => {
+ const node = researchNodesByKey[entry.nodeKey];
+ const prerequisiteNode = researchNodesByKey[entry.prerequisiteKey];
+ if (!node) {
+ throw new Error(`research prerequisite references unknown node key ${entry.nodeKey}`);
+ }
+ if (!prerequisiteNode) {
+ throw new Error(`research prerequisite references unknown prerequisite key ${entry.prerequisiteKey}`);
+ }
+ return {
+ nodeId: node.id,
+ prerequisiteNodeId: prerequisiteNode.id
+ };
+ })
+ });
+
+ const allRecipes = Object.values(recipesByKey);
+ const autoUnlockedRecipeIdSet = new Set(
+ allRecipes.filter((recipe) => isRecipeAutoUnlocked(recipe.code)).map((recipe) => recipe.id)
+ );
+
+ let companyRecipeLinksCreated = 0;
+ if (allRecipes.length > 0) {
+ const companies = await tx.company.findMany({
+ select: { id: true }
+ });
+
+ for (const company of companies) {
+ const rows = allRecipes.map((recipe) => ({
+ companyId: company.id,
+ recipeId: recipe.id,
+ isUnlocked: autoUnlockedRecipeIdSet.has(recipe.id)
+ }));
+
+ for (const batch of chunkArray(rows, 1000)) {
+ const created = await tx.companyRecipe.createMany({
+ data: batch,
+ skipDuplicates: true
+ });
+ companyRecipeLinksCreated += created.count;
+ }
+ }
+
+ const autoUnlockedRecipeIds = Array.from(autoUnlockedRecipeIdSet);
+ if (autoUnlockedRecipeIds.length > 0) {
+ await tx.companyRecipe.updateMany({
+ where: {
+ recipeId: {
+ in: autoUnlockedRecipeIds
+ },
+ isUnlocked: false
+ },
+ data: {
+ isUnlocked: true
+ }
+ });
+ }
+ }
+
+ return {
+ itemsSynced: ITEM_DEFINITIONS.length,
+ recipesSynced: RECIPE_DEFINITIONS.length,
+ researchNodesSynced: RESEARCH_DEFINITIONS.length,
+ prerequisitesSynced: RESEARCH_PREREQUISITES.length,
+ companyRecipeLinksCreated
+ };
+ });
+}
+
export async function seedWorld(
prisma: PrismaClient,
options: SeedWorldOptions = {}
diff --git a/scripts/sim-sync-static.ts b/scripts/sim-sync-static.ts
new file mode 100644
index 00000000..7949ec4b
--- /dev/null
+++ b/scripts/sim-sync-static.ts
@@ -0,0 +1,26 @@
+import { createPrismaClient, syncStaticCatalog } from "@corpsim/db";
+
+async function main(): Promise {
+ const prisma = createPrismaClient();
+
+ try {
+ const result = await syncStaticCatalog(prisma);
+ console.log(
+ [
+ "Static catalog sync complete.",
+ `Items: ${result.itemsSynced}`,
+ `Recipes: ${result.recipesSynced}`,
+ `Research nodes: ${result.researchNodesSynced}`,
+ `Prerequisites: ${result.prerequisitesSynced}`,
+ `Company recipe links created: ${result.companyRecipeLinksCreated}`
+ ].join(" ")
+ );
+ } finally {
+ await prisma.$disconnect();
+ }
+}
+
+main().catch((error: unknown) => {
+ console.error("Static catalog sync failed", error);
+ process.exitCode = 1;
+});
diff --git a/scripts/start-container.sh b/scripts/start-container.sh
index 05b58288..599fb48e 100644
--- a/scripts/start-container.sh
+++ b/scripts/start-container.sh
@@ -36,9 +36,37 @@ apply_migrations() {
pnpm exec prisma migrate deploy --schema packages/db/prisma/schema.prisma
}
+is_truthy() {
+ local value="${1:-}"
+ local normalized
+ normalized="$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')"
+ [[ "$normalized" == "1" || "$normalized" == "true" || "$normalized" == "yes" || "$normalized" == "on" ]]
+}
+
+should_sync_static_catalog() {
+ local configured="${CORPSIM_SYNC_STATIC_DATA_ON_START:-}"
+ if [[ -n "$configured" ]]; then
+ is_truthy "$configured"
+ return $?
+ fi
+
+ if [[ "$role" == "all" ]]; then
+ return 0
+ fi
+
+ return 1
+}
+
+sync_static_catalog() {
+ if should_sync_static_catalog; then
+ pnpm sim:sync-static
+ fi
+}
+
run_all() {
# Ensure schema is current before starting long-running processes in single-container mode.
apply_migrations
+ sync_static_catalog
pnpm --filter @corpsim/api start &
api_pid=$!
@@ -59,6 +87,7 @@ run_all() {
case "$role" in
api)
+ sync_static_catalog
run_api
;;
web)
From ea9c76c21371a0ebddfcf4a71f023d0440afde78 Mon Sep 17 00:00:00 2001
From: BENZOOgataga
Date: Mon, 23 Feb 2026 00:21:25 +0100
Subject: [PATCH 52/52] chore(release): cut v0.10.1
---
.../20260222232200-auto-sync-static-catalog-on-startup.md | 0
CHANGELOG.md | 6 ++++++
package.json | 2 +-
3 files changed, 7 insertions(+), 1 deletion(-)
rename .releases/{unreleased => released/v0.10.1}/20260222232200-auto-sync-static-catalog-on-startup.md (100%)
diff --git a/.releases/unreleased/20260222232200-auto-sync-static-catalog-on-startup.md b/.releases/released/v0.10.1/20260222232200-auto-sync-static-catalog-on-startup.md
similarity index 100%
rename from .releases/unreleased/20260222232200-auto-sync-static-catalog-on-startup.md
rename to .releases/released/v0.10.1/20260222232200-auto-sync-static-catalog-on-startup.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 05a60b16..b8e23509 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -301,3 +301,9 @@ All notable changes to CorpSim are documented in this file.
- [db] Run Prisma generate without relying on dotenv-cli shell binary
- [web] Fix buildings definitions API response parsing in web client
- [web] Accept legacy object-shaped buildings definitions payloads
+
+## 0.10.1 - 2026-02-22
+
+### What's Changed
+
+- [db] Add idempotent static catalog sync and run it on container startup
diff --git a/package.json b/package.json
index af61f30a..ef267bd0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "corpsim",
- "version": "0.10.0",
+ "version": "0.10.1",
"private": true,
"workspaces": [
"apps/*",