diff --git a/.releases/unreleased/20260218155700-add-building-infrastructure-phase-1.md b/.releases/unreleased/20260218155700-add-building-infrastructure-phase-1.md new file mode 100644 index 00000000..590cf386 --- /dev/null +++ b/.releases/unreleased/20260218155700-add-building-infrastructure-phase-1.md @@ -0,0 +1,14 @@ +--- +type: minor +area: sim +summary: Add building infrastructure domain layer for capital-based production system +--- + +- Add Building model with BuildingType and BuildingStatus enums to Prisma schema +- Add BUILDING_OPERATING_COST and BUILDING_ACQUISITION ledger entry types +- Implement building acquisition, operating cost application, and reactivation services +- Add production capacity tracking based on active buildings +- Buildings have weekly operating costs (7 ticks interval) +- Buildings deactivate when company cannot afford operating costs +- Create comprehensive test suite (12 passing tests) +- Prepare foundation for infrastructure-based production requirements diff --git a/docs/agents/AGENTS.md b/docs/agents/AGENTS.md index 800a209a..d3469db9 100644 --- a/docs/agents/AGENTS.md +++ b/docs/agents/AGENTS.md @@ -298,15 +298,16 @@ availableInventory = quantity - reservedQuantity ### Tick Pipeline Order (NEVER REORDER) 1. Bot actions -2. Production completions -3. Research completions -4. Market matching -5. Shipment deliveries -6. Workforce updates -7. Demand sink -8. Contract lifecycle -9. Market candles -10. World state update +2. Building operating costs +3. Production completions +4. Research completions +5. Market matching +6. Shipment deliveries +7. Workforce updates +8. Demand sink +9. Contract lifecycle +10. Market candles +11. World state update Changing this order is a **breaking change**. diff --git a/packages/db/prisma/migrations/20260218155056_add_building_infrastructure/migration.sql b/packages/db/prisma/migrations/20260218155056_add_building_infrastructure/migration.sql new file mode 100644 index 00000000..432d6f51 --- /dev/null +++ b/packages/db/prisma/migrations/20260218155056_add_building_infrastructure/migration.sql @@ -0,0 +1,66 @@ +-- CreateEnum +CREATE TYPE "BuildingType" AS ENUM ('MINE', 'FARM', 'FACTORY', 'MEGA_FACTORY', 'WAREHOUSE', 'HEADQUARTERS', 'RND_CENTER'); + +-- CreateEnum +CREATE TYPE "BuildingStatus" AS ENUM ('ACTIVE', 'INACTIVE', 'CONSTRUCTION'); + +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "LedgerEntryType" ADD VALUE 'BUILDING_OPERATING_COST'; +ALTER TYPE "LedgerEntryType" ADD VALUE 'BUILDING_ACQUISITION'; + +-- AlterTable +ALTER TABLE "ProductionJob" ADD COLUMN "buildingId" TEXT; + +-- AlterTable +ALTER TABLE "SimulationControlState" ALTER COLUMN "updatedAt" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "SimulationLease" ALTER COLUMN "updatedAt" DROP DEFAULT; + +-- CreateTable +CREATE TABLE "Building" ( + "id" TEXT NOT NULL, + "companyId" TEXT NOT NULL, + "regionId" TEXT NOT NULL, + "buildingType" "BuildingType" NOT NULL, + "status" "BuildingStatus" NOT NULL DEFAULT 'ACTIVE', + "name" TEXT, + "acquisitionCostCents" BIGINT NOT NULL, + "weeklyOperatingCostCents" BIGINT NOT NULL, + "capacitySlots" INTEGER NOT NULL DEFAULT 1, + "tickAcquired" INTEGER NOT NULL, + "tickConstructionCompletes" INTEGER, + "lastOperatingCostTick" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Building_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Building_companyId_status_idx" ON "Building"("companyId", "status"); + +-- CreateIndex +CREATE INDEX "Building_regionId_buildingType_idx" ON "Building"("regionId", "buildingType"); + +-- CreateIndex +CREATE INDEX "Building_status_lastOperatingCostTick_idx" ON "Building"("status", "lastOperatingCostTick"); + +-- CreateIndex +CREATE INDEX "ProductionJob_buildingId_status_idx" ON "ProductionJob"("buildingId", "status"); + +-- AddForeignKey +ALTER TABLE "ProductionJob" ADD CONSTRAINT "ProductionJob_buildingId_fkey" FOREIGN KEY ("buildingId") REFERENCES "Building"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Building" ADD CONSTRAINT "Building_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Building" ADD CONSTRAINT "Building_regionId_fkey" FOREIGN KEY ("regionId") REFERENCES "Region"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index e312bea2..b640fe77 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -71,56 +71,75 @@ enum LedgerEntryType { PRODUCTION_COST WORKFORCE_SALARY_EXPENSE WORKFORCE_RECRUITMENT_EXPENSE + BUILDING_OPERATING_COST + BUILDING_ACQUISITION MANUAL_ADJUSTMENT } +enum BuildingType { + MINE + FARM + FACTORY + MEGA_FACTORY + WAREHOUSE + HEADQUARTERS + RND_CENTER +} + +enum BuildingStatus { + ACTIVE + INACTIVE + CONSTRUCTION +} + model Company { - id String @id @default(cuid()) - code String @unique - name String - isPlayer Boolean @default(false) - specialization CompanySpecialization @default(UNASSIGNED) - specializationChangedAt DateTime? - ownerPlayerId String? - regionId String - cashCents BigInt - reservedCashCents BigInt @default(0) - workforceCapacity Int @default(0) - workforceAllocationOpsPct Int @default(40) - workforceAllocationRngPct Int @default(20) - workforceAllocationLogPct Int @default(20) - workforceAllocationCorpPct Int @default(20) - orgEfficiencyBps Int @default(7000) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - ownerPlayer Player? @relation(fields: [ownerPlayerId], references: [id], onDelete: SetNull) - region Region @relation(fields: [regionId], references: [id], onDelete: Restrict) - inventories Inventory[] - orders MarketOrder[] - productionJobs ProductionJob[] - companyRecipes CompanyRecipe[] - companyResearches CompanyResearch[] - researchJobs ResearchJob[] - shipments Shipment[] - ledgerEntries LedgerEntry[] - workforceCapacityDeltas WorkforceCapacityDelta[] - buyContracts Contract[] @relation("ContractBuyerCompany") - sellContracts Contract[] @relation("ContractSellerCompany") - contractFulfillments ContractFulfillment[] @relation("ContractFulfillmentSellerCompany") - buyTrades Trade[] @relation("TradeBuyerCompany") - sellTrades Trade[] @relation("TradeSellerCompany") + id String @id @default(cuid()) + code String @unique + name String + isPlayer Boolean @default(false) + specialization CompanySpecialization @default(UNASSIGNED) + specializationChangedAt DateTime? + ownerPlayerId String? + regionId String + cashCents BigInt + reservedCashCents BigInt @default(0) + workforceCapacity Int @default(0) + workforceAllocationOpsPct Int @default(40) + workforceAllocationRngPct Int @default(20) + workforceAllocationLogPct Int @default(20) + workforceAllocationCorpPct Int @default(20) + orgEfficiencyBps Int @default(7000) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + ownerPlayer Player? @relation(fields: [ownerPlayerId], references: [id], onDelete: SetNull) + region Region @relation(fields: [regionId], references: [id], onDelete: Restrict) + inventories Inventory[] + orders MarketOrder[] + productionJobs ProductionJob[] + companyRecipes CompanyRecipe[] + companyResearches CompanyResearch[] + researchJobs ResearchJob[] + shipments Shipment[] + ledgerEntries LedgerEntry[] + workforceCapacityDeltas WorkforceCapacityDelta[] + buyContracts Contract[] @relation("ContractBuyerCompany") + sellContracts Contract[] @relation("ContractSellerCompany") + contractFulfillments ContractFulfillment[] @relation("ContractFulfillmentSellerCompany") + buyTrades Trade[] @relation("TradeBuyerCompany") + sellTrades Trade[] @relation("TradeSellerCompany") + buildings Building[] @@index([ownerPlayerId]) @@index([regionId]) } model Player { - id String @id @default(cuid()) - handle String @unique + id String @id @default(cuid()) + handle String @unique tutorialCompletedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - companies Company[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + companies Company[] } model User { @@ -148,16 +167,16 @@ model User { } model Session { - id String @id - expiresAt DateTime - token String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - ipAddress String? - userAgent String? + id String @id + expiresAt DateTime + token String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + ipAddress String? + userAgent String? impersonatedBy String? - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([token]) @@index([userId]) @@ -209,48 +228,49 @@ model TwoFactor { } model Item { - id String @id @default(cuid()) - code String @unique - name String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - inventories Inventory[] - orders MarketOrder[] - trades Trade[] - candles ItemTickCandle[] - shipments Shipment[] - outputRecipes Recipe[] @relation("RecipeOutput") - recipeInputs RecipeInput[] - contracts Contract[] + id String @id @default(cuid()) + code String @unique + name String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + inventories Inventory[] + orders MarketOrder[] + trades Trade[] + candles ItemTickCandle[] + shipments Shipment[] + outputRecipes Recipe[] @relation("RecipeOutput") + recipeInputs RecipeInput[] + contracts Contract[] contractFulfillments ContractFulfillment[] } model Region { - id String @id @default(cuid()) - code String @unique - name String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - companies Company[] - inventories Inventory[] - marketOrders MarketOrder[] - trades Trade[] - itemTickCandles ItemTickCandle[] - shipmentsFrom Shipment[] @relation("ShipmentFromRegion") - shipmentsTo Shipment[] @relation("ShipmentToRegion") + id String @id @default(cuid()) + code String @unique + name String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + companies Company[] + inventories Inventory[] + marketOrders MarketOrder[] + trades Trade[] + itemTickCandles ItemTickCandle[] + shipmentsFrom Shipment[] @relation("ShipmentFromRegion") + shipmentsTo Shipment[] @relation("ShipmentToRegion") + buildings Building[] } model Inventory { - companyId String - itemId String - regionId String - quantity Int @default(0) - reservedQuantity Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) - item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) - region Region @relation(fields: [regionId], references: [id], onDelete: Restrict) + companyId String + itemId String + regionId String + quantity Int @default(0) + reservedQuantity Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) + region Region @relation(fields: [regionId], references: [id], onDelete: Restrict) @@id([companyId, itemId, regionId]) @@index([companyId]) @@ -259,22 +279,22 @@ model Inventory { } model Shipment { - id String @id @default(cuid()) - companyId String - fromRegionId String - toRegionId String - itemId String - quantity Int - status ShipmentStatus @default(IN_TRANSIT) - tickCreated Int - tickArrives Int - tickClosed Int? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - company Company @relation(fields: [companyId], references: [id], onDelete: Restrict) - fromRegion Region @relation("ShipmentFromRegion", fields: [fromRegionId], references: [id], onDelete: Restrict) - toRegion Region @relation("ShipmentToRegion", fields: [toRegionId], references: [id], onDelete: Restrict) - item Item @relation(fields: [itemId], references: [id], onDelete: Restrict) + id String @id @default(cuid()) + companyId String + fromRegionId String + toRegionId String + itemId String + quantity Int + status ShipmentStatus @default(IN_TRANSIT) + tickCreated Int + tickArrives Int + tickClosed Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + company Company @relation(fields: [companyId], references: [id], onDelete: Restrict) + fromRegion Region @relation("ShipmentFromRegion", fields: [fromRegionId], references: [id], onDelete: Restrict) + toRegion Region @relation("ShipmentToRegion", fields: [toRegionId], references: [id], onDelete: Restrict) + item Item @relation(fields: [itemId], references: [id], onDelete: Restrict) @@index([companyId, status, tickCreated]) @@index([status, tickArrives]) @@ -282,27 +302,27 @@ model Shipment { } model MarketOrder { - id String @id @default(cuid()) + id String @id @default(cuid()) companyId String itemId String regionId String side OrderSide - status OrderStatus @default(OPEN) + status OrderStatus @default(OPEN) quantity Int remainingQuantity Int unitPriceCents BigInt - reservedCashCents BigInt @default(0) - reservedQuantity Int @default(0) + reservedCashCents BigInt @default(0) + reservedQuantity Int @default(0) tickPlaced Int tickClosed Int? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt closedAt DateTime? - company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) - item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) - region Region @relation(fields: [regionId], references: [id], onDelete: Restrict) - buyTrades Trade[] @relation("TradeBuyOrder") - sellTrades Trade[] @relation("TradeSellOrder") + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) + region Region @relation(fields: [regionId], references: [id], onDelete: Restrict) + buyTrades Trade[] @relation("TradeBuyOrder") + sellTrades Trade[] @relation("TradeSellOrder") @@index([regionId, itemId, side, status, unitPriceCents, createdAt]) @@index([companyId, status]) @@ -310,24 +330,24 @@ model MarketOrder { } model Trade { - id String @id @default(cuid()) - buyOrderId String - sellOrderId String - buyerCompanyId String - sellerCompanyId String - itemId String - regionId String - quantity Int - unitPriceCents BigInt - totalPriceCents BigInt - tick Int - createdAt DateTime @default(now()) - buyOrder MarketOrder @relation("TradeBuyOrder", fields: [buyOrderId], references: [id], onDelete: Cascade) - sellOrder MarketOrder @relation("TradeSellOrder", fields: [sellOrderId], references: [id], onDelete: Cascade) - buyerCompany Company @relation("TradeBuyerCompany", fields: [buyerCompanyId], references: [id], onDelete: Cascade) - sellerCompany Company @relation("TradeSellerCompany", fields: [sellerCompanyId], references: [id], onDelete: Cascade) - item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) - region Region @relation(fields: [regionId], references: [id], onDelete: Restrict) + id String @id @default(cuid()) + buyOrderId String + sellOrderId String + buyerCompanyId String + sellerCompanyId String + itemId String + regionId String + quantity Int + unitPriceCents BigInt + totalPriceCents BigInt + tick Int + createdAt DateTime @default(now()) + buyOrder MarketOrder @relation("TradeBuyOrder", fields: [buyOrderId], references: [id], onDelete: Cascade) + sellOrder MarketOrder @relation("TradeSellOrder", fields: [sellOrderId], references: [id], onDelete: Cascade) + buyerCompany Company @relation("TradeBuyerCompany", fields: [buyerCompanyId], references: [id], onDelete: Cascade) + sellerCompany Company @relation("TradeSellerCompany", fields: [sellerCompanyId], references: [id], onDelete: Cascade) + item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) + region Region @relation(fields: [regionId], references: [id], onDelete: Restrict) @@index([regionId, itemId, tick]) @@index([buyerCompanyId, tick]) @@ -336,110 +356,137 @@ model Trade { } model Recipe { - id String @id @default(cuid()) - code String @unique - name String - durationTicks Int - outputItemId String - outputQuantity Int - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - outputItem Item @relation("RecipeOutput", fields: [outputItemId], references: [id], onDelete: Restrict) - inputs RecipeInput[] - jobs ProductionJob[] - companyRecipes CompanyRecipe[] - unlockedByNodes ResearchNodeUnlockRecipe[] + id String @id @default(cuid()) + code String @unique + name String + durationTicks Int + outputItemId String + outputQuantity Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + outputItem Item @relation("RecipeOutput", fields: [outputItemId], references: [id], onDelete: Restrict) + inputs RecipeInput[] + jobs ProductionJob[] + companyRecipes CompanyRecipe[] + unlockedByNodes ResearchNodeUnlockRecipe[] } model RecipeInput { - recipeId String - itemId String - quantity Int - recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) - item Item @relation(fields: [itemId], references: [id], onDelete: Restrict) + recipeId String + itemId String + quantity Int + recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) + item Item @relation(fields: [itemId], references: [id], onDelete: Restrict) @@id([recipeId, itemId]) } model ProductionJob { - id String @id @default(cuid()) - companyId String - recipeId String - status ProductionJobStatus @default(QUEUED) - runs Int @default(1) - startedTick Int - dueTick Int - completedTick Int? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) - recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Restrict) + id String @id @default(cuid()) + companyId String + recipeId String + buildingId String? + status ProductionJobStatus @default(QUEUED) + runs Int @default(1) + startedTick Int + dueTick Int + completedTick Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Restrict) + building Building? @relation(fields: [buildingId], references: [id], onDelete: Restrict) @@index([status, dueTick]) @@index([companyId, status]) + @@index([buildingId, status]) +} + +model Building { + id String @id @default(cuid()) + companyId String + regionId String + buildingType BuildingType + status BuildingStatus @default(ACTIVE) + name String? + acquisitionCostCents BigInt + weeklyOperatingCostCents BigInt + capacitySlots Int @default(1) + tickAcquired Int + tickConstructionCompletes Int? + lastOperatingCostTick Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + region Region @relation(fields: [regionId], references: [id], onDelete: Restrict) + productionJobs ProductionJob[] + + @@index([companyId, status]) + @@index([regionId, buildingType]) + @@index([status, lastOperatingCostTick]) } model CompanyRecipe { - companyId String - recipeId String - isUnlocked Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) - recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) + companyId String + recipeId String + isUnlocked Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) @@id([companyId, recipeId]) @@index([companyId, isUnlocked]) } model ResearchNode { - id String @id @default(cuid()) - code String @unique + id String @id @default(cuid()) + code String @unique name String description String costCashCents BigInt durationTicks Int - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt unlockRecipes ResearchNodeUnlockRecipe[] - prerequisites ResearchPrerequisite[] @relation("ResearchNodePrerequisites") - requiredBy ResearchPrerequisite[] @relation("ResearchNodeRequiredBy") + prerequisites ResearchPrerequisite[] @relation("ResearchNodePrerequisites") + requiredBy ResearchPrerequisite[] @relation("ResearchNodeRequiredBy") companyResearches CompanyResearch[] researchJobs ResearchJob[] } model ResearchNodeUnlockRecipe { - nodeId String - recipeId String - node ResearchNode @relation(fields: [nodeId], references: [id], onDelete: Cascade) - recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) + nodeId String + recipeId String + node ResearchNode @relation(fields: [nodeId], references: [id], onDelete: Cascade) + recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) @@id([nodeId, recipeId]) @@index([recipeId]) } model ResearchPrerequisite { - id String @id @default(cuid()) - nodeId String - prerequisiteNodeId String - node ResearchNode @relation("ResearchNodePrerequisites", fields: [nodeId], references: [id], onDelete: Cascade) - prerequisiteNode ResearchNode @relation("ResearchNodeRequiredBy", fields: [prerequisiteNodeId], references: [id], onDelete: Restrict) + id String @id @default(cuid()) + nodeId String + prerequisiteNodeId String + node ResearchNode @relation("ResearchNodePrerequisites", fields: [nodeId], references: [id], onDelete: Cascade) + prerequisiteNode ResearchNode @relation("ResearchNodeRequiredBy", fields: [prerequisiteNodeId], references: [id], onDelete: Restrict) @@unique([nodeId, prerequisiteNodeId]) @@index([prerequisiteNodeId]) } model CompanyResearch { - id String @id @default(cuid()) + id String @id @default(cuid()) companyId String nodeId String status CompanyResearchStatus @default(LOCKED) tickStarted Int? tickCompletes Int? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) - node ResearchNode @relation(fields: [nodeId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + node ResearchNode @relation(fields: [nodeId], references: [id], onDelete: Cascade) @@unique([companyId, nodeId]) @@index([companyId, status]) @@ -464,32 +511,32 @@ model ResearchJob { } model WorldTickState { - id Int @id @default(1) - currentTick Int @default(0) - lockVersion Int @default(0) - lastAdvancedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id Int @id @default(1) + currentTick Int @default(0) + lockVersion Int @default(0) + lastAdvancedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model Contract { - id String @id @default(cuid()) + id String @id @default(cuid()) buyerCompanyId String sellerCompanyId String? itemId String quantity Int remainingQuantity Int priceCents BigInt - status ContractStatus @default(OPEN) + status ContractStatus @default(OPEN) tickCreated Int tickExpires Int tickAccepted Int? tickClosed Int? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - buyerCompany Company @relation("ContractBuyerCompany", fields: [buyerCompanyId], references: [id], onDelete: Restrict) - sellerCompany Company? @relation("ContractSellerCompany", fields: [sellerCompanyId], references: [id], onDelete: SetNull) - item Item @relation(fields: [itemId], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + buyerCompany Company @relation("ContractBuyerCompany", fields: [buyerCompanyId], references: [id], onDelete: Restrict) + sellerCompany Company? @relation("ContractSellerCompany", fields: [sellerCompanyId], references: [id], onDelete: SetNull) + item Item @relation(fields: [itemId], references: [id], onDelete: Restrict) fulfillments ContractFulfillment[] @@index([status, tickExpires]) @@ -498,17 +545,17 @@ model Contract { } model ContractFulfillment { - id String @id @default(cuid()) + id String @id @default(cuid()) contractId String sellerCompanyId String itemId String quantity Int priceCents BigInt tick Int - createdAt DateTime @default(now()) - contract Contract @relation(fields: [contractId], references: [id], onDelete: Restrict) - sellerCompany Company @relation("ContractFulfillmentSellerCompany", fields: [sellerCompanyId], references: [id], onDelete: Restrict) - item Item @relation(fields: [itemId], references: [id], onDelete: Restrict) + createdAt DateTime @default(now()) + contract Contract @relation(fields: [contractId], references: [id], onDelete: Restrict) + sellerCompany Company @relation("ContractFulfillmentSellerCompany", fields: [sellerCompanyId], references: [id], onDelete: Restrict) + item Item @relation(fields: [itemId], references: [id], onDelete: Restrict) @@index([contractId, tick]) @@index([sellerCompanyId, tick]) @@ -516,40 +563,40 @@ model ContractFulfillment { } model LedgerEntry { - id String @id @default(cuid()) - companyId String - tick Int - entryType LedgerEntryType + id String @id @default(cuid()) + companyId String + tick Int + entryType LedgerEntryType /// Delta on total cashCents. - deltaCashCents BigInt + deltaCashCents BigInt /// Delta on reservedCashCents; positive reserves cash, negative releases it. - deltaReservedCashCents BigInt @default(0) + deltaReservedCashCents BigInt @default(0) /// cashCents balance after applying this entry. - balanceAfterCents BigInt - referenceType String - referenceId String - createdAt DateTime @default(now()) - company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + balanceAfterCents BigInt + referenceType String + referenceId String + createdAt DateTime @default(now()) + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) @@index([companyId, tick]) } model ItemTickCandle { - id String @id @default(cuid()) - itemId String - regionId String - tick Int - openCents BigInt - highCents BigInt - lowCents BigInt - closeCents BigInt - volumeQty Int - tradeCount Int - vwapCents BigInt? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) - region Region @relation(fields: [regionId], references: [id], onDelete: Restrict) + id String @id @default(cuid()) + itemId String + regionId String + tick Int + openCents BigInt + highCents BigInt + lowCents BigInt + closeCents BigInt + volumeQty Int + tradeCount Int + vwapCents BigInt? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) + region Region @relation(fields: [regionId], references: [id], onDelete: Restrict) @@unique([itemId, regionId, tick]) @@index([itemId, regionId, tick]) @@ -599,23 +646,23 @@ model SimulationTickExecution { } model SimulationControlState { - id Int @id @default(1) - botsPaused Boolean @default(false) - processingStopped Boolean @default(false) + id Int @id @default(1) + botsPaused Boolean @default(false) + processingStopped Boolean @default(false) lastInvariantViolationTick Int? - lastInvariantViolationAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + lastInvariantViolationAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model MissingItemLog { - id String @id @default(cuid()) - itemCode String? - itemName String - context String - source String - metadata String? - createdAt DateTime @default(now()) + id String @id @default(cuid()) + itemCode String? + itemName String + context String + source String + metadata String? + createdAt DateTime @default(now()) @@index([source, createdAt]) @@index([itemCode]) diff --git a/packages/shared/src/api-types.ts b/packages/shared/src/api-types.ts index 788b1c3c..519d3227 100644 --- a/packages/shared/src/api-types.ts +++ b/packages/shared/src/api-types.ts @@ -453,6 +453,8 @@ export type FinanceLedgerEntryType = | "PRODUCTION_COST" | "WORKFORCE_SALARY_EXPENSE" | "WORKFORCE_RECRUITMENT_EXPENSE" + | "BUILDING_OPERATING_COST" + | "BUILDING_ACQUISITION" | "MANUAL_ADJUSTMENT"; export interface FinanceLedgerEntry { diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index 5baea787..caa6c2a8 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -13,6 +13,7 @@ export * from "./services/research"; export * from "./services/contracts"; export * from "./services/regions"; export * from "./services/shipments"; +export * from "./services/buildings"; export * from "./services/reset-simulation"; export * from "./services/tick-engine"; export * from "./bots/bot-runner"; diff --git a/packages/sim/src/services/buildings.ts b/packages/sim/src/services/buildings.ts new file mode 100644 index 00000000..09e3e679 --- /dev/null +++ b/packages/sim/src/services/buildings.ts @@ -0,0 +1,514 @@ +/** + * Building Infrastructure Service + * + * @module buildings + * + * ## Purpose + * Manages the lifecycle of buildings in the infrastructure-based production system. + * Buildings provide production capacity, storage, and corporate capabilities with + * mandatory operating costs. This enforces capital investment requirements and + * introduces fixed-cost financial risk. + * + * ## Building Lifecycle + * 1. **Acquisition**: Company purchases building, pays acquisition cost, building created in ACTIVE state + * 2. **Operation**: Weekly operating costs deducted from company cash during tick processing + * 3. **Deactivation**: If operating costs cannot be paid, building becomes INACTIVE, production paused + * 4. **Reactivation**: When cash is available, building can be manually or automatically reactivated + * + * ## Building Types and Categories + * - **PRODUCTION**: MINE, FARM, FACTORY, MEGA_FACTORY + * - Provide production job capacity + * - Required for production jobs + * - Have capacity slots limiting concurrent jobs + * - **STORAGE**: WAREHOUSE + * - Increase regional storage capacity + * - Prevent infinite stock scaling + * - Have weekly operating costs + * - **CORPORATE**: HEADQUARTERS, RND_CENTER + * - Unlock corporate-level capabilities + * - May provide strategic bonuses (future) + * - Required for advanced automation (future) + * + * ## Invariants Enforced + * - **No Negative Cash**: Operating costs cannot create negative balance + * - **Mandatory Ledger Entries**: All financial mutations write ledger entries + * - **Reserved Cash Respect**: Operating costs check available cash (after reservations) + * - **Regional Association**: Buildings tied to specific region + * - **Operating Cost Tracking**: lastOperatingCostTick prevents duplicate charges + * + * ## Invariants Planned (Not Yet Enforced) + * - **Capacity Limits**: Production jobs cannot exceed building capacity (Phase 2) + * - **Active Building Requirement**: Production requires ACTIVE building (Phase 2) + * - **Storage Limits**: Warehouse capacity limits inventory (Phase 3) + * + * ## Financial Rules + * - Acquisition cost paid upfront (immediate ledger entry) + * - Operating costs charged weekly (7 ticks) + * - If cash insufficient: + * - Building status set to INACTIVE + * - Production paused (no new jobs, existing jobs continue) + * - No silent balance mutation allowed + * + * ## Side Effects + * All operations are transactional: + * - Building creation: Deducts acquisition cost, creates building record, creates ledger entry + * - Operating cost application: Deducts cost OR deactivates building, creates ledger entry + * - Building reactivation: Updates status to ACTIVE (no cost) + * + * ## Transaction Boundaries + * - Each operation (acquire, deactivate, reactivate) is atomic + * - Operating costs applied in batch during tick processing + * - Rollback on any validation failure or constraint violation + * + * ## Determinism + * - Operating costs apply every 7 ticks deterministically + * - Processing order: by building ID (lexicographic) + * - Deactivation deterministic based on cash availability + * + * ## Error Handling + * - NotFoundError: Building or company doesn't exist + * - InsufficientFundsError: Cannot afford acquisition cost + * - DomainInvariantError: Validation failures (negative costs, invalid type) + * - All state changes are transactional; failures leave no partial state + */ + +import { + BuildingStatus, + BuildingType, + LedgerEntryType, + Prisma, + PrismaClient +} from "@prisma/client"; +import { + DomainInvariantError, + InsufficientFundsError, + NotFoundError +} from "../domain/errors"; +import { availableCash } from "../domain/reservations"; + +/** + * Input for acquiring a new building + */ +export interface AcquireBuildingInput { + companyId: string; + regionId: string; + buildingType: BuildingType; + acquisitionCostCents: bigint; + weeklyOperatingCostCents: bigint; + capacitySlots?: number; + tick: number; + name?: string; +} + +/** + * Input for applying operating costs to all active buildings + */ +export interface ApplyBuildingOperatingCostsInput { + tick: number; +} + +/** + * Result of applying operating costs + */ +export interface ApplyBuildingOperatingCostsResult { + processedCount: number; + deactivatedCount: number; + totalCostCents: bigint; +} + +/** + * Input for reactivating an inactive building + */ +export interface ReactivateBuildingInput { + buildingId: string; + tick: number; +} + +/** + * Constants + */ +export const BUILDING_OPERATING_COST_INTERVAL_TICKS = 7; // Weekly + +/** + * Validates building acquisition input + */ +function validateAcquireBuildingInput(input: AcquireBuildingInput): void { + if (!input.companyId) { + throw new DomainInvariantError("companyId is required"); + } + + if (!input.regionId) { + throw new DomainInvariantError("regionId is required"); + } + + if (!input.buildingType) { + throw new DomainInvariantError("buildingType is required"); + } + + if (input.acquisitionCostCents < 0n) { + throw new DomainInvariantError("acquisitionCostCents cannot be negative"); + } + + if (input.weeklyOperatingCostCents < 0n) { + throw new DomainInvariantError("weeklyOperatingCostCents cannot be negative"); + } + + if (input.capacitySlots !== undefined && input.capacitySlots < 1) { + throw new DomainInvariantError("capacitySlots must be at least 1"); + } + + if (input.tick < 0) { + throw new DomainInvariantError("tick must be non-negative"); + } +} + +/** + * Acquires a new building for a company + * + * @param tx - Prisma transaction client + * @param input - Building acquisition parameters + * @returns Created building + * + * @throws {NotFoundError} If company or region doesn't exist + * @throws {InsufficientFundsError} If company cannot afford acquisition cost + * @throws {DomainInvariantError} If validation fails + */ +export async function acquireBuildingWithTx( + tx: Prisma.TransactionClient, + input: AcquireBuildingInput +) { + validateAcquireBuildingInput(input); + + const company = await tx.company.findUnique({ + where: { id: input.companyId }, + select: { + id: true, + cashCents: true, + reservedCashCents: true + } + }); + + if (!company) { + throw new NotFoundError(`company ${input.companyId} not found`); + } + + const region = await tx.region.findUnique({ + where: { id: input.regionId }, + select: { id: true } + }); + + if (!region) { + throw new NotFoundError(`region ${input.regionId} not found`); + } + + const available = availableCash({ + cashCents: company.cashCents, + reservedCashCents: company.reservedCashCents + }); + + if (available < input.acquisitionCostCents) { + throw new InsufficientFundsError( + `insufficient cash to acquire building: need ${input.acquisitionCostCents}, have ${available}` + ); + } + + const newCashCents = company.cashCents - input.acquisitionCostCents; + + await tx.company.update({ + where: { id: input.companyId }, + data: { + cashCents: newCashCents + } + }); + + const building = await tx.building.create({ + data: { + companyId: input.companyId, + regionId: input.regionId, + buildingType: input.buildingType, + status: BuildingStatus.ACTIVE, + name: input.name ?? null, + acquisitionCostCents: input.acquisitionCostCents, + weeklyOperatingCostCents: input.weeklyOperatingCostCents, + capacitySlots: input.capacitySlots ?? 1, + tickAcquired: input.tick, + lastOperatingCostTick: input.tick + } + }); + + await tx.ledgerEntry.create({ + data: { + companyId: input.companyId, + tick: input.tick, + entryType: LedgerEntryType.BUILDING_ACQUISITION, + deltaCashCents: -input.acquisitionCostCents, + deltaReservedCashCents: 0n, + balanceAfterCents: newCashCents, + referenceType: "BUILDING", + referenceId: building.id + } + }); + + return building; +} + +/** + * Applies operating costs to all active buildings for the current tick + * + * @param tx - Prisma transaction client + * @param input - Tick information + * @returns Result summary + * + * @remarks + * - Operating costs charged every BUILDING_OPERATING_COST_INTERVAL_TICKS (weekly) + * - Buildings with insufficient available cash (after reservations) are deactivated + * - Ledger entries created for each cost application + * - Processing order is deterministic (by building ID) + * - Fresh company data fetched for each building to avoid stale cash values + */ +export async function applyBuildingOperatingCostsWithTx( + tx: Prisma.TransactionClient, + input: ApplyBuildingOperatingCostsInput +): Promise { + const { tick } = input; + + if (tick < 0) { + throw new DomainInvariantError("tick must be non-negative"); + } + + // Find buildings due for operating cost (without company data to avoid stale values) + const dueBuildings = await tx.building.findMany({ + where: { + status: BuildingStatus.ACTIVE, + OR: [ + { + lastOperatingCostTick: null + }, + { + lastOperatingCostTick: { + lte: tick - BUILDING_OPERATING_COST_INTERVAL_TICKS + } + } + ] + }, + orderBy: { + id: "asc" // Deterministic processing order + }, + select: { + id: true, + companyId: true, + weeklyOperatingCostCents: true + } + }); + + let processedCount = 0; + let deactivatedCount = 0; + let totalCostCents = 0n; + + for (const building of dueBuildings) { + const operatingCost = building.weeklyOperatingCostCents; + + // Fetch fresh company data to avoid stale cash values when processing multiple buildings + const company = await tx.company.findUniqueOrThrow({ + where: { id: building.companyId }, + select: { + id: true, + cashCents: true, + reservedCashCents: true + } + }); + + // Check if company has sufficient available cash (respecting reservations) + const available = availableCash({ + cashCents: company.cashCents, + reservedCashCents: company.reservedCashCents + }); + + if (available >= operatingCost) { + // Company can afford operating cost + const newCashCents = company.cashCents - operatingCost; + + await tx.company.update({ + where: { id: building.companyId }, + data: { + cashCents: newCashCents + } + }); + + await tx.building.update({ + where: { id: building.id }, + data: { + lastOperatingCostTick: tick + } + }); + + await tx.ledgerEntry.create({ + data: { + companyId: building.companyId, + tick, + entryType: LedgerEntryType.BUILDING_OPERATING_COST, + deltaCashCents: -operatingCost, + deltaReservedCashCents: 0n, + balanceAfterCents: newCashCents, + referenceType: "BUILDING", + referenceId: building.id + } + }); + + totalCostCents += operatingCost; + processedCount++; + } else { + // Company cannot afford operating cost - deactivate building + await tx.building.update({ + where: { id: building.id }, + data: { + status: BuildingStatus.INACTIVE, + lastOperatingCostTick: tick + } + }); + + deactivatedCount++; + } + } + + return { + processedCount, + deactivatedCount, + totalCostCents + }; +} + +/** + * Reactivates an inactive building + * + * @param tx - Prisma transaction client + * @param input - Building and tick information + * @returns Updated building + * + * @throws {NotFoundError} If building doesn't exist + * @throws {DomainInvariantError} If building is not inactive + * + * @remarks + * - No cost to reactivate + * - Building must be in INACTIVE status + * - Does not charge backdated operating costs + */ +export async function reactivateBuildingWithTx( + tx: Prisma.TransactionClient, + input: ReactivateBuildingInput +) { + if (!input.buildingId) { + throw new DomainInvariantError("buildingId is required"); + } + + if (input.tick < 0) { + throw new DomainInvariantError("tick must be non-negative"); + } + + const building = await tx.building.findUnique({ + where: { id: input.buildingId } + }); + + if (!building) { + throw new NotFoundError(`building ${input.buildingId} not found`); + } + + if (building.status !== BuildingStatus.INACTIVE) { + throw new DomainInvariantError( + `building ${input.buildingId} is not inactive (current status: ${building.status})` + ); + } + + return tx.building.update({ + where: { id: input.buildingId }, + data: { + status: BuildingStatus.ACTIVE, + lastOperatingCostTick: input.tick + } + }); +} + +/** + * Gets buildings for a company + * + * @param tx - Prisma transaction client or client + * @param companyId - Company ID + * @returns Array of buildings + */ +export async function getBuildingsForCompany( + tx: Prisma.TransactionClient | PrismaClient, + companyId: string +) { + return tx.building.findMany({ + where: { companyId }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }] + }); +} + +/** + * Checks if a company has an active building of a specific type + * + * @param tx - Prisma transaction client or client + * @param companyId - Company ID + * @param buildingType - Building type to check + * @returns True if company has active building of that type + */ +export async function hasActiveBuildingOfType( + tx: Prisma.TransactionClient | PrismaClient, + companyId: string, + buildingType: BuildingType +): Promise { + const count = await tx.building.count({ + where: { + companyId, + buildingType, + status: BuildingStatus.ACTIVE + } + }); + + return count > 0; +} + +/** + * Gets available production capacity for a company + * + * @param tx - Prisma transaction client or client + * @param companyId - Company ID + * @returns Object with total capacity and used capacity + */ +export async function getProductionCapacityForCompany( + tx: Prisma.TransactionClient | PrismaClient, + companyId: string +): Promise<{ totalCapacity: number; usedCapacity: number }> { + const productionBuildingTypes = [ + BuildingType.MINE, + BuildingType.FARM, + BuildingType.FACTORY, + BuildingType.MEGA_FACTORY + ]; + + const buildings = await tx.building.findMany({ + where: { + companyId, + buildingType: { in: productionBuildingTypes }, + status: BuildingStatus.ACTIVE + }, + select: { + capacitySlots: true + } + }); + + const totalCapacity = buildings.reduce( + (sum, building) => sum + building.capacitySlots, + 0 + ); + + const usedCapacity = await tx.productionJob.count({ + where: { + companyId, + status: "IN_PROGRESS" + } + }); + + return { totalCapacity, usedCapacity }; +} diff --git a/packages/sim/src/services/tick-engine.ts b/packages/sim/src/services/tick-engine.ts index 7bf399d9..c10ea494 100644 --- a/packages/sim/src/services/tick-engine.ts +++ b/packages/sim/src/services/tick-engine.ts @@ -23,6 +23,7 @@ * ## Side Effects * All state mutations occur within a single transaction boundary: * - Bot actions (market orders, production starts) + * - Building operating costs (deduct costs, deactivate buildings) * - Production/research completions * - Market trades and settlements * - Shipment deliveries @@ -39,7 +40,7 @@ * * ## Data Flow * ``` - * Tick N → [Bots] → [Production] → [Research] → [Market Matching] + * Tick N → [Bots] → [Building Operating Costs] → [Production] → [Research] → [Market Matching] * → [Shipments] → [Workforce] → [Demand] → [Contracts] * → [Candles] → [World State Update] → Tick N+1 * ``` @@ -66,6 +67,7 @@ import { completeDueProductionJobs } from "./production"; import { completeDueResearchJobs } from "./research"; import { deliverDueShipmentsForTick } from "./shipments"; import { runWorkforceForTick, WorkforceRuntimeConfig } from "./workforce"; +import { applyBuildingOperatingCostsWithTx } from "./buildings"; /** * Internal representation of world tick state for optimistic locking. @@ -236,14 +238,16 @@ function isTickExecutionConflict(error: unknown, executionKey: string | undefine * @remarks * ## Pipeline Stages (Executed Sequentially) * 1. Bot actions (market orders, production starts) - * 2. Production job completions - * 3. Research completions and recipe unlocks - * 4. Market matching and settlement - * 5. Shipment deliveries - * 6. Workforce updates (arrivals, salaries, efficiency) - * 7. Demand sink consumption (baseline market activity) - * 8. Contract lifecycle (expiration and generation) - * 9. Market candle aggregation (OHLC/VWAP/volume) + * 2. Building operating costs (deduct costs, deactivate unpaid buildings) + * - Note: Production validation doesn't yet check building status (Phase 2) + * 3. Production job completions + * 4. Research completions and recipe unlocks + * 5. Market matching and settlement + * 6. Shipment deliveries + * 7. Workforce updates (arrivals, salaries, efficiency) + * 8. Demand sink consumption (baseline market activity) + * 9. Contract lifecycle (expiration and generation) + * 10. Market candle aggregation (OHLC/VWAP/volume) * * ## Determinism * - Order is fixed and must not change (breaking change if reordered) @@ -261,19 +265,22 @@ async function runTickPipeline( ): Promise { // Tick pipeline order: // 1) bot actions (orders / production starts) - // 2) production completions - // 3) research completions and recipe unlocks - // 4) market matching and settlement - // 5) shipment deliveries - // 6) workforce update (scheduled arrivals, salary ledger, efficiency) - // 7) baseline demand sink consumption - // 8) contract lifecycle (expire and generate) - // 9) market candle aggregation (OHLC/VWAP/volume) - // 10) finalize world tick state + // 2) building operating costs (deactivate unpaid buildings) + // Note: Production validation doesn't yet check building status (Phase 2) + // 3) production completions + // 4) research completions and recipe unlocks + // 5) market matching and settlement + // 6) shipment deliveries + // 7) workforce update (scheduled arrivals, salary ledger, efficiency) + // 8) baseline demand sink consumption + // 9) contract lifecycle (expire and generate) + // 10) market candle aggregation (OHLC/VWAP/volume) + // 11) finalize world tick state if (options.runBots) { await runBotsForTick(tx, nextTick, options.botConfig); } + await applyBuildingOperatingCostsWithTx(tx, { tick: nextTick }); await completeDueProductionJobs(tx, nextTick); await completeDueResearchJobs(tx, nextTick); // Matching runs in tick processing, not in request path. diff --git a/packages/sim/tests/buildings.test.ts b/packages/sim/tests/buildings.test.ts new file mode 100644 index 00000000..1c05a2f1 --- /dev/null +++ b/packages/sim/tests/buildings.test.ts @@ -0,0 +1,526 @@ +import { BuildingStatus, BuildingType, Prisma } from "@prisma/client"; +import { describe, expect, it, vi } from "vitest"; +import { + DomainInvariantError, + InsufficientFundsError, + NotFoundError, + acquireBuildingWithTx, + applyBuildingOperatingCostsWithTx, + reactivateBuildingWithTx, + getProductionCapacityForCompany, + BUILDING_OPERATING_COST_INTERVAL_TICKS +} from "../src"; + +describe("building service", () => { + describe("acquireBuildingWithTx", () => { + it("validates input parameters", async () => { + const tx = {} as Prisma.TransactionClient; + + await expect( + acquireBuildingWithTx(tx, { + companyId: "", + regionId: "region-1", + buildingType: BuildingType.FACTORY, + acquisitionCostCents: 10000n, + weeklyOperatingCostCents: 500n, + tick: 10 + }) + ).rejects.toThrow(DomainInvariantError); + + await expect( + acquireBuildingWithTx(tx, { + companyId: "company-1", + regionId: "region-1", + buildingType: BuildingType.FACTORY, + acquisitionCostCents: -100n, + weeklyOperatingCostCents: 500n, + tick: 10 + }) + ).rejects.toThrow(DomainInvariantError); + + await expect( + acquireBuildingWithTx(tx, { + companyId: "company-1", + regionId: "region-1", + buildingType: BuildingType.FACTORY, + acquisitionCostCents: 10000n, + weeklyOperatingCostCents: -500n, + tick: 10 + }) + ).rejects.toThrow(DomainInvariantError); + + await expect( + acquireBuildingWithTx(tx, { + companyId: "company-1", + regionId: "region-1", + buildingType: BuildingType.FACTORY, + acquisitionCostCents: 10000n, + weeklyOperatingCostCents: 500n, + capacitySlots: 0, + tick: 10 + }) + ).rejects.toThrow(DomainInvariantError); + }); + + it("throws NotFoundError if company doesn't exist", async () => { + const tx = { + company: { + findUnique: vi.fn().mockResolvedValue(null) + }, + building: {}, + region: {} + } as unknown as Prisma.TransactionClient; + + await expect( + acquireBuildingWithTx(tx, { + companyId: "nonexistent", + regionId: "region-1", + buildingType: BuildingType.FACTORY, + acquisitionCostCents: 10000n, + weeklyOperatingCostCents: 500n, + tick: 10 + }) + ).rejects.toThrow(NotFoundError); + }); + + it("throws InsufficientFundsError if company cannot afford acquisition cost", async () => { + const tx = { + company: { + findUnique: vi.fn().mockResolvedValue({ + id: "company-1", + cashCents: 5000n, + reservedCashCents: 1000n + }) + }, + region: { + findUnique: vi.fn().mockResolvedValue({ id: "region-1" }) + } + } as unknown as Prisma.TransactionClient; + + await expect( + acquireBuildingWithTx(tx, { + companyId: "company-1", + regionId: "region-1", + buildingType: BuildingType.FACTORY, + acquisitionCostCents: 10000n, + weeklyOperatingCostCents: 500n, + tick: 10 + }) + ).rejects.toThrow(InsufficientFundsError); + }); + + it("creates building and deducts acquisition cost with ledger entry", async () => { + const companyUpdate = vi.fn().mockResolvedValue(null); + const buildingCreate = vi.fn().mockResolvedValue({ + id: "building-1", + companyId: "company-1", + regionId: "region-1", + buildingType: BuildingType.FACTORY, + status: BuildingStatus.ACTIVE, + acquisitionCostCents: 10000n, + weeklyOperatingCostCents: 500n, + capacitySlots: 5, + tickAcquired: 10, + lastOperatingCostTick: 10 + }); + const ledgerCreate = vi.fn().mockResolvedValue(null); + + const tx = { + company: { + findUnique: vi.fn().mockResolvedValue({ + id: "company-1", + cashCents: 15000n, + reservedCashCents: 0n + }), + update: companyUpdate + }, + region: { + findUnique: vi.fn().mockResolvedValue({ id: "region-1" }) + }, + building: { + create: buildingCreate + }, + ledgerEntry: { + create: ledgerCreate + } + } as unknown as Prisma.TransactionClient; + + const building = await acquireBuildingWithTx(tx, { + companyId: "company-1", + regionId: "region-1", + buildingType: BuildingType.FACTORY, + acquisitionCostCents: 10000n, + weeklyOperatingCostCents: 500n, + capacitySlots: 5, + tick: 10 + }); + + expect(building.id).toBe("building-1"); + expect(companyUpdate).toHaveBeenCalledWith({ + where: { id: "company-1" }, + data: { cashCents: 5000n } + }); + + expect(buildingCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + companyId: "company-1", + regionId: "region-1", + buildingType: BuildingType.FACTORY, + status: BuildingStatus.ACTIVE, + acquisitionCostCents: 10000n, + weeklyOperatingCostCents: 500n, + capacitySlots: 5, + tickAcquired: 10, + lastOperatingCostTick: 10 + }) + }); + + expect(ledgerCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + companyId: "company-1", + tick: 10, + entryType: "BUILDING_ACQUISITION", + deltaCashCents: -10000n, + balanceAfterCents: 5000n, + referenceType: "BUILDING", + referenceId: "building-1" + }) + }); + }); + }); + + describe("applyBuildingOperatingCostsWithTx", () => { + it("applies operating costs to buildings due for payment", async () => { + const companyUpdate = vi.fn().mockResolvedValue(null); + const buildingUpdate = vi.fn().mockResolvedValue(null); + const ledgerCreate = vi.fn().mockResolvedValue(null); + + const tx = { + building: { + findMany: vi.fn().mockResolvedValue([ + { + id: "building-1", + companyId: "company-1", + weeklyOperatingCostCents: 500n + }, + { + id: "building-2", + companyId: "company-2", + weeklyOperatingCostCents: 300n + } + ]), + update: buildingUpdate + }, + company: { + findUniqueOrThrow: vi + .fn() + .mockResolvedValueOnce({ + id: "company-1", + cashCents: 10000n, + reservedCashCents: 0n + }) + .mockResolvedValueOnce({ + id: "company-2", + cashCents: 5000n, + reservedCashCents: 0n + }), + update: companyUpdate + }, + ledgerEntry: { + create: ledgerCreate + } + } as unknown as Prisma.TransactionClient; + + const currentTick = 10 + BUILDING_OPERATING_COST_INTERVAL_TICKS; + const result = await applyBuildingOperatingCostsWithTx(tx, { + tick: currentTick + }); + + expect(result.processedCount).toBe(2); + expect(result.deactivatedCount).toBe(0); + expect(result.totalCostCents).toBe(800n); + + expect(companyUpdate).toHaveBeenCalledTimes(2); + expect(buildingUpdate).toHaveBeenCalledTimes(2); + expect(ledgerCreate).toHaveBeenCalledTimes(2); + }); + + it("deactivates buildings when company cannot afford operating cost", async () => { + const buildingUpdate = vi.fn().mockResolvedValue(null); + + const tx = { + building: { + findMany: vi.fn().mockResolvedValue([ + { + id: "building-1", + companyId: "company-1", + weeklyOperatingCostCents: 500n + } + ]), + update: buildingUpdate + }, + company: { + findUniqueOrThrow: vi.fn().mockResolvedValue({ + id: "company-1", + cashCents: 100n, // Insufficient + reservedCashCents: 0n + }), + update: vi.fn() + }, + ledgerEntry: { + create: vi.fn() + } + } as unknown as Prisma.TransactionClient; + + const currentTick = 10 + BUILDING_OPERATING_COST_INTERVAL_TICKS; + const result = await applyBuildingOperatingCostsWithTx(tx, { + tick: currentTick + }); + + expect(result.processedCount).toBe(0); + expect(result.deactivatedCount).toBe(1); + expect(result.totalCostCents).toBe(0n); + + expect(buildingUpdate).toHaveBeenCalledWith({ + where: { id: "building-1" }, + data: { + status: BuildingStatus.INACTIVE, + lastOperatingCostTick: currentTick + } + }); + }); + + it("skips buildings not yet due for operating cost", async () => { + const tx = { + building: { + findMany: vi.fn().mockResolvedValue([]) + } + } as unknown as Prisma.TransactionClient; + + const result = await applyBuildingOperatingCostsWithTx(tx, { + tick: 10 + }); + + expect(result.processedCount).toBe(0); + expect(result.deactivatedCount).toBe(0); + expect(result.totalCostCents).toBe(0n); + }); + + it("respects reserved cash when checking affordability", async () => { + const buildingUpdate = vi.fn().mockResolvedValue(null); + + const tx = { + building: { + findMany: vi.fn().mockResolvedValue([ + { + id: "building-1", + companyId: "company-1", + weeklyOperatingCostCents: 500n + } + ]), + update: buildingUpdate + }, + company: { + findUniqueOrThrow: vi.fn().mockResolvedValue({ + id: "company-1", + cashCents: 600n, // Enough total cash + reservedCashCents: 200n // But only 400 available + }), + update: vi.fn() + }, + ledgerEntry: { + create: vi.fn() + } + } as unknown as Prisma.TransactionClient; + + const currentTick = 10 + BUILDING_OPERATING_COST_INTERVAL_TICKS; + const result = await applyBuildingOperatingCostsWithTx(tx, { + tick: currentTick + }); + + // Should deactivate because available cash (400) < operating cost (500) + expect(result.processedCount).toBe(0); + expect(result.deactivatedCount).toBe(1); + expect(buildingUpdate).toHaveBeenCalledWith({ + where: { id: "building-1" }, + data: { + status: BuildingStatus.INACTIVE, + lastOperatingCostTick: currentTick + } + }); + }); + + it("handles multiple buildings for same company with fresh cash values", async () => { + const companyUpdate = vi.fn().mockResolvedValue(null); + const buildingUpdate = vi.fn().mockResolvedValue(null); + const ledgerCreate = vi.fn().mockResolvedValue(null); + + const tx = { + building: { + findMany: vi.fn().mockResolvedValue([ + { + id: "building-1", + companyId: "company-1", + weeklyOperatingCostCents: 300n + }, + { + id: "building-2", + companyId: "company-1", // Same company + weeklyOperatingCostCents: 200n + } + ]), + update: buildingUpdate + }, + company: { + findUniqueOrThrow: vi + .fn() + // First building: company has 1000 + .mockResolvedValueOnce({ + id: "company-1", + cashCents: 1000n, + reservedCashCents: 0n + }) + // Second building: company now has 700 (after first deduction) + .mockResolvedValueOnce({ + id: "company-1", + cashCents: 700n, + reservedCashCents: 0n + }), + update: companyUpdate + }, + ledgerEntry: { + create: ledgerCreate + } + } as unknown as Prisma.TransactionClient; + + const currentTick = 10 + BUILDING_OPERATING_COST_INTERVAL_TICKS; + const result = await applyBuildingOperatingCostsWithTx(tx, { + tick: currentTick + }); + + // Both buildings should be processed with correct cash values + expect(result.processedCount).toBe(2); + expect(result.deactivatedCount).toBe(0); + expect(result.totalCostCents).toBe(500n); + + // Verify company.findUniqueOrThrow was called twice (once per building) + expect(tx.company.findUniqueOrThrow).toHaveBeenCalledTimes(2); + + // First update: 1000 - 300 = 700 + expect(companyUpdate).toHaveBeenNthCalledWith(1, { + where: { id: "company-1" }, + data: { cashCents: 700n } + }); + + // Second update: 700 - 200 = 500 + expect(companyUpdate).toHaveBeenNthCalledWith(2, { + where: { id: "company-1" }, + data: { cashCents: 500n } + }); + }); + }); + + describe("reactivateBuildingWithTx", () => { + it("reactivates an inactive building", async () => { + const buildingUpdate = vi.fn().mockResolvedValue({ + id: "building-1", + status: BuildingStatus.ACTIVE + }); + + const tx = { + building: { + findUnique: vi.fn().mockResolvedValue({ + id: "building-1", + status: BuildingStatus.INACTIVE + }), + update: buildingUpdate + } + } as unknown as Prisma.TransactionClient; + + const result = await reactivateBuildingWithTx(tx, { + buildingId: "building-1", + tick: 20 + }); + + expect(result.status).toBe(BuildingStatus.ACTIVE); + expect(buildingUpdate).toHaveBeenCalledWith({ + where: { id: "building-1" }, + data: { + status: BuildingStatus.ACTIVE, + lastOperatingCostTick: 20 + } + }); + }); + + it("throws error if building is not inactive", async () => { + const tx = { + building: { + findUnique: vi.fn().mockResolvedValue({ + id: "building-1", + status: BuildingStatus.ACTIVE + }) + } + } as unknown as Prisma.TransactionClient; + + await expect( + reactivateBuildingWithTx(tx, { + buildingId: "building-1", + tick: 20 + }) + ).rejects.toThrow(DomainInvariantError); + }); + + it("throws NotFoundError if building doesn't exist", async () => { + const tx = { + building: { + findUnique: vi.fn().mockResolvedValue(null) + } + } as unknown as Prisma.TransactionClient; + + await expect( + reactivateBuildingWithTx(tx, { + buildingId: "nonexistent", + tick: 20 + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("getProductionCapacityForCompany", () => { + it("calculates total and used production capacity", async () => { + const tx = { + building: { + findMany: vi.fn().mockResolvedValue([ + { capacitySlots: 5 }, + { capacitySlots: 3 }, + { capacitySlots: 10 } + ]) + }, + productionJob: { + count: vi.fn().mockResolvedValue(7) + } + } as unknown as Prisma.TransactionClient; + + const result = await getProductionCapacityForCompany(tx, "company-1"); + + expect(result.totalCapacity).toBe(18); + expect(result.usedCapacity).toBe(7); + }); + + it("returns zero capacity when no buildings exist", async () => { + const tx = { + building: { + findMany: vi.fn().mockResolvedValue([]) + }, + productionJob: { + count: vi.fn().mockResolvedValue(0) + } + } as unknown as Prisma.TransactionClient; + + const result = await getProductionCapacityForCompany(tx, "company-1"); + + expect(result.totalCapacity).toBe(0); + expect(result.usedCapacity).toBe(0); + }); + }); +});