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

Currency Exchange & Multi-Currency System

Last updated 2026-08-21
Player wikiCurrency Exchange
Source files

Document status, 2026-08-21: The overview, exchange-rate model, fee routing, and canonical storage rule describe the shipped system. The later schema proposals and migration sequence are retained as implementation history, not as a current rollout checklist. For current fields and execution paths, use , , src/lib/currency/, and .

A dynamic foreign exchange system where each active country uses its native currency, exchange rates float based on macroeconomic conditions and player trading activity, and players can speculate on currency markets.

Overview#

Why Not USD-Canonical?#

An earlier design considered storing everything in USD and converting at display time. The problem: if US players tank their economy through bad fiscal policy, every other country's internal values shift because the yardstick itself moved. A UK player's fund generation hasn't changed, but their displayed £ amount fluctuates because the USD anchor weakened.

The abstract internal unit solves this, each currency floats independently. US inflation spikes only affect the USD rate; other currencies are unaffected unless their own economies deteriorate.

Exchange Rate Model#

Country Scope#

CurrencyCode () defines 25 currency codes total, one per country ever playable. Of these, FOREX_ACTIVE_CURRENCIES lists 18 as active in the forex system: USD, GBP, JPY, EUR, IEP, CNY, BRL, NGN, SUR, DDM, FRF, ITL, ESP, SEK, TRL, GRD, ATS, FIM. The remaining codes (e.g. CAD, HUF, PLZ, ROL, YUD, BGL, CSK) are reserved for countries not yet active in forex.

Absolute Inflation Penalty#

computeMacroTarget() () applies an absoluteInflationPenalty on top of the usual baseline-relative deviation terms: any inflation above ABSOLUTE_INFLATION_DEPRECIATION_THRESHOLD weakens the currency regardless of that country's own inflation baseline. This closed a carry-trade exploit, currencies with no full economic baseline (or a high one) previously acted as a fixed peg that never depreciated no matter how high inflation ran, making them a free real-yield carry vehicle. The penalty applies universally so high inflation always costs the holder.

Internal Unit Calibration#

All existing monetary values in the database are reinterpreted as internal units. Initial exchange rates are set so that existing values map cleanly:

Country Currency Initial rate Meaning Status
US USD 1.0 1 internal unit = $1.00 ✅ Active
UK GBP 0.75 1 internal unit = £0.75 ✅ Active
JP JPY 106.0 1 internal unit = ¥106 ✅ Active
DE EUR 0.92 1 internal unit = €0.92 ✅ Active

Cross-country rates are derived: USD/JPY = jpyRate / usdRate. No separate storage needed for currency pairs.

Rate Update Formula (Per Turn)#

Rates update each turn through macro drift, player volume, random noise, and a fourth 12-turn directional regime from applyCyclePressure.

1. Macro Fundamental Drift#

Each country's rate moves toward a target derived from macroeconomic data, measured against fixed baselines (not relative to another country).

Data sources per country:

The forex phase reads only from centralBanks, the budget mirror keeps it to one collection read per country.

macroTarget = baseRate × max(0.01, 1
  - (primeRate - baselinePrime) × 0.02          // higher rates → stronger currency (lower rate value)
  + (inflationRate - baselineInflation) × 0.015  // higher inflation → weaker currency
  - (gdpGrowth - baselineGDP) × 0.01            // higher growth → stronger currency
  - (tradeGrowth - baselineTrade) × 0.005        // trade surplus → stronger currency
)

Rate is "local currency per internal unit," so a lower rate means a stronger currency. Each sensitivity term above is signed so that the described real-world effect (higher rates/growth/trade surplus → stronger currency) comes out of the subtraction, not the addition.

The rate drifts toward the macro target rather than snapping to it:

newRate = currentRate + (macroTarget - currentRate) × DRIFT_SPEED

At drift speed 0.05, a rate shock takes roughly one full game year (~48 turns) to converge 90%. This creates multi-month currency trends that give players time to notice, build positions, and exit, matching real-world forex pacing.

2. Player Volume Pressure#

Net buy/sell volume from the past 24 turns creates a short-term offset:

netVolume = buyVolume24 - sellVolume24   // Sum of internal-unit values of all trades, past 24 turns
rawPressure = netVolume × VOLUME_PRESSURE_SENSITIVITY
volumePressure = clamp(rawPressure, -VOLUME_PRESSURE_CAP, +VOLUME_PRESSURE_CAP)

