Skip to content

Banking

ROADMAP.md Milestone 14's first build item: "legacy built the enabling hook and never the bank" — see Legacy/01_FEATURE_INVENTORY.md's Economy section, which records exactly one line for it: Banks | ❌ | hook exists, bank never built | New. A full search of Documentation/Legacy/ turns up no bank UI, no deposit/withdraw command, and no persisted bank balance anywhere — just the disposition line itself. This is new design with no legacy behaviour to preserve, the same category as this milestone's Licences and Vendors.


1. Why a second balance, not a UI on top of IEconomyService

IEconomyService already gives every character one balance — cash on hand, floored at zero, checked with CanAfford, moved with DebitAsync/ CreditAsync. The tempting shortcut is to make "a bank" nothing but a WorldContainer-shaped label painted over that same balance: no new state, just a place to stand while the existing HUD number goes up or down.

That shortcut fails the exit criterion on its own terms. ROADMAP.md Milestone 14 says explicitly: "a player can open an account at a bank." If "the account" is just IEconomyService's existing balance, every character already has one from the moment LoadOrCreateAsync first runs (Code/Economy/README.md's own words: "Default Money: 0") — "opening an account" would be a no-op that always trivially succeeds, never a real action with a real precondition. That is not what the roadmap asked for.

Banking therefore introduces a second, bank-held balance — BankAccount.SavingsBalance — distinct from IEconomyService's cash-on-hand, and a real precondition on touching it: an account must exist before it can be deposited into or withdrawn from. IEconomyService is unmodified and still the single source of truth for cash; Applejack.Banking composes with it through its existing public interface (LoadOrCreateAsync, CanAfford, DebitAsync, CreditAsync) exactly the way Code/World/PropertyStore.cs already does for door purchases — it never reaches into EconomyStore's internals, and it never duplicates balance-tracking logic. Depositing moves money from cash into savings; withdrawing moves it back. The total money a character has is unchanged by either operation — only which of the two balances holds it changes.

This also gives the rest of the codebase a real, if currently unused, hook: cash-on-hand is the only balance any future system can put at risk (theft, a death penalty, robbery) — Code/ Economy/README.md already notes the legacy death-money-penalty was dropped, and Legacy/ 01_FEATURE_INVENTORY.md keeps "Player trade (no escrow)" as a deliberate kept risk. A bank-held balance being the safe alternative to carrying cash is the entire reason banks exist as a concept; a single fused balance would have made this milestone's exit criterion, and every future risk-bearing system, meaningless to build against. No such risk system exists yet — this is named as forward-looking rationale for the split, not a claim that anything currently threatens cash-on-hand.


2. BankAccount

namespace Applejack.Banking;

/// <summary>
/// A character's bank-held balance -- distinct from IEconomyService's cash-on-hand. See
/// Documentation/Architecture/26_BANKING.md §1 for why this is a second balance and not a
/// view over the existing one.
/// </summary>
public sealed class BankAccount
{
    public CharacterId CharacterId { get; init; }
    public int SavingsBalance { get; init; }
}

Deliberately thin, following Door/WorldContainer's precedent: the account itself carries no behaviour, and its existence is the whole signal — IBankingService.HasAccount is a membership check, not a field read. There is no Bank domain type: unlike a Door or a WorldContainer, a bank location has no owned/locked/sealed state of its own to persist — see §4.

Persistence. BankAccountDocument { CharacterId, SavingsBalance } under bankaccounts/{characterId}, schema version 1 — the same per-character keying EconomyDocument already uses under economy/{characterId}, a separate document because Code/Economy's own "not responsible for" boundary says cash-on-hand is the only thing that module owns.


3. IBankingService / BankAccountStore

namespace Applejack.Banking;

public interface IBankingService
{
    Task<Result> LoadAsync(CharacterId characterId);
    bool HasAccount(CharacterId characterId);
    int GetSavingsBalance(CharacterId characterId);
    Task<Result> OpenAccountAsync(CharacterId characterId);
    Task<Result> DepositAsync(CharacterId characterId, int amount);
    Task<Result> WithdrawAsync(CharacterId characterId, int amount);
}

LoadAsync mirrors IEconomyService.LoadOrCreateAsync's shape but deliberately does not create anything — the same in-memory-cache-must-be-primed-before-a-synchronous-read discipline (HasAccount/GetSavingsBalance are synchronous, matching GetBalance/CanAfford), but "loading" and "opening" are different actions here: looking at a bank's existence must never itself create one, or "opening an account" could never fail.

