A House Divided A House DividedDocumentation
Changelog
Game Design/Economy & Finance

Labour & Unions

Last updated 2026-08-21
Source files

The labour system models per-sector wage decisions as a first-class economic and political lever: explicit worker pay carved out of corporate maintenance costs, a macro feedback loop tying wages into unemployment/income/migration, an NPC unionization metric with strikes, and a full player-run union layer (found a union, organize/raid sectors, elect a leader, strike, bargain collectively, demand wages, set dues/services/political contributions, endorse legislation, get busted or write union law). It ships as one graduated feature flag so every tier can be validated independently before the next unlocks.

Entry point: (labourSystemMode, isLabourWagesEnabled()/isLabourMacroEnabled()/isLabourUnionsEnabled()/isLabourFullMode())

Overview#

labourSystemMode tiers#

One graduated flag on GameConfig, each tier a strict superset of the previous. Production seed is "full" (gameConfig.labourSystemMode). getLabourSystemMode() returns "off" only when the field is absent or unknown.

Tier Unlocks
off Nothing, legacy flat maintenance cost, no labour fields read or written
wages Explicit per-sector labor cost (profit-invariant at baseline), CEO wage-level slider (0.8×, 1.5×), minimum wage (Kaitz ratio), automation tech effect
macro Wage decisions feed medianIncome (Phillips-curve passthrough) and unemploymentRate (wage + automation pressure terms), and modulate international migration pull
unions NPC-driven per-sector unionization (0-100), unionPremium labor-cost surcharge, strikes (trigger/concession/waitout)
full Union-busting, union-law legislation, and the entire player-run union layer (found/organize/raid/vote-leader/strike/bargain/demand-wage/dues/services/political-contributions/endorse)

Data model#

// src/lib/labour/laborCost.ts (LabourContext, assembled per corp turn)
interface LabourContext {
  wagesEnabled: boolean;
  unionsEnabled: boolean;
  fullEnabled: boolean;
  minWageRatioByCountry: Map<CountryId, number>;
  unionLawBiasByCountry: Map<CountryId, number>;
  ownedUnionMembershipPressureByKey: Map<string, number>; // key: `${countryId}|${sectorType}`
}
// src/lib/db/types/corporation.ts (CorporateSector labour fields)
interface CorporateSector {
  // ...
  wageLevel?: number; // CEO slider, 0.8-1.5, default 1.0
  unionization?: number; // 0-100, NPC-driven trending stock (mode >= "unions")
  workerExpectationIndex?: number; // slow-trending real-wage expectation (strikes)
  strikeStartedAtTurn?: number | null;
  strikeCooldownUntilTurn?: number | null;
  bustingCooldownUntilTurn?: number | null; // mode >= "full"
}
// src/lib/db/types/union.ts
interface Union {
  _id: ObjectId;
  countryId: CountryId;
  sectorType: CorporationType; // NOT unique on (countryId, sectorType) under union dues v1, foundUnion() lets a rival union coexist in the same pair
  name: string; // era-appropriate seeded display name, or player-chosen on foundUnion
  ownerId: ObjectId | null; // null = NPC/unmanned; source of truth for leadership
  pendingLeaderCharacterId?: ObjectId | null; // top vote-getter from voteUnionLeader, awaiting acceptance
  treasury: number;
  membershipPressure: number; // 0-100, additive term into unionizationDriftTarget()
  suspended?: boolean;
  duesPerWorkerAnnual?: number;
  activeServices?: string[];
  politicalContributionPct?: number; // 0-0.5
  lastCalledStrikeTurn: number | null;
  demandedWageLevel: number | null;
  createdAt: Date;
  updatedAt: Date;
}

interface UnionEndorsement {
  _id: ObjectId;
  unionId: ObjectId;
  billId: ObjectId;
  stance: "endorse" | "oppose";
  createdAt: Date;
}

Character.unionLeaderOf is a denormalized read cache of Union.ownerId, mirroring the existing factionId/caucusMemberships pattern, reconciled on read (reconcileUnionOwnerCache/reconcileUnionLeaderCache in ) rather than via a periodic sweep, since the one-leader/one-union invariant is far simpler than caucus membership.

Phase 0-1, wages (labourSystemMode >= "wages")#