finalRate = newRate × (1 - volumePressure × VOLUME_DIRECTION_WEIGHT)

Positive volume pressure (net buying) lowers the rate, since buying a currency strengthens it.

Volume pressure is calculated using the total internal-unit value of trades. To prevent a single "whale" trade from instantly hitting the cap, each individual trade's contribution to the 24-turn volume is capped at a specific threshold (e.g., 10x the average trade size).

Volume pressure is weighted by VOLUME_DIRECTION_WEIGHT = 0.2, meaning trade volume accounts for 20% of the rate direction while macro fundamentals drive the remaining 80%. This dampens speculative swings while still rewarding well-timed volume.

Volume pressure is capped at ±5% (VOLUME_PRESSURE_CAP = 0.05) to prevent extreme rate swings from outsized trades. The cap is a tunable constant, can be loosened as the player base and trade volume grow. The sensitivity constant may also need tuning based on observed trade volumes at launch.

Heavy yen buying pushes the yen rate below fundamentals temporarily (the yen strengthens more than the macro picture alone would justify). As volume normalizes, macro drift pulls it back, classic overshoot/correction cycle that rewards well-timed speculation.

3. Random Noise#

Small per-turn jitter (up to ±0.4%, RATE_NOISE_MAX = 0.004) prevents perfectly predictable rate movement.

Baseline Economic Values (Fixed)#

Each country has baseline values representing a "neutral" economic state. These anchor the rate math so no country is structurally advantaged:

Country Baseline prime Baseline inflation Baseline GDP growth Baseline trade growth
US 3.0% 2.0% 2.5% 0% (neutral)
UK 3.0% 2.0% 1.5% 0% (neutral)
JP 1.0% 1.0% 1.0% 0% (neutral)
DE 3.0% 2.0% 1.5% 0% (neutral)