OpenAccountAsync fails if the character already has one — a real, checkable precondition, unlike IPropertyService.RegisterAsync's idempotent load-or-create (that method exists for infrastructure registering itself at scene load, not for a deliberate player action). This mirrors PropertyStore.PurchaseAsync refusing an already-owned door rather than silently succeeding.

Ordering: credit the destination before debiting the source

PropertyStore.PurchaseAsync's header comment names the specific legacy defect this codebase never wants to reproduce: Legacy/13_DEPENDENCY_GRAPH.md §3"if setOwnerPlayer fails after GiveMoney(-150), the money is gone and the door is not owned." PropertyStore's fix is to assign the thing the buyer is gaining (ownership) before taking the cost (the debit): if the gain fails, abort cleanly, nothing happened; if the gain succeeds but the cost-taking then fails, that is a rare, logged, operator-visible anomaly — the buyer keeps the door without being charged — but never "cost taken, nothing gained."

Deposit and withdraw are both a two-sided money movement, the same shape as a purchase, so BankAccountStore generalises the identical rule: credit the destination balance before debiting the source balance.

  • DepositAsync (cash → savings): the destination is savings, the source is cash. Savings is credited (persisted) first. If that write fails, abort — Result.Failure, cash untouched, a safe no-op exactly like a failed ownership assignment. If it succeeds but the cash debit then fails, the anomaly is logged and the call still returns success: the deposit's real effect — money now sitting in the bank — genuinely happened and is genuinely persisted, the same reasoning PropertyStore.PurchaseAsync uses for "the door is genuinely theirs."
  • WithdrawAsync (savings → cash): the destination is cash, the source is savings. Cash is credited first via IEconomyService.CreditAsync. If that fails, abort — savings untouched. If it succeeds but the savings debit then fails, the anomaly is logged and the call still returns success: the player has their cash in hand, which is withdrawal's real effect.

Both directions therefore risk the exact same class of anomaly PropertyStore already accepts — "the player profited by one logged, rare write failure" — and never the reverse, "the player paid and got nothing." This is not a new principle invented for Banking; it is PropertyStore.PurchaseAsync's own rule, applied a second time to a second two-sided exchange.

Concurrency

A single per-character in-flight guard (_actionsInFlight, a HashSet<CharacterId>, checked and added synchronously before any await, removed in a finally) covers OpenAccountAsync/DepositAsync/WithdrawAsync together — the same shape as PropertyStore._purchasesInFlight, but one guard rather than three, because all three mutate the same character's single BankAccount record and a second concurrent call for any of them against the same character is exactly the "read-before-either-write-lands" race the guard exists to prevent, regardless of which of the three operations either call is.

Affordability

DepositAsync delegates to IEconomyService.CanAfford/DebitAsync — never trusted from a client, never re-implemented. WithdrawAsync checks SavingsBalance >= amount against its own in-memory cache, the same pattern EconomyStore.DebitAsync uses for cash, floored the same way (an amount larger than the balance is refused outright, not partially honoured).


4. Why there is no Bank world-entity domain type

Door and WorldContainer both persist per-instance state (IsLocked/IsSealed, and an Ownership claim keyed by the same id) — that per-instance state is why they need a registered domain record and a store. A bank location has none of that: it is never owned, never locked, never sealed, and the balance a player interacts with through it belongs to the character, not the location — the same way a vendor's price list belongs to VendorConfiguration rather than to one specific shop instance (see 27_VENDORS.md, this milestone's sibling doc). Two different bank terminals placed in a map are two ways to reach the same character-scoped account, exactly the way IEconomyService's balance is already server-wide rather than per-location.

BankComponent is therefore a placed-in-map RPC entry point with no fire-and-forget async registration step (unlike DoorComponent.RegisterAsync) — there is nothing to load for the component itself, only services to resolve, which happens synchronously in OnAwake. It still carries a [Property] Guid BankId, generated once at spawn like DoorComponent.DoorId, but purely for log correlation across multiple bank placements — it is never a persistence key.

5. Components

