diff --git a/packages/db/src/seed-world.ts b/packages/db/src/seed-world.ts index 1bf4e5dc..d7746f74 100644 --- a/packages/db/src/seed-world.ts +++ b/packages/db/src/seed-world.ts @@ -721,14 +721,14 @@ export async function seedWorld( if (playerCompany) { inventoryRows.unshift( - { companyId: playerCompany.id, itemKey: "ironOre", quantity: 240 }, - { companyId: playerCompany.id, itemKey: "coal", quantity: 140 }, - { companyId: playerCompany.id, itemKey: "copperOre", quantity: 180 }, - { companyId: playerCompany.id, itemKey: "water", quantity: 200 }, - { companyId: playerCompany.id, itemKey: "fertilizer", quantity: 150 }, - { companyId: playerCompany.id, itemKey: "bioSubstrate", quantity: 160 }, - { companyId: playerCompany.id, itemKey: "ironIngot", quantity: 12 }, - { companyId: playerCompany.id, itemKey: "copperIngot", quantity: 6 } + { companyId: playerCompany.id, itemKey: "ironOre", quantity: 200 }, + { companyId: playerCompany.id, itemKey: "coal", quantity: 120 }, + { companyId: playerCompany.id, itemKey: "copperOre", quantity: 150 }, + { companyId: playerCompany.id, itemKey: "water", quantity: 150 }, + { companyId: playerCompany.id, itemKey: "fertilizer", quantity: 120 }, + { companyId: playerCompany.id, itemKey: "bioSubstrate", quantity: 130 }, + { companyId: playerCompany.id, itemKey: "ironIngot", quantity: 10 }, + { companyId: playerCompany.id, itemKey: "copperIngot", quantity: 5 } ); } @@ -766,6 +766,23 @@ export async function seedWorld( })) }); + // Create factory for player company to support production + if (playerCompany) { + await prisma.building.create({ + data: { + companyId: playerCompany.id, + regionId: coreRegion.id, + buildingType: "FACTORY", + status: "ACTIVE", + acquisitionCostCents: 0n, + weeklyOperatingCostCents: 0n, + capacitySlots: 5, + tickAcquired: 0, + lastOperatingCostTick: 0 + } + }); + } + const recipesByKey: Record = {}; for (const definition of RECIPE_DEFINITIONS) { const recipe = await prisma.recipe.create({ diff --git a/packages/sim/src/services/buildings.ts b/packages/sim/src/services/buildings.ts index 09e3e679..96fa98a6 100644 --- a/packages/sim/src/services/buildings.ts +++ b/packages/sim/src/services/buildings.ts @@ -124,10 +124,26 @@ export interface ReactivateBuildingInput { tick: number; } +/** + * ## Building Staffing (Future Enhancement) + * + * When employee assignment is implemented: + * - Buildings will have minEmployees, maxEmployees, employeesAssigned fields + * - Effective capacity = baseCapacity * (employeesAssigned - minEmployees) / (maxEmployees - minEmployees) + * - Buildings with employeesAssigned < minEmployees automatically set to INACTIVE status + * - Staffing changes do not affect running production jobs, only new jobs + * + * Current implementation: + * - All buildings use full capacity when ACTIVE + * - Staffing mechanics deferred to future phase + */ + /** * Constants */ export const BUILDING_OPERATING_COST_INTERVAL_TICKS = 7; // Weekly +export const BASE_STORAGE_CAPACITY_PER_REGION = 1000; +export const WAREHOUSE_CAPACITY_PER_SLOT = 500; /** * Validates building acquisition input @@ -512,3 +528,136 @@ export async function getProductionCapacityForCompany( return { totalCapacity, usedCapacity }; } + +/** + * Calculates total storage capacity for a region + * + * @param warehouseCount - Number of active warehouses in the region + * @param capacityPerWarehouse - Storage capacity per warehouse (default: WAREHOUSE_CAPACITY_PER_SLOT) + * @param baseCapacity - Base storage capacity per region (default: BASE_STORAGE_CAPACITY_PER_REGION) + * @returns Total storage capacity + * + * @remarks + * - Base capacity is the minimum storage available in any region + * - Each warehouse adds additional capacity based on capacitySlots or fixed amount + * - Formula: baseCapacity + (warehouseCount * capacityPerWarehouse) + */ +export function calculateRegionalStorageCapacity( + warehouseCount: number, + capacityPerWarehouse: number = WAREHOUSE_CAPACITY_PER_SLOT, + baseCapacity: number = BASE_STORAGE_CAPACITY_PER_REGION +): number { + if (!Number.isInteger(warehouseCount) || warehouseCount < 0) { + throw new DomainInvariantError("warehouseCount must be a non-negative integer"); + } + if (!Number.isInteger(capacityPerWarehouse) || capacityPerWarehouse < 0) { + throw new DomainInvariantError("capacityPerWarehouse must be a non-negative integer"); + } + if (!Number.isInteger(baseCapacity) || baseCapacity < 0) { + throw new DomainInvariantError("baseCapacity must be a non-negative integer"); + } + + return baseCapacity + (warehouseCount * capacityPerWarehouse); +} + +/** + * Validates that adding inventory to a region would not exceed storage capacity + * + * @param tx - Prisma transaction client + * @param companyId - Company ID + * @param regionId - Region ID + * @param quantityToAdd - Quantity of inventory to add + * + * @throws {DomainInvariantError} If adding inventory would exceed regional storage capacity + * + * @remarks + * - Calculates current total inventory in region (all items combined) + * - Gets warehouse count to calculate total capacity + * - Throws error if current + quantityToAdd exceeds capacity + * - Must be called before any inventory mutation (production, market, shipments) + */ +export async function validateStorageCapacity( + tx: Prisma.TransactionClient, + companyId: string, + regionId: string, + quantityToAdd: number +): Promise { + if (!companyId) { + throw new DomainInvariantError("companyId is required"); + } + if (!regionId) { + throw new DomainInvariantError("regionId is required"); + } + if (!Number.isInteger(quantityToAdd) || quantityToAdd < 0) { + throw new DomainInvariantError("quantityToAdd must be a non-negative integer"); + } + + // Get current total inventory in region + const currentInventory = await tx.inventory.aggregate({ + where: { companyId, regionId }, + _sum: { quantity: true } + }); + + // Get warehouse count for capacity calculation + const warehouseCount = await tx.building.count({ + where: { + companyId, + regionId, + buildingType: BuildingType.WAREHOUSE, + status: BuildingStatus.ACTIVE + } + }); + + const capacity = calculateRegionalStorageCapacity(warehouseCount); + const currentTotal = currentInventory._sum.quantity || 0; + + if (currentTotal + quantityToAdd > capacity) { + throw new DomainInvariantError( + `storage capacity exceeded: current=${currentTotal}, adding=${quantityToAdd}, capacity=${capacity}` + ); + } +} + +/** + * Validates that a company has at least one active production building + * + * @param tx - Prisma transaction client + * @param companyId - Company ID + * + * @throws {DomainInvariantError} If company has no active production buildings + * + * @remarks + * - Production buildings include: MINE, FARM, FACTORY, MEGA_FACTORY + * - Only ACTIVE buildings are counted + * - Must be called before creating production jobs + */ +export async function validateProductionBuildingAvailable( + tx: Prisma.TransactionClient, + companyId: string +): Promise { + if (!companyId) { + throw new DomainInvariantError("companyId is required"); + } + + const productionBuildingTypes = [ + BuildingType.MINE, + BuildingType.FARM, + BuildingType.FACTORY, + BuildingType.MEGA_FACTORY + ]; + + const activeBuildingCount = await tx.building.count({ + where: { + companyId, + buildingType: { in: productionBuildingTypes }, + status: BuildingStatus.ACTIVE + } + }); + + if (activeBuildingCount === 0) { + throw new DomainInvariantError( + `company ${companyId} has no active production buildings` + ); + } +} + diff --git a/packages/sim/src/services/market-matching.ts b/packages/sim/src/services/market-matching.ts index df5b34a6..ec117812 100644 --- a/packages/sim/src/services/market-matching.ts +++ b/packages/sim/src/services/market-matching.ts @@ -61,6 +61,7 @@ import { PrismaClient } from "@prisma/client"; import { DomainInvariantError, NotFoundError } from "../domain/errors"; +import { validateStorageCapacity } from "./buildings"; /** * Order representation for matching purposes. @@ -297,6 +298,22 @@ async function settleMatch( const buyReservedCash = buyOrder.reservedCashCents - reserveReduction; const sellReservedQuantity = sellOrder.reservedQuantity - match.quantity; + // Validate storage capacity BEFORE any inventory mutations + // Skip validation for self-trades in the same region and item (net inventory change is zero) + const isSelfTradeInSameRegionAndItem = + buyOrder.companyId === sellOrder.companyId && + buyOrder.regionId === sellOrder.regionId && + buyOrder.itemId === sellOrder.itemId; + + if (!isSelfTradeInSameRegionAndItem) { + await validateStorageCapacity( + tx, + buyOrder.companyId, + buyOrder.regionId, + match.quantity + ); + } + if (buyerIsSeller) { await tx.company.update({ where: { id: buyerCompany.id }, diff --git a/packages/sim/src/services/production.ts b/packages/sim/src/services/production.ts index 722a4c8f..060a584a 100644 --- a/packages/sim/src/services/production.ts +++ b/packages/sim/src/services/production.ts @@ -81,6 +81,10 @@ import { applyDurationMultiplierTicks, resolveWorkforceRuntimeModifiers } from "./workforce"; +import { + validateProductionBuildingAvailable, + validateStorageCapacity +} from "./buildings"; interface RecipeInputRow { itemId: string; @@ -488,6 +492,12 @@ export async function createProductionJobWithTx( throw new DomainInvariantError("recipe durationTicks cannot be negative"); } + // Validate player company has at least one active production building + // (bots operate with different rules and may not have buildings) + if (company.isPlayer) { + await validateProductionBuildingAvailable(tx, input.companyId); + } + const requirements = calculateRecipeInputRequirements(recipe.inputs, input.quantity); const requiredItemIds = requirements.map((entry) => entry.itemId); @@ -845,6 +855,26 @@ export async function completeDueProductionJobs( ); } + const outputQuantity = job.recipe.outputQuantity * job.runs; + + // Calculate net inventory change (outputs added minus inputs consumed) + const totalInputQuantity = requirements.reduce( + (sum, requirement) => sum + requirement.quantity, + 0 + ); + const netInventoryChange = outputQuantity - totalInputQuantity; + + // Validate storage capacity accounts for net change after consuming inputs + // (only validate if net change is positive, i.e., we're adding more than consuming) + if (netInventoryChange > 0) { + await validateStorageCapacity( + tx, + job.companyId, + job.company.regionId, + netInventoryChange + ); + } + for (const requirement of requirements) { await tx.inventory.update({ where: { @@ -865,8 +895,6 @@ export async function completeDueProductionJobs( }); } - const outputQuantity = job.recipe.outputQuantity * job.runs; - await tx.inventory.upsert({ where: { companyId_itemId_regionId: { diff --git a/packages/sim/src/services/shipments.ts b/packages/sim/src/services/shipments.ts index ffe3566e..92a0d723 100644 --- a/packages/sim/src/services/shipments.ts +++ b/packages/sim/src/services/shipments.ts @@ -84,6 +84,7 @@ import { applyDurationMultiplierTicks, resolveWorkforceRuntimeModifiers } from "./workforce"; +import { validateStorageCapacity } from "./buildings"; export interface ShipmentRuntimeConfig { baseFeeCents: bigint; @@ -670,6 +671,14 @@ export async function deliverDueShipmentsForTick( continue; } + // Validate storage capacity before adding delivered inventory + await validateStorageCapacity( + tx, + shipment.companyId, + shipment.toRegionId, + shipment.quantity + ); + await tx.inventory.upsert({ where: { companyId_itemId_regionId: { diff --git a/packages/sim/src/services/tick-engine.ts b/packages/sim/src/services/tick-engine.ts index c10ea494..635a73e1 100644 --- a/packages/sim/src/services/tick-engine.ts +++ b/packages/sim/src/services/tick-engine.ts @@ -239,7 +239,6 @@ function isTickExecutionConflict(error: unknown, executionKey: string | undefine * ## Pipeline Stages (Executed Sequentially) * 1. Bot actions (market orders, production starts) * 2. Building operating costs (deduct costs, deactivate unpaid buildings) - * - Note: Production validation doesn't yet check building status (Phase 2) * 3. Production job completions * 4. Research completions and recipe unlocks * 5. Market matching and settlement @@ -249,6 +248,13 @@ function isTickExecutionConflict(error: unknown, executionKey: string | undefine * 9. Contract lifecycle (expiration and generation) * 10. Market candle aggregation (OHLC/VWAP/volume) * + * ## Phase 3 Validations + * - Storage capacity enforced at inventory mutation points + * - Production: validates net inventory change (outputs - inputs consumed) + * - Market settlement: validates buyer's capacity before trade execution + * - Shipment delivery: validates destination capacity before delivery + * - Building availability validated for production job creation + * * ## Determinism * - Order is fixed and must not change (breaking change if reordered) * - Each stage reads state modified by previous stages @@ -266,7 +272,6 @@ async function runTickPipeline( // Tick pipeline order: // 1) bot actions (orders / production starts) // 2) building operating costs (deactivate unpaid buildings) - // Note: Production validation doesn't yet check building status (Phase 2) // 3) production completions // 4) research completions and recipe unlocks // 5) market matching and settlement @@ -276,6 +281,13 @@ async function runTickPipeline( // 9) contract lifecycle (expire and generate) // 10) market candle aggregation (OHLC/VWAP/volume) // 11) finalize world tick state + // + // Phase 3 Validations: + // - Storage capacity enforced at inventory mutation points: + // * Production: validates net change (outputs - inputs), skips if net negative + // * Market: validates buyer capacity before trade execution + // * Shipments: validates destination capacity before delivery + // - Building availability validated for production job creation if (options.runBots) { await runBotsForTick(tx, nextTick, options.botConfig); } diff --git a/packages/sim/tests/buildings.test.ts b/packages/sim/tests/buildings.test.ts index 1c05a2f1..ee14db7f 100644 --- a/packages/sim/tests/buildings.test.ts +++ b/packages/sim/tests/buildings.test.ts @@ -8,6 +8,7 @@ import { applyBuildingOperatingCostsWithTx, reactivateBuildingWithTx, getProductionCapacityForCompany, + validateProductionBuildingAvailable, BUILDING_OPERATING_COST_INTERVAL_TICKS } from "../src"; @@ -523,4 +524,42 @@ describe("building service", () => { expect(result.usedCapacity).toBe(0); }); }); + + describe("validateProductionBuildingAvailable", () => { + it("validates company has active production building", async () => { + const tx = { + building: { + count: vi.fn().mockResolvedValue(2) + } + } as unknown as Prisma.TransactionClient; + + await expect( + validateProductionBuildingAvailable(tx, "company-1") + ).resolves.not.toThrow(); + }); + + it("throws when company has no active production buildings", async () => { + const tx = { + building: { + count: vi.fn().mockResolvedValue(0) + } + } as unknown as Prisma.TransactionClient; + + await expect( + validateProductionBuildingAvailable(tx, "company-1") + ).rejects.toThrow(DomainInvariantError); + }); + + it("validates required parameters", async () => { + const tx = { + building: { + count: vi.fn().mockResolvedValue(1) + } + } as unknown as Prisma.TransactionClient; + + await expect( + validateProductionBuildingAvailable(tx, "") + ).rejects.toThrow(DomainInvariantError); + }); + }); }); diff --git a/packages/sim/tests/storage.test.ts b/packages/sim/tests/storage.test.ts new file mode 100644 index 00000000..80de02a9 --- /dev/null +++ b/packages/sim/tests/storage.test.ts @@ -0,0 +1,94 @@ +import { Prisma } from "@prisma/client"; +import { describe, expect, it, vi } from "vitest"; +import { + DomainInvariantError, + calculateRegionalStorageCapacity, + validateStorageCapacity, + BASE_STORAGE_CAPACITY_PER_REGION, + WAREHOUSE_CAPACITY_PER_SLOT +} from "../src"; + +describe("storage capacity system", () => { + it("calculates base storage capacity correctly", () => { + const capacity = calculateRegionalStorageCapacity(0); + expect(capacity).toBe(BASE_STORAGE_CAPACITY_PER_REGION); + }); + + it("adds warehouse capacity to base", () => { + const capacity = calculateRegionalStorageCapacity(2); + expect(capacity).toBe(BASE_STORAGE_CAPACITY_PER_REGION + (2 * WAREHOUSE_CAPACITY_PER_SLOT)); + }); + + it("validates storage capacity and throws on overflow", async () => { + const tx = { + inventory: { + aggregate: vi.fn().mockResolvedValue({ + _sum: { quantity: 900 } + }) + }, + building: { + count: vi.fn().mockResolvedValue(0) // No warehouses + } + } as unknown as Prisma.TransactionClient; + + // Should fail: 900 + 200 = 1100 > 1000 base capacity + await expect( + validateStorageCapacity(tx, "company-1", "region-1", 200) + ).rejects.toThrow(DomainInvariantError); + }); + + it("allows storage within capacity", async () => { + const tx = { + inventory: { + aggregate: vi.fn().mockResolvedValue({ + _sum: { quantity: 500 } + }) + }, + building: { + count: vi.fn().mockResolvedValue(1) // 1 warehouse = 1500 total capacity + } + } as unknown as Prisma.TransactionClient; + + // Should succeed: 500 + 800 = 1300 < 1500 + await expect( + validateStorageCapacity(tx, "company-1", "region-1", 800) + ).resolves.not.toThrow(); + }); + + it("throws on negative warehouse count", () => { + expect(() => calculateRegionalStorageCapacity(-1)).toThrow(DomainInvariantError); + }); + + it("throws on negative capacity per warehouse", () => { + expect(() => calculateRegionalStorageCapacity(1, -500)).toThrow(DomainInvariantError); + }); + + it("throws on negative base capacity", () => { + expect(() => calculateRegionalStorageCapacity(1, 500, -1000)).toThrow(DomainInvariantError); + }); + + it("validates required parameters in validateStorageCapacity", async () => { + const tx = { + inventory: { + aggregate: vi.fn().mockResolvedValue({ + _sum: { quantity: 0 } + }) + }, + building: { + count: vi.fn().mockResolvedValue(0) + } + } as unknown as Prisma.TransactionClient; + + await expect( + validateStorageCapacity(tx, "", "region-1", 100) + ).rejects.toThrow(DomainInvariantError); + + await expect( + validateStorageCapacity(tx, "company-1", "", 100) + ).rejects.toThrow(DomainInvariantError); + + await expect( + validateStorageCapacity(tx, "company-1", "region-1", -10) + ).rejects.toThrow(DomainInvariantError); + }); +});