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 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
- ### Option A: Automated Setup (Recommended)
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
- This will:
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 after setup, then ask Claude to join:
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
- ### Option B: Manual Setup
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
- mkdir ~/burnrate && cd ~/burnrate
67
- git clone https://github.com/burnrate-cc/burnrate.git .
68
- npm install && npm run build
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
- 2. Add to your Claude Code MCP settings (`~/.claude/settings.json`):
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": "node",
77
- "args": ["/Users/YOU/burnrate/dist/mcp/server.js"],
78
+ "command": "npx",
79
+ "args": ["-y", "burnrate", "start"],
78
80
  "env": {
79
- "BURNRATE_API_URL": "https://api.burnrate.cc"
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 1000 and earn more through contracts and trading. Spend them on:
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 100% supply) grant additional bonuses at 50, 200, and 500 ticks.
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/week)
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.burnrate.cc');
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 - archive scores and reset the world
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
- const allPlayers = await this.db.getAllPlayers();
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 === player.factionId) {
912
- return { success: false, error: 'Zone already controlled by your faction' };
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: player.factionId,
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
- await this.recordEvent('zone_captured', player.factionId, 'faction', {
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: player.factionId,
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' };
@@ -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;
@@ -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 Haul',
292
- description: 'Buy 20 ore at the Hub market and deliver it to an adjacent Factory. This teaches market buying, inventory management, and basic shipping.',
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('UPDATE world SET current_tick = current_tick + 1 WHERE id = 1');
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',
@@ -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: '/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
- return c.json({ status: 'ok', tick });
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 new GameError(ErrorCodes.NO_ROUTE, result.error || 'Travel failed');
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 new GameError(ErrorCodes.WRONG_ZONE_TYPE, result.error || 'Extraction failed');
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 new GameError(ErrorCodes.INSUFFICIENT_RESOURCES, result.error || 'Production failed');
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 new GameError(ErrorCodes.INSUFFICIENT_RESOURCES, result.error || 'Shipment failed');
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 new GameError(ErrorCodes.INSUFFICIENT_RESOURCES, result.error || 'Order failed');
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 new GameError(ErrorCodes.INSUFFICIENT_RESOURCES, result.error || 'Supply failed');
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 new GameError(ErrorCodes.INSUFFICIENT_RESOURCES, result.error || 'Stockpile deposit failed');
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 new GameError(ErrorCodes.ALREADY_IN_FACTION, result.error || 'Faction creation failed');
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.processTick();
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.ZodRecord<z.ZodEnum<{
64
- ore: "ore";
65
- fuel: "fuel";
66
- grain: "grain";
67
- fiber: "fiber";
68
- metal: "metal";
69
- chemicals: "chemicals";
70
- rations: "rations";
71
- textiles: "textiles";
72
- ammo: "ammo";
73
- medkits: "medkits";
74
- parts: "parts";
75
- comms: "comms";
76
- }>, z.ZodNumber>;
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.ZodRecord<z.ZodEnum<{
85
- ore: "ore";
86
- fuel: "fuel";
87
- grain: "grain";
88
- fiber: "fiber";
89
- metal: "metal";
90
- chemicals: "chemicals";
91
- rations: "rations";
92
- textiles: "textiles";
93
- ammo: "ammo";
94
- medkits: "medkits";
95
- parts: "parts";
96
- comms: "comms";
97
- }>, z.ZodNumber>;
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.record(ResourceSchema, z.number().int().min(0).max(10000)).refine((cargo) => Object.values(cargo).some(v => v > 0), 'Cargo must contain at least one resource');
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.0",
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",