Following CharacterEconomyComponent/DoorComponent's existing split between "the character's own replicated state" and "the placed-in-map thing the character interacts with":

  • CharacterBankAccountComponent — mirrors CharacterEconomyComponent exactly: polls IBankingService.HasAccount/GetSavingsBalance for the owning character every OnUpdate and assigns to [Sync(FromHost)] HasAccount/SavingsBalance, calling IBankingService.LoadAsync once per character change. [Sync] only sends on real change, so this is a dictionary lookup per frame, not a network packet — the same non-polling argument CharacterEconomyComponent's header comment already makes.
  • BankComponent — the placed-in-map entity. [Rpc.Host] RequestOpenAccount(), RequestDeposit(int amount), RequestWithdraw(int amount), each resolving the caller's Character from Rpc.Caller.GetCharacter() (never a client-supplied id), gated by the same real distance check DoorComponent established in Milestone 11 (IPawnLocator.PositionOf against BankConfiguration.InteractionRange).

BankConfiguration is a one-property GameResource (InteractionRange, default 150f, matching DoorConfiguration.InteractionRange's own unverified-unit-scale default — no legacy precedent, same as that property's own doc comment already says) following 05_CONFIGURATION.md's per-module pattern.


6. A traced flow: depositing cash

sequenceDiagram
    participant C as Client
    participant BC as Host: BankComponent
    participant BS as BankAccountStore
    participant E as EconomyStore
    participant Sync as CharacterBankAccountComponent

    C->>BC: [Rpc.Host] RequestDeposit(amount)
    BC->>BC: identity, existence(none needed), distance, state, amount > 0
    BC->>BS: DepositAsync(characterId, amount)
    BS->>BS: HasAccount? CanAfford(amount)?
    BS->>BS: credit savings, persist (destination first)
    BS->>E: DebitAsync(characterId, amount) (source second)
    Sync->>Sync: OnUpdate re-reads HasAccount/SavingsBalance -- replicates to the depositing client only

Withdraw is the mirror image: IEconomyService.CreditAsync (destination, cash) before BankAccountStore's own savings debit (source).


7. Exploit checklist summary

Full walk lives as a header comment on Code/Banking/BankComponent.cs, following DoorComponent.cs's exact convention. Summary:

Check RequestOpenAccount / RequestDeposit / RequestWithdraw
Identity Rpc.Caller.GetCharacter() — never a client-supplied character id
Existence N/A — BankComponent has no async registration step; services are resolved synchronously in OnAwake (see §4)
Ownership N/A — the caller can only ever act on their own account; there is no target parameter to forge
Distance IPawnLocator.PositionOf within BankConfiguration.InteractionRange, same shape as DoorComponent
State character.IsInitialised
Affordability RequestDeposit delegates to IEconomyService.CanAfford; RequestWithdraw delegates to BankAccountStore's own savings check; never trusted client-side
Concurrency BankAccountStore._actionsInFlight, one guard covering all three operations per character

8. Not built — a bigger scope, not a blocked gap

  • Interest, fees, and multiple named account types — no legacy precedent (the hook was never wired to a real bank at all), and no consumer needs them yet. SavingsBalance is a single flat number, matching IEconomyService.GetBalance's own flatness.
  • Transfers directly between two characters' bank accounts — legacy's "player trade (no escrow)" stays cash-only (/givemoney, /dropmoney); a bank-to-bank transfer is a new kind of trade, not this milestone's scope.
  • Robbing a bank, or any risk on the savings balance itself — see §1's forward-looking rationale; nothing currently threatens either balance, so nothing here builds a heist system.
  • A bank UI panelCode/Ui's IHudRegistry pattern applies the same way MoneyDisplay/InventoryPanel already do; deferred the same way EconomyModule.Ready()'s own MoneyDisplay registration currently is (see that file's TODO), since the underlying Panel-vs-PanelComponent registry question is still open project-wide, not specific to Banking.

9. Testing

BankAccountStoreTests.cs covers: opening an account (success, and refusing a second open), deposit (success debiting cash and crediting savings, refusing without an account, refusing without enough cash, the debit-fails-after-credit-succeeds anomaly logged as success), withdraw (the mirror image, including the credit-fails-aborts-cleanly path and the debit-fails-after-credit-succeeds anomaly), the concurrency guard rejecting a second in-flight call for the same character, and that both balances persist across a fresh store instance sharing the same backing persistence — following PropertyStoreTests.cs's established precedent of exercising a real EconomyStore collaborator rather than a fake, with one narrow fake economy service reserved for the anomaly paths a real EconomyStore has no way to trigger deliberately.