Prime rate and inflation baselines come from MONETARY_BASELINES; GDP and trade growth baselines come from a separate ECONOMIC_BASELINES constant (). A third module, monetaryEra.ts, layers era-specific overrides on top of MONETARY_BASELINES for pre-1999 worlds (e.g. JP's 1953-era neutral prime rate is 5.5%, not the modern 1.0%), keyed on the current in-game year so a long-lived world graduates through eras as its clock advances.

Values reflect real-world historical norms. Tunable during playtesting.

CentralBank Schema Fields#

The forex system uses fields already defined on the CentralBank document:

tradeGrowth: number; // Mirrored from FederalBudget.economicFactors.tradeGrowth each turn
forexRevenue: number; // Accumulated intervention reserve share of spread fees
spreadFeeReserveBalances: Record<string, number>; // Currency-specific sell-defense pools

The tradeGrowth mirror write happens during national aggregation, after national metrics are current and before the forex phase runs. The budget document remains the authoritative source; this is a read-cache only.

forexRevenue accumulates 25% of spread fees. The 50% reserve slice is stored under spreadFeeReserveBalances.<currency>, not the general reserveBalance.

CountryConfig Currency Mapping#

CountryConfig includes a currencyCode field in :

currencyCode: CurrencyCode; // "USD", "GBP", "JPY", etc.

This provides the programmatic countryId → currencyCode mapping needed by the abstraction helpers, migration script, display formatters, and income routing. Without this, every system would hardcode the mapping independently.

Guardrails#

Spread Fee Revenue#

Spread fees are split roughly 25/25/50 ():

Together, forexRevenue and currency-specific spread-fee reserves make up the central bank's 75% share (SPREAD_FEE_CENTRAL_BANK_RATIO). Flooring is handled in spreadFees.ts, with the destroyed share absorbing rounding remainder. The split applies to all three trading tiers.

Turn Processing Placement#

The forex phase runs after per-turn inflation recalculation and before central-bank chair phases and history snapshots. It is gated by GameState.forexEnabled. The phase reads centralBanks (primeRate, inflationHistory, gdpGrowthHistory, tradeGrowth), computes net volume from tradeHistory, writes updated rates and snapshots to exchangeRates, and processes triggered or expired limit orders.

Multi-Currency Wallet#

Character Financial Structure#

Existing fields become internal-only:

New currencyBalances, the player-facing wallet:

// CurrencyCode is defined in src/lib/constants/currencies.ts.
// 18 active runtime currencies (FOREX_ACTIVE_CURRENCIES): "USD" | "GBP" | "JPY" | "EUR" | "IEP" | "CNY" | "BRL" | "NGN" | "SUR" | "DDM" | "FRF" | "ITL" | "ESP" | "SEK" | "TRL" | "GRD" | "ATS" | "FIM".
// Remaining CurrencyCode values (e.g. "CAD", "HUF", "PLZ") are reserved for countries not yet active in forex.
type CurrencyCode = "USD" | "GBP" | "JPY" | "EUR" | "IEP" | "CNY" | "BRL" | "NGN" | "SUR" | "DDM" | "FRF" | "ITL" | "ESP" | "SEK" | "TRL" | "GRD" | "ATS" | "FIM" | "CAD" | ...;

currencyBalances: {
  // Campaign funds, always home currency only. Never holds foreign currency.
  campaign: number;
  // Personal wealth, multi-currency. Missing keys treated as 0.
  // Partial allows graceful scaling when future currencies launch, no migration needed.
  personal: Partial<Record<CurrencyCode, number>>;
}

Campaign funds are strictly home-currency. All campaign income (fund generation, party distributions) is deposited in the player's home currency. Campaign spending always deducts from this single balance. Campaign funds never participate in forex, no foreign-currency campaign balances, no cross-currency campaign spending.

Personal wealth is multi-currency. Foreign income (stock dividends, bond coupons, CEO salary) deposits into the corresponding personal currency slot. Players can hold and speculate with any active currency.

A US player's fund generation: currencyBalances.campaign += generatedAmount (in USD). A JP player's: same, in JPY. Home currency is always where players naturally accumulate wealth.

Spending Flow#

Campaign purchases (ads, party actions, etc.) always deduct from currencyBalances.campaign in the player's home currency. No forex involvement.

Personal purchases (stocks, bonds, foreign assets):

  1. Check currencyBalances.personal[requiredCurrency], if sufficient, spend directly, no fee
  2. If partial, spend what they have in that currency, convert the shortfall from currencyBalances.personal[homeCurrency] at market-maker rate (1% spread), apply atomically
  3. If none, convert the full amount from currencyBalances.personal[homeCurrency] at market-maker rate, apply atomically

Shortfall Failure: If both the target currency balance and the home currency balance are insufficient to cover the cost, the transaction is rejected. The system does not chain conversions from other foreign currencies; only the home currency is used as the automatic fallback source.

With auto-convert on (default), the player sees a confirmation prompt before submitting:

"You have ¥2,300,000. This costs ¥5,000,000. Use your yen and convert ≈$25,400 (1% fee) for the remainder?"

With auto-convert off, insufficient foreign currency rejects the transaction with a message directing the player to the exchange. The auto-convert shortfall always draws from personal home currency, campaign funds are never touched for personal purchases.

Foreign Income Handling#

Foreign-denominated income (JP stock dividends, UK bond coupons, CEO salary from foreign corporations) has a per-holding income preference set by the player:

This preference is stored per holding (per stock position, per bond position) so a player can auto-convert UK bond coupons while accumulating JP dividends in yen. The setting is accessible from the portfolio page next to each holding.

Schema note: Stock holding and bond holding documents need a new incomeConversionPreference: "foreign" | "home" field (default "foreign"). This must be added to the relevant holding types during implementation.

Income routes based on source:

Currency Exchange, Three-Tier System#

Fee Schedule#

Tier Method Spread Fill guarantee
1 Market maker (auto-convert) 1% Instant, infinite liquidity
2 Public limit order 0.64% When matched or expired
3 Direct player request 0.36% When target player accepts

Each step down rewards more player engagement. Auto-convert uses Tier 1 by default.

Market Maker#

System-provided liquidity at the current exchange rate ± 1% spread. Always available, always fills instantly. This is what auto-convert uses under the hood.

Player trades against the market maker still exert volume pressure on the rate, buying yen from the market maker pushes yen stronger, same as any other trade.

Public Limit Orders#

Player posts an order visible to all on the exchange: "Buy ¥5,000,000 at rate 104.5 or better." The order sits on the book until the market rate reaches the player's limit price, at which point it auto-executes against the market maker at the prevailing rate (0.64% spread). The order book is primarily a display of what rates players are waiting for, not a peer-matching engine.

Execution Priority:

  1. Manual Player-to-Player Fills: If another player chooses to manually fill an order from the exchange page, this takes absolute priority.
  2. Automatic Market Maker Fills: If no player manually fills the order, the system executes the order against the market maker once the limit price is met.

Player-to-player fills: A player viewing the exchange page can see open limit orders and choose to fill one directly, taking the other side at the posted rate. This is a manual, opportunistic action: the seller browses open buy orders and fills one. The 0.64% spread is split evenly, each side pays 0.32% on the filled amount. Total system take is 0.64%, equivalent to a single market-maker trade at the discounted tier.

Partial fills: Whether filled by the market maker or by another player manually, if less than the full order amount is covered, the matched portion executes and the remainder stays on the book unchanged. status transitions to "partial", filledAmount tracks cumulative filled volume. Spread is charged proportionally on each fill tranche; spreadCharged accumulates across all fill events.

Optional expiry: player can set a turn-based expiry ("cancel after N turns if unfilled"). Orders without expiry persist until manually cancelled.

Direct Player Requests#

Player sends a conversion offer to a specific character: "Buy ¥2,000,000 from [CharacterName] at rate 105.0." Target receives an in-game mail notification. They can:

No counter-offers. If the target wants different terms they decline and post their own order or direct request.

Requests expire after a configurable window (default: 24 turns / ~6 game months).

Player Currency Visibility#

Other players can see which currencies a character holds (e.g., "Holds JPY, GBP") but not the amounts. This enables informed direct trade requests without exposing exact financial positions.

Data Model#

New Collection: exchangeRates#

One document per country, updated each turn:

interface ExchangeRate {
  _id: string; // CountryId
  countryId: CountryId;
  currencyCode: CurrencyCode; // "USD", "GBP", "JPY"
  rate: number; // local currency per 1 internal unit
  baseRate: number; // initial calibration (never changes)
  macroTarget: number; // current fundamental target rate
  rateHistory: TurnSnapshot[]; // per-turn snapshots for charting, pruned to last 240 turns (5 in-game years, FOREX_AND_MACRO_CHART_HISTORY_TURNS) each turn
  /** Gross buy volume for this currency over the past 24 turns, in internal units.
   *  Recomputed each turn by the forex phase from tradeHistory. */
  buyVolume24: number;
  /** Gross sell volume for this currency over the past 24 turns, in internal units.
   *  Recomputed each turn by the forex phase from tradeHistory. */
  sellVolume24: number;
  updatedAt: Date;
}

Net volume is derived at formula time: netVolume = buyVolume24 - sellVolume24. Storing gross values separately makes volume imbalances debuggable without re-querying tradeHistory. The forex turn phase computes these by querying tradeHistory for all trades in the past 24 turns, grouping by currency, and summing internal-unit amounts into buy/sell buckets per country.

New Collection: currencyOrders#

interface CurrencyOrder {
  _id: ObjectId;
  characterId: ObjectId;
  characterName: string;
  countryId: CountryId;
  type: "market" | "limit" | "direct";
  fromCurrency: CurrencyCode;
  toCurrency: CurrencyCode;
  amount: number; // amount of fromCurrency to spend
  limitRate?: number; // limit orders, worst acceptable rate
  targetCharacterId?: ObjectId; // direct orders only
  targetCharacterName?: string; // direct orders only
  expiresAtTurn?: number;
  status: "open" | "filled" | "partial" | "cancelled" | "expired";
  filledAmount: number;
  filledRate?: number;
  spreadCharged: number;
  createdAt: Date;
  updatedAt: Date;
}

Note: balanceType was removed, all forex trades operate on personal funds only. Campaign funds are strictly home-currency and never participate in forex.

Indexes: { status: 1, expiresAtTurn: 1 } for the turn phase scan of open/expired orders. { characterId: 1, status: 1 } for the player's open orders view.

New Collection: tradeHistory#

Executed trade log for volume calculations and player transaction history:

interface TradeHistoryEntry {
  _id: ObjectId;
  buyerCharacterId: ObjectId;
  sellerCharacterId: ObjectId | null; // null = market maker
  fromCurrency: CurrencyCode;
  toCurrency: CurrencyCode;
  amount: number;
  rate: number;
  spread: number;
  turn: number;
  createdAt: Date;
}

Indexes: { turn: -1 } for the forex phase's 24-turn volume lookback query. { buyerCharacterId: 1, turn: -1 } for player transaction history display.

Character Document Changes#

// New fields
currencyBalances: {
  campaign: number; // home currency only, never holds foreign currency
  personal: Partial<Record<CurrencyCode, number>>; // missing keys = 0 balance
}
displayCurrencyPreference: "local" | "home" | "USD"; // default "local"
autoConvertEnabled: boolean; // default true

New character initialization: Characters created after forex is enabled must have currencyBalances initialized with campaign: 0 and personal: { [homeCurrency]: 0 }. The character creation route handles this based on the forex feature flag.

Deprecated Fields (After Migration)#

funds: number        → currencyBalances.campaign          // home currency, no conversion needed
cashOnHand: number   → currencyBalances.personal[homeCurrency]

Migration#

Deployment Strategy: Abstraction Layer + Feature Flag#

This is a zero-downtime progressive deployment, not a hard cutover. New code deploys alongside existing functionality, and an admin button triggers the migration when ready.

Rationale: a hard cutover across ~307 files requires maintenance mode and a big-bang deploy. The abstraction layer approach lets code ship incrementally while the game continues running. All fund read/write logic is funneled through a small set of helper functions that internally switch between old fields (funds/cashOnHand) and new fields (currencyBalances) based on a feature flag.

Deployment sequence:

  1. Deploy abstraction layer, helper functions (getCharacterFunds, getCampaignBalance, getPersonalBalance, deductCampaignFunds, depositPersonalFunds, etc.) that read from funds/cashOnHand by default. All ~307 files migrate to use these helpers. Game continues as normal.
  2. Deploy forex system (feature-flagged off), new types, collections, turn phase, API routes, UI. The forex turn phase and exchange UI are gated behind GameState.forexEnabled (default false).
  3. Admin clicks "Enable Forex" button, triggers the migration: a. Set GameState.isProcessing = true (prevents turns during migration) b. Run migration script (see below) c. Set GameState.forexEnabled = true d. Set GameState.isProcessing = false
  4. Post-migration: helpers now read from currencyBalances. Old funds/cashOnHand fields remain on documents (ignored) until a cleanup pass removes them.
  5. Cleanup PR (later, low priority), remove the fallback branch from helpers, unset old fields from character documents.

Feature Flag: GameState.forexEnabled#

Added to the GameState document (_id: "current"):

forexEnabled: boolean; // default false, gates forex turn phase, exchange UI, and helper read source

The abstraction helpers check this flag (cached per request, not per call) to determine whether to read from old or new fields.

Abstraction Layer (Helper Functions)#

Located in :

// Campaign funds, always home currency
getCharacterCampaignFunds(character): number
deductCampaignFunds(db, characterId, amount): Promise<void>
depositCampaignFunds(db, characterId, amount): Promise<void>

// Personal wealth, currency-aware post-migration
getPersonalBalance(character, currencyCode?): number
depositPersonalFunds(db, characterId, amount, currencyCode): Promise<void>
deductPersonalFunds(db, characterId, amount, currencyCode): Promise<void>

// Display, returns formatted amount in appropriate currency
formatCharacterFunds(character, type: "campaign" | "personal"): string

Pre-migration: these read/write funds and cashOnHand directly. Post-migration: these read/write currencyBalances.campaign and currencyBalances.personal[currencyCode].

One-Time Migration Script (Triggered by Admin Button)#

  1. For each character, read countryId, funds, cashOnHand (treat missing/undefined cashOnHand as 0)
  2. Look up home currency from country config (US→USD, UK→GBP, JP→JPY, DE→EUR)
  3. Create currencyBalances from existing values:
    currencyBalances: {
      campaign: funds,
      personal: {
        [homeCurrency]: cashOnHand ?? 0
      }
    }
    
  4. Set displayCurrencyPreference: "local", autoConvertEnabled: true
  5. Do not unset funds and cashOnHand yet, they remain as inert data until the cleanup pass
  6. Seed exchangeRates documents for US, UK, JP, and DE with initial rates
  7. Ensure active centralBanks documents have tradeGrowth, forexRevenue, and reserveBalance initialized
  8. Create indexes: tradeHistory: { turn: -1 }, currencyOrders: { status: 1, expiresAtTurn: 1 }
  9. Set GameState.forexEnabled = true

Code Migration (Blast Radius)#

Scope warning: A grep of \.funds and cashOnHand across src/ returns ~307 files (142 in src/lib, 165 in src/app/api). This is the largest single refactor in the project. Implement in phases, abstraction layer first, then turn system, then API routes, then display components.

Every code path that reads or writes funds or cashOnHand must be updated to use the abstraction helpers. Key systems:

Run grep -rn "\.funds\b\|cashOnHand" src/ before starting to generate the full touchpoint list. Do not rely solely on the list above.

UI#

Currency Exchange Page#

Nav placement: World dropdown → between Stock Market and News

Page content:

Player Wallet#

New section on the character portfolio page:

Display Preference Toggle#

Three options, stored as displayCurrencyPreference on the character:

Setting Behavior Example (JP stock at ¥26,900)
Local (default) Prices shown in the asset's native currency ¥26,900
Home Everything converted to player's home currency ≈$254
USD Everything converted to US dollars ≈$254

The prefix indicates a converted value, distinguishing it from native prices. Accessible from the wallet section.

Note: for US players, "home" and "USD" are identical, both show USD. The "USD" option exists as a common reference currency for non-US players who want to benchmark everything in dollars regardless of their home currency.

Auto-Convert Toggle#

Direct Trade Flow#

  1. Player views another character's profile → sees "Holds JPY, GBP" (amounts hidden)
  2. Clicks "Propose currency trade" → form: currency pair, amount, proposed rate
  3. Target receives in-game mail notification
  4. Target accepts (executes at proposed rate + 0.36%) or declines, no counter-offers
  5. Expiry countdown shown on both sides

Notifications (In-Game Mail)#

Future Enhancement (v2): Rate Alerts#

Players set a watch on a currency pair: "Notify me when USD/JPY drops below 95." Receive in-game mail when the rate crosses their threshold. Useful for players who want to time currency purchases without monitoring the exchange page. Not in v1 scope, no schema pre-created. Define the rateAlerts collection when v2 is actively built.

Constants & Configuration#

// Spread schedule
const MARKET_MAKER_SPREAD = 0.01; // 1%
const LIMIT_ORDER_SPREAD = 0.0064; // 0.64%
const DIRECT_TRADE_SPREAD = 0.0036; // 0.36%

// Rate dynamics
const DRIFT_SPEED = 0.05; // ~48 turns to 90% convergence
const RATE_NOISE_MAX = 0.004; // ±0.4% per-turn jitter
const RATE_FLOOR_MULTIPLIER = 0.5; // 50% below base rate
const RATE_CEILING_MULTIPLIER = 1.5; // 50% above base rate

// Macro sensitivity coefficients
const PRIME_RATE_SENSITIVITY = 0.02;
const INFLATION_SENSITIVITY = 0.015;
const GDP_GROWTH_SENSITIVITY = 0.01;
const TRADE_GROWTH_SENSITIVITY = 0.005;

// Baselines (neutral economic state), split across two constants in source
const MONETARY_BASELINES: Record<CountryId, MonetaryBaseline> = {
  US: { targetInflation: 2.0, neutralPrimeRate: 3.0 },
  UK: { targetInflation: 2.0, neutralPrimeRate: 3.0 },
  JP: { targetInflation: 1.0, neutralPrimeRate: 1.0 },
  DE: { targetInflation: 2.0, neutralPrimeRate: 3.0 },
};

const ECONOMIC_BASELINES: Partial<Record<CountryId, EconomicBaseline>> = {
  US: { gdpGrowth: 2.5, tradeGrowth: 0 }, // 0 = neutral
  UK: { gdpGrowth: 1.5, tradeGrowth: 0 }, // 0 = neutral
  JP: { gdpGrowth: 1.0, tradeGrowth: 0 }, // 0 = neutral
  DE: { gdpGrowth: 1.5, tradeGrowth: 0 }, // 0 = neutral
};

// Initial exchange rates (local currency per 1 internal unit)
const INITIAL_RATES: Partial<Record<CountryId, number>> = {
  US: 1.0,
  UK: 0.75,
  JP: 106.0,
  DE: 0.92,
};

// Player volume pressure
const VOLUME_PRESSURE_SENSITIVITY = 0.0001; // per internal-unit of net volume
const VOLUME_PRESSURE_CAP = 0.05; // ±5% max rate impact from volume, tunable
const VOLUME_LOOKBACK_TURNS = 24; // ~6 game months of trade history

// Order expiry defaults
const DIRECT_TRADE_EXPIRY_TURNS = 24; // ~6 game months

Integration with Existing Systems#

Systems That Need Currency Awareness#

System Change required
Fund generation Deposit to currencyBalances.campaign (home currency)
Campaign spending Read/write from currencyBalances.campaign (home currency only)
Corporation financials Dividends/salary deposit in corporation's country currency
Stock exchange Trades settle in the stock's country currency; auto-convert if needed
Bond system Coupons/maturity payments in the bond's country currency
Party treasury Taxes collect in party's country currency
Budget system Already has currencyCode field, minimal changes
NPP funds Deposit to home currency
Formatters All currency formatters accept currencyCode parameter
Display components Thread countryId/currencyCode through ~60+ component files

Systems That Don't Change#

System Why unchanged
Election mechanics No monetary component
Vote distribution No monetary component
Turn processing order Forex runs after inflation recalc and before central-bank/history phases
Demographic effects Operate on non-monetary metrics
NPP election/voting behavior Decision logic is ideology-based, not money-based

Currency storage (v0.2.6)#

The canonical storage rule for every money field in the simulation is: stored in its owning entity's native currency, aggregated in ₳ (anchor), displayed via wallet preference. Forex v1 (shipped earlier) established this for Corporation.liquidCapital and Character.currencyBalances. v0.2.6 extends the rule to every remaining money field.

Domain Stored in
Corp fields (liquidCapital, marketingBudget, logisticsBudget, ceoSalary, sharePrice) corporation.liquidCurrencyCode
Sector fields (revenue, currentGrowthCost) parent corp's liquidCurrencyCode (not the state's currency)
Bond face value / coupon / totalIssued bond.currencyCode (sovereign → country; corporate → issuing corp's liquidCurrencyCode)
Federal budget (revenue, spending, taxBases, debt, gdp, surplus) country's currencyCode (stamped on federalBudget.currencyCode)
State budget (revenue, spending, taxBases, balance, surplus, stateGdp) parent country's currency
enactedLaws.annualCostUsd country's currency (legacy field name; Usd suffix predates v0.2.6)
Cross-entity sums (global GDP, global market cap, commodity flows, rankings) computed in ₳ via sumAsAnchor / readCorpEconomicAnchor; displayed via wallet pref
sharePriceFormula intermediate ₳ (anchor), converted to corp-local at persistence boundary
corporationHistory, marketCapHistory, corporationPortfolioHistory corp's liquidCurrencyCode at time of write (currencyCode stamped on each row)
federalBudgetSnapshots.budget.* country's currency at time of write (budget.currencyCode stamped on each snapshot)
debtToGdpRatio dimensionless, not scaled

Deferred (post-v0.2.6): Campaign.funds, PoliticalParty.treasury, StatePartyOrg.treasury, DiscordBotFund, campaign/party-side money stays ₳ for now. A future v0.2.x migration will move these too.

Helpers (authoritative)#

Helper Module Purpose
readCorpEconomicAnchor(corp, rates) Convert any corp-local money to ₳ for aggregation
writeCorpEconomicLocal(anchor, rate) Convert ₳-denominated derivations back to corp-local for write
resolveCountryCurrencyCode({ countryId }) Country → ISO currency code
resolveCorpLiquidCurrencyCode(corp) Corp → its liquidCurrencyCode (with country fallback)
fxRateForCorpFromMap(corp, rates) Corp → FX rate against ₳
loadFxRatesByCurrency(db) Load exchangeRates into a Map<currencyCode, rate>
sumAsAnchor(items, valueFn, rateFn) Mixed-currency sum → ₳
toInternalFrom(local, code, rates) src/lib/currency/ (via useCurrency) Local → ₳ (display helper)
formatAmount(anchor, nativeCurrencyCode) useCurrency hook Render per the user's wallet preference

History backfill (option 3)#

The v0.2.6 migration rescales every historical snapshot (corporationHistory, marketCapHistory, corporationPortfolioHistory, federalBudgetSnapshots) at today's FX rate. Historical FX accuracy is intentionally sacrificed in favor of visually-continuous charts across the migration moment. This was evaluated against two alternatives:

Migration scripts#

Run in this order against a staging clone first (see ):

(No government-budget migration is shipped, federal and state budgets, snapshots, and enacted laws were already stored in each country's currency pre-v0.2.6; see docs/design/national-budget.md.) Each script writes a marker into the migrationsRun collection and exits early on re-run. Production rollout is an atomic cutover: pause turns → run both scripts → un-pause → merge the code deploy.