From 00960d8d684f7733074f077b50f3b1f6863a6a0c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 09:46:08 +0000 Subject: [PATCH 1/9] Initial plan From c421d3f55b9c0b755c369239f96ec246c921cf26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 09:51:12 +0000 Subject: [PATCH 2/9] feat: implement storage capacity system and building validation - Add BASE_STORAGE_CAPACITY_PER_REGION and WAREHOUSE_CAPACITY_PER_SLOT constants - Implement calculateRegionalStorageCapacity function - Implement validateStorageCapacity function to check inventory limits - Integrate storage validation in production completions - Integrate storage validation in market settlement - Integrate storage validation in shipment deliveries - Add validateProductionBuildingAvailable function - Integrate building validation in production job creation - Update tick-engine JSDoc with Phase 3 validation notes - Add comprehensive tests for storage capacity system - Add tests for production building validation Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com> --- packages/sim/src/services/buildings.ts | 149 +++++++++++++++++++ packages/sim/src/services/market-matching.ts | 9 ++ packages/sim/src/services/production.ts | 15 ++ packages/sim/src/services/shipments.ts | 9 ++ packages/sim/src/services/tick-engine.ts | 10 +- packages/sim/tests/buildings.test.ts | 39 +++++ packages/sim/tests/storage.test.ts | 94 ++++++++++++ 7 files changed, 323 insertions(+), 2 deletions(-) create mode 100644 packages/sim/tests/storage.test.ts diff --git a/packages/sim/src/services/buildings.ts b/packages/sim/src/services/buildings.ts index 09e3e679..96fa98a6 100644 --- a/packages/sim/src/services/buildings.ts +++ b/packages/sim/src/services/buildings.ts @@ -124,10 +124,26 @@ export interface ReactivateBuildingInput { tick: number; } +/** + * ## Building Staffing (Future Enhancement) + * + * When employee assignment is implemented: + * - Buildings will have minEmployees, maxEmployees, employeesAssigned fields + * - Effective capacity = baseCapacity * (employeesAssigned - minEmployees) / (maxEmployees - minEmployees) + * - Buildings with employeesAssigned < minEmployees automatically set to INACTIVE status + * - Staffing changes do not affect running production jobs, only new jobs + * + * Current implementation: + * - All buildings use full capacity when ACTIVE + * - Staffing mechanics deferred to future phase + */ + /** * Constants */ export const BUILDING_OPERATING_COST_INTERVAL_TICKS = 7; // Weekly +export const BASE_STORAGE_CAPACITY_PER_REGION = 1000; +export const WAREHOUSE_CAPACITY_PER_SLOT = 500; /** * Validates building acquisition input @@ -512,3 +528,136 @@ export async function getProductionCapacityForCompany( return { totalCapacity, usedCapacity }; } + +/** + * Calculates total storage capacity for a region + * + * @param warehouseCount - Number of active warehouses in the region + * @param capacityPerWarehouse - Storage capacity per warehouse (default: WAREHOUSE_CAPACITY_PER_SLOT) + * @param baseCapacity - Base storage capacity per region (default: BASE_STORAGE_CAPACITY_PER_REGION) + * @returns Total storage capacity + * + * @remarks + * - Base capacity is the minimum storage available in any region + * - Each warehouse adds additional capacity based on capacitySlots or fixed amount + * - Formula: baseCapacity + (warehouseCount * capacityPerWarehouse) + */ +export function calculateRegionalStorageCapacity( + warehouseCount: number, + capacityPerWarehouse: number = WAREHOUSE_CAPACITY_PER_SLOT, + baseCapacity: number = BASE_STORAGE_CAPACITY_PER_REGION +): number { + if (!Number.isInteger(warehouseCount) || warehouseCount < 0) { + throw new DomainInvariantError("warehouseCount must be a non-negative integer"); + } + if (!Number.isInteger(capacityPerWarehouse) || capacityPerWarehouse < 0) { + throw new DomainInvariantError("capacityPerWarehouse must be a non-negative integer"); + } + if (!Number.isInteger(baseCapacity) || baseCapacity < 0) { + throw new DomainInvariantError("baseCapacity must be a non-negative integer"); + } + + return baseCapacity + (warehouseCount * capacityPerWarehouse); +} + +/** + * Validates that adding inventory to a region would not exceed storage capacity + * + * @param tx - Prisma transaction client + * @param companyId - Company ID + * @param regionId - Region ID + * @param quantityToAdd - Quantity of inventory to add + * + * @throws {DomainInvariantError} If adding inventory would exceed regional storage capacity + * + * @remarks + * - Calculates current total inventory in region (all items combined) + * - Gets warehouse count to calculate total capacity + * - Throws error if current + quantityToAdd exceeds capacity + * - Must be called before any inventory mutation (production, market, shipments) + */ +export async function validateStorageCapacity( + tx: Prisma.TransactionClient, + companyId: string, + regionId: string, + quantityToAdd: number +): Promise { + if (!companyId) { + throw new DomainInvariantError("companyId is required"); + } + if (!regionId) { + throw new DomainInvariantError("regionId is required"); + } + if (!Number.isInteger(quantityToAdd) || quantityToAdd < 0) { + throw new DomainInvariantError("quantityToAdd must be a non-negative integer"); + } + + // Get current total inventory in region + const currentInventory = await tx.inventory.aggregate({ + where: { companyId, regionId }, + _sum: { quantity: true } + }); + + // Get warehouse count for capacity calculation + const warehouseCount = await tx.building.count({ + where: { + companyId, + regionId, + buildingType: BuildingType.WAREHOUSE, + status: BuildingStatus.ACTIVE + } + }); + + const capacity = calculateRegionalStorageCapacity(warehouseCount); + const currentTotal = currentInventory._sum.quantity || 0; + + if (currentTotal + quantityToAdd > capacity) { + throw new DomainInvariantError( + `storage capacity exceeded: current=${currentTotal}, adding=${quantityToAdd}, capacity=${capacity}` + ); + } +} + +/** + * Validates that a company has at least one active production building + * + * @param tx - Prisma transaction client + * @param companyId - Company ID + * + * @throws {DomainInvariantError} If company has no active production buildings + * + * @remarks + * - Production buildings include: MINE, FARM, FACTORY, MEGA_FACTORY + * - Only ACTIVE buildings are counted + * - Must be called before creating production jobs + */ +export async function validateProductionBuildingAvailable( + tx: Prisma.TransactionClient, + companyId: string +): Promise { + if (!companyId) { + throw new DomainInvariantError("companyId is required"); + } + + const productionBuildingTypes = [ + BuildingType.MINE, + BuildingType.FARM, + BuildingType.FACTORY, + BuildingType.MEGA_FACTORY + ]; + + const activeBuildingCount = await tx.building.count({ + where: { + companyId, + buildingType: { in: productionBuildingTypes }, + status: BuildingStatus.ACTIVE + } + }); + + if (activeBuildingCount === 0) { + throw new DomainInvariantError( + `company ${companyId} has no active production buildings` + ); + } +} + diff --git a/packages/sim/src/services/market-matching.ts b/packages/sim/src/services/market-matching.ts index df5b34a6..6eac8322 100644 --- a/packages/sim/src/services/market-matching.ts +++ b/packages/sim/src/services/market-matching.ts @@ -61,6 +61,7 @@ import { PrismaClient } from "@prisma/client"; import { DomainInvariantError, NotFoundError } from "../domain/errors"; +import { validateStorageCapacity } from "./buildings"; /** * Order representation for matching purposes. @@ -337,6 +338,14 @@ async function settleMatch( } }); + // Validate storage capacity before adding buyer inventory + await validateStorageCapacity( + tx, + buyOrder.companyId, + buyOrder.regionId, + match.quantity + ); + await tx.inventory.upsert({ where: { companyId_itemId_regionId: { diff --git a/packages/sim/src/services/production.ts b/packages/sim/src/services/production.ts index 722a4c8f..b958066e 100644 --- a/packages/sim/src/services/production.ts +++ b/packages/sim/src/services/production.ts @@ -81,6 +81,10 @@ import { applyDurationMultiplierTicks, resolveWorkforceRuntimeModifiers } from "./workforce"; +import { + validateProductionBuildingAvailable, + validateStorageCapacity +} from "./buildings"; interface RecipeInputRow { itemId: string; @@ -488,6 +492,9 @@ export async function createProductionJobWithTx( throw new DomainInvariantError("recipe durationTicks cannot be negative"); } + // Validate company has at least one active production building + await validateProductionBuildingAvailable(tx, input.companyId); + const requirements = calculateRecipeInputRequirements(recipe.inputs, input.quantity); const requiredItemIds = requirements.map((entry) => entry.itemId); @@ -867,6 +874,14 @@ export async function completeDueProductionJobs( const outputQuantity = job.recipe.outputQuantity * job.runs; + // Validate storage capacity before adding output inventory + await validateStorageCapacity( + tx, + job.companyId, + job.company.regionId, + outputQuantity + ); + await tx.inventory.upsert({ where: { companyId_itemId_regionId: { diff --git a/packages/sim/src/services/shipments.ts b/packages/sim/src/services/shipments.ts index ffe3566e..92a0d723 100644 --- a/packages/sim/src/services/shipments.ts +++ b/packages/sim/src/services/shipments.ts @@ -84,6 +84,7 @@ import { applyDurationMultiplierTicks, resolveWorkforceRuntimeModifiers } from "./workforce"; +import { validateStorageCapacity } from "./buildings"; export interface ShipmentRuntimeConfig { baseFeeCents: bigint; @@ -670,6 +671,14 @@ export async function deliverDueShipmentsForTick( continue; } + // Validate storage capacity before adding delivered inventory + await validateStorageCapacity( + tx, + shipment.companyId, + shipment.toRegionId, + shipment.quantity + ); + await tx.inventory.upsert({ where: { companyId_itemId_regionId: { diff --git a/packages/sim/src/services/tick-engine.ts b/packages/sim/src/services/tick-engine.ts index c10ea494..bcd64419 100644 --- a/packages/sim/src/services/tick-engine.ts +++ b/packages/sim/src/services/tick-engine.ts @@ -239,7 +239,6 @@ function isTickExecutionConflict(error: unknown, executionKey: string | undefine * ## Pipeline Stages (Executed Sequentially) * 1. Bot actions (market orders, production starts) * 2. Building operating costs (deduct costs, deactivate unpaid buildings) - * - Note: Production validation doesn't yet check building status (Phase 2) * 3. Production job completions * 4. Research completions and recipe unlocks * 5. Market matching and settlement @@ -249,6 +248,10 @@ function isTickExecutionConflict(error: unknown, executionKey: string | undefine * 9. Contract lifecycle (expiration and generation) * 10. Market candle aggregation (OHLC/VWAP/volume) * + * ## Phase 3 Validations + * - Storage capacity checked before inventory mutations (production, market, shipments) + * - Building availability validated for production job creation + * * ## Determinism * - Order is fixed and must not change (breaking change if reordered) * - Each stage reads state modified by previous stages @@ -266,7 +269,6 @@ async function runTickPipeline( // Tick pipeline order: // 1) bot actions (orders / production starts) // 2) building operating costs (deactivate unpaid buildings) - // Note: Production validation doesn't yet check building status (Phase 2) // 3) production completions // 4) research completions and recipe unlocks // 5) market matching and settlement @@ -276,6 +278,10 @@ async function runTickPipeline( // 9) contract lifecycle (expire and generate) // 10) market candle aggregation (OHLC/VWAP/volume) // 11) finalize world tick state + // + // Phase 3 Validations: + // - Storage capacity checked before inventory mutations (production, market, shipments) + // - Building availability validated for production job creation if (options.runBots) { await runBotsForTick(tx, nextTick, options.botConfig); } diff --git a/packages/sim/tests/buildings.test.ts b/packages/sim/tests/buildings.test.ts index 1c05a2f1..ee14db7f 100644 --- a/packages/sim/tests/buildings.test.ts +++ b/packages/sim/tests/buildings.test.ts @@ -8,6 +8,7 @@ import { applyBuildingOperatingCostsWithTx, reactivateBuildingWithTx, getProductionCapacityForCompany, + validateProductionBuildingAvailable, BUILDING_OPERATING_COST_INTERVAL_TICKS } from "../src"; @@ -523,4 +524,42 @@ describe("building service", () => { expect(result.usedCapacity).toBe(0); }); }); + + describe("validateProductionBuildingAvailable", () => { + it("validates company has active production building", async () => { + const tx = { + building: { + count: vi.fn().mockResolvedValue(2) + } + } as unknown as Prisma.TransactionClient; + + await expect( + validateProductionBuildingAvailable(tx, "company-1") + ).resolves.not.toThrow(); + }); + + it("throws when company has no active production buildings", async () => { + const tx = { + building: { + count: vi.fn().mockResolvedValue(0) + } + } as unknown as Prisma.TransactionClient; + + await expect( + validateProductionBuildingAvailable(tx, "company-1") + ).rejects.toThrow(DomainInvariantError); + }); + + it("validates required parameters", async () => { + const tx = { + building: { + count: vi.fn().mockResolvedValue(1) + } + } as unknown as Prisma.TransactionClient; + + await expect( + validateProductionBuildingAvailable(tx, "") + ).rejects.toThrow(DomainInvariantError); + }); + }); }); diff --git a/packages/sim/tests/storage.test.ts b/packages/sim/tests/storage.test.ts new file mode 100644 index 00000000..d92b1daf --- /dev/null +++ b/packages/sim/tests/storage.test.ts @@ -0,0 +1,94 @@ +import { BuildingType, BuildingStatus, Prisma } from "@prisma/client"; +import { describe, expect, it, vi } from "vitest"; +import { + DomainInvariantError, + calculateRegionalStorageCapacity, + validateStorageCapacity, + BASE_STORAGE_CAPACITY_PER_REGION, + WAREHOUSE_CAPACITY_PER_SLOT +} from "../src"; + +describe("storage capacity system", () => { + it("calculates base storage capacity correctly", () => { + const capacity = calculateRegionalStorageCapacity(0); + expect(capacity).toBe(BASE_STORAGE_CAPACITY_PER_REGION); + }); + + it("adds warehouse capacity to base", () => { + const capacity = calculateRegionalStorageCapacity(2); + expect(capacity).toBe(BASE_STORAGE_CAPACITY_PER_REGION + (2 * WAREHOUSE_CAPACITY_PER_SLOT)); + }); + + it("validates storage capacity and throws on overflow", async () => { + const tx = { + inventory: { + aggregate: vi.fn().mockResolvedValue({ + _sum: { quantity: 900 } + }) + }, + building: { + count: vi.fn().mockResolvedValue(0) // No warehouses + } + } as unknown as Prisma.TransactionClient; + + // Should fail: 900 + 200 = 1100 > 1000 base capacity + await expect( + validateStorageCapacity(tx, "company-1", "region-1", 200) + ).rejects.toThrow(DomainInvariantError); + }); + + it("allows storage within capacity", async () => { + const tx = { + inventory: { + aggregate: vi.fn().mockResolvedValue({ + _sum: { quantity: 500 } + }) + }, + building: { + count: vi.fn().mockResolvedValue(1) // 1 warehouse = 1500 total capacity + } + } as unknown as Prisma.TransactionClient; + + // Should succeed: 500 + 800 = 1300 < 1500 + await expect( + validateStorageCapacity(tx, "company-1", "region-1", 800) + ).resolves.not.toThrow(); + }); + + it("throws on negative warehouse count", () => { + expect(() => calculateRegionalStorageCapacity(-1)).toThrow(DomainInvariantError); + }); + + it("throws on negative capacity per warehouse", () => { + expect(() => calculateRegionalStorageCapacity(1, -500)).toThrow(DomainInvariantError); + }); + + it("throws on negative base capacity", () => { + expect(() => calculateRegionalStorageCapacity(1, 500, -1000)).toThrow(DomainInvariantError); + }); + + it("validates required parameters in validateStorageCapacity", async () => { + const tx = { + inventory: { + aggregate: vi.fn().mockResolvedValue({ + _sum: { quantity: 0 } + }) + }, + building: { + count: vi.fn().mockResolvedValue(0) + } + } as unknown as Prisma.TransactionClient; + + await expect( + validateStorageCapacity(tx, "", "region-1", 100) + ).rejects.toThrow(DomainInvariantError); + + await expect( + validateStorageCapacity(tx, "company-1", "", 100) + ).rejects.toThrow(DomainInvariantError); + + await expect( + validateStorageCapacity(tx, "company-1", "region-1", -10) + ).rejects.toThrow(DomainInvariantError); + }); +}); From fe9ff7928a2b55b630f3b8bd4a3b2188a1263a9c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 09:52:02 +0000 Subject: [PATCH 3/9] fix: remove unused imports in storage.test.ts --- packages/sim/tests/storage.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sim/tests/storage.test.ts b/packages/sim/tests/storage.test.ts index d92b1daf..80de02a9 100644 --- a/packages/sim/tests/storage.test.ts +++ b/packages/sim/tests/storage.test.ts @@ -1,4 +1,4 @@ -import { BuildingType, BuildingStatus, Prisma } from "@prisma/client"; +import { Prisma } from "@prisma/client"; import { describe, expect, it, vi } from "vitest"; import { DomainInvariantError, From 55191a23de051f3726cc6d743dd160a5be0e1699 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:03:42 +0000 Subject: [PATCH 4/9] fix: resolve storage validation race conditions and bot production - Move storage validation BEFORE inventory mutations in production - Move storage validation BEFORE inventory mutations in market settlement - Only validate production buildings for player companies (bots exempt) - Prevents batch processing race condition where multiple operations in same tick could exceed capacity Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com> --- packages/sim/src/services/market-matching.ts | 16 ++++++------ packages/sim/src/services/production.ts | 27 +++++++++++--------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/sim/src/services/market-matching.ts b/packages/sim/src/services/market-matching.ts index 6eac8322..a163db61 100644 --- a/packages/sim/src/services/market-matching.ts +++ b/packages/sim/src/services/market-matching.ts @@ -298,6 +298,14 @@ async function settleMatch( const buyReservedCash = buyOrder.reservedCashCents - reserveReduction; const sellReservedQuantity = sellOrder.reservedQuantity - match.quantity; + // Validate storage capacity BEFORE any inventory mutations + await validateStorageCapacity( + tx, + buyOrder.companyId, + buyOrder.regionId, + match.quantity + ); + if (buyerIsSeller) { await tx.company.update({ where: { id: buyerCompany.id }, @@ -338,14 +346,6 @@ async function settleMatch( } }); - // Validate storage capacity before adding buyer inventory - await validateStorageCapacity( - tx, - buyOrder.companyId, - buyOrder.regionId, - match.quantity - ); - await tx.inventory.upsert({ where: { companyId_itemId_regionId: { diff --git a/packages/sim/src/services/production.ts b/packages/sim/src/services/production.ts index b958066e..5b6d168f 100644 --- a/packages/sim/src/services/production.ts +++ b/packages/sim/src/services/production.ts @@ -492,8 +492,11 @@ export async function createProductionJobWithTx( throw new DomainInvariantError("recipe durationTicks cannot be negative"); } - // Validate company has at least one active production building - await validateProductionBuildingAvailable(tx, input.companyId); + // Validate player company has at least one active production building + // (bots operate with different rules and may not have buildings) + if (company.isPlayer) { + await validateProductionBuildingAvailable(tx, input.companyId); + } const requirements = calculateRecipeInputRequirements(recipe.inputs, input.quantity); const requiredItemIds = requirements.map((entry) => entry.itemId); @@ -852,6 +855,16 @@ export async function completeDueProductionJobs( ); } + const outputQuantity = job.recipe.outputQuantity * job.runs; + + // Validate storage capacity BEFORE any inventory mutations + await validateStorageCapacity( + tx, + job.companyId, + job.company.regionId, + outputQuantity + ); + for (const requirement of requirements) { await tx.inventory.update({ where: { @@ -872,16 +885,6 @@ export async function completeDueProductionJobs( }); } - const outputQuantity = job.recipe.outputQuantity * job.runs; - - // Validate storage capacity before adding output inventory - await validateStorageCapacity( - tx, - job.companyId, - job.company.regionId, - outputQuantity - ); - await tx.inventory.upsert({ where: { companyId_itemId_regionId: { From e7b7fb6aed379a6293398a790d7cc8a166035121 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:07:10 +0000 Subject: [PATCH 5/9] fix: account for net inventory change in production storage validation - Calculate net inventory change (outputs - inputs consumed) - Only validate storage if net change is positive - Update documentation to accurately reflect validation behavior - Addresses bot reviewer feedback on production capacity logic Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com> --- packages/sim/src/services/production.ts | 24 +++++++++++++++++------- packages/sim/src/services/tick-engine.ts | 10 ++++++++-- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/sim/src/services/production.ts b/packages/sim/src/services/production.ts index 5b6d168f..060a584a 100644 --- a/packages/sim/src/services/production.ts +++ b/packages/sim/src/services/production.ts @@ -856,14 +856,24 @@ export async function completeDueProductionJobs( } const outputQuantity = job.recipe.outputQuantity * job.runs; - - // Validate storage capacity BEFORE any inventory mutations - await validateStorageCapacity( - tx, - job.companyId, - job.company.regionId, - outputQuantity + + // Calculate net inventory change (outputs added minus inputs consumed) + const totalInputQuantity = requirements.reduce( + (sum, requirement) => sum + requirement.quantity, + 0 ); + const netInventoryChange = outputQuantity - totalInputQuantity; + + // Validate storage capacity accounts for net change after consuming inputs + // (only validate if net change is positive, i.e., we're adding more than consuming) + if (netInventoryChange > 0) { + await validateStorageCapacity( + tx, + job.companyId, + job.company.regionId, + netInventoryChange + ); + } for (const requirement of requirements) { await tx.inventory.update({ diff --git a/packages/sim/src/services/tick-engine.ts b/packages/sim/src/services/tick-engine.ts index bcd64419..635a73e1 100644 --- a/packages/sim/src/services/tick-engine.ts +++ b/packages/sim/src/services/tick-engine.ts @@ -249,7 +249,10 @@ function isTickExecutionConflict(error: unknown, executionKey: string | undefine * 10. Market candle aggregation (OHLC/VWAP/volume) * * ## Phase 3 Validations - * - Storage capacity checked before inventory mutations (production, market, shipments) + * - Storage capacity enforced at inventory mutation points + * - Production: validates net inventory change (outputs - inputs consumed) + * - Market settlement: validates buyer's capacity before trade execution + * - Shipment delivery: validates destination capacity before delivery * - Building availability validated for production job creation * * ## Determinism @@ -280,7 +283,10 @@ async function runTickPipeline( // 11) finalize world tick state // // Phase 3 Validations: - // - Storage capacity checked before inventory mutations (production, market, shipments) + // - Storage capacity enforced at inventory mutation points: + // * Production: validates net change (outputs - inputs), skips if net negative + // * Market: validates buyer capacity before trade execution + // * Shipments: validates destination capacity before delivery // - Building availability validated for production job creation if (options.runBots) { await runBotsForTick(tx, nextTick, options.botConfig); From 834f1d32eee471a4b449b5c849a5f341c2fa86d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:16:04 +0000 Subject: [PATCH 6/9] fix: skip storage validation for self-trades in market settlement - Self-trades (same company, region, and item) have net zero inventory change - Skip storage capacity validation when buyer and seller are the same entity - Prevents false positive rejections when company at capacity trades with itself - Addresses bot reviewer feedback on self-trade edge case Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com> --- packages/sim/src/services/market-matching.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/sim/src/services/market-matching.ts b/packages/sim/src/services/market-matching.ts index a163db61..ec117812 100644 --- a/packages/sim/src/services/market-matching.ts +++ b/packages/sim/src/services/market-matching.ts @@ -299,12 +299,20 @@ async function settleMatch( const sellReservedQuantity = sellOrder.reservedQuantity - match.quantity; // Validate storage capacity BEFORE any inventory mutations - await validateStorageCapacity( - tx, - buyOrder.companyId, - buyOrder.regionId, - match.quantity - ); + // Skip validation for self-trades in the same region and item (net inventory change is zero) + const isSelfTradeInSameRegionAndItem = + buyOrder.companyId === sellOrder.companyId && + buyOrder.regionId === sellOrder.regionId && + buyOrder.itemId === sellOrder.itemId; + + if (!isSelfTradeInSameRegionAndItem) { + await validateStorageCapacity( + tx, + buyOrder.companyId, + buyOrder.regionId, + match.quantity + ); + } if (buyerIsSeller) { await tx.company.update({ From 72ab42d2116a30e44a980d380dfea9cf0c5ac0c2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:33:11 +0000 Subject: [PATCH 7/9] fix: add warehouse to market-candles test to ensure storage capacity - Test was failing because buyer company lacked sufficient storage capacity - Added warehouse to buyer company in test setup - Warehouse increases capacity by 500 units (base 1000 + warehouse 500 = 1500 total) - Ensures trade settlement doesn't hit storage limit validation Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com> --- apps/api/test/market-candles.integration.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/api/test/market-candles.integration.test.ts b/apps/api/test/market-candles.integration.test.ts index 94c521fe..2e86b0c8 100644 --- a/apps/api/test/market-candles.integration.test.ts +++ b/apps/api/test/market-candles.integration.test.ts @@ -55,6 +55,21 @@ describe("market candles integration", () => { playerId = playerCompany.ownerPlayerId; playerRegionId = playerCompany.regionId; + // Add warehouse to buyer to ensure sufficient storage capacity + await prisma.building.create({ + data: { + companyId: playerCompanyId, + regionId: playerRegionId, + buildingType: "WAREHOUSE", + status: "ACTIVE", + acquisitionCostCents: 0n, + weeklyOperatingCostCents: 0n, + capacitySlots: 1, + tickAcquired: 0, + lastOperatingCostTick: 0 + } + }); + const sellerCompany = await prisma.company.create({ data: { code: "PLAYER_CANDLE_SELLER", From b10a5637912fea68bc52fb8edab1c5a3b3eb0752 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:56:15 +0000 Subject: [PATCH 8/9] fix: add production building and warehouse to seeded player company - Player company now gets a FACTORY (5 capacity slots) for production jobs - Player company now gets a WAREHOUSE (500 capacity) to handle 1088 units of starting inventory - Total capacity: 1000 (base) + 500 (warehouse) = 1500 units - Removes need for per-test warehouse setup in market-candles test - Fixes all integration tests that rely on player company having buildings Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com> --- .../test/market-candles.integration.test.ts | 15 ---------- packages/db/src/seed-world.ts | 30 +++++++++++++++++++ 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/apps/api/test/market-candles.integration.test.ts b/apps/api/test/market-candles.integration.test.ts index 2e86b0c8..94c521fe 100644 --- a/apps/api/test/market-candles.integration.test.ts +++ b/apps/api/test/market-candles.integration.test.ts @@ -55,21 +55,6 @@ describe("market candles integration", () => { playerId = playerCompany.ownerPlayerId; playerRegionId = playerCompany.regionId; - // Add warehouse to buyer to ensure sufficient storage capacity - await prisma.building.create({ - data: { - companyId: playerCompanyId, - regionId: playerRegionId, - buildingType: "WAREHOUSE", - status: "ACTIVE", - acquisitionCostCents: 0n, - weeklyOperatingCostCents: 0n, - capacitySlots: 1, - tickAcquired: 0, - lastOperatingCostTick: 0 - } - }); - const sellerCompany = await prisma.company.create({ data: { code: "PLAYER_CANDLE_SELLER", diff --git a/packages/db/src/seed-world.ts b/packages/db/src/seed-world.ts index 1bf4e5dc..08c03d82 100644 --- a/packages/db/src/seed-world.ts +++ b/packages/db/src/seed-world.ts @@ -766,6 +766,36 @@ export async function seedWorld( })) }); + // Create buildings for player company to support production and storage + if (playerCompany) { + await prisma.building.createMany({ + data: [ + { + companyId: playerCompany.id, + regionId: coreRegion.id, + buildingType: "FACTORY", + status: "ACTIVE", + acquisitionCostCents: 0n, + weeklyOperatingCostCents: 0n, + capacitySlots: 5, + tickAcquired: 0, + lastOperatingCostTick: 0 + }, + { + companyId: playerCompany.id, + regionId: coreRegion.id, + buildingType: "WAREHOUSE", + status: "ACTIVE", + acquisitionCostCents: 0n, + weeklyOperatingCostCents: 0n, + capacitySlots: 1, + tickAcquired: 0, + lastOperatingCostTick: 0 + } + ] + }); + } + const recipesByKey: Record = {}; for (const definition of RECIPE_DEFINITIONS) { const recipe = await prisma.recipe.create({ From f1490da8a5d0fcc054cb8cfc128d449932a6376d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:01:27 +0000 Subject: [PATCH 9/9] refactor: reduce starting inventory and remove free warehouse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reduced player starting inventory from 1088 to 885 units (fits in base 1000 capacity) - Removed free warehouse to avoid giving players buildings with upkeep costs - Keep FACTORY for production capability (no operating cost in seed) - Starting inventory breakdown: * ironOre: 240 → 200 * coal: 140 → 120 * copperOre: 180 → 150 * water: 200 → 150 * fertilizer: 150 → 120 * bioSubstrate: 160 → 130 * ironIngot: 12 → 10 * copperIngot: 6 → 5 Co-authored-by: BENZOOgataga <50145143+BENZOOgataga@users.noreply.github.com> --- packages/db/src/seed-world.ts | 55 +++++++++++++---------------------- 1 file changed, 21 insertions(+), 34 deletions(-) diff --git a/packages/db/src/seed-world.ts b/packages/db/src/seed-world.ts index 08c03d82..d7746f74 100644 --- a/packages/db/src/seed-world.ts +++ b/packages/db/src/seed-world.ts @@ -721,14 +721,14 @@ export async function seedWorld( if (playerCompany) { inventoryRows.unshift( - { companyId: playerCompany.id, itemKey: "ironOre", quantity: 240 }, - { companyId: playerCompany.id, itemKey: "coal", quantity: 140 }, - { companyId: playerCompany.id, itemKey: "copperOre", quantity: 180 }, - { companyId: playerCompany.id, itemKey: "water", quantity: 200 }, - { companyId: playerCompany.id, itemKey: "fertilizer", quantity: 150 }, - { companyId: playerCompany.id, itemKey: "bioSubstrate", quantity: 160 }, - { companyId: playerCompany.id, itemKey: "ironIngot", quantity: 12 }, - { companyId: playerCompany.id, itemKey: "copperIngot", quantity: 6 } + { companyId: playerCompany.id, itemKey: "ironOre", quantity: 200 }, + { companyId: playerCompany.id, itemKey: "coal", quantity: 120 }, + { companyId: playerCompany.id, itemKey: "copperOre", quantity: 150 }, + { companyId: playerCompany.id, itemKey: "water", quantity: 150 }, + { companyId: playerCompany.id, itemKey: "fertilizer", quantity: 120 }, + { companyId: playerCompany.id, itemKey: "bioSubstrate", quantity: 130 }, + { companyId: playerCompany.id, itemKey: "ironIngot", quantity: 10 }, + { companyId: playerCompany.id, itemKey: "copperIngot", quantity: 5 } ); } @@ -766,33 +766,20 @@ export async function seedWorld( })) }); - // Create buildings for player company to support production and storage + // Create factory for player company to support production if (playerCompany) { - await prisma.building.createMany({ - data: [ - { - companyId: playerCompany.id, - regionId: coreRegion.id, - buildingType: "FACTORY", - status: "ACTIVE", - acquisitionCostCents: 0n, - weeklyOperatingCostCents: 0n, - capacitySlots: 5, - tickAcquired: 0, - lastOperatingCostTick: 0 - }, - { - companyId: playerCompany.id, - regionId: coreRegion.id, - buildingType: "WAREHOUSE", - status: "ACTIVE", - acquisitionCostCents: 0n, - weeklyOperatingCostCents: 0n, - capacitySlots: 1, - tickAcquired: 0, - lastOperatingCostTick: 0 - } - ] + await prisma.building.create({ + data: { + companyId: playerCompany.id, + regionId: coreRegion.id, + buildingType: "FACTORY", + status: "ACTIVE", + acquisitionCostCents: 0n, + weeklyOperatingCostCents: 0n, + capacitySlots: 5, + tickAcquired: 0, + lastOperatingCostTick: 0 + } }); }