diff --git a/godot/data/actions/financing.json b/godot/data/actions/financing.json index f1548686..4595232c 100644 --- a/godot/data/actions/financing.json +++ b/godot/data/actions/financing.json @@ -37,6 +37,33 @@ "action_points": 1 }, "category": "financing" + }, + { + "id": "seek_financing", + "name": "Seek Financing", + "description": "Run a raise (ADR-0013): the cost-of-debt engine quotes 2-3 concurrent offers priced by org type, counterparty, reputation and leverage. Offers stand until they expire; accept at plan speed.", + "costs": { + "action_points": 1 + }, + "category": "financing" + }, + { + "id": "accept_financing_offer", + "name": "Accept Offer", + "description": "Accept a standing financing offer by id (from Seek Financing) — cash now, its quoted terms mint a ledger entry.", + "costs": { + "action_points": 1 + }, + "category": "financing" + }, + { + "id": "pay_bills", + "name": "Pay the Bill", + "description": "Settle the soonest-due affordable loan early from cash (#566), retiring its balloon before it bills.", + "costs": { + "action_points": 1 + }, + "category": "financing" } ] } diff --git a/godot/data/balance/defaults.json b/godot/data/balance/defaults.json index 82b9ed06..d3cffc99 100644 --- a/godot/data/balance/defaults.json +++ b/godot/data/balance/defaults.json @@ -93,5 +93,117 @@ "easy": { "money_multiplier": 1.5, "max_action_points": 4 }, "standard": { "money_multiplier": 1.0, "max_action_points": 3 }, "hard": { "money_multiplier": 0.75, "max_action_points": 2 } + }, + "financing": { + "_description": "Cost-of-debt pricing engine + financing instruments (ADR-0013, lane L5/#616). FinanceEngine.price() computes terms from org_type + counterparty + typed reputation + current leverage; generate_offers() mints 2-3 concurrent STANDING offers with an expiry (ADR-0012 — expiry mints no ledger entry). Engine: godot/scripts/core/finance_engine.gd. GOTCHA: computed interest rates are snapped to 0.001 (JSON save-safe, probe-verified against the real full-precision save path) and principals to whole units before entering a Ledger.Entry, so minted offers survive save/load (calibration open-Q #2). Baseline continuity: bank_loan at default context reproduces the calibrated ledger.loan terms (rate 0.008, ~77-tick fuse, 1.2x balloon); the sweep's take_loan path is UNCHANGED (routes Ledger.loan directly), so this block is additive and does not move the L1 medians.", + "ticks_per_month": 22.0, + "offer_ttl_turns": 44, + "leverage_scale": 120000.0, + "rate_floor": 0.002, + "rate_ceiling": 0.05, + "rep_rate_relief": 0.004, + "leverage_rate_penalty": 0.004, + "org_factors": { + "_description": "Multiply the base interest (ADR-0013: org type is the pricing engine's biggest input). >1 = worse cost of capital; <1 = cheaper. for_profit borrows cheapest (equity as collateral); independent/solo pays a premium.", + "nonprofit": 1.0, + "for_profit": 0.9, + "academic": 1.05, + "independent": 1.15 + }, + "counterparty_factors": { + "_description": "Who you owe changes what it costs (ADR-0013 major input; ADR-0007 counterparty dread). philanthropist = 0.0 => a gift, not a debt. shadow (desperation) = the punitive last resort.", + "bank": 1.0, + "vc": 0.85, + "government": 0.75, + "foundation": 0.8, + "philanthropist": 0.0, + "shadow": 2.2 + }, + "offer_tiers": { + "_description": "Reputation-gated menu quality (ADR-0013: better standing => better menus). The instrument's rep_channel picks safety- or finance-rep; that value selects the highest threshold it clears; counts[tier] caps concurrent offers.", + "thresholds": [0, 30, 60], + "counts": [2, 3, 3] + }, + "instruments": { + "bank_loan": { + "name": "Bank term loan", + "factory": "loan", + "counterparty": "bank", + "rep_channel": "finance", + "principal_min": 30000, + "principal_max": 80000, + "principal_multiplier": 1.2, + "term_months": 3.5, + "base_rate": 0.008, + "non_cash": {}, + "availability": { "min_rep": 15, "max_leverage": 3.0, "org_types": [] } + }, + "credit_line": { + "name": "Revolving credit line", + "factory": "loan", + "counterparty": "bank", + "rep_channel": "finance", + "principal_min": 60000, + "principal_max": 160000, + "principal_multiplier": 1.15, + "term_months": 4.0, + "base_rate": 0.010, + "non_cash": {}, + "availability": { "min_rep": 45, "max_leverage": 1.5, "org_types": [] } + }, + "vc_equity": { + "name": "VC equity round", + "factory": "equity", + "counterparty": "vc", + "rep_channel": "finance", + "principal_min": 120000, + "principal_max": 400000, + "principal_multiplier": 1.0, + "term_months": 0.0, + "base_rate": 0.0, + "non_cash": { "equity_dilution": 0.12, "board_seat": true }, + "availability": { "min_rep": 30, "min_hype": 25, "max_leverage": 5.0, "org_types": ["for_profit"] } + }, + "grant_strings": { + "name": "Directed grant (agenda strings)", + "factory": "funding_strings", + "counterparty": "government", + "rep_channel": "safety", + "principal_min": 25000, + "principal_max": 70000, + "principal_multiplier": 0.10, + "term_months": 4.0, + "base_rate": 0.0025, + "non_cash": { "agenda_narrowing": true }, + "availability": { "min_rep": 25, "org_types": [] } + }, + "philanthropy": { + "name": "Philanthropic gift", + "factory": "philanthropy", + "counterparty": "philanthropist", + "rep_channel": "safety", + "principal_min": 15000, + "principal_max": 45000, + "principal_multiplier": 1.0, + "term_months": 0.0, + "base_rate": 0.0, + "appearance_chance": 0.35, + "non_cash": {}, + "availability": { "min_rep": 30, "org_types": [] } + }, + "desperation": { + "name": "Desperation payroll lever", + "factory": "desperation_payroll", + "counterparty": "shadow", + "rep_channel": "finance", + "principal_min": 0, + "principal_max": 0, + "principal_multiplier": 1.0, + "term_months": 2.5, + "base_rate": 0.008, + "non_cash": { "secret": true }, + "availability": { "min_rep": 0, "org_types": [] } + } + } } } diff --git a/godot/scripts/core/actions.gd b/godot/scripts/core/actions.gd index 61b57d45..d970e9b5 100644 --- a/godot/scripts/core/actions.gd +++ b/godot/scripts/core/actions.gd @@ -370,6 +370,46 @@ static func execute_action(action_id: String, state: GameState) -> Dictionary: state.ledger.add(Ledger.staff_rider("Contractor")) result["message"] = "Brought on a contractor (+2 AP; a governance rider bills later)" + # --- ADR-0013 cost-of-debt engine (L5 #616): the raise-as-campaign flow --- + "seek_financing": + # Run a purpose-tagged raise: the pricing engine quotes a menu of standing offers + # (ADR-0012 expiry). They persist on state.financing_offers for accept at plan speed. + var rng: RandomNumberGenerator = state.rng if ("rng" in state and state.rng != null) else RandomNumberGenerator.new() + var ctx: Dictionary = FinanceEngine.context_from_state(state) + state.financing_offers = FinanceEngine.generate_offers(ctx, "general", rng) + if state.financing_offers.is_empty(): + result["message"] = "No financiers are interested right now (reputation/standing too low)." + else: + var lines: Array = [] + for o in state.financing_offers: + lines.append("%s ($%d @ %.1f%%/mo)" % [o["name"], int(o["principal"]), float(o["interest_rate"]) * FinanceEngine.ticks_per_month() * 100.0]) + result["message"] = "Offers on the table: " + "; ".join(lines) + + "accept_financing_offer": + # Accept the first still-live standing offer (the UI selects a specific one via + # FinanceEngine.accept_offer(offer, state); the headless/action default is best-first). + var accepted := false + for o in state.financing_offers: + if FinanceEngine.offer_live(o, int(state.turn)): + var ar: Dictionary = FinanceEngine.accept_offer(o, state) + result["message"] = ar.get("message", "") + result["success"] = bool(ar.get("success", false)) + accepted = ar.get("success", false) + if accepted: + state.financing_offers.erase(o) + break + if not accepted: + result["message"] = "No live offer to accept (seek financing first, or offers expired)." + + "pay_bills": + # The "pay the bill" interaction (#566): settle the soonest-due affordable loan early. + if state.ledger: + var pr: Dictionary = state.ledger.pay_soonest_payable(state) + result["message"] = pr.get("message", "") + result["success"] = bool(pr.get("success", false)) + else: + result["message"] = "No ledger to pay against." + "submit_paper": # Paper submission action (Issue #468) # This is handled by UI - creates paper and adds to state diff --git a/godot/scripts/core/finance_engine.gd b/godot/scripts/core/finance_engine.gd new file mode 100644 index 00000000..451766f9 --- /dev/null +++ b/godot/scripts/core/finance_engine.gd @@ -0,0 +1,300 @@ +class_name FinanceEngine +extends RefCounted +## The cost-of-debt pricing engine + financing instruments (ADR-0013, lane L5/#616). +## +## ONE pricing function for all liabilities (loans, funding-with-strings, equity, +## philanthropy, the desperation lever). Cost is a function of, at Pip's weights: +## - org type (MAJOR — set at char/org creation; DQ-4) +## - counterparty (MAJOR — who you owe changes what it costs; ADR-0007) +## - typed reputation (safety-rep prices grants; finance-rep prices debt; ADR-0010) +## - hype (scoped to the raise's purpose) +## - current leverage (MINOR direct — outstanding payables vs a scale) +## Coefficients are data-driven (Balance "financing.*"); this file is the mechanism. +## +## OPTIONALITY (Pip's authorization: "give the player some optionality in what they +## choose out of what they're offered"): generate_offers() mints 2-3 concurrent +## STANDING offers with varied terms and an expiry (ADR-0012 — expiry mints no ledger +## entry, the offer simply evaporates). Better standing => better menus. A raise is a +## campaign with lead time (ADR-0009): seek -> offers stand for N turns -> accept at +## plan speed. +## +## BOUNDARY (L5 lane): entries carry money-side TERMS. Doom conversion belongs to the +## doom-streams lane — this engine never touches doom_system nor how a ledger bill +## converts to doom. The desperation lever's immediate doom SUPPRESSION reuses the +## existing add_resources path (unchanged), same as the pre-L5 desperation_lever action. +## +## Stateless: pure static utility (never instantiated), mirroring GameActions. + +const DEFAULT_ORG := "nonprofit" + +# --- Balance surface accessors (all fall back to the shipped defaults) -------- + +static func _cfg_num(key: String, fallback: float) -> float: + return Balance.num("financing." + key, fallback) + +static func ticks_per_month() -> float: + return _cfg_num("ticks_per_month", 22.0) + +static func offer_ttl_turns() -> int: + return Balance.inum("financing.offer_ttl_turns", 44) + +static func instruments() -> Dictionary: + return Balance.table("financing.instruments") + +static func instrument_ids() -> Array: + # Deterministic order (defaults.json insertion order preserved by Godot's JSON). + return instruments().keys() + +static func instrument_def(id: String) -> Dictionary: + var all := instruments() + return all.get(id, {}) if all.get(id, {}) is Dictionary else {} + +# --- Pricing context ---------------------------------------------------------- + +## Assemble the pricing inputs from game state. Typed reputation and org type do not +## yet exist as first-class GameState fields (owed by ADR-0010 typed-attention and the +## DQ-4 char/org-creation lanes); until then this reads them if present and falls back +## to the scalar reputation / a nonprofit default. Duck-typed so lightweight test +## doubles work. `turn` seeds standing-offer expiry. +static func context_from_state(state) -> Dictionary: + var scalar_rep: float = float(state.reputation) if _has(state, "reputation") else 50.0 + var leverage_scale: float = _cfg_num("leverage_scale", 120000.0) + var outstanding := 0.0 + if _has(state, "ledger") and state.ledger != null: + outstanding = state.ledger.outstanding(Ledger.Side.PAYABLE) + return { + "org_type": _read(state, "org_type", DEFAULT_ORG), + "safety_rep": float(_read(state, "safety_reputation", scalar_rep)), + "finance_rep": float(_read(state, "finance_reputation", scalar_rep)), + "hype": float(_read(state, "hype", 0.0)), + "leverage": (outstanding / leverage_scale) if leverage_scale > 0.0 else 0.0, + "turn": int(_read(state, "turn", 0)), + } + +static func _rep_for_channel(ctx: Dictionary, channel: String) -> float: + return float(ctx.get("safety_rep", 50.0)) if channel == "safety" else float(ctx.get("finance_rep", 50.0)) + +# --- Availability + pricing (the engine's thesis) ----------------------------- + +## Is this instrument on the menu for the current context? (org type gate, typed-rep +## floor, hype floor, leverage ceiling.) Data-driven per instrument.availability. +static func is_available(id: String, ctx: Dictionary) -> bool: + var def := instrument_def(id) + if def.is_empty(): + return false + var av: Dictionary = def.get("availability", {}) if def.get("availability", {}) is Dictionary else {} + var org_types: Array = av.get("org_types", []) if av.get("org_types", []) is Array else [] + if not org_types.is_empty() and not (String(ctx.get("org_type", DEFAULT_ORG)) in org_types): + return false + var rep := _rep_for_channel(ctx, String(def.get("rep_channel", "finance"))) + if rep < float(av.get("min_rep", 0.0)): + return false + if float(ctx.get("hype", 0.0)) < float(av.get("min_hype", 0.0)): + return false + if float(ctx.get("leverage", 0.0)) > float(av.get("max_leverage", 1e9)): + return false + return true + +## THE cost-of-debt function. Returns quoted terms for one instrument in one context: +## {available, instrument_id, name, counterparty, factory, rep_channel, +## principal_min, principal_max, principal_multiplier, +## term_months, fuse_ticks, interest_rate, non_cash} +## Interest is snapped to 0.001 (save-safe) and clamped to [rate_floor, rate_ceiling]. +## A zero base_rate (equity, philanthropy) stays exactly 0 — not debt. +static func price(id: String, ctx: Dictionary) -> Dictionary: + var def := instrument_def(id) + if def.is_empty(): + return {"available": false, "instrument_id": id} + var base_rate := float(def.get("base_rate", 0.0)) + var org := String(ctx.get("org_type", DEFAULT_ORG)) + var cp := String(def.get("counterparty", "bank")) + var org_factor := Balance.num("financing.org_factors." + org, 1.0) + var cp_factor := Balance.num("financing.counterparty_factors." + cp, 1.0) + var rep := _rep_for_channel(ctx, String(def.get("rep_channel", "finance"))) + var leverage := float(ctx.get("leverage", 0.0)) + + var rate := 0.0 + if base_rate > 0.0: + # rate = base x org x counterparty, penalised by leverage, relieved by reputation. + rate = base_rate * org_factor * cp_factor + rate += leverage * _cfg_num("leverage_rate_penalty", 0.004) + rate -= (rep / 100.0) * _cfg_num("rep_rate_relief", 0.004) + rate = clampf(rate, _cfg_num("rate_floor", 0.002), _cfg_num("rate_ceiling", 0.05)) + # Save-safe quantisation: 0.001 grid round-trips through the full-precision JSON + # save path exactly (calibration open-Q #2), so a minted entry survives save/load. + rate = snappedf(rate, 0.001) + + var fuse_ticks := int(round(float(def.get("term_months", 0.0)) * ticks_per_month())) + return { + "available": is_available(id, ctx), + "instrument_id": id, + "name": String(def.get("name", id)), + "counterparty": cp, + "factory": String(def.get("factory", "loan")), + "rep_channel": String(def.get("rep_channel", "finance")), + "principal_min": float(def.get("principal_min", 0.0)), + "principal_max": float(def.get("principal_max", 0.0)), + "principal_multiplier": float(def.get("principal_multiplier", 1.0)), + "term_months": float(def.get("term_months", 0.0)), + "fuse_ticks": fuse_ticks, + "interest_rate": rate, + "non_cash": (def.get("non_cash", {}) as Dictionary).duplicate(true), + } + +# --- Offer generation (standing offers with optionality) ---------------------- + +static func _tier_count(ctx: Dictionary) -> int: + # Best of the two typed reputations selects the menu size (ADR-0013 better-standing + # => better-menus). thresholds/counts are paired arrays on the Balance surface. + var best := maxf(float(ctx.get("safety_rep", 0.0)), float(ctx.get("finance_rep", 0.0))) + var thresholds: Array = Balance.table("financing.offer_tiers").get("thresholds", [0]) + var counts: Array = Balance.table("financing.offer_tiers").get("counts", [2]) + var count := int(counts[0]) if counts.size() > 0 else 2 + for i in range(thresholds.size()): + if best >= float(thresholds[i]) and i < counts.size(): + count = int(counts[i]) + return count + +## Generate a menu of 2-3 concurrent STANDING offers (ADR-0012) for a purpose-tagged +## raise. Deterministic in `rng` (WS-0). Principals are drawn per-offer from each +## instrument's range so the menu has genuine spread; philanthropy appears only on a +## scarce dice roll (ADR-0013: philanthropy is starved by design). Each offer carries +## an expiry_turn (ctx.turn + offer_ttl); a lapsed offer evaporates, minting nothing. +static func generate_offers(ctx: Dictionary, purpose: String, rng: RandomNumberGenerator, max_count: int = 3) -> Array: + var offers: Array = [] + var cap := mini(_tier_count(ctx), max_count) + var expiry := int(ctx.get("turn", 0)) + offer_ttl_turns() + var idx := 0 + for id in instrument_ids(): + if offers.size() >= cap: + break + if not is_available(id, ctx): + continue + var def := instrument_def(id) + # Scarcity gate: philanthropy (and any instrument with appearance_chance) rolls to appear. + if def.has("appearance_chance") and rng.randf() > float(def["appearance_chance"]): + continue + var quote := price(id, ctx) + var pmin := float(quote["principal_min"]) + var pmax := float(quote["principal_max"]) + var principal := pmin + if pmax > pmin: + principal = roundf(pmin + rng.randf() * (pmax - pmin)) + var repay := roundf(principal * float(quote["principal_multiplier"])) + idx += 1 + offers.append({ + "offer_id": "%s#%d" % [id, idx], + "instrument_id": id, + "name": quote["name"], + "counterparty": quote["counterparty"], + "factory": quote["factory"], + "purpose": purpose, + "principal": principal, # cash the player receives now + "repayment": repay, # what the ledger entry bills (loans/strings) + "term_months": quote["term_months"], + "fuse_ticks": quote["fuse_ticks"], + "interest_rate": quote["interest_rate"], + "non_cash": quote["non_cash"], + "expiry_turn": expiry, + }) + return offers + +## Is a standing offer still open at `turn`? (ADR-0012 standing-offer expiry.) +static func offer_live(offer: Dictionary, turn: int) -> bool: + return turn <= int(offer.get("expiry_turn", 0)) + +# --- Acceptance: apply cash now, mint the future bill with the QUOTED terms -------- + +## Accept a standing offer: apply the immediate benefit (cash now / doom suppressed now) +## and mint the Ledger entry(ies) carrying the offer's OWN quoted terms (not the default +## factory magnitudes — the menu is honest: what you saw is what you owe). Non-cash terms +## (equity dilution, board seat, agenda strings) mint ledger riders (ADR-0011). Returns +## {success, message, money_delta, doom_delta, entries:[Entry]}. +static func accept_offer(offer: Dictionary, state) -> Dictionary: + var result := {"success": false, "message": "", "money_delta": 0.0, "doom_delta": 0.0, "entries": []} + if offer.is_empty(): + result["message"] = "No offer to accept" + return result + var turn := int(_read(state, "turn", 0)) + if not offer_live(offer, turn): + result["message"] = "Offer has expired" + return result + + var factory := String(offer.get("factory", "loan")) + var principal := float(offer.get("principal", 0.0)) + var repayment := float(offer.get("repayment", principal)) + var fuse := int(offer.get("fuse_ticks", 0)) + var rate := float(offer.get("interest_rate", 0.0)) + var cp := String(offer.get("counterparty", "")) + var non_cash: Dictionary = offer.get("non_cash", {}) if offer.get("non_cash", {}) is Dictionary else {} + var entries: Array = [] + + match factory: + "loan": + state.add_resources({"money": principal}) + result["money_delta"] = principal + entries.append(Ledger.Entry.new("loan:" + cp, "money", repayment, fuse, rate, false, Ledger.Side.PAYABLE, cp)) + result["message"] = "Accepted %s: +$%d now; ~$%d bills in %d ticks" % [offer.get("name", "loan"), int(principal), int(repayment), fuse] + "funding_strings": + state.add_resources({"money": principal}) + result["money_delta"] = principal + # The agenda-narrowing cost bills in governance (reuses the calibrated strings machinery). + entries.append(Ledger.Entry.new("funding_strings:" + cp, "governance", repayment, fuse, rate, false, Ledger.Side.PAYABLE, cp)) + result["message"] = "Accepted %s: +$%d now; agenda strings bill later in governance" % [offer.get("name", "grant"), int(principal)] + "equity": + state.add_resources({"money": principal}) + result["money_delta"] = principal + # Equity is not debt: no repayment entry. The dilution + board seat are priced-regret + # riders (ADR-0013 single dilution scalar v1; board seat = options-curtailed stub, DQ-7). + result["message"] = "Accepted %s: +$%d now (equity round — no repayment, but dilution + board terms apply)" % [offer.get("name", "equity"), int(principal)] + "philanthropy": + state.add_resources({"money": principal}) + result["money_delta"] = principal + result["message"] = "Accepted %s: +$%d, a gift — no repayment (scarce)" % [offer.get("name", "gift"), int(principal)] + "desperation_payroll": + # Immediate doom SUPPRESSION (unchanged pre-L5 path — boundary: no doom_system touch), + # plants a secret, compounding governance liability with the QUOTED terms. + var suppress := _cfg_num("desperation_doom_suppression", 10.0) + state.add_resources({"doom": -suppress}) + result["doom_delta"] = -suppress + var sev := Balance.num("ledger.desperation_payroll.severity_base", 1200.0) + if _has(state, "rng") and state.rng != null: + sev += state.rng.randf() * Balance.num("ledger.desperation_payroll.severity_spread", 800.0) + entries.append(Ledger.Entry.new("payroll_coinflip", "governance", sev, fuse, rate, true)) + result["message"] = "Pulled the desperation lever: -%d doom now; a SECRET liability is planted" % int(suppress) + _: + result["message"] = "Unknown instrument factory: %s" % factory + return result + + # Non-cash riders (ADR-0011: agenda riders are ledger entries). Inert standing terms — + # a huge fuse + zero interest keep them visible in the post-mortem trail without ever + # billing; the governance/equity lanes (DQ-7, late-game equity) wire their consequences. + if bool(non_cash.get("board_seat", false)): + entries.append(_standing_rider("board_seat:" + cp, "board_seat", 1.0, cp)) + if non_cash.has("equity_dilution"): + var bps := roundf(float(non_cash["equity_dilution"]) * 10000.0) # basis points, integer + entries.append(_standing_rider("equity_dilution:" + cp, "equity", bps, cp)) + if bool(non_cash.get("agenda_narrowing", false)) and factory != "funding_strings": + entries.append(_standing_rider("agenda_narrowing:" + cp, "agenda", 1.0, cp)) + + if _has(state, "ledger") and state.ledger != null: + for e in entries: + state.ledger.add(e) + result["entries"] = entries + result["success"] = true + return result + +## A non-cash standing term as an inert ledger rider (never bills: 10^9 fuse, 0 interest). +static func _standing_rider(source: String, currency: String, principal: float, counterparty: String) -> Object: + return Ledger.Entry.new(source, currency, principal, 1000000000, 0.0, false, Ledger.Side.PAYABLE, counterparty) + +# --- Duck-typing helpers (lightweight test doubles carry only some fields) ----- + +static func _has(obj, field: String) -> bool: + return field in obj + +static func _read(obj, field: String, fallback): + if field in obj: + var v = obj.get(field) + return v if v != null else fallback + return fallback diff --git a/godot/scripts/core/finance_engine.gd.uid b/godot/scripts/core/finance_engine.gd.uid new file mode 100644 index 00000000..ba58bdbe --- /dev/null +++ b/godot/scripts/core/finance_engine.gd.uid @@ -0,0 +1 @@ +uid://dt8of2modiwcr diff --git a/godot/scripts/core/game_state.gd b/godot/scripts/core/game_state.gd index fc6c438f..5b68afc4 100644 --- a/godot/scripts/core/game_state.gd +++ b/godot/scripts/core/game_state.gd @@ -29,6 +29,13 @@ var governance: float = 50.0 # per game in reset(); compounding payables are the mortality guarantee (ADR-0002). var ledger: Ledger +# Standing financing offers (ADR-0013 / L5 #616): the current menu minted by +# FinanceEngine.generate_offers when the player seeks debt/funding. TRANSIENT (not +# serialized): offers are cheap to regenerate and carry their own expiry_turn, so a +# save/load simply drops any pending menu (the player re-seeks). Kept off to_dict so +# the L7 save round-trip stays byte-stable. +var financing_offers: Array = [] + # The month plan layer (L1 / ADR-0009): the founder currency Attention, the crisp reserve, # and duration-bearing queued strategic actions. The plan cadence is monthly; the turn above # is the day-grained resolution tick. Rebuilt per game in reset(). diff --git a/godot/scripts/core/ledger.gd b/godot/scripts/core/ledger.gd index bd613340..9f7685ad 100644 --- a/godot/scripts/core/ledger.gd +++ b/godot/scripts/core/ledger.gd @@ -160,14 +160,23 @@ func tick_and_bill(state) -> Array: func _bill(e: Entry, state) -> void: match e.currency: "money": + # #566: reconcile the magnitudes. The bill charges the FULL principal; whatever + # cash covered it is `paid_from_cash`, and only the uncovered `shortfall` converts + # to (capped) doom + rep. The old note recorded only the shortfall, so a bill that + # drained $345k of cash but left a $21k shortfall read as a "$21k problem" in the + # death chain — the numbers didn't reconcile. Now the note states billed = + # paid_from_cash + shortfall, and shortfall -> capped doom/rep. + var billed: float = e.principal state.money -= e.principal if state.money < 0.0: var shortfall: float = -state.money state.money = 0.0 + var paid_from_cash: float = billed - shortfall var raw_doom: float = shortfall / 1000.0 * Balance.num("ledger.doom_per_unpaid_1000", DOOM_PER_UNPAID_1000) var doom_hit: float = _apply_capped_doom(e, raw_doom, state) # Reputation teeth are capped per bill too (dial 3: a single default is a loan, - # not a one-click rep-collapse). Unlike doom, the residual is NOT rolled forward — + # not a one-click rep-collapse — this is the #566 reputation-crash fix, 50->7.5 + # was uncapped rep damage). Unlike doom, the residual is NOT rolled forward — # rep damage beyond the cap is forgiven; the DOOM teeth (rolled) carry the mortality. var rep_amount: float = minf(shortfall / 1000.0 * Balance.num("ledger.rep_per_unpaid_1000", REP_PER_UNPAID_1000), Balance.num("ledger.max_rep_per_bill", 1000000000.0)) @@ -175,7 +184,8 @@ func _bill(e: Entry, state) -> void: state.reputation = max(0.0, state.reputation - rep_amount) _attribute(e, shortfall, state) _note(state, "ledger_default", e.source, - {"money_shortfall": shortfall, "doom": doom_hit, "reputation": -rep_hit}) + {"principal_billed": billed, "paid_from_cash": paid_from_cash, + "money_shortfall": shortfall, "doom": doom_hit, "reputation": -rep_hit}) "reputation": var rep_hit: float = minf(state.reputation, e.principal) state.reputation = max(0.0, state.reputation - e.principal) @@ -199,6 +209,13 @@ func _bill(e: Entry, state) -> void: _note(state, "ledger_doom_bill", e.source, {"doom": applied}) "action_points": state.action_points = max(0, state.action_points - int(e.principal)) + "equity", "board_seat", "agenda": + # Non-cash standing TERMS from the financing engine (ADR-0013 riders: equity + # dilution, board seat, agenda narrowing). Inert by design — recorded, no resource + # conversion; their consequences are owned by other lanes (DQ-7 governance/voting, + # late-game equity). Minted with a ~10^9 fuse so this is defensive only (never billed + # in a real run); L5's boundary forbids doom conversion here. + _note(state, "ledger_standing_term", e.source, {String(e.currency): e.principal}) ## Apply a doom bill, bounded to ledger.max_doom_per_bill in any single tick (ADR-0013: ## a defer/loan is a bill that lands over MONTHS, never a one-click guillotine). Residual @@ -276,6 +293,42 @@ func expose(entry: Entry, state, offer_blackmail: bool = true) -> void: Balance.inum("ledger.blackmail.fuse_turns", 2), Balance.num("ledger.blackmail.interest_rate", 0.5), true)) +# ---- Voluntary repayment (the "pay the bill" interaction, #566) ---- + +## Settle a live MONEY payable early from cash, retiring the future balloon before it +## bills (ADR-0013 optionality — the player can buy down leverage). Only money-currency +## payables are cash-payable; governance/doom/equity terms are not. Returns +## {success, paid, message}. Records a benign `ledger_paid` note (NOT a LEDGER_KINDS +## damage cause — early repayment is the opposite of a default). +func pay_entry(entry, state) -> Dictionary: + if entry == null or entry.settled or entry.side != Side.PAYABLE: + return {"success": false, "paid": 0.0, "message": "Nothing to pay"} + if entry.currency != "money": + return {"success": false, "paid": 0.0, "message": "Only money bills can be settled in cash"} + var due: float = entry.principal + if state.money < due: + return {"success": false, "paid": 0.0, "message": "Cannot afford to settle ($%d needed)" % int(due)} + state.money -= due + entry.settled = true + _note(state, "ledger_paid", entry.source, {"money": -due}) + return {"success": true, "paid": due, "message": "Settled %s early (-$%d)" % [entry.source, int(due)]} + +## Convenience: settle the soonest-due affordable money payable (the plan-screen +## "pay the bill" button). Returns the pay_entry result, or a no-op result if none. +func pay_soonest_payable(state) -> Dictionary: + var best = null + for e in entries: + if e.settled or e.side != Side.PAYABLE or e.currency != "money": + continue + if state.money < e.principal: + continue + if best == null or e.fuse < best.fuse: + best = e + if best == null: + return {"success": false, "paid": 0.0, "message": "No affordable bill to pay"} + return pay_entry(best, state) + + # ---- Read helpers ---- func outstanding(side: int = Side.PAYABLE) -> float: diff --git a/godot/tests/unit/test_finance_engine.gd b/godot/tests/unit/test_finance_engine.gd new file mode 100644 index 00000000..77b163e1 --- /dev/null +++ b/godot/tests/unit/test_finance_engine.gd @@ -0,0 +1,270 @@ +extends GutTest +## L5 (#616, ADR-0013): the cost-of-debt pricing engine + financing instruments. +## Covers pricing determinism + input-sensitivity, availability gating, standing-offer +## generation with optionality/expiry, offer minting with quoted terms, the #566 +## billing regression (capped default, reconciled magnitudes), the "pay the bill" +## early-repayment interaction, and save-safe (0.001-grid) computed interest. + +const NONPROFIT := "nonprofit" + +func _ctx(overrides: Dictionary = {}) -> Dictionary: + var base := { + "org_type": NONPROFIT, + "safety_rep": 50.0, "finance_rep": 50.0, + "hype": 0.0, "leverage": 0.0, "turn": 0, + } + for k in overrides: + base[k] = overrides[k] + return base + +func _rng(seed_str: String = "l5-test") -> RandomNumberGenerator: + var r := RandomNumberGenerator.new() + r.seed = seed_str.hash() + return r + +var _created: Array = [] + +func _fresh_state(seed_str: String = "l5-test") -> GameState: + var s := GameState.new(seed_str) + s.money = 300000 + s.action_points = 6 + _created.append(s) + return s + +func after_each() -> void: + # GameState is a Node that spawns doom_system/risk_system Nodes — free them so the + # fast gate doesn't accumulate orphans (mirrors the L1 sweep's cleanup). + for s in _created: + if not is_instance_valid(s): + continue + if s.doom_system != null and is_instance_valid(s.doom_system): + s.doom_system.free() + if ("risk_system" in s) and s.risk_system != null and is_instance_valid(s.risk_system): + s.risk_system.free() + s.free() + _created.clear() + +# ---- Pricing engine: determinism + the ADR-0013 thesis (inputs move cost) ---- + +func test_price_is_deterministic() -> void: + var a := FinanceEngine.price("bank_loan", _ctx()) + var b := FinanceEngine.price("bank_loan", _ctx()) + assert_eq(a["interest_rate"], b["interest_rate"], "same context -> same rate") + assert_eq(a["fuse_ticks"], b["fuse_ticks"], "same context -> same term") + +func test_higher_reputation_cheapens_debt() -> void: + var lo := FinanceEngine.price("bank_loan", _ctx({"finance_rep": 10.0})) + var hi := FinanceEngine.price("bank_loan", _ctx({"finance_rep": 90.0})) + assert_lt(float(hi["interest_rate"]), float(lo["interest_rate"]), + "better finance reputation prices debt cheaper (ADR-0010 typed rep)") + +func test_higher_leverage_raises_rate() -> void: + var lo := FinanceEngine.price("bank_loan", _ctx({"leverage": 0.0})) + var hi := FinanceEngine.price("bank_loan", _ctx({"leverage": 2.5})) + assert_gt(float(hi["interest_rate"]), float(lo["interest_rate"]), + "more existing leverage raises the cost of the next loan") + +func test_org_type_moves_cost() -> void: + var np := FinanceEngine.price("bank_loan", _ctx({"org_type": "nonprofit"})) + var fp := FinanceEngine.price("bank_loan", _ctx({"org_type": "for_profit"})) + assert_lt(float(fp["interest_rate"]), float(np["interest_rate"]), + "for-profit borrows cheaper than nonprofit (org type is the biggest input)") + +func test_philanthropy_is_a_gift_not_debt() -> void: + var q := FinanceEngine.price("philanthropy", _ctx({"safety_rep": 60.0})) + assert_eq(float(q["interest_rate"]), 0.0, "philanthropy (counterparty factor 0) carries no interest") + +func test_bank_loan_baseline_matches_calibration() -> void: + # Continuity guard: at default context the engine's bank loan reproduces the calibrated + # ledger.loan shape (~77-tick fuse, month-scale, ~0.008/tick) so financing does not + # regress the L1 medians. Exact rate flexes with rep relief; the FUSE must stay months. + var q := FinanceEngine.price("bank_loan", _ctx()) + assert_gt(int(q["fuse_ticks"]), 60, "bank loan fuse is month-scale, not day-scale (respects calibration)") + assert_lt(int(q["fuse_ticks"]), 100, "bank loan fuse near the calibrated ~77 ticks") + +# ---- Availability gating (org type / typed rep / hype) ---- + +func test_vc_equity_requires_forprofit_and_hype() -> void: + assert_false(FinanceEngine.is_available("vc_equity", _ctx({"org_type": "nonprofit", "hype": 50.0, "finance_rep": 80.0})), + "VC equity is closed to nonprofits") + assert_false(FinanceEngine.is_available("vc_equity", _ctx({"org_type": "for_profit", "hype": 0.0, "finance_rep": 80.0})), + "VC equity needs hype") + assert_true(FinanceEngine.is_available("vc_equity", _ctx({"org_type": "for_profit", "hype": 40.0, "finance_rep": 80.0})), + "for-profit + hype + rep opens the VC round") + +func test_philanthropy_gated_on_safety_rep() -> void: + assert_false(FinanceEngine.is_available("philanthropy", _ctx({"safety_rep": 10.0})), "low safety-rep, no philanthropy") + assert_true(FinanceEngine.is_available("philanthropy", _ctx({"safety_rep": 45.0})), "safety-rep opens philanthropy") + +func test_desperation_always_available() -> void: + assert_true(FinanceEngine.is_available("desperation", _ctx({"safety_rep": 0.0, "finance_rep": 0.0})), + "the desperation lever is always on the table (last resort)") + +# ---- Offer generation: optionality, better-standing-better-menu, expiry ---- + +func test_generate_offers_menu_size_and_liveness() -> void: + var offers := FinanceEngine.generate_offers(_ctx({"finance_rep": 80.0, "safety_rep": 80.0}), "compute", _rng(), 3) + assert_between(offers.size(), 1, 3, "a raise yields a small concurrent menu") + for o in offers: + assert_true(FinanceEngine.offer_live(o, 0), "fresh offers are live") + assert_false(FinanceEngine.offer_live(o, int(o["expiry_turn"]) + 1), "offers expire after their TTL (ADR-0012)") + +func test_better_standing_gives_at_least_as_many_offers() -> void: + var low := FinanceEngine.generate_offers(_ctx({"finance_rep": 20.0, "safety_rep": 20.0}), "x", _rng("a"), 3) + var high := FinanceEngine.generate_offers(_ctx({"finance_rep": 80.0, "safety_rep": 80.0}), "x", _rng("a"), 3) + assert_true(high.size() >= low.size(), "better reputation yields a menu at least as rich") + +func test_offer_generation_is_seed_deterministic() -> void: + var a := FinanceEngine.generate_offers(_ctx({"finance_rep": 70.0}), "x", _rng("same"), 3) + var b := FinanceEngine.generate_offers(_ctx({"finance_rep": 70.0}), "x", _rng("same"), 3) + assert_eq(a.size(), b.size(), "same seed -> same menu size") + if a.size() > 0: + assert_eq(float(a[0]["principal"]), float(b[0]["principal"]), "same seed -> same drawn principals") + +func test_low_reputation_menu_is_desperation_heavy() -> void: + var offers := FinanceEngine.generate_offers(_ctx({"finance_rep": 8.0, "safety_rep": 8.0}), "x", _rng(), 3) + var ids: Array = [] + for o in offers: + ids.append(o["instrument_id"]) + assert_true("desperation" in ids, "with almost no standing, only the desperation lever answers") + +# ---- Acceptance: cash now, ledger entry carries the QUOTED terms ---- + +func test_accept_loan_offer_pays_cash_and_mints_quoted_entry() -> void: + var s := _fresh_state() + var ctx := FinanceEngine.context_from_state(s) + var offers := FinanceEngine.generate_offers(ctx, "runway", s.rng, 3) + var loan_offer: Dictionary = {} + for o in offers: + if String(o["factory"]) == "loan": + loan_offer = o + break + if loan_offer.is_empty(): + # Force a loan offer deterministically if the menu did not surface one. + loan_offer = {"offer_id": "bank_loan#1", "instrument_id": "bank_loan", "name": "Bank term loan", + "counterparty": "bank", "factory": "loan", "purpose": "runway", "principal": 50000.0, + "repayment": 60000.0, "term_months": 3.5, "fuse_ticks": 77, "interest_rate": 0.007, + "non_cash": {}, "expiry_turn": 44} + var before := s.money + var res := FinanceEngine.accept_offer(loan_offer, s) + assert_true(res["success"], "accepting a live loan offer succeeds") + assert_eq(s.money, before + float(loan_offer["principal"]), "cash lands immediately") + var e = s.ledger.entries.back() + assert_eq(e.currency, "money", "a loan mints a money payable") + assert_eq(e.interest, float(loan_offer["interest_rate"]), "the entry carries the QUOTED rate (menu is honest)") + assert_eq(e.principal, float(loan_offer["repayment"]), "the entry bills the quoted repayment") + +func test_expired_offer_cannot_be_accepted() -> void: + var s := _fresh_state() + s.turn = 100 + var stale := {"offer_id": "x", "instrument_id": "bank_loan", "name": "L", "counterparty": "bank", + "factory": "loan", "purpose": "x", "principal": 40000.0, "repayment": 48000.0, + "term_months": 3.5, "fuse_ticks": 77, "interest_rate": 0.007, "non_cash": {}, "expiry_turn": 44} + var res := FinanceEngine.accept_offer(stale, s) + assert_false(res["success"], "a lapsed standing offer mints nothing (ADR-0012 evaporation)") + +func test_equity_round_mints_noncash_riders_not_debt() -> void: + var s := _fresh_state() + var offer := {"offer_id": "vc#1", "instrument_id": "vc_equity", "name": "VC round", "counterparty": "vc", + "factory": "equity", "purpose": "scale", "principal": 250000.0, "repayment": 250000.0, + "term_months": 0.0, "fuse_ticks": 0, "interest_rate": 0.0, + "non_cash": {"equity_dilution": 0.12, "board_seat": true}, "expiry_turn": 44} + var before := s.money + var res := FinanceEngine.accept_offer(offer, s) + assert_true(res["success"], "equity round accepts") + assert_eq(s.money, before + 250000.0, "equity delivers cash") + var currencies: Array = [] + for e in s.ledger.entries: + currencies.append(e.currency) + assert_true("equity" in currencies, "dilution is a recorded rider") + assert_true("board_seat" in currencies, "board seat is a recorded rider (DQ-7 stub)") + # Non-cash riders are inert: ticking should never bill them (huge fuse). + var billed := s.ledger.tick_and_bill(s) + for b in billed: + assert_false(b.currency in ["equity", "board_seat", "agenda"], "non-cash riders never bill") + +# ---- #566 regression: capped default + reconciled magnitudes ---- + +func test_566_large_default_is_capped_not_a_guillotine() -> void: + var s := _fresh_state() + s.money = 50000.0 + s.reputation = 50.0 + var doom_before := s.doom + # A large balloon the debtor cannot cover (the #566 shape: money zeroes, shortfall huge). + s.ledger.add(Ledger.Entry.new("loan", "money", 500000.0, 0, 0.0)) + s.ledger.tick_and_bill(s) + assert_eq(s.money, 0.0, "the bill drains available cash to zero") + var rep_drop := 50.0 - s.reputation + var doom_rise := s.doom - doom_before + assert_lte(rep_drop, Balance.num("ledger.max_rep_per_bill", 10.0) + 0.001, + "#566: reputation loss is capped per bill (was an uncapped 50->7.5 crash)") + assert_lte(doom_rise, Balance.num("ledger.max_doom_per_bill", 10.0) + 0.001, + "#566: doom conversion is capped per tick (no one-click guillotine)") + +func test_566_default_note_reconciles_magnitudes() -> void: + var s := _fresh_state() + s.money = 50000.0 + s.ledger.add(Ledger.Entry.new("loan", "money", 500000.0, 0, 0.0)) + s.ledger.tick_and_bill(s) + var note := {} + for c in s.cause_log: + if String(c.get("kind", "")) == "ledger_default": + note = c.get("effects", {}) + assert_false(note.is_empty(), "a default logs a ledger_default note") + assert_eq(float(note["principal_billed"]), 500000.0, "note states the full bill (#566 reconciliation)") + assert_eq(float(note["paid_from_cash"]), 50000.0, "note states cash applied") + # billed == paid_from_cash + shortfall (the magnitudes now reconcile). + assert_eq(float(note["principal_billed"]), float(note["paid_from_cash"]) + float(note["money_shortfall"]), + "billed = paid_from_cash + shortfall (the #566 'magnitudes do not reconcile' fix)") + +# ---- "Pay the bill" early repayment (#566 UX ask) ---- + +func test_pay_entry_settles_a_money_bill_early() -> void: + var s := _fresh_state() + s.money = 100000.0 + s.ledger.add(Ledger.Entry.new("loan", "money", 60000.0, 40, 0.008)) + var res := s.ledger.pay_soonest_payable(s) + assert_true(res["success"], "an affordable bill can be settled early") + assert_eq(s.money, 40000.0, "settling debits the principal from cash") + assert_eq(s.ledger.outstanding(Ledger.Side.PAYABLE), 0.0, "the retired bill no longer looms") + # A settled entry never bills. + var billed := s.ledger.tick_and_bill(s) + assert_eq(billed.size(), 0, "a pre-paid bill does not bill") + +func test_pay_entry_refuses_when_unaffordable() -> void: + var s := _fresh_state() + s.money = 1000.0 + s.ledger.add(Ledger.Entry.new("loan", "money", 60000.0, 40, 0.008)) + var res := s.ledger.pay_soonest_payable(s) + assert_false(res["success"], "cannot settle a bill you cannot afford") + assert_eq(s.money, 1000.0, "an unaffordable settle leaves cash untouched") + +# ---- Save-safe computed interest (calibration open-Q #2) ---- + +func test_computed_rate_is_on_the_save_safe_grid() -> void: + # Every computed rate must be a 0.001-grid value (probe-verified to round-trip through + # the full-precision JSON save path), else a minted entry drifts 1 ulp on load. + for rep in [0.0, 17.0, 33.0, 51.0, 88.0, 100.0]: + for lev in [0.0, 1.3, 2.7]: + var q := FinanceEngine.price("bank_loan", _ctx({"finance_rep": rep, "leverage": lev})) + var r := float(q["interest_rate"]) + assert_eq(r, snappedf(r, 0.001), "rate %f is on the 0.001 save-safe grid" % r) + +func test_minted_entry_survives_json_roundtrip() -> void: + # The real save path: JSON.stringify(..., "\t", true, true) -> parse -> from_dict. + var s := _fresh_state() + var offer := {"offer_id": "bank_loan#1", "instrument_id": "bank_loan", "name": "Bank term loan", + "counterparty": "bank", "factory": "loan", "purpose": "x", "principal": 55000.0, + "repayment": 66000.0, "term_months": 3.5, "fuse_ticks": 77, "interest_rate": 0.007, + "non_cash": {}, "expiry_turn": 44} + FinanceEngine.accept_offer(offer, s) + var dict := s.ledger.to_dict() + var text := JSON.stringify(dict, "\t", true, true) + var parsed = JSON.parse_string(text) + var restored := Ledger.new() + restored.from_dict(parsed) + assert_eq(restored.entries.size(), s.ledger.entries.size(), "entries survive the round trip") + var orig = s.ledger.entries.back() + var back = restored.entries.back() + assert_eq(back.interest, orig.interest, "computed interest survives save/load exactly") + assert_eq(back.principal, orig.principal, "principal survives save/load exactly") diff --git a/godot/tests/unit/test_finance_engine.gd.uid b/godot/tests/unit/test_finance_engine.gd.uid new file mode 100644 index 00000000..ce08c231 --- /dev/null +++ b/godot/tests/unit/test_finance_engine.gd.uid @@ -0,0 +1 @@ +uid://bosbsb60ja1qd