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 reasoningPropertyStore.PurchaseAsyncuses for "the door is genuinely theirs."WithdrawAsync(savings → cash): the destination is cash, the source is savings. Cash is credited first viaIEconomyService.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— mirrorsCharacterEconomyComponentexactly: pollsIBankingService.HasAccount/GetSavingsBalancefor the owning character everyOnUpdateand assigns to[Sync(FromHost)] HasAccount/SavingsBalance, callingIBankingService.LoadAsynconce 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 argumentCharacterEconomyComponent's header comment already makes.BankComponent— the placed-in-map entity.[Rpc.Host] RequestOpenAccount(),RequestDeposit(int amount),RequestWithdraw(int amount), each resolving the caller'sCharacterfromRpc.Caller.GetCharacter()(never a client-supplied id), gated by the same real distance checkDoorComponentestablished in Milestone 11 (IPawnLocator.PositionOfagainstBankConfiguration.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.
SavingsBalanceis a single flat number, matchingIEconomyService.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 panel —
Code/Ui'sIHudRegistrypattern applies the same wayMoneyDisplay/InventoryPanelalready do; deferred the same wayEconomyModule.Ready()'s ownMoneyDisplayregistration currently is (see that file'sTODO), since the underlyingPanel-vs-PanelComponentregistry 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.