Explicit per-sector labor cost carved OUT of maintenance (never added on top), profit-invariant at the baseline wage multiplier. SECTOR_LABOR_INTENSITY (cost share of revenue) and SECTOR_WAGE_LEVEL (relative pay per worker) are separate tables in , so minimum wage correctly bites labor-intensive/low-paid sectors (retail, agriculture) instead of labor-light ones (extraction). CEO wage slider: WageLevelPanel.tsx. Minimum wage: Kaitz ratio on FederalBudget.minimumWageKaitzRatio, admin-settable via Admin → Economy → Labour (LabourAdminPanel.tsx). Automation tech reduces labor cost via the existing tech-tree framework.

Phase 2 (v2), macro coupling (labourSystemMode >= "macro")#

Routes labour wage decisions into the existing medianIncome Phillips-curve node rather than wiring every downstream channel individually:

Convention: Δ not level for anything read into a compounding series (medianIncome, unemploymentRate), a level term would march the series to its bound and pin there. Unionization itself is the exception (see Phase 5) because it's a target-trending stock, not a recursive series.

Phase 5, NPC unionization metric (labourSystemMode >= "unions")#

Per-sector unionization (0-100) drifts each turn toward a condition-driven target (unionizationDriftTarget() in ), inputs: CEO wageLevel vs. state cost-of-living (realWageIndex()), state unemploymentRate (worker leverage), country minimum-wage Kaitz ratio, plus (once full) unionLawBias and owned-union membershipPressure. unionPremium(unionization) is a standing labor-cost surcharge, linear 0-15pp (UNION_PREMIUM_MAX_PCT), folded into wageMultiplier using the prior turn's persisted value (one-turn lag, consistent with the rest of the labour system).

Phase 6, strikes (labourSystemMode >= "unions")#

Per-sector bounded-duration event (). Triggers when unionization > STRIKE_UNIONIZATION_THRESHOLD (55, calibrated well under unionizationDriftTarget()'s composed ceiling, see the calibration note below) and a slow-trending workerExpectationIndex lags the current real wage by more than STRIKE_EXPECTATION_GAP_THRESHOLD (0.12). While active: revenue ×0.75 (STRIKE_REVENUE_THROTTLE), margin −8pp. Resolves via concession (gap closes to ≤0.04) or wait-it-out (STRIKE_DURATION_TURNS=4 turns, unionization +10). Cooldown (STRIKE_COOLDOWN_TURNS=12) is set on both resolution paths, that, plus the trigger/concession hysteresis gap and the slow expectation index, is what prevents an always-strike/never-strike equilibrium.

Calibration note: the trigger/gap pair is deliberately reachable through purely political levers (union-law bias, membership pressure) with zero real wage or employment stress, not just economic ones, this is intended (see strikes.ts's docblock for the exact composed-ceiling math), not a miscalibration. No single factor alone crosses the threshold.

Phase 7, union-busting + union law (labourSystemMode >= "full")#

Phase 8, player-run unions (labourSystemMode >= "full")#

Under "union dues v1" a union is no longer a single seeded singleton per (countryId, sectorType) with first-come claim leadership. Any character may found a rival union in an industry that already has one (), and a union only represents the specific CorporateSectors it has actually organized or raided, not every sector matching its (country, sector type) pair.

Per-turn processing (, "unionsTurn" phase, runs immediately after "corporationTurn"): treasury dues trickle, services cost, political contributions, membership pressure decay, and inactivity auto-vacancy release the union back to NPC control. Non-additive mutations (union-busting, organizeSector, strike calls) are rejected with 409 while isTurnProcessingNow(), the corp turn's bulk sectorOps write and the unions-turn bulk write both recompute from a pre-mutation snapshot with no optimistic-concurrency filter, so an action landing mid-turn would otherwise be silently clobbered. All union routes and the turn phase are gated on isLabourFullMode(); the leaderboard/dashboard pages (, ) distinguish a disabled-feature/network error from the legitimate empty state.

Turn-processing race safety#

Any player-facing union/labour mutation that isn't purely additive (union-busting, recruit, strike-call) checks isTurnProcessingNow(gameState) () before writing, rejecting with 409 while the turn engine's bulk corp-turn write is in flight, reusing the existing GameState.isProcessing/heartbeat lock rather than retrofitting optimistic concurrency into the shared turn-processing code path.

Connected pages

References →
None
← Referenced by
docsOccupational Pensions