burnrate 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +198 -27
- package/dist/cli/index.js +0 -0
- package/dist/cli/setup.js +1 -1
- package/dist/core/async-engine.d.ts +22 -0
- package/dist/core/async-engine.js +105 -35
- package/dist/core/types.d.ts +6 -1
- package/dist/core/types.js +28 -6
- package/dist/db/turso-database.d.ts +10 -1
- package/dist/db/turso-database.js +67 -7
- package/dist/server/api.js +46 -11
- package/dist/server/async-tick-server.js +8 -1
- package/dist/server/validation.d.ts +28 -28
- package/dist/server/validation.js +14 -1
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -41,55 +41,51 @@ The players who learn to work WITH Claude—analyzing intel, optimizing routes,
|
|
|
41
41
|
|
|
42
42
|
## Quick Start
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
**One command to set up, then you're in.**
|
|
45
45
|
|
|
46
46
|
```bash
|
|
47
47
|
npx burnrate setup
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
- Ask for the game server URL (defaults to `https://api.burnrate.cc`)
|
|
52
|
-
- Optionally validate your API key
|
|
53
|
-
- Write your Claude Code MCP settings automatically
|
|
54
|
-
- Verify the connection
|
|
50
|
+
The setup wizard connects to the live server, auto-configures your Claude Code MCP settings, and verifies the connection.
|
|
55
51
|
|
|
56
|
-
Restart Claude Code
|
|
52
|
+
**Restart Claude Code**, then tell Claude:
|
|
57
53
|
|
|
58
54
|
```
|
|
59
55
|
Use burnrate_join to create a character named "YourName"
|
|
60
56
|
```
|
|
61
57
|
|
|
62
|
-
|
|
58
|
+
You'll get an API key. Run `npx burnrate setup` again and paste it in, or manually add `"BURNRATE_API_KEY": "your-key"` to the env block in `~/.claude/settings.json`. Restart Claude Code one more time, and you're set.
|
|
59
|
+
|
|
60
|
+
### Setup from Source (Alternative)
|
|
61
|
+
|
|
62
|
+
If you want to contribute or run a local server:
|
|
63
63
|
|
|
64
|
-
1. Clone and build:
|
|
65
64
|
```bash
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
npm
|
|
65
|
+
git clone https://github.com/burnrate-cc/burnrate.git ~/burnrate
|
|
66
|
+
cd ~/burnrate && npm install && npm run build
|
|
67
|
+
npm run setup
|
|
69
68
|
```
|
|
70
69
|
|
|
71
|
-
|
|
70
|
+
### Manual Config (Alternative)
|
|
71
|
+
|
|
72
|
+
Add this directly to `~/.claude/settings.json`:
|
|
73
|
+
|
|
72
74
|
```json
|
|
73
75
|
{
|
|
74
76
|
"mcpServers": {
|
|
75
77
|
"burnrate": {
|
|
76
|
-
"command": "
|
|
77
|
-
"args": ["
|
|
78
|
+
"command": "npx",
|
|
79
|
+
"args": ["-y", "burnrate", "start"],
|
|
78
80
|
"env": {
|
|
79
|
-
"BURNRATE_API_URL": "https://api.
|
|
81
|
+
"BURNRATE_API_URL": "https://burnrate-api-server-production.up.railway.app",
|
|
82
|
+
"BURNRATE_API_KEY": "your-key-here"
|
|
80
83
|
}
|
|
81
84
|
}
|
|
82
85
|
}
|
|
83
86
|
}
|
|
84
87
|
```
|
|
85
88
|
|
|
86
|
-
3. Restart Claude Code and join:
|
|
87
|
-
```
|
|
88
|
-
Use burnrate_join to create a character named "YourName"
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
You'll receive an API key. **Save it!** Then add `"BURNRATE_API_KEY": "your-key-here"` to your env config and restart Claude Code.
|
|
92
|
-
|
|
93
89
|
### Start Playing
|
|
94
90
|
|
|
95
91
|
```
|
|
@@ -103,10 +99,10 @@ New players start with a 5-step tutorial that teaches core mechanics. Use `burnr
|
|
|
103
99
|
## Core Concepts
|
|
104
100
|
|
|
105
101
|
### The Burn
|
|
106
|
-
Every controlled zone consumes **Supply Units (SU)** each tick. If supply runs out, the zone collapses and becomes neutral. Winners are the factions that keep their zones fed while starving enemies.
|
|
102
|
+
Every controlled zone consumes **Supply Units (SU)** each tick. If supply runs out, the zone collapses and becomes neutral. Winners are the players and factions that keep their zones fed while starving enemies. You can play solo or join a faction — solo players can capture and hold zones independently, though factions make sustained logistics far more manageable.
|
|
107
103
|
|
|
108
104
|
### Credits
|
|
109
|
-
Credits are the in-game currency. You start with
|
|
105
|
+
Credits are the in-game currency. You start with 500 and earn more through contracts, trading, and zone income. Spend them on:
|
|
110
106
|
- Extraction (5 credits per raw resource unit)
|
|
111
107
|
- Licenses (500-2000 credits)
|
|
112
108
|
- Hiring units from other players
|
|
@@ -127,6 +123,27 @@ grain, fiber rations, textiles parts, comms
|
|
|
127
123
|
- **Fronts** - Contested territory, high burn
|
|
128
124
|
- **Strongholds** - Victory objectives, highest burn
|
|
129
125
|
|
|
126
|
+
### Field Resources
|
|
127
|
+
Each Field zone produces a specific raw resource based on its name:
|
|
128
|
+
| Field Name Pattern | Resource |
|
|
129
|
+
|-------------------|----------|
|
|
130
|
+
| Mine | ore |
|
|
131
|
+
| Refinery | fuel |
|
|
132
|
+
| Farm | grain |
|
|
133
|
+
| Grove | fiber |
|
|
134
|
+
|
|
135
|
+
### Shipment Types
|
|
136
|
+
| Type | Capacity | Requirements |
|
|
137
|
+
|------|----------|-------------|
|
|
138
|
+
| Courier | 100 total units | Free (default license) |
|
|
139
|
+
| Freight | 500 total units | 50 rep + 500cr license |
|
|
140
|
+
| Convoy | 2000 total units | 200 rep + 2000cr license |
|
|
141
|
+
|
|
142
|
+
Cargo is specified per-resource. Only include resources you're shipping (others default to 0):
|
|
143
|
+
```json
|
|
144
|
+
{ "ore": 50, "fuel": 20 }
|
|
145
|
+
```
|
|
146
|
+
|
|
130
147
|
### Intel Decay
|
|
131
148
|
Intelligence gathered through scanning decays over time:
|
|
132
149
|
- **Fresh** (<10 ticks) - Full accuracy
|
|
@@ -161,11 +178,31 @@ Well-supplied zones gain battlefield bonuses. Supply states:
|
|
|
161
178
|
- **Medkits**: Deposit to zone → boosts escort strength in combat (up to +50%). Decays 1 per 10 ticks.
|
|
162
179
|
- **Comms**: Deposit to zone → degrades enemy scan quality (up to -50%). Decays 1 per 20 ticks.
|
|
163
180
|
|
|
164
|
-
Compliance streaks (consecutive ticks at
|
|
181
|
+
**Compliance streaks** (consecutive ticks at full supply) multiply a zone's season-end score value:
|
|
182
|
+
|
|
183
|
+
| Streak | Multiplier |
|
|
184
|
+
|--------|-----------|
|
|
185
|
+
| 0-4 ticks | 1.0x |
|
|
186
|
+
| 5-19 ticks | 1.2x |
|
|
187
|
+
| 20-49 ticks | 1.5x |
|
|
188
|
+
| 50-99 ticks | 2.0x |
|
|
189
|
+
| 100+ ticks | 3.0x |
|
|
190
|
+
|
|
191
|
+
### Zone Income
|
|
192
|
+
Owned zones generate **credits per tick** distributed to the owner (solo player) or split among faction members:
|
|
193
|
+
|
|
194
|
+
| Zone Type | Credits/tick |
|
|
195
|
+
|-----------|-------------|
|
|
196
|
+
| Field | 5 |
|
|
197
|
+
| Factory | 10 |
|
|
198
|
+
| Front | 25 |
|
|
199
|
+
| Stronghold | 50 |
|
|
200
|
+
|
|
201
|
+
This rewards sustained territory control — hoarding until season end is suboptimal compared to capturing and holding zones early.
|
|
165
202
|
|
|
166
203
|
### Seasons
|
|
167
204
|
The game runs in seasons (4 weeks each). Earn points through:
|
|
168
|
-
- Controlling zones (100 pts/zone
|
|
205
|
+
- Controlling zones at season end (100 pts/zone, multiplied by compliance streak)
|
|
169
206
|
- Completing shipments (10 pts each)
|
|
170
207
|
- Fulfilling contracts (25 pts each)
|
|
171
208
|
- Delivering supplies (1 pt per SU)
|
|
@@ -193,6 +230,117 @@ Operator+ players can register HTTPS webhooks to receive real-time notifications
|
|
|
193
230
|
- **Analytics** (Operator+, officer+) — Member activity, zone control summary, and resource flow tracking from audit logs.
|
|
194
231
|
- **Audit Logs** (Command) — Full history of all faction member actions for accountability and coordination.
|
|
195
232
|
|
|
233
|
+
## REST API Reference
|
|
234
|
+
|
|
235
|
+
All authenticated endpoints require the `X-API-Key` header. Get your key from `POST /join`.
|
|
236
|
+
|
|
237
|
+
### Public Endpoints
|
|
238
|
+
| Method | Path | Description |
|
|
239
|
+
|--------|------|-------------|
|
|
240
|
+
| `GET` | `/` | Server info and endpoint summary |
|
|
241
|
+
| `GET` | `/health` | Health check with tick info |
|
|
242
|
+
| `GET` | `/world/status` | World overview (tick, season, zone/faction counts) |
|
|
243
|
+
| `POST` | `/join` | Create account `{ "name": "YourName" }` → returns API key |
|
|
244
|
+
|
|
245
|
+
### Player & Navigation
|
|
246
|
+
| Method | Path | Description |
|
|
247
|
+
|--------|------|-------------|
|
|
248
|
+
| `GET` | `/me` | Your status, inventory, location |
|
|
249
|
+
| `GET` | `/world/zones` | All zones (id, name, type, owner, supply) |
|
|
250
|
+
| `GET` | `/world/zones/:id` | Zone details with connections and market |
|
|
251
|
+
| `GET` | `/routes` | Routes from current location (or `?from=zoneId`) |
|
|
252
|
+
| `POST` | `/travel` | Move to adjacent zone `{ "to": "zone-id" }` |
|
|
253
|
+
|
|
254
|
+
### Economy
|
|
255
|
+
| Method | Path | Description |
|
|
256
|
+
|--------|------|-------------|
|
|
257
|
+
| `POST` | `/extract` | Extract resources at a Field `{ "quantity": 10 }` |
|
|
258
|
+
| `POST` | `/produce` | Produce at a Factory `{ "output": "metal", "quantity": 5 }` |
|
|
259
|
+
| `POST` | `/ship` | Create shipment `{ "type", "path", "cargo" }` |
|
|
260
|
+
| `GET` | `/shipments` | Your active shipments |
|
|
261
|
+
| `POST` | `/market/order` | Place market order `{ "resource", "side", "price", "quantity" }` |
|
|
262
|
+
| `GET` | `/market/orders` | Orders at your location (optional `?resource=ore`) |
|
|
263
|
+
| `GET` | `/market/units` | Units for sale at your location |
|
|
264
|
+
|
|
265
|
+
### Military & Intel
|
|
266
|
+
| Method | Path | Description |
|
|
267
|
+
|--------|------|-------------|
|
|
268
|
+
| `GET` | `/units` | Your units |
|
|
269
|
+
| `POST` | `/units/:id/escort` | Assign escort `{ "shipmentId": "..." }` |
|
|
270
|
+
| `POST` | `/units/:id/raider` | Deploy raider `{ "routeId": "..." }` |
|
|
271
|
+
| `POST` | `/units/:id/sell` | List unit for sale `{ "price": 100 }` |
|
|
272
|
+
| `POST` | `/hire/:unitId` | Purchase a unit |
|
|
273
|
+
| `POST` | `/scan` | Scan zone/route `{ "targetType", "targetId" }` |
|
|
274
|
+
| `GET` | `/intel` | Your intel reports (optional `?limit=100`) |
|
|
275
|
+
| `GET` | `/intel/:targetType/:targetId` | Intel on specific target |
|
|
276
|
+
|
|
277
|
+
### Territory
|
|
278
|
+
| Method | Path | Description |
|
|
279
|
+
|--------|------|-------------|
|
|
280
|
+
| `POST` | `/supply` | Deposit SU to zone `{ "amount": 5 }` |
|
|
281
|
+
| `POST` | `/capture` | Capture neutral/collapsed zone |
|
|
282
|
+
| `POST` | `/stockpile` | Deposit medkits/comms `{ "resource", "amount" }` |
|
|
283
|
+
| `GET` | `/zone/:zoneId/efficiency` | Zone bonuses and supply state |
|
|
284
|
+
|
|
285
|
+
### Factions
|
|
286
|
+
| Method | Path | Description |
|
|
287
|
+
|--------|------|-------------|
|
|
288
|
+
| `GET` | `/factions` | List all factions |
|
|
289
|
+
| `POST` | `/factions` | Create faction `{ "name", "tag" }` |
|
|
290
|
+
| `POST` | `/factions/:id/join` | Join a faction |
|
|
291
|
+
| `POST` | `/factions/leave` | Leave current faction |
|
|
292
|
+
| `GET` | `/factions/mine` | Your faction details |
|
|
293
|
+
| `GET` | `/factions/intel` | Shared faction intel |
|
|
294
|
+
| `POST` | `/factions/members/:id/promote` | Promote member |
|
|
295
|
+
| `POST` | `/factions/members/:id/demote` | Demote member |
|
|
296
|
+
| `DELETE` | `/factions/members/:id` | Kick member |
|
|
297
|
+
| `POST` | `/factions/transfer-leadership` | Transfer ownership |
|
|
298
|
+
| `POST` | `/factions/treasury/deposit` | Deposit to treasury |
|
|
299
|
+
| `POST` | `/factions/treasury/withdraw` | Withdraw from treasury |
|
|
300
|
+
|
|
301
|
+
### Contracts
|
|
302
|
+
| Method | Path | Description |
|
|
303
|
+
|--------|------|-------------|
|
|
304
|
+
| `GET` | `/contracts` | Open contracts |
|
|
305
|
+
| `GET` | `/contracts/mine` | Your posted/accepted contracts |
|
|
306
|
+
| `POST` | `/contracts` | Create contract |
|
|
307
|
+
| `POST` | `/contracts/:id/accept` | Accept contract |
|
|
308
|
+
| `POST` | `/contracts/:id/complete` | Complete contract |
|
|
309
|
+
| `DELETE` | `/contracts/:id` | Cancel contract |
|
|
310
|
+
|
|
311
|
+
### Progression & Seasons
|
|
312
|
+
| Method | Path | Description |
|
|
313
|
+
|--------|------|-------------|
|
|
314
|
+
| `GET` | `/reputation` | Your rep score and title |
|
|
315
|
+
| `GET` | `/licenses` | License status |
|
|
316
|
+
| `POST` | `/licenses/:type/unlock` | Unlock a license |
|
|
317
|
+
| `GET` | `/events` | Event history (optional `?type=&limit=`) |
|
|
318
|
+
| `GET` | `/tutorial` | Tutorial progress |
|
|
319
|
+
| `POST` | `/tutorial/complete` | Complete tutorial step `{ "step": 1 }` |
|
|
320
|
+
| `GET` | `/season` | Current season info |
|
|
321
|
+
| `GET` | `/leaderboard` | Rankings (optional `?type=player&limit=50`) |
|
|
322
|
+
| `GET` | `/season/me` | Your season score |
|
|
323
|
+
| `GET` | `/subscription` | Your tier and limits |
|
|
324
|
+
|
|
325
|
+
### Advanced (Tier-Gated)
|
|
326
|
+
| Method | Path | Description |
|
|
327
|
+
|--------|------|-------------|
|
|
328
|
+
| `POST` | `/market/conditional` | Conditional order (Operator+) |
|
|
329
|
+
| `POST` | `/market/time-weighted` | Time-weighted order (Command) |
|
|
330
|
+
| `GET` | `/webhooks` | Your webhooks (Operator+) |
|
|
331
|
+
| `POST` | `/webhooks` | Register webhook (Operator+) |
|
|
332
|
+
| `DELETE` | `/webhooks/:id` | Delete webhook |
|
|
333
|
+
| `GET` | `/me/export` | Export all your data |
|
|
334
|
+
| `POST` | `/batch` | Batch operations (max 10) |
|
|
335
|
+
| `GET` | `/faction/analytics` | Faction analytics (Operator+) |
|
|
336
|
+
| `GET` | `/faction/audit` | Audit logs (Command) |
|
|
337
|
+
| `GET` | `/doctrines` | Faction doctrines |
|
|
338
|
+
| `POST` | `/doctrines` | Create doctrine (officer+) |
|
|
339
|
+
| `PUT` | `/doctrines/:id` | Update doctrine (officer+) |
|
|
340
|
+
| `DELETE` | `/doctrines/:id` | Delete doctrine (officer+) |
|
|
341
|
+
|
|
342
|
+
---
|
|
343
|
+
|
|
196
344
|
## MCP Tools Reference
|
|
197
345
|
|
|
198
346
|
### Status & Navigation
|
|
@@ -498,6 +646,29 @@ curl -H "X-Admin-Key: your-secret" http://localhost:3000/admin/players?sort=acti
|
|
|
498
646
|
curl -H "X-Admin-Key: your-secret" http://localhost:3000/admin/activity?limit=50
|
|
499
647
|
```
|
|
500
648
|
|
|
649
|
+
## Development & Deployment
|
|
650
|
+
|
|
651
|
+
### Branch Strategy
|
|
652
|
+
|
|
653
|
+
| Branch | Purpose | Deploys to |
|
|
654
|
+
|--------|---------|------------|
|
|
655
|
+
| `main` | Production | Railway production environment |
|
|
656
|
+
| `dev` | Testing | Railway development environment (separate Turso DB) |
|
|
657
|
+
|
|
658
|
+
**Workflow:**
|
|
659
|
+
1. Create a feature branch from `dev`
|
|
660
|
+
2. Open a PR into `dev` — test on the dev environment
|
|
661
|
+
3. When verified, open a PR from `dev` into `main` — deploys to production
|
|
662
|
+
|
|
663
|
+
### Zero-Downtime Deploys
|
|
664
|
+
|
|
665
|
+
Production deploys are safe mid-season:
|
|
666
|
+
|
|
667
|
+
- **API server**: Railway does rolling deploys — new instance starts, passes health check, old one drains. No downtime.
|
|
668
|
+
- **Tick server**: Uses idempotent tick claiming — if two instances overlap during a deploy, only one processes each tick. The guard prevents double-processing by checking `last_tick_at` timestamp before incrementing.
|
|
669
|
+
|
|
670
|
+
The admin `POST /admin/tick` endpoint bypasses the idempotency guard (for manual tick advancement during testing).
|
|
671
|
+
|
|
501
672
|
## License
|
|
502
673
|
|
|
503
674
|
MIT
|
package/dist/cli/index.js
CHANGED
|
File without changes
|
package/dist/cli/setup.js
CHANGED
|
@@ -29,7 +29,7 @@ async function main() {
|
|
|
29
29
|
╚══════════════════════════════════════════════════════════════╝
|
|
30
30
|
`);
|
|
31
31
|
// 1. Get API URL
|
|
32
|
-
const apiUrl = await ask('API server URL', 'https://api.
|
|
32
|
+
const apiUrl = await ask('API server URL', 'https://burnrate-api-server-production.up.railway.app');
|
|
33
33
|
// 2. Get API key (optional — can join later)
|
|
34
34
|
const apiKey = await ask('API key (press Enter to skip — you can join in-game later)', '');
|
|
35
35
|
// 3. Validate connection
|
|
@@ -7,11 +7,22 @@ import { Player, Shipment, Unit, MarketOrder, Contract, GameEvent, getZoneEffici
|
|
|
7
7
|
export declare class AsyncGameEngine {
|
|
8
8
|
private db;
|
|
9
9
|
constructor(db: TursoDatabase);
|
|
10
|
+
/**
|
|
11
|
+
* Try to process the next tick with idempotency guard.
|
|
12
|
+
* Returns null if another instance already processed this tick recently.
|
|
13
|
+
* Used by the tick server to prevent double-processing during deploys.
|
|
14
|
+
*/
|
|
15
|
+
tryProcessTick(minIntervalSeconds: number): Promise<{
|
|
16
|
+
tick: number;
|
|
17
|
+
events: GameEvent[];
|
|
18
|
+
} | null>;
|
|
10
19
|
processTick(): Promise<{
|
|
11
20
|
tick: number;
|
|
12
21
|
events: GameEvent[];
|
|
13
22
|
}>;
|
|
23
|
+
private _processTickWork;
|
|
14
24
|
private processSupplyBurn;
|
|
25
|
+
private distributeZoneIncome;
|
|
15
26
|
private processShipments;
|
|
16
27
|
private completeShipment;
|
|
17
28
|
private checkInterception;
|
|
@@ -25,27 +36,32 @@ export declare class AsyncGameEngine {
|
|
|
25
36
|
canPlayerAct(playerId: string): Promise<{
|
|
26
37
|
allowed: boolean;
|
|
27
38
|
reason?: string;
|
|
39
|
+
code?: string;
|
|
28
40
|
}>;
|
|
29
41
|
recordPlayerAction(playerId: string): Promise<void>;
|
|
30
42
|
createShipmentWithPath(playerId: string, type: 'courier' | 'freight' | 'convoy', path: string[], cargo: Partial<Inventory>): Promise<{
|
|
31
43
|
success: boolean;
|
|
32
44
|
shipment?: Shipment;
|
|
33
45
|
error?: string;
|
|
46
|
+
code?: string;
|
|
34
47
|
}>;
|
|
35
48
|
placeOrder(playerId: string, zoneId: string, resource: Resource, side: 'buy' | 'sell', price: number, quantity: number): Promise<{
|
|
36
49
|
success: boolean;
|
|
37
50
|
order?: MarketOrder;
|
|
38
51
|
error?: string;
|
|
52
|
+
code?: string;
|
|
39
53
|
}>;
|
|
40
54
|
private matchOrders;
|
|
41
55
|
private executeTrade;
|
|
42
56
|
depositSU(playerId: string, zoneId: string, amount: number): Promise<{
|
|
43
57
|
success: boolean;
|
|
44
58
|
error?: string;
|
|
59
|
+
code?: string;
|
|
45
60
|
}>;
|
|
46
61
|
depositStockpile(playerId: string, zoneId: string, resource: 'medkits' | 'comms', amount: number): Promise<{
|
|
47
62
|
success: boolean;
|
|
48
63
|
error?: string;
|
|
64
|
+
code?: string;
|
|
49
65
|
}>;
|
|
50
66
|
getZoneEfficiency(zoneId: string): Promise<{
|
|
51
67
|
success: boolean;
|
|
@@ -56,12 +72,14 @@ export declare class AsyncGameEngine {
|
|
|
56
72
|
success: boolean;
|
|
57
73
|
intel?: any;
|
|
58
74
|
error?: string;
|
|
75
|
+
code?: string;
|
|
59
76
|
}>;
|
|
60
77
|
produce(playerId: string, output: string, quantity: number): Promise<{
|
|
61
78
|
success: boolean;
|
|
62
79
|
produced?: number;
|
|
63
80
|
units?: Unit[];
|
|
64
81
|
error?: string;
|
|
82
|
+
code?: string;
|
|
65
83
|
}>;
|
|
66
84
|
extract(playerId: string, quantity: number): Promise<{
|
|
67
85
|
success: boolean;
|
|
@@ -70,10 +88,12 @@ export declare class AsyncGameEngine {
|
|
|
70
88
|
amount: number;
|
|
71
89
|
};
|
|
72
90
|
error?: string;
|
|
91
|
+
code?: string;
|
|
73
92
|
}>;
|
|
74
93
|
captureZone(playerId: string, zoneId: string): Promise<{
|
|
75
94
|
success: boolean;
|
|
76
95
|
error?: string;
|
|
96
|
+
code?: string;
|
|
77
97
|
}>;
|
|
78
98
|
assignEscort(playerId: string, unitId: string, shipmentId: string): Promise<{
|
|
79
99
|
success: boolean;
|
|
@@ -91,6 +111,7 @@ export declare class AsyncGameEngine {
|
|
|
91
111
|
success: boolean;
|
|
92
112
|
unit?: Unit;
|
|
93
113
|
error?: string;
|
|
114
|
+
code?: string;
|
|
94
115
|
}>;
|
|
95
116
|
deployRaider(playerId: string, unitId: string, routeId: string): Promise<{
|
|
96
117
|
success: boolean;
|
|
@@ -305,6 +326,7 @@ export declare class AsyncGameEngine {
|
|
|
305
326
|
travel(playerId: string, toZoneId: string): Promise<{
|
|
306
327
|
success: boolean;
|
|
307
328
|
error?: string;
|
|
329
|
+
code?: string;
|
|
308
330
|
}>;
|
|
309
331
|
private findRoute;
|
|
310
332
|
private recordEvent;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* BURNRATE Async Game Engine
|
|
3
3
|
* Multiplayer-ready engine using TursoDatabase
|
|
4
4
|
*/
|
|
5
|
-
import { getSupplyState, getZoneEfficiency, SHIPMENT_SPECS, SU_RECIPE, TIER_LIMITS, RECIPES, getFieldResource, FACTION_PERMISSIONS, LICENSE_REQUIREMENTS, REPUTATION_REWARDS, getReputationTitle, SEASON_CONFIG, TUTORIAL_CONTRACTS } from './types.js';
|
|
5
|
+
import { getSupplyState, getZoneEfficiency, SHIPMENT_SPECS, SU_RECIPE, TIER_LIMITS, RECIPES, getFieldResource, FACTION_PERMISSIONS, LICENSE_REQUIREMENTS, REPUTATION_REWARDS, getReputationTitle, SEASON_CONFIG, TUTORIAL_CONTRACTS, ZONE_INCOME, getStreakMultiplier } from './types.js';
|
|
6
6
|
export class AsyncGameEngine {
|
|
7
7
|
db;
|
|
8
8
|
constructor(db) {
|
|
@@ -11,8 +11,22 @@ export class AsyncGameEngine {
|
|
|
11
11
|
// ============================================================================
|
|
12
12
|
// TICK PROCESSING
|
|
13
13
|
// ============================================================================
|
|
14
|
+
/**
|
|
15
|
+
* Try to process the next tick with idempotency guard.
|
|
16
|
+
* Returns null if another instance already processed this tick recently.
|
|
17
|
+
* Used by the tick server to prevent double-processing during deploys.
|
|
18
|
+
*/
|
|
19
|
+
async tryProcessTick(minIntervalSeconds) {
|
|
20
|
+
const tick = await this.db.tryClaimTick(minIntervalSeconds);
|
|
21
|
+
if (tick === null)
|
|
22
|
+
return null;
|
|
23
|
+
return this._processTickWork(tick);
|
|
24
|
+
}
|
|
14
25
|
async processTick() {
|
|
15
26
|
const tick = await this.db.incrementTick();
|
|
27
|
+
return this._processTickWork(tick);
|
|
28
|
+
}
|
|
29
|
+
async _processTickWork(tick) {
|
|
16
30
|
const events = [];
|
|
17
31
|
events.push(await this.recordEvent('tick', null, 'system', { tick }));
|
|
18
32
|
const burnEvents = await this.processSupplyBurn(tick);
|
|
@@ -47,7 +61,23 @@ export class AsyncGameEngine {
|
|
|
47
61
|
await this.db.advanceWeek();
|
|
48
62
|
}
|
|
49
63
|
else {
|
|
50
|
-
// End of season -
|
|
64
|
+
// End of season - tally zone control scores with streak multipliers, then reset
|
|
65
|
+
const zones = await this.db.getAllZones();
|
|
66
|
+
for (const zone of zones) {
|
|
67
|
+
if (!zone.ownerId)
|
|
68
|
+
continue;
|
|
69
|
+
const multiplier = getStreakMultiplier(zone.complianceStreak);
|
|
70
|
+
const zonePoints = Math.floor(multiplier);
|
|
71
|
+
// Award zonesControlled score (multiplied by streak) to the owning faction
|
|
72
|
+
await this.db.incrementSeasonScore(season.seasonNumber, zone.ownerId, 'faction', (await this.db.getFaction(zone.ownerId))?.name || 'Unknown', 'zonesControlled', zonePoints);
|
|
73
|
+
events.push(await this.recordEvent('season_zone_scored', zone.ownerId, 'faction', {
|
|
74
|
+
zoneName: zone.name,
|
|
75
|
+
zoneType: zone.type,
|
|
76
|
+
complianceStreak: zone.complianceStreak,
|
|
77
|
+
streakMultiplier: multiplier,
|
|
78
|
+
pointsAwarded: zonePoints * SEASON_CONFIG.scoring.zonesControlled
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
51
81
|
const newSeasonNumber = season.seasonNumber + 1;
|
|
52
82
|
await this.db.seasonReset(newSeasonNumber);
|
|
53
83
|
events.push(await this.recordEvent('tick', null, 'system', {
|
|
@@ -119,8 +149,57 @@ export class AsyncGameEngine {
|
|
|
119
149
|
}
|
|
120
150
|
}
|
|
121
151
|
}
|
|
152
|
+
// Distribute credit income from owned zones to faction members
|
|
153
|
+
await this.distributeZoneIncome(zones, events);
|
|
122
154
|
return events;
|
|
123
155
|
}
|
|
156
|
+
async distributeZoneIncome(zones, events) {
|
|
157
|
+
// Aggregate income per owner (faction or solo player)
|
|
158
|
+
const ownerIncome = new Map();
|
|
159
|
+
for (const zone of zones) {
|
|
160
|
+
if (!zone.ownerId)
|
|
161
|
+
continue;
|
|
162
|
+
const income = ZONE_INCOME[zone.type];
|
|
163
|
+
if (income <= 0)
|
|
164
|
+
continue;
|
|
165
|
+
ownerIncome.set(zone.ownerId, (ownerIncome.get(zone.ownerId) || 0) + income);
|
|
166
|
+
}
|
|
167
|
+
// Distribute income — check if owner is a faction or a solo player
|
|
168
|
+
for (const [ownerId, totalIncome] of ownerIncome) {
|
|
169
|
+
const faction = await this.db.getFaction(ownerId);
|
|
170
|
+
if (faction && faction.members.length > 0) {
|
|
171
|
+
// Faction-owned: split among members
|
|
172
|
+
const perMember = Math.floor(totalIncome / faction.members.length);
|
|
173
|
+
if (perMember <= 0)
|
|
174
|
+
continue;
|
|
175
|
+
for (const member of faction.members) {
|
|
176
|
+
const player = await this.db.getPlayer(member.playerId);
|
|
177
|
+
if (!player)
|
|
178
|
+
continue;
|
|
179
|
+
const newInventory = { ...player.inventory, credits: player.inventory.credits + perMember };
|
|
180
|
+
await this.db.updatePlayer(player.id, { inventory: newInventory });
|
|
181
|
+
}
|
|
182
|
+
events.push(await this.recordEvent('zone_income', ownerId, 'faction', {
|
|
183
|
+
factionName: faction.name,
|
|
184
|
+
totalIncome,
|
|
185
|
+
perMember,
|
|
186
|
+
memberCount: faction.members.length
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
// Solo player-owned: full income to the player
|
|
191
|
+
const player = await this.db.getPlayer(ownerId);
|
|
192
|
+
if (!player)
|
|
193
|
+
continue;
|
|
194
|
+
const newInventory = { ...player.inventory, credits: player.inventory.credits + totalIncome };
|
|
195
|
+
await this.db.updatePlayer(player.id, { inventory: newInventory });
|
|
196
|
+
events.push(await this.recordEvent('zone_income', ownerId, 'player', {
|
|
197
|
+
playerName: player.name,
|
|
198
|
+
totalIncome
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
124
203
|
// ============================================================================
|
|
125
204
|
// SHIPMENT PROCESSING
|
|
126
205
|
// ============================================================================
|
|
@@ -131,7 +210,7 @@ export class AsyncGameEngine {
|
|
|
131
210
|
const newTicks = shipment.ticksToNextZone - 1;
|
|
132
211
|
if (newTicks <= 0) {
|
|
133
212
|
const newPosition = shipment.currentPosition + 1;
|
|
134
|
-
if (newPosition >= shipment.path.length) {
|
|
213
|
+
if (newPosition >= shipment.path.length - 1) {
|
|
135
214
|
await this.completeShipment(shipment, tick, events);
|
|
136
215
|
}
|
|
137
216
|
else {
|
|
@@ -224,17 +303,7 @@ export class AsyncGameEngine {
|
|
|
224
303
|
return Math.random() < interceptionChance;
|
|
225
304
|
}
|
|
226
305
|
async getRaidersOnRoute(routeId) {
|
|
227
|
-
|
|
228
|
-
const raiders = [];
|
|
229
|
-
for (const player of allPlayers) {
|
|
230
|
-
const units = await this.db.getPlayerUnits(player.id);
|
|
231
|
-
for (const unit of units) {
|
|
232
|
-
if (unit.type === 'raider' && unit.assignmentId === routeId) {
|
|
233
|
-
raiders.push(unit);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
return raiders;
|
|
306
|
+
return this.db.getUnitsByAssignment(routeId, 'raider');
|
|
238
307
|
}
|
|
239
308
|
async interceptShipment(shipment, tick, events) {
|
|
240
309
|
const fromZoneId = shipment.path[shipment.currentPosition];
|
|
@@ -436,14 +505,14 @@ export class AsyncGameEngine {
|
|
|
436
505
|
async canPlayerAct(playerId) {
|
|
437
506
|
const player = await this.db.getPlayer(playerId);
|
|
438
507
|
if (!player)
|
|
439
|
-
return { allowed: false, reason: 'Player not found' };
|
|
508
|
+
return { allowed: false, reason: 'Player not found', code: 'PLAYER_NOT_FOUND' };
|
|
440
509
|
const tick = await this.db.getCurrentTick();
|
|
441
510
|
const limits = TIER_LIMITS[player.tier];
|
|
442
511
|
if (player.actionsToday >= limits.dailyActions) {
|
|
443
|
-
return { allowed: false, reason: `Daily action limit (${limits.dailyActions}) reached
|
|
512
|
+
return { allowed: false, reason: `Daily action limit (${limits.dailyActions}) reached`, code: 'DAILY_LIMIT_REACHED' };
|
|
444
513
|
}
|
|
445
514
|
if (player.lastActionTick >= tick) {
|
|
446
|
-
return { allowed: false, reason: 'Rate limited. Wait for next tick.' };
|
|
515
|
+
return { allowed: false, reason: 'Rate limited. Wait for next tick.', code: 'TICK_RATE_LIMITED' };
|
|
447
516
|
}
|
|
448
517
|
return { allowed: true };
|
|
449
518
|
}
|
|
@@ -460,7 +529,7 @@ export class AsyncGameEngine {
|
|
|
460
529
|
async createShipmentWithPath(playerId, type, path, cargo) {
|
|
461
530
|
const canAct = await this.canPlayerAct(playerId);
|
|
462
531
|
if (!canAct.allowed)
|
|
463
|
-
return { success: false, error: canAct.reason };
|
|
532
|
+
return { success: false, error: canAct.reason, code: canAct.code };
|
|
464
533
|
const player = await this.db.getPlayer(playerId);
|
|
465
534
|
if (!player)
|
|
466
535
|
return { success: false, error: 'Player not found' };
|
|
@@ -527,7 +596,7 @@ export class AsyncGameEngine {
|
|
|
527
596
|
async placeOrder(playerId, zoneId, resource, side, price, quantity) {
|
|
528
597
|
const canAct = await this.canPlayerAct(playerId);
|
|
529
598
|
if (!canAct.allowed)
|
|
530
|
-
return { success: false, error: canAct.reason };
|
|
599
|
+
return { success: false, error: canAct.reason, code: canAct.code };
|
|
531
600
|
const player = await this.db.getPlayer(playerId);
|
|
532
601
|
if (!player)
|
|
533
602
|
return { success: false, error: 'Player not found' };
|
|
@@ -622,7 +691,7 @@ export class AsyncGameEngine {
|
|
|
622
691
|
async depositSU(playerId, zoneId, amount) {
|
|
623
692
|
const canAct = await this.canPlayerAct(playerId);
|
|
624
693
|
if (!canAct.allowed)
|
|
625
|
-
return { success: false, error: canAct.reason };
|
|
694
|
+
return { success: false, error: canAct.reason, code: canAct.code };
|
|
626
695
|
const player = await this.db.getPlayer(playerId);
|
|
627
696
|
if (!player)
|
|
628
697
|
return { success: false, error: 'Player not found' };
|
|
@@ -661,7 +730,7 @@ export class AsyncGameEngine {
|
|
|
661
730
|
async depositStockpile(playerId, zoneId, resource, amount) {
|
|
662
731
|
const canAct = await this.canPlayerAct(playerId);
|
|
663
732
|
if (!canAct.allowed)
|
|
664
|
-
return { success: false, error: canAct.reason };
|
|
733
|
+
return { success: false, error: canAct.reason, code: canAct.code };
|
|
665
734
|
const player = await this.db.getPlayer(playerId);
|
|
666
735
|
if (!player)
|
|
667
736
|
return { success: false, error: 'Player not found' };
|
|
@@ -703,7 +772,7 @@ export class AsyncGameEngine {
|
|
|
703
772
|
async scan(playerId, targetType, targetId) {
|
|
704
773
|
const canAct = await this.canPlayerAct(playerId);
|
|
705
774
|
if (!canAct.allowed)
|
|
706
|
-
return { success: false, error: canAct.reason };
|
|
775
|
+
return { success: false, error: canAct.reason, code: canAct.code };
|
|
707
776
|
const player = await this.db.getPlayer(playerId);
|
|
708
777
|
if (!player)
|
|
709
778
|
return { success: false, error: 'Player not found' };
|
|
@@ -784,7 +853,7 @@ export class AsyncGameEngine {
|
|
|
784
853
|
async produce(playerId, output, quantity) {
|
|
785
854
|
const canAct = await this.canPlayerAct(playerId);
|
|
786
855
|
if (!canAct.allowed)
|
|
787
|
-
return { success: false, error: canAct.reason };
|
|
856
|
+
return { success: false, error: canAct.reason, code: canAct.code };
|
|
788
857
|
const player = await this.db.getPlayer(playerId);
|
|
789
858
|
if (!player)
|
|
790
859
|
return { success: false, error: 'Player not found' };
|
|
@@ -854,7 +923,7 @@ export class AsyncGameEngine {
|
|
|
854
923
|
async extract(playerId, quantity) {
|
|
855
924
|
const canAct = await this.canPlayerAct(playerId);
|
|
856
925
|
if (!canAct.allowed)
|
|
857
|
-
return { success: false, error: canAct.reason };
|
|
926
|
+
return { success: false, error: canAct.reason, code: canAct.code };
|
|
858
927
|
const player = await this.db.getPlayer(playerId);
|
|
859
928
|
if (!player)
|
|
860
929
|
return { success: false, error: 'Player not found' };
|
|
@@ -894,22 +963,21 @@ export class AsyncGameEngine {
|
|
|
894
963
|
async captureZone(playerId, zoneId) {
|
|
895
964
|
const canAct = await this.canPlayerAct(playerId);
|
|
896
965
|
if (!canAct.allowed)
|
|
897
|
-
return { success: false, error: canAct.reason };
|
|
966
|
+
return { success: false, error: canAct.reason, code: canAct.code };
|
|
898
967
|
const player = await this.db.getPlayer(playerId);
|
|
899
968
|
if (!player)
|
|
900
969
|
return { success: false, error: 'Player not found' };
|
|
901
|
-
if (!player.factionId) {
|
|
902
|
-
return { success: false, error: 'Must be in a faction to capture zones' };
|
|
903
|
-
}
|
|
904
970
|
const zone = await this.db.getZone(zoneId);
|
|
905
971
|
if (!zone)
|
|
906
972
|
return { success: false, error: 'Zone not found' };
|
|
907
973
|
if (player.locationId !== zoneId) {
|
|
908
974
|
return { success: false, error: 'Must be at the zone to capture it' };
|
|
909
975
|
}
|
|
976
|
+
// Owner is faction if in one, otherwise the player directly
|
|
977
|
+
const newOwnerId = player.factionId || player.id;
|
|
910
978
|
if (zone.ownerId) {
|
|
911
|
-
if (zone.ownerId ===
|
|
912
|
-
return { success: false, error: 'Zone already controlled by
|
|
979
|
+
if (zone.ownerId === newOwnerId) {
|
|
980
|
+
return { success: false, error: 'Zone already controlled by you' };
|
|
913
981
|
}
|
|
914
982
|
// Front efficiency: capture defense check
|
|
915
983
|
const efficiency = getZoneEfficiency(zone.supplyLevel, zone.complianceStreak, zone.medkitStockpile, zone.commsStockpile);
|
|
@@ -919,18 +987,20 @@ export class AsyncGameEngine {
|
|
|
919
987
|
}
|
|
920
988
|
}
|
|
921
989
|
await this.db.updateZone(zoneId, {
|
|
922
|
-
ownerId:
|
|
990
|
+
ownerId: newOwnerId,
|
|
923
991
|
supplyLevel: 0,
|
|
924
992
|
complianceStreak: 0,
|
|
925
993
|
medkitStockpile: 0,
|
|
926
994
|
commsStockpile: 0
|
|
927
995
|
});
|
|
928
996
|
await this.recordPlayerAction(playerId);
|
|
929
|
-
|
|
997
|
+
const actorType = player.factionId ? 'faction' : 'player';
|
|
998
|
+
const actorId = player.factionId || player.id;
|
|
999
|
+
await this.recordEvent('zone_captured', actorId, actorType, {
|
|
930
1000
|
zoneId,
|
|
931
1001
|
zoneName: zone.name,
|
|
932
1002
|
previousOwner: zone.ownerId,
|
|
933
|
-
newOwner:
|
|
1003
|
+
newOwner: newOwnerId,
|
|
934
1004
|
capturedBy: playerId
|
|
935
1005
|
});
|
|
936
1006
|
return { success: true };
|
|
@@ -998,7 +1068,7 @@ export class AsyncGameEngine {
|
|
|
998
1068
|
async hireUnit(playerId, unitId) {
|
|
999
1069
|
const canAct = await this.canPlayerAct(playerId);
|
|
1000
1070
|
if (!canAct.allowed)
|
|
1001
|
-
return { success: false, error: canAct.reason };
|
|
1071
|
+
return { success: false, error: canAct.reason, code: canAct.code };
|
|
1002
1072
|
const player = await this.db.getPlayer(playerId);
|
|
1003
1073
|
if (!player)
|
|
1004
1074
|
return { success: false, error: 'Player not found' };
|
|
@@ -1800,7 +1870,7 @@ export class AsyncGameEngine {
|
|
|
1800
1870
|
async travel(playerId, toZoneId) {
|
|
1801
1871
|
const canAct = await this.canPlayerAct(playerId);
|
|
1802
1872
|
if (!canAct.allowed)
|
|
1803
|
-
return { success: false, error: canAct.reason };
|
|
1873
|
+
return { success: false, error: canAct.reason, code: canAct.code };
|
|
1804
1874
|
const player = await this.db.getPlayer(playerId);
|
|
1805
1875
|
if (!player)
|
|
1806
1876
|
return { success: false, error: 'Player not found' };
|
package/dist/core/types.d.ts
CHANGED
|
@@ -92,6 +92,11 @@ export interface ZoneEfficiency {
|
|
|
92
92
|
export declare function getZoneEfficiency(supplyLevel: number, complianceStreak: number, medkitStockpile?: number, commsStockpile?: number): ZoneEfficiency;
|
|
93
93
|
/** Burn rates by zone type */
|
|
94
94
|
export declare const BURN_RATES: Record<ZoneType, number>;
|
|
95
|
+
/** Credits generated per tick for each owned zone type (split among faction members) */
|
|
96
|
+
export declare const ZONE_INCOME: Record<ZoneType, number>;
|
|
97
|
+
/** Compliance streak multiplier for season-end zone scoring.
|
|
98
|
+
* Zones held at full supply for longer streaks are worth more. */
|
|
99
|
+
export declare function getStreakMultiplier(complianceStreak: number): number;
|
|
95
100
|
export interface Route {
|
|
96
101
|
id: string;
|
|
97
102
|
fromZoneId: string;
|
|
@@ -357,7 +362,7 @@ export interface Contract {
|
|
|
357
362
|
status: 'open' | 'active' | 'completed' | 'failed' | 'expired';
|
|
358
363
|
createdAt: number;
|
|
359
364
|
}
|
|
360
|
-
export type GameEventType = 'tick' | 'shipment_created' | 'shipment_moved' | 'shipment_arrived' | 'shipment_intercepted' | 'trade_executed' | 'order_placed' | 'order_cancelled' | 'zone_supplied' | 'zone_state_changed' | 'zone_captured' | 'combat_resolved' | 'intel_gathered' | 'contract_posted' | 'contract_accepted' | 'contract_completed' | 'contract_failed' | 'faction_created' | 'faction_joined' | 'faction_left' | 'player_action' | 'stockpile_deposited' | 'tutorial_completed' | 'doctrine_updated' | 'webhook_triggered';
|
|
365
|
+
export type GameEventType = 'tick' | 'shipment_created' | 'shipment_moved' | 'shipment_arrived' | 'shipment_intercepted' | 'trade_executed' | 'order_placed' | 'order_cancelled' | 'zone_supplied' | 'zone_state_changed' | 'zone_captured' | 'combat_resolved' | 'intel_gathered' | 'contract_posted' | 'contract_accepted' | 'contract_completed' | 'contract_failed' | 'faction_created' | 'faction_joined' | 'faction_left' | 'player_action' | 'stockpile_deposited' | 'tutorial_completed' | 'doctrine_updated' | 'webhook_triggered' | 'zone_income' | 'season_zone_scored';
|
|
361
366
|
export interface GameEvent {
|
|
362
367
|
id: string;
|
|
363
368
|
type: GameEventType;
|
package/dist/core/types.js
CHANGED
|
@@ -36,13 +36,13 @@ export const RECIPES = {
|
|
|
36
36
|
};
|
|
37
37
|
/** What raw resource a Field produces based on its name */
|
|
38
38
|
export function getFieldResource(fieldName) {
|
|
39
|
-
if (fieldName.includes('Ore'))
|
|
39
|
+
if (fieldName.includes('Mine') || fieldName.includes('Ore'))
|
|
40
40
|
return 'ore';
|
|
41
|
-
if (fieldName.includes('Fuel'))
|
|
41
|
+
if (fieldName.includes('Refinery') || fieldName.includes('Fuel'))
|
|
42
42
|
return 'fuel';
|
|
43
|
-
if (fieldName.includes('Grain'))
|
|
43
|
+
if (fieldName.includes('Farm') || fieldName.includes('Grain'))
|
|
44
44
|
return 'grain';
|
|
45
|
-
if (fieldName.includes('Fiber'))
|
|
45
|
+
if (fieldName.includes('Grove') || fieldName.includes('Fiber'))
|
|
46
46
|
return 'fiber';
|
|
47
47
|
return null;
|
|
48
48
|
}
|
|
@@ -121,6 +121,28 @@ export const BURN_RATES = {
|
|
|
121
121
|
front: 10,
|
|
122
122
|
stronghold: 20
|
|
123
123
|
};
|
|
124
|
+
/** Credits generated per tick for each owned zone type (split among faction members) */
|
|
125
|
+
export const ZONE_INCOME = {
|
|
126
|
+
hub: 0, // Hubs are neutral
|
|
127
|
+
factory: 10,
|
|
128
|
+
field: 5,
|
|
129
|
+
junction: 0, // Junctions are transit only
|
|
130
|
+
front: 25,
|
|
131
|
+
stronghold: 50
|
|
132
|
+
};
|
|
133
|
+
/** Compliance streak multiplier for season-end zone scoring.
|
|
134
|
+
* Zones held at full supply for longer streaks are worth more. */
|
|
135
|
+
export function getStreakMultiplier(complianceStreak) {
|
|
136
|
+
if (complianceStreak >= 100)
|
|
137
|
+
return 3.0;
|
|
138
|
+
if (complianceStreak >= 50)
|
|
139
|
+
return 2.0;
|
|
140
|
+
if (complianceStreak >= 20)
|
|
141
|
+
return 1.5;
|
|
142
|
+
if (complianceStreak >= 5)
|
|
143
|
+
return 1.2;
|
|
144
|
+
return 1.0;
|
|
145
|
+
}
|
|
124
146
|
export const SHIPMENT_SPECS = {
|
|
125
147
|
courier: { capacity: 10, speedModifier: 0.67, visibilityModifier: 0.5 },
|
|
126
148
|
freight: { capacity: 50, speedModifier: 1.0, visibilityModifier: 1.0 },
|
|
@@ -288,8 +310,8 @@ export function applyIntelDecay(data, freshness) {
|
|
|
288
310
|
export const TUTORIAL_CONTRACTS = [
|
|
289
311
|
{
|
|
290
312
|
step: 1,
|
|
291
|
-
title: 'First
|
|
292
|
-
description: '
|
|
313
|
+
title: 'First Extraction',
|
|
314
|
+
description: 'Travel to a Field zone and extract 10 raw resources. This teaches navigation, zone types, and resource extraction.',
|
|
293
315
|
type: 'tutorial',
|
|
294
316
|
reward: { credits: 100, reputation: 5 }
|
|
295
317
|
},
|
|
@@ -19,8 +19,16 @@ export declare class TursoDatabase {
|
|
|
19
19
|
*/
|
|
20
20
|
batch(statements: InStatement[]): Promise<void>;
|
|
21
21
|
private initSchema;
|
|
22
|
+
private runMigrations;
|
|
22
23
|
getCurrentTick(): Promise<number>;
|
|
23
24
|
incrementTick(): Promise<number>;
|
|
25
|
+
/**
|
|
26
|
+
* Try to claim the next tick. Returns the new tick number if claimed,
|
|
27
|
+
* or null if another instance already processed it recently.
|
|
28
|
+
* Uses last_tick_at as a guard — skips if a tick was processed within
|
|
29
|
+
* the given minimum interval (in seconds).
|
|
30
|
+
*/
|
|
31
|
+
tryClaimTick(minIntervalSeconds: number): Promise<number | null>;
|
|
24
32
|
getSeasonInfo(): Promise<{
|
|
25
33
|
seasonNumber: number;
|
|
26
34
|
seasonWeek: number;
|
|
@@ -61,6 +69,7 @@ export declare class TursoDatabase {
|
|
|
61
69
|
getUnit(id: string): Promise<Unit | null>;
|
|
62
70
|
getPlayerUnits(playerId: string): Promise<Unit[]>;
|
|
63
71
|
getUnitsForSaleAtZone(zoneId: string): Promise<Unit[]>;
|
|
72
|
+
getUnitsByAssignment(assignmentId: string, type?: string): Promise<Unit[]>;
|
|
64
73
|
updateUnit(id: string, updates: Partial<Unit>): Promise<void>;
|
|
65
74
|
deleteUnit(id: string): Promise<void>;
|
|
66
75
|
private rowToUnit;
|
|
@@ -122,7 +131,7 @@ export declare class TursoDatabase {
|
|
|
122
131
|
relations: Record<string, 'allied' | 'neutral' | 'war'>;
|
|
123
132
|
}>): Promise<void>;
|
|
124
133
|
getOrCreateSeasonScore(seasonNumber: number, entityId: string, entityType: 'player' | 'faction', entityName: string): Promise<SeasonScore>;
|
|
125
|
-
incrementSeasonScore(seasonNumber: number, entityId: string, entityType: 'player' | 'faction', entityName: string, field: 'supplyDelivered' | 'shipmentsCompleted' | 'contractsCompleted' | 'reputationGained' | 'combatVictories', amount: number): Promise<void>;
|
|
134
|
+
incrementSeasonScore(seasonNumber: number, entityId: string, entityType: 'player' | 'faction', entityName: string, field: 'zonesControlled' | 'supplyDelivered' | 'shipmentsCompleted' | 'contractsCompleted' | 'reputationGained' | 'combatVictories', amount: number): Promise<void>;
|
|
126
135
|
updateSeasonZoneScores(seasonNumber: number): Promise<void>;
|
|
127
136
|
getSeasonLeaderboard(seasonNumber: number, entityType?: 'player' | 'faction', limit?: number): Promise<SeasonScore[]>;
|
|
128
137
|
getEntitySeasonScore(seasonNumber: number, entityId: string): Promise<SeasonScore | null>;
|
|
@@ -51,7 +51,8 @@ export class TursoDatabase {
|
|
|
51
51
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
52
52
|
current_tick INTEGER NOT NULL DEFAULT 0,
|
|
53
53
|
season_number INTEGER NOT NULL DEFAULT 1,
|
|
54
|
-
season_week INTEGER NOT NULL DEFAULT 1
|
|
54
|
+
season_week INTEGER NOT NULL DEFAULT 1,
|
|
55
|
+
last_tick_at TEXT
|
|
55
56
|
)`,
|
|
56
57
|
// Zones
|
|
57
58
|
`CREATE TABLE IF NOT EXISTS zones (
|
|
@@ -313,12 +314,37 @@ export class TursoDatabase {
|
|
|
313
314
|
`CREATE INDEX IF NOT EXISTS idx_time_weighted_orders_status ON time_weighted_orders(status)`,
|
|
314
315
|
`CREATE INDEX IF NOT EXISTS idx_audit_logs_faction ON audit_logs(faction_id)`,
|
|
315
316
|
`CREATE INDEX IF NOT EXISTS idx_audit_logs_tick ON audit_logs(tick)`,
|
|
317
|
+
// Tier 1: Critical indexes for tick processing
|
|
318
|
+
`CREATE INDEX IF NOT EXISTS idx_shipments_status ON shipments(status)`,
|
|
319
|
+
`CREATE INDEX IF NOT EXISTS idx_units_player_id ON units(player_id)`,
|
|
320
|
+
`CREATE INDEX IF NOT EXISTS idx_units_assignment_id ON units(assignment_id)`,
|
|
321
|
+
`CREATE INDEX IF NOT EXISTS idx_routes_from_zone ON routes(from_zone_id)`,
|
|
322
|
+
`CREATE INDEX IF NOT EXISTS idx_market_orders_zone ON market_orders(zone_id, resource, side, price)`,
|
|
323
|
+
`CREATE INDEX IF NOT EXISTS idx_contracts_status ON contracts(status, created_at)`,
|
|
324
|
+
`CREATE INDEX IF NOT EXISTS idx_shipments_player_id ON shipments(player_id)`,
|
|
325
|
+
// Tier 2: High priority indexes for queries
|
|
326
|
+
`CREATE INDEX IF NOT EXISTS idx_zones_owner_id ON zones(owner_id)`,
|
|
327
|
+
`CREATE INDEX IF NOT EXISTS idx_intel_player_gathered ON intel(player_id, gathered_at)`,
|
|
328
|
+
`CREATE INDEX IF NOT EXISTS idx_intel_target ON intel(target_type, target_id, gathered_at)`,
|
|
329
|
+
`CREATE INDEX IF NOT EXISTS idx_contracts_poster ON contracts(poster_id, created_at)`,
|
|
330
|
+
`CREATE INDEX IF NOT EXISTS idx_contracts_accepted ON contracts(accepted_by, created_at)`,
|
|
316
331
|
// Initialize world state
|
|
317
332
|
`INSERT OR IGNORE INTO world (id, current_tick, season_number, season_week) VALUES (1, 0, 1, 1)`,
|
|
318
333
|
];
|
|
319
334
|
for (const sql of statements) {
|
|
320
335
|
await this.client.execute(sql);
|
|
321
336
|
}
|
|
337
|
+
// Migrations for existing databases
|
|
338
|
+
await this.runMigrations();
|
|
339
|
+
}
|
|
340
|
+
async runMigrations() {
|
|
341
|
+
// Add last_tick_at column if missing (tick idempotency for zero-downtime deploys)
|
|
342
|
+
try {
|
|
343
|
+
await this.client.execute(`ALTER TABLE world ADD COLUMN last_tick_at TEXT`);
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
// Column already exists — ignore
|
|
347
|
+
}
|
|
322
348
|
}
|
|
323
349
|
// ============================================================================
|
|
324
350
|
// WORLD STATE
|
|
@@ -328,7 +354,25 @@ export class TursoDatabase {
|
|
|
328
354
|
return result.rows[0]?.current_tick || 0;
|
|
329
355
|
}
|
|
330
356
|
async incrementTick() {
|
|
331
|
-
await this.client.execute(
|
|
357
|
+
await this.client.execute(`UPDATE world SET current_tick = current_tick + 1, last_tick_at = datetime('now') WHERE id = 1`);
|
|
358
|
+
return this.getCurrentTick();
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Try to claim the next tick. Returns the new tick number if claimed,
|
|
362
|
+
* or null if another instance already processed it recently.
|
|
363
|
+
* Uses last_tick_at as a guard — skips if a tick was processed within
|
|
364
|
+
* the given minimum interval (in seconds).
|
|
365
|
+
*/
|
|
366
|
+
async tryClaimTick(minIntervalSeconds) {
|
|
367
|
+
// Atomically check and update: only increment if enough time has passed
|
|
368
|
+
const result = await this.client.execute({
|
|
369
|
+
sql: `UPDATE world SET current_tick = current_tick + 1, last_tick_at = datetime('now')
|
|
370
|
+
WHERE id = 1 AND (last_tick_at IS NULL OR unixepoch('now') - unixepoch(last_tick_at) >= ?)`,
|
|
371
|
+
args: [minIntervalSeconds]
|
|
372
|
+
});
|
|
373
|
+
if (result.rowsAffected === 0) {
|
|
374
|
+
return null; // Another instance already processed this tick
|
|
375
|
+
}
|
|
332
376
|
return this.getCurrentTick();
|
|
333
377
|
}
|
|
334
378
|
async getSeasonInfo() {
|
|
@@ -453,17 +497,24 @@ export class TursoDatabase {
|
|
|
453
497
|
}
|
|
454
498
|
async getRoutesBetween(fromZoneId, toZoneId) {
|
|
455
499
|
const result = await this.client.execute({
|
|
456
|
-
sql: 'SELECT * FROM routes WHERE from_zone_id = ? AND to_zone_id = ?',
|
|
457
|
-
args: [fromZoneId, toZoneId]
|
|
500
|
+
sql: 'SELECT * FROM routes WHERE (from_zone_id = ? AND to_zone_id = ?) OR (from_zone_id = ? AND to_zone_id = ?)',
|
|
501
|
+
args: [fromZoneId, toZoneId, toZoneId, fromZoneId]
|
|
458
502
|
});
|
|
459
503
|
return result.rows.map(row => this.rowToRoute(row));
|
|
460
504
|
}
|
|
461
505
|
async getRoutesFromZone(zoneId) {
|
|
462
506
|
const result = await this.client.execute({
|
|
463
|
-
sql: 'SELECT * FROM routes WHERE from_zone_id = ?',
|
|
464
|
-
args: [zoneId]
|
|
507
|
+
sql: 'SELECT * FROM routes WHERE from_zone_id = ? OR to_zone_id = ?',
|
|
508
|
+
args: [zoneId, zoneId]
|
|
509
|
+
});
|
|
510
|
+
// Normalize: always make the queried zone the "from" side
|
|
511
|
+
return result.rows.map(row => {
|
|
512
|
+
const route = this.rowToRoute(row);
|
|
513
|
+
if (route.toZoneId === zoneId) {
|
|
514
|
+
return { ...route, fromZoneId: route.toZoneId, toZoneId: route.fromZoneId };
|
|
515
|
+
}
|
|
516
|
+
return route;
|
|
465
517
|
});
|
|
466
|
-
return result.rows.map(row => this.rowToRoute(row));
|
|
467
518
|
}
|
|
468
519
|
async getAllRoutes() {
|
|
469
520
|
const result = await this.client.execute('SELECT * FROM routes');
|
|
@@ -797,6 +848,14 @@ export class TursoDatabase {
|
|
|
797
848
|
});
|
|
798
849
|
return result.rows.map(row => this.rowToUnit(row));
|
|
799
850
|
}
|
|
851
|
+
async getUnitsByAssignment(assignmentId, type) {
|
|
852
|
+
const sql = type
|
|
853
|
+
? 'SELECT * FROM units WHERE assignment_id = ? AND type = ?'
|
|
854
|
+
: 'SELECT * FROM units WHERE assignment_id = ?';
|
|
855
|
+
const args = type ? [assignmentId, type] : [assignmentId];
|
|
856
|
+
const result = await this.client.execute({ sql, args });
|
|
857
|
+
return result.rows.map(row => this.rowToUnit(row));
|
|
858
|
+
}
|
|
800
859
|
async updateUnit(id, updates) {
|
|
801
860
|
const sets = [];
|
|
802
861
|
const values = [];
|
|
@@ -1214,6 +1273,7 @@ export class TursoDatabase {
|
|
|
1214
1273
|
// Ensure record exists
|
|
1215
1274
|
await this.getOrCreateSeasonScore(seasonNumber, entityId, entityType, entityName);
|
|
1216
1275
|
const fieldMap = {
|
|
1276
|
+
zonesControlled: 'zones_controlled',
|
|
1217
1277
|
supplyDelivered: 'supply_delivered',
|
|
1218
1278
|
shipmentsCompleted: 'shipments_completed',
|
|
1219
1279
|
contractsCompleted: 'contracts_completed',
|
package/dist/server/api.js
CHANGED
|
@@ -10,7 +10,7 @@ import { TursoDatabase } from '../db/turso-database.js';
|
|
|
10
10
|
import { AsyncGameEngine } from '../core/async-engine.js';
|
|
11
11
|
import { generateWorldData, mapRouteNamesToIds } from '../core/async-worldgen.js';
|
|
12
12
|
import { TIER_LIMITS } from '../core/types.js';
|
|
13
|
-
import { GameError, AuthError, ValidationError, NotFoundError, errorResponse, validateBody, ErrorCodes } from './errors.js';
|
|
13
|
+
import { GameError, AuthError, ValidationError, NotFoundError, RateLimitError, errorResponse, validateBody, ErrorCodes } from './errors.js';
|
|
14
14
|
import { JoinSchema, TravelSchema, ExtractSchema, ProduceSchema, ShipSchema, MarketOrderSchema, ScanSchema, SupplySchema, FactionCreateSchema, ContractCreateSchema } from './validation.js';
|
|
15
15
|
import { rateLimitMiddleware, writeRateLimitMiddleware } from './rate-limit.js';
|
|
16
16
|
let db;
|
|
@@ -60,6 +60,18 @@ const authMiddleware = async (c, next) => {
|
|
|
60
60
|
const getPlayer = (c) => c.get('player');
|
|
61
61
|
const getPlayerId = (c) => c.get('playerId');
|
|
62
62
|
const getRequestId = (c) => c.get('requestId');
|
|
63
|
+
// Convert engine result with code to proper GameError/RateLimitError
|
|
64
|
+
function engineErrorToGameError(result, fallbackCode, fallbackMsg) {
|
|
65
|
+
const code = result.code;
|
|
66
|
+
const msg = result.error || fallbackMsg;
|
|
67
|
+
if (code === ErrorCodes.TICK_RATE_LIMITED || code === ErrorCodes.DAILY_LIMIT_REACHED) {
|
|
68
|
+
return new RateLimitError(code, msg);
|
|
69
|
+
}
|
|
70
|
+
if (code === ErrorCodes.PLAYER_NOT_FOUND) {
|
|
71
|
+
return new NotFoundError('Player');
|
|
72
|
+
}
|
|
73
|
+
return new GameError(code || fallbackCode, msg);
|
|
74
|
+
}
|
|
63
75
|
// ============================================================================
|
|
64
76
|
// PUBLIC ENDPOINTS (no auth required)
|
|
65
77
|
// ============================================================================
|
|
@@ -68,13 +80,36 @@ app.get('/', (c) => {
|
|
|
68
80
|
name: 'BURNRATE',
|
|
69
81
|
tagline: 'The front doesn\'t feed itself.',
|
|
70
82
|
version: '1.0.0',
|
|
71
|
-
docs: '/
|
|
83
|
+
docs: 'https://github.com/burnrate-cc/burnrate#readme',
|
|
84
|
+
endpoints: {
|
|
85
|
+
health: 'GET /health',
|
|
86
|
+
join: 'POST /join { "name": "YourName" }',
|
|
87
|
+
status: 'GET /me',
|
|
88
|
+
world: 'GET /world/zones',
|
|
89
|
+
routes: 'GET /routes',
|
|
90
|
+
travel: 'POST /travel { "to": "zone-id" }',
|
|
91
|
+
extract: 'POST /extract { "quantity": 10 }',
|
|
92
|
+
produce: 'POST /produce { "output": "metal", "quantity": 5 }',
|
|
93
|
+
ship: 'POST /ship { "type": "courier", "path": [...], "cargo": {...} }',
|
|
94
|
+
market: 'POST /market/order, GET /market/orders',
|
|
95
|
+
units: 'GET /units',
|
|
96
|
+
intel: 'POST /scan, GET /intel',
|
|
97
|
+
factions: 'GET /factions, POST /factions',
|
|
98
|
+
contracts: 'GET /contracts, POST /contracts',
|
|
99
|
+
tutorial: 'GET /tutorial, POST /tutorial/complete'
|
|
100
|
+
}
|
|
72
101
|
});
|
|
73
102
|
});
|
|
74
103
|
app.get('/health', async (c) => {
|
|
75
104
|
try {
|
|
76
105
|
const tick = await db.getCurrentTick();
|
|
77
|
-
|
|
106
|
+
const tickIntervalMs = parseInt(process.env.TICK_INTERVAL || '600000');
|
|
107
|
+
return c.json({
|
|
108
|
+
status: 'ok',
|
|
109
|
+
tick,
|
|
110
|
+
tickIntervalMs,
|
|
111
|
+
tickIntervalSeconds: Math.round(tickIntervalMs / 1000)
|
|
112
|
+
});
|
|
78
113
|
}
|
|
79
114
|
catch (e) {
|
|
80
115
|
return c.json({ status: 'error', error: String(e) }, 500);
|
|
@@ -197,7 +232,7 @@ app.post('/travel', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
|
197
232
|
const { to } = await validateBody(c, TravelSchema);
|
|
198
233
|
const result = await engine.travel(playerId, to);
|
|
199
234
|
if (!result.success) {
|
|
200
|
-
throw
|
|
235
|
+
throw engineErrorToGameError(result, ErrorCodes.NO_ROUTE, 'Travel failed');
|
|
201
236
|
}
|
|
202
237
|
const zone = await db.getZone(to);
|
|
203
238
|
return c.json({
|
|
@@ -211,7 +246,7 @@ app.post('/extract', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
|
211
246
|
const { quantity } = await validateBody(c, ExtractSchema);
|
|
212
247
|
const result = await engine.extract(playerId, quantity);
|
|
213
248
|
if (!result.success) {
|
|
214
|
-
throw
|
|
249
|
+
throw engineErrorToGameError(result, ErrorCodes.WRONG_ZONE_TYPE, 'Extraction failed');
|
|
215
250
|
}
|
|
216
251
|
return c.json({ success: true, extracted: result.extracted });
|
|
217
252
|
});
|
|
@@ -221,7 +256,7 @@ app.post('/produce', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
|
221
256
|
const { output, quantity } = await validateBody(c, ProduceSchema);
|
|
222
257
|
const result = await engine.produce(playerId, output, quantity);
|
|
223
258
|
if (!result.success) {
|
|
224
|
-
throw
|
|
259
|
+
throw engineErrorToGameError(result, ErrorCodes.INSUFFICIENT_RESOURCES, 'Production failed');
|
|
225
260
|
}
|
|
226
261
|
return c.json({
|
|
227
262
|
success: true,
|
|
@@ -235,7 +270,7 @@ app.post('/ship', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
|
235
270
|
const { type, path, cargo } = await validateBody(c, ShipSchema);
|
|
236
271
|
const result = await engine.createShipmentWithPath(playerId, type, path, cargo);
|
|
237
272
|
if (!result.success) {
|
|
238
|
-
throw
|
|
273
|
+
throw engineErrorToGameError(result, ErrorCodes.INSUFFICIENT_RESOURCES, 'Shipment failed');
|
|
239
274
|
}
|
|
240
275
|
return c.json({ success: true, shipment: result.shipment });
|
|
241
276
|
});
|
|
@@ -263,7 +298,7 @@ app.post('/market/order', authMiddleware, writeRateLimitMiddleware(), async (c)
|
|
|
263
298
|
}
|
|
264
299
|
const result = await engine.placeOrder(playerId, player.locationId, resource, side, price, quantity);
|
|
265
300
|
if (!result.success) {
|
|
266
|
-
throw
|
|
301
|
+
throw engineErrorToGameError(result, ErrorCodes.INSUFFICIENT_RESOURCES, 'Order failed');
|
|
267
302
|
}
|
|
268
303
|
return c.json({ success: true, order: result.order });
|
|
269
304
|
});
|
|
@@ -301,7 +336,7 @@ app.post('/supply', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
|
301
336
|
}
|
|
302
337
|
const result = await engine.depositSU(playerId, player.locationId, amount);
|
|
303
338
|
if (!result.success) {
|
|
304
|
-
throw
|
|
339
|
+
throw engineErrorToGameError(result, ErrorCodes.INSUFFICIENT_RESOURCES, 'Supply failed');
|
|
305
340
|
}
|
|
306
341
|
return c.json({ success: true });
|
|
307
342
|
});
|
|
@@ -335,7 +370,7 @@ app.post('/stockpile', authMiddleware, writeRateLimitMiddleware(), async (c) =>
|
|
|
335
370
|
throw new NotFoundError('Player', playerId);
|
|
336
371
|
const result = await engine.depositStockpile(playerId, player.locationId, resource, amount);
|
|
337
372
|
if (!result.success) {
|
|
338
|
-
throw
|
|
373
|
+
throw engineErrorToGameError(result, ErrorCodes.INSUFFICIENT_RESOURCES, 'Stockpile deposit failed');
|
|
339
374
|
}
|
|
340
375
|
return c.json({ success: true });
|
|
341
376
|
});
|
|
@@ -455,7 +490,7 @@ app.post('/factions', authMiddleware, writeRateLimitMiddleware(), async (c) => {
|
|
|
455
490
|
const { name, tag } = await validateBody(c, FactionCreateSchema);
|
|
456
491
|
const result = await engine.createFaction(playerId, name, tag);
|
|
457
492
|
if (!result.success) {
|
|
458
|
-
throw
|
|
493
|
+
throw engineErrorToGameError(result, ErrorCodes.ALREADY_IN_FACTION, 'Faction creation failed');
|
|
459
494
|
}
|
|
460
495
|
return c.json({ success: true, faction: result.faction });
|
|
461
496
|
});
|
|
@@ -20,10 +20,17 @@ async function main() {
|
|
|
20
20
|
const engine = new AsyncGameEngine(db);
|
|
21
21
|
const currentTick = await db.getCurrentTick();
|
|
22
22
|
console.log(`[TICK SERVER] Starting at tick ${currentTick}`);
|
|
23
|
+
// Minimum interval guard: skip tick if one was processed less than half the interval ago.
|
|
24
|
+
// This prevents double-processing during rolling deploys when two instances overlap.
|
|
25
|
+
const minIntervalSeconds = Math.floor(TICK_INTERVAL / 1000 / 2);
|
|
23
26
|
async function processTick() {
|
|
24
27
|
const startTime = Date.now();
|
|
25
28
|
try {
|
|
26
|
-
const result = await engine.
|
|
29
|
+
const result = await engine.tryProcessTick(minIntervalSeconds);
|
|
30
|
+
if (result === null) {
|
|
31
|
+
console.log(`[TICK SERVER] Skipped — another instance already processed this tick`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
27
34
|
const duration = Date.now() - startTime;
|
|
28
35
|
console.log(`[TICK ${result.tick}] Processed in ${duration}ms, ${result.events.length} events`);
|
|
29
36
|
// Log significant events
|
|
@@ -60,20 +60,20 @@ export declare const ProduceSchema: z.ZodObject<{
|
|
|
60
60
|
output: z.ZodString;
|
|
61
61
|
quantity: z.ZodNumber;
|
|
62
62
|
}, z.core.$strip>;
|
|
63
|
-
export declare const CargoSchema: z.
|
|
64
|
-
ore:
|
|
65
|
-
fuel:
|
|
66
|
-
grain:
|
|
67
|
-
fiber:
|
|
68
|
-
metal:
|
|
69
|
-
chemicals:
|
|
70
|
-
rations:
|
|
71
|
-
textiles:
|
|
72
|
-
ammo:
|
|
73
|
-
medkits:
|
|
74
|
-
parts:
|
|
75
|
-
comms:
|
|
76
|
-
}
|
|
63
|
+
export declare const CargoSchema: z.ZodObject<{
|
|
64
|
+
ore: z.ZodDefault<z.ZodNumber>;
|
|
65
|
+
fuel: z.ZodDefault<z.ZodNumber>;
|
|
66
|
+
grain: z.ZodDefault<z.ZodNumber>;
|
|
67
|
+
fiber: z.ZodDefault<z.ZodNumber>;
|
|
68
|
+
metal: z.ZodDefault<z.ZodNumber>;
|
|
69
|
+
chemicals: z.ZodDefault<z.ZodNumber>;
|
|
70
|
+
rations: z.ZodDefault<z.ZodNumber>;
|
|
71
|
+
textiles: z.ZodDefault<z.ZodNumber>;
|
|
72
|
+
ammo: z.ZodDefault<z.ZodNumber>;
|
|
73
|
+
medkits: z.ZodDefault<z.ZodNumber>;
|
|
74
|
+
parts: z.ZodDefault<z.ZodNumber>;
|
|
75
|
+
comms: z.ZodDefault<z.ZodNumber>;
|
|
76
|
+
}, z.core.$strip>;
|
|
77
77
|
export declare const ShipSchema: z.ZodObject<{
|
|
78
78
|
type: z.ZodEnum<{
|
|
79
79
|
courier: "courier";
|
|
@@ -81,20 +81,20 @@ export declare const ShipSchema: z.ZodObject<{
|
|
|
81
81
|
convoy: "convoy";
|
|
82
82
|
}>;
|
|
83
83
|
path: z.ZodArray<z.ZodString>;
|
|
84
|
-
cargo: z.
|
|
85
|
-
ore:
|
|
86
|
-
fuel:
|
|
87
|
-
grain:
|
|
88
|
-
fiber:
|
|
89
|
-
metal:
|
|
90
|
-
chemicals:
|
|
91
|
-
rations:
|
|
92
|
-
textiles:
|
|
93
|
-
ammo:
|
|
94
|
-
medkits:
|
|
95
|
-
parts:
|
|
96
|
-
comms:
|
|
97
|
-
}
|
|
84
|
+
cargo: z.ZodObject<{
|
|
85
|
+
ore: z.ZodDefault<z.ZodNumber>;
|
|
86
|
+
fuel: z.ZodDefault<z.ZodNumber>;
|
|
87
|
+
grain: z.ZodDefault<z.ZodNumber>;
|
|
88
|
+
fiber: z.ZodDefault<z.ZodNumber>;
|
|
89
|
+
metal: z.ZodDefault<z.ZodNumber>;
|
|
90
|
+
chemicals: z.ZodDefault<z.ZodNumber>;
|
|
91
|
+
rations: z.ZodDefault<z.ZodNumber>;
|
|
92
|
+
textiles: z.ZodDefault<z.ZodNumber>;
|
|
93
|
+
ammo: z.ZodDefault<z.ZodNumber>;
|
|
94
|
+
medkits: z.ZodDefault<z.ZodNumber>;
|
|
95
|
+
parts: z.ZodDefault<z.ZodNumber>;
|
|
96
|
+
comms: z.ZodDefault<z.ZodNumber>;
|
|
97
|
+
}, z.core.$strip>;
|
|
98
98
|
}, z.core.$strip>;
|
|
99
99
|
export declare const MarketOrderSchema: z.ZodObject<{
|
|
100
100
|
resource: z.ZodEnum<{
|
|
@@ -41,7 +41,20 @@ export const ProduceSchema = z.object({
|
|
|
41
41
|
output: z.string().min(1, 'Output type required'),
|
|
42
42
|
quantity: z.number().int().min(1, 'Quantity must be at least 1').max(100, 'Quantity cannot exceed 100')
|
|
43
43
|
});
|
|
44
|
-
export const CargoSchema = z.
|
|
44
|
+
export const CargoSchema = z.object({
|
|
45
|
+
ore: z.number().int().min(0).max(10000).default(0),
|
|
46
|
+
fuel: z.number().int().min(0).max(10000).default(0),
|
|
47
|
+
grain: z.number().int().min(0).max(10000).default(0),
|
|
48
|
+
fiber: z.number().int().min(0).max(10000).default(0),
|
|
49
|
+
metal: z.number().int().min(0).max(10000).default(0),
|
|
50
|
+
chemicals: z.number().int().min(0).max(10000).default(0),
|
|
51
|
+
rations: z.number().int().min(0).max(10000).default(0),
|
|
52
|
+
textiles: z.number().int().min(0).max(10000).default(0),
|
|
53
|
+
ammo: z.number().int().min(0).max(10000).default(0),
|
|
54
|
+
medkits: z.number().int().min(0).max(10000).default(0),
|
|
55
|
+
parts: z.number().int().min(0).max(10000).default(0),
|
|
56
|
+
comms: z.number().int().min(0).max(10000).default(0),
|
|
57
|
+
}).refine((cargo) => Object.values(cargo).some(v => v > 0), 'Cargo must contain at least one resource');
|
|
45
58
|
export const ShipSchema = z.object({
|
|
46
59
|
type: ShipmentTypeSchema,
|
|
47
60
|
path: z.array(z.string()).min(2, 'Path must have at least origin and destination'),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "burnrate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "A logistics war MMO for Claude Code. The front doesn't feed itself.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/cli/index.js",
|
|
@@ -49,7 +49,6 @@
|
|
|
49
49
|
"@hono/node-server": "^1.19.9",
|
|
50
50
|
"@libsql/client": "^0.17.0",
|
|
51
51
|
"@modelcontextprotocol/sdk": "^1.25.3",
|
|
52
|
-
"better-sqlite3": "^12.6.2",
|
|
53
52
|
"commander": "^14.0.2",
|
|
54
53
|
"hono": "^4.11.7",
|
|
55
54
|
"tsx": "^4.21.0",
|
|
@@ -57,6 +56,12 @@
|
|
|
57
56
|
"uuid": "^13.0.0",
|
|
58
57
|
"zod": "^4.3.6"
|
|
59
58
|
},
|
|
59
|
+
"optionalDependencies": {
|
|
60
|
+
"better-sqlite3": "^12.6.2"
|
|
61
|
+
},
|
|
62
|
+
"engines": {
|
|
63
|
+
"node": ">=20"
|
|
64
|
+
},
|
|
60
65
|
"devDependencies": {
|
|
61
66
|
"@types/better-sqlite3": "^7.6.13",
|
|
62
67
|
"@types/node": "^25.0.10",
|