From e784de685a7a9366df2fe00b27208396663c9602 Mon Sep 17 00:00:00 2001 From: Spbd1 <148923621+Spbd1@users.noreply.github.com> Date: Fri, 8 May 2026 00:19:05 +0000 Subject: [PATCH] Implement core engine package --- packages/engine/src/contracts.ts | 43 +++++++ packages/engine/src/decisions.ts | 160 +++++++++++++++++++++++ packages/engine/src/gini.ts | 22 ++++ packages/engine/src/index.test.ts | 172 ++++++++++++++++++++++++- packages/engine/src/index.ts | 11 ++ packages/engine/src/mapGenerator.ts | 64 +++++++++ packages/engine/src/production.ts | 25 ++++ packages/engine/src/random.ts | 55 ++++++++ packages/engine/src/roundResolver.ts | 135 +++++++++++++++++++ packages/engine/src/scoring.ts | 65 ++++++++++ packages/engine/src/serverSimulator.ts | 166 ++++++++++++++++++++++++ packages/engine/src/shocks.ts | 48 +++++++ packages/engine/src/types.ts | 169 ++++++++++++++++++++++++ packages/engine/src/validation.ts | 153 ++++++++++++++++++++++ 14 files changed, 1286 insertions(+), 2 deletions(-) create mode 100644 packages/engine/src/contracts.ts create mode 100644 packages/engine/src/decisions.ts create mode 100644 packages/engine/src/gini.ts create mode 100644 packages/engine/src/mapGenerator.ts create mode 100644 packages/engine/src/production.ts create mode 100644 packages/engine/src/random.ts create mode 100644 packages/engine/src/roundResolver.ts create mode 100644 packages/engine/src/scoring.ts create mode 100644 packages/engine/src/serverSimulator.ts create mode 100644 packages/engine/src/shocks.ts create mode 100644 packages/engine/src/validation.ts diff --git a/packages/engine/src/contracts.ts b/packages/engine/src/contracts.ts new file mode 100644 index 0000000..d284ad4 --- /dev/null +++ b/packages/engine/src/contracts.ts @@ -0,0 +1,43 @@ +import type { Contract } from "./types"; +import type { RandomGenerator } from "./random"; + +export const createContract = ({ + id, + round, + type, + fromPlayerId, + toPlayerId, + amount, + fee, + defaultRisk, +}: Omit): Contract => ({ + id, + round, + type, + fromPlayerId, + toPlayerId, + amount, + fee, + defaultRisk, +}); + +export const resolveContracts = ({ + contracts, + random, +}: { + contracts: readonly Contract[]; + random: RandomGenerator; +}): Contract[] => + contracts.map((contract) => ({ + ...contract, + fulfilled: !random.boolean(contract.defaultRisk), + })); + +export const contractReliability = (contracts: readonly Contract[]): number => { + if (contracts.length === 0) { + return 1; + } + return ( + contracts.filter((contract) => contract.fulfilled).length / contracts.length + ); +}; diff --git a/packages/engine/src/decisions.ts b/packages/engine/src/decisions.ts new file mode 100644 index 0000000..385218a --- /dev/null +++ b/packages/engine/src/decisions.ts @@ -0,0 +1,160 @@ +import { calculateOutput } from "./production"; +import { decisionCost, validateDecisions } from "./validation"; +import type { + Contract, + Decision, + EngineConfig, + Parcel, + PlayerState, + Shock, + TreasuryTransaction, +} from "./types"; + +export { decisionCost, validateDecisions }; + +const clonePlayers = (players: readonly PlayerState[]): PlayerState[] => + players.map((player) => ({ ...player, parcelIds: [...player.parcelIds] })); + +export const applyValidDecisions = ({ + players, + parcels, + decisions, + config, + round, + shock, +}: { + players: readonly PlayerState[]; + parcels: readonly Parcel[]; + decisions: readonly Decision[]; + config: EngineConfig; + round: number; + shock: Shock; +}): { + players: PlayerState[]; + contracts: Contract[]; + treasuryTransactions: TreasuryTransaction[]; + totalOutput: number; + taxesCollected: number; + publicContributions: number; +} => { + const updatedPlayers = clonePlayers(players); + const contracts: Contract[] = []; + const treasuryTransactions: TreasuryTransaction[] = []; + let totalOutput = 0; + let taxesCollected = 0; + let publicContributions = 0; + + for (const decision of decisions) { + const player = updatedPlayers.find( + (candidate) => candidate.id === decision.playerId, + ); + if (!player) { + continue; + } + + player.actionPointsRemaining -= 1; + const amount = decision.amount ?? 0; + + switch (decision.type) { + case "PRODUCE": { + const parcel = parcels.find( + (candidate) => candidate.id === decision.parcelId, + ); + if (!parcel) { + break; + } + const output = calculateOutput({ + parcel, + productiveCapital: player.productiveCapital, + shockMultiplier: shock.multiplier, + config: config.production, + }); + const tax = output * config.taxRate; + player.wealth += output - tax; + totalOutput += output; + taxesCollected += tax; + treasuryTransactions.push({ + round, + playerId: player.id, + amount: tax, + reason: "TAX", + }); + break; + } + case "PRODUCTIVE_INVESTMENT": + player.wealth -= amount; + player.productiveCapital += amount / config.investmentUnitCost; + player.spentOnProductiveInvestment += amount; + break; + case "SAFE_ASSET": + player.wealth -= amount; + player.safeAssets += amount * (1 + config.safeAssetReturn); + player.spentOnSafeAssets += amount; + break; + case "PUBLIC_CONTRIBUTION": + player.wealth -= amount; + player.contributedPublic += amount; + publicContributions += amount; + treasuryTransactions.push({ + round, + playerId: player.id, + amount, + reason: "PUBLIC_CONTRIBUTION", + }); + break; + case "INFORMAL_CONTRACT": + case "FORMAL_CONTRACT": { + const isFormal = decision.type === "FORMAL_CONTRACT"; + const fee = isFormal + ? config.formalContractFee + : config.informalContractFee; + player.wealth -= amount + fee; + contracts.push({ + id: `contract-${round}-${contracts.length + 1}`, + round, + type: isFormal ? "FORMAL" : "INFORMAL", + fromPlayerId: player.id, + toPlayerId: decision.counterpartyId as string, + amount, + fee, + defaultRisk: isFormal + ? config.formalDefaultRisk + : config.informalDefaultRisk, + }); + treasuryTransactions.push({ + round, + playerId: player.id, + amount: fee, + reason: `${isFormal ? "FORMAL" : "INFORMAL"}_CONTRACT_FEE`, + }); + break; + } + case "LOBBYING": + player.wealth -= amount || config.lobbyingCost; + player.spentOnLobbying += amount || config.lobbyingCost; + break; + case "EXIT": + player.exited = true; + break; + } + } + + if (publicContributions > 0) { + const activePlayers = updatedPlayers.filter((player) => !player.exited); + const publicGoodPayout = + (publicContributions * config.publicGoodMultiplier) / + activePlayers.length; + for (const player of activePlayers) { + player.wealth += publicGoodPayout; + } + } + + return { + players: updatedPlayers, + contracts, + treasuryTransactions, + totalOutput, + taxesCollected, + publicContributions, + }; +}; diff --git a/packages/engine/src/gini.ts b/packages/engine/src/gini.ts new file mode 100644 index 0000000..0b7f287 --- /dev/null +++ b/packages/engine/src/gini.ts @@ -0,0 +1,22 @@ +export const gini = (values: readonly number[]): number => { + if (values.length === 0) { + return 0; + } + + const sorted = [...values].sort((a, b) => a - b); + const sum = sorted.reduce((total, value) => total + value, 0); + + if (sum === 0) { + return 0; + } + + const weightedSum = sorted.reduce( + (total, value, index) => total + (index + 1) * value, + 0, + ); + + return ( + (2 * weightedSum) / (sorted.length * sum) - + (sorted.length + 1) / sorted.length + ); +}; diff --git a/packages/engine/src/index.test.ts b/packages/engine/src/index.test.ts index 5874c5b..1720b58 100644 --- a/packages/engine/src/index.test.ts +++ b/packages/engine/src/index.test.ts @@ -1,12 +1,180 @@ import { describe, expect, it } from "vitest"; -import { ENGINE_PACKAGE_READY, type ActionType } from "./index"; +import { + DEFAULT_ENGINE_CONFIG, + calculateOutput, + createInitialPlayers, + createInitialServerState, + generateMap, + generateShockSchedule, + gini, + parcelQualityGini, + resolveRound, + validateDecisions, + type ActionType, + type Decision, + type EngineConfig, + type Parcel, +} from "./index"; + +const config: EngineConfig = { ...DEFAULT_ENGINE_CONFIG, seed: "test-seed" }; + +const ownedParcel = (ownerId = "player-1"): Parcel => ({ + id: "parcel-0-0", + x: 0, + y: 0, + soil: 0.5, + water: 0.5, + marketAccess: 0.5, + risk: 0.5, + quality: 0.5, + ownerId, +}); describe("engine package", () => { it("exports initial research action types", () => { const action: ActionType = "PRODUCE"; expect(action).toBe("PRODUCE"); - expect(ENGINE_PACKAGE_READY).toBe(true); + }); + + it("generates deterministic maps", () => { + expect(generateMap({ seed: "abc", inequality: "LOW" })).toEqual( + generateMap({ seed: "abc", inequality: "LOW" }), + ); + expect(generateMap({ seed: "abc", inequality: "LOW" })).toHaveLength(100); + }); + + it("calculates the Gini coefficient", () => { + expect(gini([1, 1, 1])).toBe(0); + expect(gini([0, 0, 10])).toBeCloseTo(2 / 3); + }); + + it("calculates production output", () => { + const output = calculateOutput({ + parcel: { quality: 0.5 }, + productiveCapital: 4, + shockMultiplier: 0.75, + config: { + A: 10, + betaQ: 0.8, + betaK: 0.5, + minShockMultiplier: 0.5, + maxShockMultiplier: 1, + }, + }); + + expect(output).toBeCloseTo(10 * 1.5 ** 0.8 * 5 ** 0.5 * 0.75); + }); + + it("validates action point limits", () => { + const players = createInitialPlayers(1, config); + const decisions: Decision[] = [ + { playerId: "player-1", type: "SAFE_ASSET", amount: 1 }, + { playerId: "player-1", type: "SAFE_ASSET", amount: 1 }, + { playerId: "player-1", type: "SAFE_ASSET", amount: 1 }, + { playerId: "player-1", type: "SAFE_ASSET", amount: 1 }, + ]; + + const result = validateDecisions({ + players, + parcels: [], + decisions, + config, + }); + + expect(result.ok).toBe(false); + expect( + result.errors.some( + (error) => error.code === "INSUFFICIENT_ACTION_POINTS", + ), + ).toBe(true); + }); + + it("prevents overspending", () => { + const players = createInitialPlayers(1, { ...config, startingWealth: 5 }); + const result = validateDecisions({ + players, + parcels: [], + decisions: [ + { playerId: "player-1", type: "PRODUCTIVE_INVESTMENT", amount: 6 }, + ], + config, + }); + + expect(result.ok).toBe(false); + expect(result.errors[0]?.code).toBe("INSUFFICIENT_RESOURCES"); + }); + + it("prevents exited players from acting", () => { + const players = createInitialPlayers(1, config).map((player) => ({ + ...player, + exited: true, + })); + const result = validateDecisions({ + players, + parcels: [], + decisions: [{ playerId: "player-1", type: "SAFE_ASSET", amount: 1 }], + config, + }); + + expect(result.ok).toBe(false); + expect(result.errors[0]?.code).toBe("PLAYER_EXITED"); + }); + + it("generates deterministic shock schedules", () => { + const first = generateShockSchedule({ + seed: "shock-seed", + rounds: 6, + shockProbability: 0.8, + production: config.production, + }); + const second = generateShockSchedule({ + seed: "shock-seed", + rounds: 6, + shockProbability: 0.8, + production: config.production, + }); + + expect(first).toEqual(second); + }); + + it("produces higher parcel quality Gini under high inequality", () => { + const low = parcelQualityGini( + generateMap({ seed: "ineq", inequality: "LOW" }), + ); + const high = parcelQualityGini( + generateMap({ seed: "ineq", inequality: "HIGH" }), + ); + + expect(high).toBeGreaterThan(low); + }); + + it("resolves a round into a valid state", () => { + const parcels = [ownedParcel()]; + const players = createInitialPlayers(2, config).map((player, index) => ({ + ...player, + parcelIds: index === 0 ? ["parcel-0-0"] : [], + })); + const result = resolveRound({ + server: createInitialServerState(config), + players, + parcels, + decisions: [ + { playerId: "player-1", type: "PRODUCE", parcelId: "parcel-0-0" }, + { playerId: "player-1", type: "PRODUCTIVE_INVESTMENT", amount: 10 }, + { playerId: "player-2", type: "PUBLIC_CONTRIBUTION", amount: 5 }, + ], + config, + seed: "round-seed", + }); + + expect(result.server.round).toBe(1); + expect(result.players).toHaveLength(2); + expect(result.roundSummary.validationErrors).toHaveLength(0); + expect(result.server.treasury).toBeGreaterThan(0); + expect( + result.players.every((player) => player.actionPointsRemaining >= 0), + ).toBe(true); }); }); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index f8814e7..2183944 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -1,3 +1,14 @@ export * from "./types"; +export * from "./random"; +export * from "./gini"; +export * from "./mapGenerator"; +export * from "./production"; +export * from "./decisions"; +export * from "./contracts"; +export * from "./shocks"; +export * from "./roundResolver"; +export * from "./serverSimulator"; +export * from "./scoring"; +export * from "./validation"; export const ENGINE_PACKAGE_READY = true; diff --git a/packages/engine/src/mapGenerator.ts b/packages/engine/src/mapGenerator.ts new file mode 100644 index 0000000..d2c8218 --- /dev/null +++ b/packages/engine/src/mapGenerator.ts @@ -0,0 +1,64 @@ +import { gini } from "./gini"; +import { createRandom } from "./random"; +import type { InequalityCondition, Parcel } from "./types"; + +const clamp01 = (value: number): number => Math.max(0, Math.min(1, value)); + +const lowInequalityQuality = (base: number): number => 0.4 + base * 0.2; + +const highInequalityQuality = (base: number, mixture: number): number => { + if (mixture < 0.3) { + return base * 0.18; + } + if (mixture > 0.78) { + return 0.72 + base * 0.28; + } + return 0.25 + base * 0.45; +}; + +export const parcelQualityGini = (parcels: readonly Parcel[]): number => + gini(parcels.map((parcel) => parcel.quality)); + +export const generateMap = ({ + seed, + inequality, + width = 10, + height = 10, +}: { + seed: string | number; + inequality: InequalityCondition; + width?: number; + height?: number; +}): Parcel[] => { + const random = createRandom(`${seed}:map:${inequality}:${width}x${height}`); + const parcels: Parcel[] = []; + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const soil = random.next(); + const water = random.next(); + const marketAccess = random.next(); + const risk = random.next(); + const baseQuality = clamp01( + soil * 0.35 + water * 0.3 + marketAccess * 0.25 + (1 - risk) * 0.1, + ); + const quality = + inequality === "LOW" + ? lowInequalityQuality(baseQuality) + : highInequalityQuality(baseQuality, random.next()); + + parcels.push({ + id: `parcel-${x}-${y}`, + x, + y, + soil, + water, + marketAccess, + risk, + quality, + }); + } + } + + return parcels; +}; diff --git a/packages/engine/src/production.ts b/packages/engine/src/production.ts new file mode 100644 index 0000000..37d7a8c --- /dev/null +++ b/packages/engine/src/production.ts @@ -0,0 +1,25 @@ +import type { Parcel, ProductionConfig } from "./types"; + +export const DEFAULT_PRODUCTION_CONFIG: ProductionConfig = { + A: 10, + betaQ: 0.8, + betaK: 0.5, + minShockMultiplier: 0.5, + maxShockMultiplier: 1, +}; + +export const calculateOutput = ({ + parcel, + productiveCapital, + shockMultiplier, + config = DEFAULT_PRODUCTION_CONFIG, +}: { + parcel: Pick; + productiveCapital: number; + shockMultiplier: number; + config?: ProductionConfig; +}): number => + config.A * + (1 + parcel.quality) ** config.betaQ * + (1 + productiveCapital) ** config.betaK * + shockMultiplier; diff --git a/packages/engine/src/random.ts b/packages/engine/src/random.ts new file mode 100644 index 0000000..70b79d6 --- /dev/null +++ b/packages/engine/src/random.ts @@ -0,0 +1,55 @@ +export interface RandomGenerator { + next(): number; + integer(minInclusive: number, maxInclusive: number): number; + float(minInclusive: number, maxExclusive: number): number; + boolean(probability: number): boolean; + pick(items: readonly T[]): T; +} + +const hashSeed = (seed: string | number): number => { + const text = String(seed); + let hash = 2166136261; + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +}; + +export class SeededRandom implements RandomGenerator { + private state: number; + + constructor(seed: string | number) { + this.state = hashSeed(seed) || 1; + } + + next(): number { + this.state += 0x6d2b79f5; + let value = this.state; + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + return ((value ^ (value >>> 14)) >>> 0) / 4294967296; + } + + integer(minInclusive: number, maxInclusive: number): number { + return Math.floor(this.float(minInclusive, maxInclusive + 1)); + } + + float(minInclusive: number, maxExclusive: number): number { + return minInclusive + this.next() * (maxExclusive - minInclusive); + } + + boolean(probability: number): boolean { + return this.next() < probability; + } + + pick(items: readonly T[]): T { + if (items.length === 0) { + throw new Error("Cannot pick from an empty array"); + } + return items[this.integer(0, items.length - 1)] as T; + } +} + +export const createRandom = (seed: string | number): RandomGenerator => + new SeededRandom(seed); diff --git a/packages/engine/src/roundResolver.ts b/packages/engine/src/roundResolver.ts new file mode 100644 index 0000000..ed72bde --- /dev/null +++ b/packages/engine/src/roundResolver.ts @@ -0,0 +1,135 @@ +import { resolveContracts } from "./contracts"; +import { applyValidDecisions } from "./decisions"; +import { createRandom } from "./random"; +import { applyRuleChangeIfNeeded } from "./serverSimulator"; +import { generateShock } from "./shocks"; +import type { RoundResolverInput, RoundResolverResult } from "./types"; +import { validateDecisions } from "./validation"; + +export const resolveRound = ({ + server, + players, + parcels, + decisions, + config, + seed, +}: RoundResolverInput): RoundResolverResult => { + const round = server.round + 1; + const resetPlayers = players.map((player) => ({ + ...player, + parcelIds: [...player.parcelIds], + actionPointsRemaining: player.exited ? 0 : config.actionPointsPerRound, + })); + const ruleChange = applyRuleChangeIfNeeded({ + server: { ...server }, + seed, + round, + }); + const workingServer = ruleChange.server; + const validation = validateDecisions({ + players: resetPlayers, + parcels, + decisions, + config: { + ...config, + formalContractFee: workingServer.formalContractFee, + taxRate: workingServer.taxRate, + shockProbability: workingServer.shockProbability, + }, + }); + const validDecisionKeys = new Set( + validation.ok + ? decisions.map((_, index) => index) + : decisions + .map((_, index) => index) + .filter( + (index) => + !validation.errors.some( + (error) => error.decision === decisions[index], + ), + ), + ); + const validDecisions = decisions.filter((_, index) => + validDecisionKeys.has(index), + ); + const random = createRandom(`${seed}:round:${round}`); + const shock = generateShock({ + round, + shockProbability: workingServer.shockProbability, + production: config.production, + random, + }); + const shockEvents = shock.occurred + ? [ + { + round, + type: "SHOCK" as const, + description: `Production shock applied with multiplier ${shock.multiplier.toFixed(3)}.`, + newValue: shock.multiplier, + }, + ] + : []; + + const effectiveConfig = { + ...config, + taxRate: workingServer.taxRate, + formalContractFee: workingServer.formalContractFee, + shockProbability: workingServer.shockProbability, + }; + const applied = applyValidDecisions({ + players: resetPlayers, + parcels, + decisions: validDecisions, + config: effectiveConfig, + round, + shock, + }); + const resolvedContracts = resolveContracts({ + contracts: applied.contracts, + random, + }); + const playersAfterContracts = applied.players.map((player) => ({ + ...player, + parcelIds: [...player.parcelIds], + })); + + for (const contract of resolvedContracts) { + if (contract.fulfilled) { + const receiver = playersAfterContracts.find( + (player) => player.id === contract.toPlayerId, + ); + if (receiver) { + receiver.wealth += contract.amount; + } + } + } + + const treasuryDelta = applied.treasuryTransactions.reduce( + (total, transaction) => total + transaction.amount, + 0, + ); + const serverEvents = [...ruleChange.events, ...shockEvents]; + const updatedServer = { + ...workingServer, + round, + treasury: workingServer.treasury + treasuryDelta, + events: [...workingServer.events, ...shockEvents], + }; + + return { + server: updatedServer, + players: playersAfterContracts, + parcels: parcels.map((parcel) => ({ ...parcel })), + contracts: resolvedContracts, + serverEvents, + treasuryTransactions: applied.treasuryTransactions, + roundSummary: { + round, + totalOutput: applied.totalOutput, + taxesCollected: applied.taxesCollected, + publicContributions: applied.publicContributions, + shocks: [shock], + validationErrors: validation.errors, + }, + }; +}; diff --git a/packages/engine/src/scoring.ts b/packages/engine/src/scoring.ts new file mode 100644 index 0000000..78c4957 --- /dev/null +++ b/packages/engine/src/scoring.ts @@ -0,0 +1,65 @@ +import { contractReliability } from "./contracts"; +import { gini } from "./gini"; +import type { Contract, Decision, PlayerState, Scores } from "./types"; + +const share = (part: number, whole: number): number => + whole === 0 ? 0 : part / whole; + +export const calculateScores = ({ + players, + contracts, + decisions, +}: { + players: readonly PlayerState[]; + contracts: readonly Contract[]; + decisions: readonly Decision[]; +}): Scores => { + const spending = players.reduce( + (total, player) => + total + + player.spentOnProductiveInvestment + + player.contributedPublic + + player.spentOnSafeAssets + + player.spentOnLobbying, + 0, + ); + const informalContracts = contracts.filter( + (contract) => contract.type === "INFORMAL", + ).length; + const contractDecisions = decisions.filter( + (decision) => + decision.type === "INFORMAL_CONTRACT" || + decision.type === "FORMAL_CONTRACT", + ).length; + + return { + informalCooperationRate: share(informalContracts, contractDecisions), + contractReliability: contractReliability(contracts), + productiveInvestmentShare: share( + players.reduce( + (total, player) => total + player.spentOnProductiveInvestment, + 0, + ), + spending, + ), + publicContributionShare: share( + players.reduce((total, player) => total + player.contributedPublic, 0), + spending, + ), + exitRate: share( + players.filter((player) => player.exited).length, + players.length, + ), + safeAssetShare: share( + players.reduce((total, player) => total + player.spentOnSafeAssets, 0), + spending, + ), + lobbyingShare: share( + players.reduce((total, player) => total + player.spentOnLobbying, 0), + spending, + ), + finalWealthGini: gini( + players.map((player) => player.wealth + player.safeAssets), + ), + }; +}; diff --git a/packages/engine/src/serverSimulator.ts b/packages/engine/src/serverSimulator.ts new file mode 100644 index 0000000..0b2aa5d --- /dev/null +++ b/packages/engine/src/serverSimulator.ts @@ -0,0 +1,166 @@ +import { generateMap } from "./mapGenerator"; +import { createRandom } from "./random"; +import { resolveRound } from "./roundResolver"; +import { DEFAULT_PRODUCTION_CONFIG } from "./production"; +import type { + Decision, + EngineConfig, + PlayerState, + RuleChangeEventType, + RoundResolverResult, + ServerEvent, + ServerState, +} from "./types"; + +export const DEFAULT_ENGINE_CONFIG: EngineConfig = { + seed: "parcel-society", + inequality: "LOW", + uncertainty: "STABLE", + mapWidth: 10, + mapHeight: 10, + actionPointsPerRound: 3, + production: DEFAULT_PRODUCTION_CONFIG, + taxRate: 0.1, + formalContractFee: 2, + informalContractFee: 0.5, + informalDefaultRisk: 0.35, + formalDefaultRisk: 0.08, + shockProbability: 0.25, + startingWealth: 100, + investmentUnitCost: 10, + safeAssetReturn: 0.02, + publicGoodMultiplier: 1.4, + lobbyingCost: 5, +}; + +export const createInitialServerState = ( + config: EngineConfig = DEFAULT_ENGINE_CONFIG, +): ServerState => ({ + round: 0, + taxRate: config.taxRate, + formalContractFee: config.formalContractFee, + shockProbability: config.shockProbability, + treasury: 0, + uncertainty: config.uncertainty, + events: [], +}); + +export const createInitialPlayers = ( + count: number, + config: EngineConfig = DEFAULT_ENGINE_CONFIG, +): PlayerState[] => + Array.from({ length: count }, (_, index) => ({ + id: `player-${index + 1}`, + wealth: config.startingWealth, + productiveCapital: 0, + safeAssets: 0, + exited: false, + parcelIds: [], + actionPointsRemaining: config.actionPointsPerRound, + contributedPublic: 0, + spentOnProductiveInvestment: 0, + spentOnSafeAssets: 0, + spentOnLobbying: 0, + })); + +export const applyRuleChangeIfNeeded = ({ + server, + seed, + round, +}: { + server: ServerState; + seed: string | number; + round: number; +}): { server: ServerState; events: ServerEvent[] } => { + if (server.uncertainty !== "UNCERTAIN" || (round !== 3 && round !== 5)) { + return { server, events: [] }; + } + + const random = createRandom(`${seed}:rule-change:${round}`); + const eventType = random.pick([ + "TAX_CHANGE", + "FORMAL_CONTRACT_FEE_CHANGE", + "SHOCK_PROBABILITY_CHANGE", + ]); + const nextServer = { ...server, events: [...server.events] }; + const direction = random.boolean(0.5) ? 1 : -1; + const event: ServerEvent = { + round, + type: eventType, + description: "Institutional rule change.", + }; + + if (eventType === "TAX_CHANGE") { + event.previousValue = nextServer.taxRate; + nextServer.taxRate = Math.max( + 0, + Math.min(0.5, nextServer.taxRate + direction * random.float(0.02, 0.08)), + ); + event.newValue = nextServer.taxRate; + } else if (eventType === "FORMAL_CONTRACT_FEE_CHANGE") { + event.previousValue = nextServer.formalContractFee; + nextServer.formalContractFee = Math.max( + 0, + nextServer.formalContractFee + direction * random.float(0.5, 2), + ); + event.newValue = nextServer.formalContractFee; + } else { + event.previousValue = nextServer.shockProbability; + nextServer.shockProbability = Math.max( + 0, + Math.min( + 1, + nextServer.shockProbability + direction * random.float(0.05, 0.2), + ), + ); + event.newValue = nextServer.shockProbability; + } + + return { + server: { ...nextServer, events: [...nextServer.events, event] }, + events: [event], + }; +}; + +export const runServerSimulation = ({ + config = DEFAULT_ENGINE_CONFIG, + playerCount, + decisionsByRound, +}: { + config?: EngineConfig; + playerCount: number; + decisionsByRound: readonly (readonly Decision[])[]; +}): RoundResolverResult[] => { + let server = createInitialServerState(config); + let players = createInitialPlayers(playerCount, config); + let parcels = generateMap({ + seed: config.seed, + inequality: config.inequality, + width: config.mapWidth, + height: config.mapHeight, + }); + + parcels = parcels.map((parcel, index) => { + const owner = players[index % players.length]; + if (!owner) { + return parcel; + } + owner.parcelIds.push(parcel.id); + return { ...parcel, ownerId: owner.id }; + }); + + return decisionsByRound.map((roundDecisions) => { + const result = resolveRound({ + server, + players, + parcels, + decisions: [...roundDecisions], + config, + seed: config.seed, + }); + server = result.server; + players = result.players; + parcels = result.parcels; + return result; + }); +}; diff --git a/packages/engine/src/shocks.ts b/packages/engine/src/shocks.ts new file mode 100644 index 0000000..048a7cf --- /dev/null +++ b/packages/engine/src/shocks.ts @@ -0,0 +1,48 @@ +import { createRandom, type RandomGenerator } from "./random"; +import type { ProductionConfig, Shock } from "./types"; + +export const generateShock = ({ + round, + shockProbability, + production, + random, +}: { + round: number; + shockProbability: number; + production: ProductionConfig; + random: RandomGenerator; +}): Shock => { + const occurred = random.boolean(shockProbability); + return { + round, + occurred, + multiplier: occurred + ? random.float( + production.minShockMultiplier, + production.maxShockMultiplier, + ) + : 1, + }; +}; + +export const generateShockSchedule = ({ + seed, + rounds, + shockProbability, + production, +}: { + seed: string | number; + rounds: number; + shockProbability: number; + production: ProductionConfig; +}): Shock[] => { + const random = createRandom(`${seed}:shocks:${rounds}:${shockProbability}`); + return Array.from({ length: rounds }, (_, index) => + generateShock({ + round: index + 1, + shockProbability, + production, + random, + }), + ); +}; diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index 611e095..b5b4e39 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -10,3 +10,172 @@ export type ActionType = | "FORMAL_CONTRACT" | "LOBBYING" | "EXIT"; + +export type RuleChangeEventType = + | "TAX_CHANGE" + | "FORMAL_CONTRACT_FEE_CHANGE" + | "SHOCK_PROBABILITY_CHANGE"; + +export interface EngineConfig { + seed: string | number; + inequality: InequalityCondition; + uncertainty: UncertaintyCondition; + mapWidth: number; + mapHeight: number; + actionPointsPerRound: number; + production: ProductionConfig; + taxRate: number; + formalContractFee: number; + informalContractFee: number; + informalDefaultRisk: number; + formalDefaultRisk: number; + shockProbability: number; + startingWealth: number; + investmentUnitCost: number; + safeAssetReturn: number; + publicGoodMultiplier: number; + lobbyingCost: number; +} + +export interface ProductionConfig { + A: number; + betaQ: number; + betaK: number; + minShockMultiplier: number; + maxShockMultiplier: number; +} + +export interface Parcel { + id: string; + x: number; + y: number; + soil: number; + water: number; + marketAccess: number; + risk: number; + quality: number; + ownerId?: string; +} + +export interface PlayerState { + id: string; + wealth: number; + productiveCapital: number; + safeAssets: number; + exited: boolean; + parcelIds: string[]; + actionPointsRemaining: number; + contributedPublic: number; + spentOnProductiveInvestment: number; + spentOnSafeAssets: number; + spentOnLobbying: number; +} + +export interface ServerState { + round: number; + taxRate: number; + formalContractFee: number; + shockProbability: number; + treasury: number; + uncertainty: UncertaintyCondition; + events: ServerEvent[]; +} + +export interface Decision { + playerId: string; + type: ActionType; + amount?: number; + parcelId?: string; + counterpartyId?: string; +} + +export interface Contract { + id: string; + round: number; + type: "INFORMAL" | "FORMAL"; + fromPlayerId: string; + toPlayerId: string; + amount: number; + fee: number; + defaultRisk: number; + fulfilled?: boolean; +} + +export interface ServerEvent { + round: number; + type: RuleChangeEventType | "SHOCK"; + description: string; + previousValue?: number; + newValue?: number; +} + +export interface TreasuryTransaction { + round: number; + playerId?: string; + amount: number; + reason: string; +} + +export interface Shock { + round: number; + occurred: boolean; + multiplier: number; +} + +export interface ValidationError { + code: + | "PLAYER_NOT_FOUND" + | "PLAYER_EXITED" + | "INSUFFICIENT_ACTION_POINTS" + | "INSUFFICIENT_RESOURCES" + | "INVALID_AMOUNT" + | "INVALID_PARCEL" + | "COUNTERPARTY_NOT_FOUND"; + message: string; + playerId?: string; + decision?: Decision; +} + +export interface ValidationResult { + ok: boolean; + errors: ValidationError[]; +} + +export interface RoundSummary { + round: number; + totalOutput: number; + taxesCollected: number; + publicContributions: number; + shocks: Shock[]; + validationErrors: ValidationError[]; +} + +export interface RoundResolverInput { + server: ServerState; + players: PlayerState[]; + parcels: Parcel[]; + decisions: Decision[]; + config: EngineConfig; + seed: string | number; +} + +export interface RoundResolverResult { + server: ServerState; + players: PlayerState[]; + parcels: Parcel[]; + contracts: Contract[]; + serverEvents: ServerEvent[]; + treasuryTransactions: TreasuryTransaction[]; + roundSummary: RoundSummary; +} + +export interface Scores { + informalCooperationRate: number; + contractReliability: number; + productiveInvestmentShare: number; + publicContributionShare: number; + exitRate: number; + safeAssetShare: number; + lobbyingShare: number; + finalWealthGini: number; +} diff --git a/packages/engine/src/validation.ts b/packages/engine/src/validation.ts new file mode 100644 index 0000000..44756e8 --- /dev/null +++ b/packages/engine/src/validation.ts @@ -0,0 +1,153 @@ +import type { + Decision, + EngineConfig, + Parcel, + PlayerState, + ValidationError, + ValidationResult, +} from "./types"; + +export const decisionCost = ( + decision: Decision, + config: EngineConfig, +): number => { + const amount = decision.amount ?? 0; + switch (decision.type) { + case "PRODUCTIVE_INVESTMENT": + return amount; + case "SAFE_ASSET": + return amount; + case "PUBLIC_CONTRIBUTION": + return amount; + case "INFORMAL_CONTRACT": + return amount + config.informalContractFee; + case "FORMAL_CONTRACT": + return amount + config.formalContractFee; + case "LOBBYING": + return decision.amount ?? config.lobbyingCost; + case "PRODUCE": + case "EXIT": + return 0; + } +}; + +export const validateDecisions = ({ + players, + parcels, + decisions, + config, +}: { + players: readonly PlayerState[]; + parcels: readonly Parcel[]; + decisions: readonly Decision[]; + config: EngineConfig; +}): ValidationResult => { + const errors: ValidationError[] = []; + const wealthRemaining = new Map( + players.map((player) => [player.id, player.wealth]), + ); + const actionPointsRemaining = new Map( + players.map((player) => [player.id, player.actionPointsRemaining]), + ); + + for (const decision of decisions) { + const player = players.find( + (candidate) => candidate.id === decision.playerId, + ); + + if (!player) { + errors.push({ + code: "PLAYER_NOT_FOUND", + message: "Player does not exist.", + playerId: decision.playerId, + decision, + }); + continue; + } + + if (player.exited) { + errors.push({ + code: "PLAYER_EXITED", + message: "Exited players cannot act.", + playerId: player.id, + decision, + }); + continue; + } + + const amount = decision.amount ?? 0; + if (amount < 0 || !Number.isFinite(amount)) { + errors.push({ + code: "INVALID_AMOUNT", + message: "Decision amount must be a non-negative finite number.", + playerId: player.id, + decision, + }); + continue; + } + + const remainingAp = actionPointsRemaining.get(player.id) ?? 0; + if (remainingAp < 1) { + errors.push({ + code: "INSUFFICIENT_ACTION_POINTS", + message: "Player has insufficient action points.", + playerId: player.id, + decision, + }); + continue; + } + actionPointsRemaining.set(player.id, remainingAp - 1); + + if (decision.type === "PRODUCE") { + const parcel = parcels.find( + (candidate) => candidate.id === decision.parcelId, + ); + if ( + !parcel || + (parcel.ownerId && parcel.ownerId !== player.id) || + (!parcel.ownerId && !player.parcelIds.includes(parcel.id)) + ) { + errors.push({ + code: "INVALID_PARCEL", + message: "Player cannot produce on this parcel.", + playerId: player.id, + decision, + }); + continue; + } + } + + if ( + decision.type === "FORMAL_CONTRACT" || + decision.type === "INFORMAL_CONTRACT" + ) { + if ( + !decision.counterpartyId || + !players.some((candidate) => candidate.id === decision.counterpartyId) + ) { + errors.push({ + code: "COUNTERPARTY_NOT_FOUND", + message: "Contract counterparty does not exist.", + playerId: player.id, + decision, + }); + continue; + } + } + + const cost = decisionCost(decision, config); + const remainingWealth = wealthRemaining.get(player.id) ?? 0; + if (cost > remainingWealth) { + errors.push({ + code: "INSUFFICIENT_RESOURCES", + message: "Player cannot spend more resources than available.", + playerId: player.id, + decision, + }); + continue; + } + wealthRemaining.set(player.id, remainingWealth - cost); + } + + return { ok: errors.length === 0, errors }; +};