Skip to content
Merged
33 changes: 25 additions & 8 deletions packages/db/src/seed-world.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
);
}

Expand Down Expand Up @@ -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<string, { id: string; code: string }> = {};
for (const definition of RECIPE_DEFINITIONS) {
const recipe = await prisma.recipe.create({
Expand Down
149 changes: 149 additions & 0 deletions packages/sim/src/services/buildings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<void> {
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<void> {
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`
);
}
}

17 changes: 17 additions & 0 deletions packages/sim/src/services/market-matching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
PrismaClient
} from "@prisma/client";
import { DomainInvariantError, NotFoundError } from "../domain/errors";
import { validateStorageCapacity } from "./buildings";

/**
* Order representation for matching purposes.
Expand Down Expand Up @@ -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 },
Expand Down
32 changes: 30 additions & 2 deletions packages/sim/src/services/production.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ import {
applyDurationMultiplierTicks,
resolveWorkforceRuntimeModifiers
} from "./workforce";
import {
validateProductionBuildingAvailable,
validateStorageCapacity
} from "./buildings";

interface RecipeInputRow {
itemId: string;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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: {
Expand All @@ -865,8 +895,6 @@ export async function completeDueProductionJobs(
});
}

const outputQuantity = job.recipe.outputQuantity * job.runs;

await tx.inventory.upsert({
where: {
companyId_itemId_regionId: {
Expand Down
9 changes: 9 additions & 0 deletions packages/sim/src/services/shipments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import {
applyDurationMultiplierTicks,
resolveWorkforceRuntimeModifiers
} from "./workforce";
import { validateStorageCapacity } from "./buildings";

export interface ShipmentRuntimeConfig {
baseFeeCents: bigint;
Expand Down Expand Up @@ -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
);
Comment thread
BENZOOgataga marked this conversation as resolved.

await tx.inventory.upsert({
where: {
companyId_itemId_regionId: {
Expand Down
16 changes: 14 additions & 2 deletions packages/sim/src/services/tick-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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);
}
Expand Down
Loading
Loading