diff --git a/deepfall/.gitignore b/deepfall/.gitignore new file mode 100644 index 0000000..0a25f36 --- /dev/null +++ b/deepfall/.gitignore @@ -0,0 +1,2 @@ +/target +/out diff --git a/deepfall/Cargo.lock b/deepfall/Cargo.lock new file mode 100644 index 0000000..380fd35 --- /dev/null +++ b/deepfall/Cargo.lock @@ -0,0 +1,162 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "deepfall-core" +version = "0.1.0" +dependencies = [ + "rand_chacha", + "serde", + "serde_json", +] + +[[package]] +name = "deepfall-sim" +version = "0.1.0" +dependencies = [ + "deepfall-core", + "serde", + "serde_json", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/deepfall/Cargo.toml b/deepfall/Cargo.toml new file mode 100644 index 0000000..0ae7c14 --- /dev/null +++ b/deepfall/Cargo.toml @@ -0,0 +1,10 @@ +[workspace] +resolver = "2" +members = ["deepfall-core", "deepfall-sim"] +default-members = ["deepfall-core", "deepfall-sim"] + +# A SELF-CONTAINED workspace, deliberately separate from the root reikhelm +# workspace (and from combat/). The root Cargo.toml lists its members explicitly +# with no globs, so this folder neither pollutes nor depends on the +# dungeon-generator crates. One experiment, one isolated build graph — same +# convention as ../combat. diff --git a/deepfall/DESIGN.md b/deepfall/DESIGN.md new file mode 100644 index 0000000..4ccff5d --- /dev/null +++ b/deepfall/DESIGN.md @@ -0,0 +1,374 @@ +# DEEPFALL — Design & Simulation Spec + +This is two documents in one: + +- **Part A — The Game.** A playable tabletop design (the rulebook in prose). +- **Part B — The Simulation.** The faithful abstraction the Rust engine implements, + so the balance numbers mean something. Where the sim simplifies the tabletop rules, + it says so explicitly. + +The vocabulary (room **themes**, depth, connectivity, doors) is borrowed straight +from `reikhelm-core` so a printed board is a real generated dungeon. + +--- + +# Part A — The Game + +## 1. The fantasy & the arc + +Reikhelm is a vertical megadungeon of stacked **Floors**. Floor 1 is the +**Threshold** (the surface mouth, safe, poor). The deeper the floor, the richer and +deadlier it is. Beneath the deepest floor waits **the Maw** — a rising hunger that +devours the hold one floor at a time, **from the bottom up**. The richest plunder is +therefore the most doomed: a deep Vault is worth a fortune and will be eaten *first*. + +Rival **delve-companies** (players) descend to plunder before it's all gone. You don't +win by surviving — you win by **Glory**, and the surest way to bank a fortune is to +spend a party deliberately: send them quietly *to ground* with their loot, or hurl +them into a **Worthy End** at the bottom and let the Maw take them gloriously. + +The game lasts until the Maw eats Floor 1. Most Glory wins. Nobody is ever eliminated. + +## 2. Your delve-company: Lineage × Calling + +At the start, and **every time you bring in a fresh party**, you draft two halves that +smash together (Smash Up / Small World / EverQuest / Daggerfall). Each half grants an +**advantage** and a **cost** — there are no free builds (Daggerfall's custom-class +trade-off). + +### Lineages (the "race" — sets your starting **dice bag** + a passive) + +| Lineage | Advantage | Cost (disadvantage) | Bag leans | +|---|---|---|---| +| **Dvergar** | +1 Steel resolved each turn (sturdy) | −1 Move (slow) | Warrior | +| **Wisp** | +1 die drawn each hand (nimble) | Setbacks hit double (fragile) | Scholar | +| **Revenant** | Ignore the first Dread each roll (fearless dead) | Faith −1 (no living soul) | Balanced, dread-proof | +| **Gnoll** | +Loot from combat rooms (Den/Forge) (scavenger) | Cunning −1 (clumsy at locks) | Warrior/Rogue | +| **Sylphid** | +1 Move (fleet) | Steel −1 (frail) | Scholar/Rogue | +| **Golem-born** | Setback-resistant (rarely loses dice) | Cannot recruit Relic dice (rigid) | Big, balanced, slow to grow | + +### Callings (the "class/faction" — special ability + recruit pool + scoring lean) + +| Calling | Special | Recruits | Scoring lean | +|---|---|---|---| +| **Reaver** | Pillage: extra loot from every room you claim | Warrior dice (cheap) | Loot | +| **Lorekeeper** | Open Vaults **solo** (no trinity needed); +Cunning at locks | Scholar dice | Deep vaults | +| **Wardancer** | Worthy-End payout **+50%**; cheap Moves | Rogue dice | Glorious death | +| **Hierophant** | Cancel extra Dread; heal Setbacks; cheap denizen rep | Priest dice | Longevity (persist) | +| **Warden** | Your control markers count **double** for majority | Champion dice | Area control | +| **Prospector** | Cheaper recruits, bigger hand, can find **Relic** dice | Relic dice | Engine / snowball | + +6 × 6 = **36 companies.** Natural synergies emerge (Dvergar Reaver = smash-and-grab; +Wisp Lorekeeper = vault cracker; Sylphid Wardancer = fast martyr; Golem-born Warden = +the immovable wall) — the simulator's job is to confirm none of them is *solved*. + +## 3. The dice bag (Quarriors) & the six faces + +Your bag is a pile of six-sided **delve dice**. Each face shows one symbol: + +| Symbol | Meaning | +|---|---| +| ⚔ **Steel** | Combat power — clear a room's threat | +| 🜏 **Cunning** | Wits — open mechanisms / locks; buy extra Moves | +| ✚ **Faith** | Spirit — cancel Dread; heal Setbacks; appease denizens | +| 💰 **Greed** | Loot — turns into banked Glory | +| ✦ **Mettle** | Wild currency — recruit new dice; pay for actions | +| ☠ **Dread** | Peril — accumulates; too much triggers a **Setback** | + +Different **die kinds** weight these faces differently. Starting bags are mostly +*Recruit* dice (balanced, one of each). You grow your bag in play by **recruiting** +stronger kinds (Warrior/Scholar/Priest/Rogue/Champion/Relic) from the dungeon's offer. + +### A turn + +1. **Draw** a hand from your bag (default 4 dice; modified by Lineage/Calling). +2. **Roll** them. +3. **Resolve Dread** — cancel Dread with Faith (and Lineage dread-resist). If Dread + still standing ≥ the **Dread threshold**, suffer a **Setback** (lose a die to your + bag's wound slot, or lose unbanked loot). +4. **Act** with your symbols — Move between floors, clear & claim a room, take loot, + recruit a die. +5. **Press On?** (push-your-luck) — you may draw & roll *another* hand for more symbols + this turn. Every press nudges the Maw and stacks more Dread risk. Stop whenever you + like; greed is punished by the dice and by the clock. +6. **Exit decision** — see §6. + +## 4. The dungeon: rooms, themes, mechanisms (EotB / Catan / reikhelm) + +Each Floor holds several **rooms**, each with a **theme** from reikhelm's palette. A +room's base Glory and its threat/lock scale with its theme and its **depth**: + +| Theme | Base value | Threat (Steel) | Lock (Cunning) | Note | +|---|---|---|---|---| +| Threshold | 1 | 0 | 0 | safe mouth | +| Stone | 2 | 1 | 0 | plain | +| Cistern | 3 | 1 | 1 | flooded | +| Den | 3 | 3 | 0 | monster lair (Gnoll loot bonus) | +| Crypt | 3 | 2 | 1 | undead | +| Hall | 4 | 1 | 1 | pillared | +| Library | 4 | 1 | 2 | Cunning-gated, loot-rich | +| Forge | 5 | 4 | 1 | lava, produces Steel | +| Vault | 6 | 2 | 3 | **needs the trinity** (Steel+Cunning+Faith present) unless Lorekeeper | +| Throne | 7 | 5 | 2 | boss; deepest | + +A room's collapse Glory = `base_value × depth`. A Vault on Floor 6 is worth `6 × 6 = +36` to whoever holds the majority when Floor 6 falls. **Holding** a room also makes it +**produce** each turn you roll its matching symbol (Catan): a held Forge throws extra +Steel, a Library extra Cunning, a Vault extra Greed — your engine grows on the ground +you keep, right up until the Maw takes it. + +**Claiming** a room: spend Steel ≥ threat (fight through) and Cunning ≥ lock (crack the +mechanism). Vaults additionally require **trinity presence** (you produced at least one +Steel *and* one Cunning *and* one Faith this turn) — the EverQuest holy-trinity made +literal — unless your Calling is **Lorekeeper**. Claiming drops a **control marker**. + +## 5. The Maw: the doom clock (Blood Rage / Dragon Rampage) + +The Maw begins below the deepest floor and **rises**. Each round it advances; its speed +**ramps up** over the game and is **pushed faster by the total Dread** everyone rolled +that round (your collective greed feeds it). When the Maw reaches a floor: + +1. **The floor collapses and scores.** For each room, the **majority** marker-holder + takes its Glory (`base × depth`). Ties split, rounding down. A **Warden's** markers + count double here; a party that went **to ground** leaves silent **Fallen** markers + that still count. +2. **Anything still on that floor is Taken by the Maw** (see §6). + +Because collapse runs **bottom-up**, the deep treasure has the *shortest* window. The +game ends when Floor 1 collapses. + +## 6. The three exits (the heart of the game) + +Every party, every turn, faces the same triangle. This is the design's core, fusing +Small World's Decline with Blood Rage's glorious death: + +- **Cash Out / "Go to Ground"** *(Small World Decline).* End this party's adventuring. + **Bank** their unbanked loot + a **depth dividend** (deepest floor reached × factor). + Their control markers become permanent **Fallen** markers — still counted at every + future collapse, but they can't move or act. Then draft a **fresh** Lineage×Calling + and re-enter at the Threshold. Safe, repeatable, keeps your territory working for you + from beyond the grave. + +- **Press On / Persist** *(Catan/Quarriors engine).* Keep the party active: keep + recruiting, keep holding rooms, keep producing. More upside, but the Maw is climbing + and a party caught flat-footed loses *everything* unbanked **and** its territory. + +- **Worthy End** *(Blood Rage Valhalla).* Declare a **Last Stand** (free if you hold a + Throne or Vault, or via Wardancer). Now, when the Maw **Takes** your active party, you + score a huge **Worthy End** bonus = `deepest_floor × worthy_factor` + held room + values (×1.5 for Wardancer). This is the single biggest payout available — and it's + all-or-nothing: you lose the party and its unbanked loot. Sometimes you *want* the + Maw to eat you, as deep as possible, in a blaze. + +Getting Taken **without** a Last Stand is the one true feel-bad in the game (lose loot ++ territory for nothing). The simulator measures how often bots fall into it — a +healthy design keeps that rare, because the exits above are always offered. + +## 7. Denizen reputation (Daggerfall / EverQuest — light) + +A few **denizen** factions live in the deep (the Deep Cult, the Gnoll Warrens, the +Revenant Court). Clearing their themed rooms angers them (reputation down → they raise +the threat of their rooms against you); making **offerings** (spend Greed/Faith) raises +reputation → unlocks premium recruit dice and safer passage. One short track per +denizen, two or three thresholds. *(Kept deliberately light; the sim models it as a +small threat modifier so it doesn't dominate the balance picture.)* + +## 8. Why this fixes the originals' worst parts + +- **No runaway leader.** A trailing player dives recklessly, takes a Worthy End or a + deep Cash-Out for a glory spike, and resets — catch-up is *built into* the core loop, + not bolted on. Leaders who over-persist feed the Maw and lose territory. +- **No elimination, no downtime.** A dead party is glory and silent territory, not a + removed player. Turns are short; you're always choosing. +- **Variance with a floor.** The dice can betray you, but Faith mitigates Dread and + Cash-Out lets you lock in value before luck turns — Quarriors' whiff is defanged. +- **Conflict without spite.** You fight for *majorities at collapse*, never to destroy + an opponent's hand. The shared "robber" is the Maw, and it is perfectly fair. + +--- + +# Part B — Simulation model + +The engine in `deepfall-core` is a **faithful abstraction** of Part A, not a literal +table simulator. It keeps everything that drives the six balance questions (the dice +faces, the bag-building, the three exits, the bottom-up Maw, the area-control scoring, +the Lineage×Calling asymmetry) and abstracts the fiddly bits (exact board geometry, +turn-by-turn movement pathing, denizen micro-management) into clean numeric rules. Each +abstraction is called out below. + +## B1. State + +- **Dungeon**: `floors[1..=D]`, default `D = 6`. Floor `f` has `R` rooms (default 4), + drawn from the theme table weighted toward richer themes at greater depth. Each + `Room` has `theme, base_value, threat, lock, produces(symbol), markers: Vec`. + *(Abstraction: rooms are an unordered set per floor; we don't model intra-floor + pathing or the corridor graph — only "which floor are you on" matters for the Maw.)* +- **Maw**: `accum: f64`, `frontier: u32` = deepest uncollapsed floor (starts at `D`). +- **Players**: `glory`, and an optional active `Party`. +- **Party**: `lineage, calling, bag: Vec, floor (starts at 1), unbanked_loot, + deepest_reached, last_stand: bool, held: Vec`. +- **Marker**: `{ owner, strength, fallen }`. Active strength = 1 (×2 for Warden); + Fallen strength = 1. + +## B2. Round structure + +Each round, in player id order, every player takes a **turn** (B3). After all turns, +the **Maw advances** (B4) and any collapses resolve (scoring + Takes). Repeat until the +frontier passes Floor 1. Then **final scoring** (B5). + +## B3. A turn (the engine's core resolution) + +1. **Decide intent** from the controller (B6): a *target floor* and a *risk appetite*. +2. **Move**: `moves = 1 + lineage_move + calling_move`; optionally spend Cunning/Mettle + for extra moves if the bot wants to dive. Step `floor` toward the target (clamped to + `[1, frontier]` — you cannot stand on an already-eaten floor). Update + `deepest_reached`. +3. **Roll** the hand: draw `hand_size` dice (`+lineage/calling`), roll each to a face, + summing the six symbol totals. The controller may **Press On** (B6) to draw & roll + additional hands, each appending to the totals and adding `press_dread_push` to the + round's Maw pressure. +4. **Dread**: `dread -= faith_cancel` (Faith spent to cancel, plus Hierophant bonus + and Revenant's "ignore first"). If remaining `dread ≥ dread_threshold`, a **Setback**: + lose one die from the bag (Golem-born resists; Wisp loses two) **or**, if the bag is + minimal, lose `setback_loot` unbanked loot. Count it. +5. **Claim**: among rooms on the current floor not already held by this party, pick the + highest-value one the party can afford this turn (Steel ≥ threat, Cunning ≥ lock, + trinity-or-Lorekeeper for Vault). Place/strengthen a marker; add to `held`. + *(Abstraction: one claim attempt per turn; "contest" = simply adding your own marker, + majority resolved at collapse.)* +6. **Loot**: `unbanked_loot += greed × loot_per_greed × depth_factor(floor) + + production(held) + reaver/gnoll bonuses`. +7. **Recruit**: if `mettle ≥ recruit_cost` and the controller wants to grow, add a die + of the Calling's recruit kind to the bag (Prospector discount; Golem-born can't take + Relics). +8. **Exit decision** (B6): Cash Out, set Last Stand, or persist. + +## B4. The Maw + +After all turns in a round `r`: +``` +speed = base_rate + accel * r + dread_push * total_dread_this_round +accum += speed +while accum >= 1.0 and frontier >= 1: + accum -= 1.0 + collapse(frontier) // score rooms (B5) + Take parties on this floor + frontier -= 1 +``` +`collapse(f)`: for each room on `f`, the owner with the greatest summed marker strength +takes `base_value × f` Glory (ties split, floor-divided). Then every **active** party +whose `floor == f` is **Taken**: if `last_stand`, score the **Worthy End** bonus +(`f × worthy_factor + held_value`, ×1.5 Wardancer); else lose unbanked loot + all its +active markers (a **feel-bad** event, counted). The party is removed; the player may +draft a new one next round at the Threshold. + +## B5. Scoring + +Glory accrues from: room majorities at collapse, Cash-Out banks (loot + depth +dividend), and Worthy-End bonuses. At game end (frontier < 1), any still-active party is +force-Taken under the same B4 rule (Last Stand pays; otherwise unbanked loot is lost). +Highest total Glory wins; we also record the **margin** (winner − runner-up). + +## B6. Controllers (the bots under test) + +Each is a pure function of the public game view. Risk appetite and exit timing are the +levers that distinguish them. + +- **CashOut (Cautious).** Targets a moderate depth; presses on rarely; **Cash-Out** as + soon as banked-worthy loot clears a small bar *or* the Maw is within `safe_margin` + floors. Rarely sets Last Stand. Models the safe Decline player. +- **PressOn (Greedy/Engine).** Recruits hard early; targets rooms to hold; presses + aggressively; **persists**, fleeing upward only when the Maw is one floor away; Cashes + Out only when forced. Models the engine-builder who risks the clock. +- **WorthyEnd (Martyr/Diver).** Rushes to the deepest reachable floor; sets Last Stand + as soon as it holds a Vault/Throne (or is Wardancer); presses hard; rides the floor + into a **Worthy End**. High variance, high ceiling. +- **Balanced (Adaptive).** Reads `collapses_until_my_floor` vs. banked value: Cash-Out + when the Maw is close *and* it has a worthwhile bank; opportunistically converts to a + Worthy End when deep and doomed; otherwise presses moderately and grows. The + "good human" baseline. + +## B7. What we measure (written to CSV/JSON; see `analysis/`) + +- **Win rate by strategy** (mixed tables) → dominance check (target: no strategy > + ~40% in 4-player, none < ~12%). +- **Win rate by Lineage and by Calling** → asymmetry fairness (flag any > ~1.6× the + mean). +- **Game length** (rounds) → pacing (target band ~8–14). +- **Victory margin** as a fraction of the winner's score → runaway check (lower = tighter). +- **Feel-bad rate**: Takes-without-Last-Stand per game → keep low. +- **Cash-Out timing spread**: distribution of the depth/round at which winners cashed + out → is the Decline decision genuinely live, or is there one correct moment? + +--- + +# Part C — Revisions after adversarial review (v2, what the engine implements) + +Two independent reviewers (a "fun/decisions" lens and a "balance/exploits" lens) tore +into v1 before any code was written. They converged on the same structural flaw, so v2 +folds the high-leverage fixes in. **The engine implements v2.** The deltas: + +1. **Value rises with the Maw (the big one).** *Problem:* `glory = base×depth` + + bottom-up collapse correlated value with time — the climax landed mid-game, the + endgame fizzled on cheap floors, and the Worthy-End catch-up valve closed in the + back half (no deep floors left to dive into) → a back-half runaway, contradicting + the whole pitch. *Fix:* on collapse, a fraction (`value_rise_frac`) of each room's + **unclaimed** value rises to the floor above as "refugee plunder." The Maw's + frontier is therefore always the swelling hot zone, all the way up — the **final + Floor-1 collapse is now the biggest scoring event**, the arc crescendos, and a + trailing player can always dive into the (now-shallow) frontier for a real comeback. + +2. **Re-entry near the frontier.** *Problem:* a fresh party from the Cash-Out loop + starts at Floor 1 and can never reach the deep live floor before it collapses → + Cash-Out self-limited to shallow farming; deep game decided once, early. *Fix:* + re-entering parties insert at `max(1, frontier − reentry_gap)` — the upper floors + are eaten anyway, so new delvers drop in near the action. Keeps every party relevant. + +3. **One collapse per round (carry the remainder).** *Problem:* `while accum ≥ 1.0` + could eat two floors in a round late-game, Taking "I'll flee next turn" parties with + no Last Stand — a systematic feel-bad. *Fix:* at most `max_collapses_per_round` + (default 1) collapses per round; the overflow carries in `accum`. "One floor away" + is now an honest promise. + +4. **Worthy End = burst + denial, not a bigger Cash-Out.** *Problem:* Cash-Out did + almost everything Worthy-End did, safely and repeatably, so the triangle was a + binary and Worthy-End read as the loser's button; also a `held_value` double-count + risk. *Fix:* the two exits now have **non-overlapping** upside. **Cash-Out = the long + game** (loot + depth dividend + Fallen markers that keep earning). **Worthy-End = + burst + denial**: a guaranteed `worthy_factor × deepest_floor` spike *and the Maw + consumes your held rooms clean* — their value is removed from the board so no rival + inherits it. Now a *leader* takes a Worthy-End to deny a chaser a 36-point Vault. + `worthy_held_bonus` is **0 by default** (no double-count); it's a separate knob. + +5. **Variable-strength claims + assault progress.** *Problem:* contest-by-marker-visit + was solitaire and un-reactable; deep claims were a ~5% one-turn lottery with no + floor. *Fix:* a claim's marker **strength = 1 + symbols invested** (Warden ×2), so + majority is a live bid; and unmet Steel/Cunning **accumulates as assault progress** + on the room across turns, so a near-miss is never a total zero. + +6. **Lineages get behavioral hooks; dead cells fixed.** *Problem:* Lineages were flat + stat tweaks (no "two halves surprise you"), and some combos negated themselves + (Golem-born Prospector locked out Relics; Gnoll Reaver / Revenant Hierophant stacked + the same axis). *Fix:* Dvergar **ignores room threat** on its claim (smash-through — + now Dvergar Lorekeeper cracks guarded Vaults); Revenant **converts Dread→Mettle** + (peril as fuel, a decision, not redundant immunity); Golem-born's cost is now **−1 + hand size** (orthogonal — and it *cancels* Prospector's +hand, no dead cell). + See `content.rs` for the final tables. + +7. **Sub-linear depth + comeback rubber-band, both as knobs.** Room glory is + `base × (1 + depth_coeff × (depth−1))` (`depth_coeff` tunes the cliff), and + `comeback_bp` optionally scales a trailing party's exit payouts by its glory deficit. + Both default conservatively and are what the sweeps actually move. + +These are the hypotheses the sim must now confirm: that the triangle is live (no exit +> ~45% of winners' choices), that deep rooms are *contestable but not free*, that the +runaway is dead (tight margins; real comeback rate), and that the feel-bad is rare. + +--- + +## B8. What the sim deliberately does *not* model + +Intra-floor geometry & corridors; denizen reputation as a full subsystem (folded into a +threat modifier); trading/negotiation (no table talk to simulate); the exact card/draft +UI. These don't move the six questions above, and adding them would trade clarity for +false precision. The balance levers that *do* matter all live in `configs/default.json` +and in the Lineage/Calling tables in `deepfall-core/src/content.rs`. diff --git a/deepfall/FINDINGS.md b/deepfall/FINDINGS.md new file mode 100644 index 0000000..f8c8fa2 --- /dev/null +++ b/deepfall/FINDINGS.md @@ -0,0 +1,157 @@ +# DEEPFALL — Simulation Findings + +What the balance-by-bot rig actually said, after 9 tuning iterations over ~thousands +of seeded games per sweep. This is the honest post-mortem: what the design got right, +what the sim broke, what I fixed, and what's still open. + +The deliverable was never "a perfectly balanced game." It was: **design a mashup, +then let a simulator tell me the truth about it.** It did. + +--- + +## 1. The headline + +Final config (`configs/default.json`), 2000 seeds × 24 seat-permutations per strategy +sweep, 2000 games per combo: + +| Metric | Result | Verdict | +|---|---|---| +| **Game length** | 10.9 rounds (10–11) | ✅ in the 8–14 target band; the Maw ends it cleanly, every time | +| **Victory margin** | 13.7% of winner's score | ✅ tight — **no runaway leader** | +| **Feel-bad rate** (Taken w/o Last Stand) | **0.00 / game** | ✅ the one true feel-bad is fully designed out | +| **Deep content reachability** | only **6.5%** of deep-floor claims by round-1 parties | ✅ the deep game is *not* decided at game start | +| **Combo spread** (best − worst of 36) | **32.5 pts** (8.1%–40.6%) | 🟡 every combo viable; still a soft Lineage/Calling tilt | +| **Strategy band** | WorthyEnd 46.7 / PressOn 25.8 / Balanced 24.8 / CashOut 2.8 | 🟡 aggression is the strongest line; pure passivity is weak | + +Three of the design's central promises held up under simulation — **no runaway, no +elimination feel-bad, and a living deep game**. Two things stayed stubborn — the +draft has a soft power tilt, and the four play-styles aren't a flat band. Both are +understood (below), not mysterious. + +--- + +## 2. The three biggest things the sim caught (and the fixes) + +### A. Value and time were correlated → the arc fizzled and the back half ran away +Room glory was `base × depth` and the Maw eats deepest-first, so the richest rooms +scored *earliest*; the game crescendo'd in the middle and ended on its cheapest floors, +and the Worthy-End catch-up valve closed once the deep floors were gone. Both critics +flagged it before a line of code ran. + +**Fix (the single best rule in the game):** on collapse, a fraction of each room's +*unclaimed* value **rises to the floor above** as "refugee plunder." The Maw's frontier +is now always the swelling hot zone, all the way to Floor 1. The data confirms it: the +claim distribution is **non-monotonic** — Floor 4 (162k claims) draws *more* than Floor 3 +(96k), because value pools wherever the frontier sits, and Floor 1 draws the most of all +as everyone converges for the finale. The endgame crescendos instead of fizzling, and +margins stayed tight (13.7%). + +### B. The deep game was nearly a game-start-only contest +A fresh party from the Cash-Out loop entered at Floor 1 and physically could not reach +the deep live floor before it collapsed (the balance critic walked the move-math). So +the Decline loop would have fed you parties that could only farm shallow rooms. + +**Fix:** re-entering parties insert at `max(1, frontier − 1)`. Result: only **6.5%** of +deep-floor claims come from round-1 parties — i.e. **93.5% of the deep game is played by +later-drafted crews.** The Decline loop stays relevant the whole game. + +### C. The multi-collapse feel-bad +`while accum ≥ 1.0` could eat two floors in one round late-game and Take a "flee next +turn" party with no Last Stand. **Fix:** cap one collapse per round (carry the +remainder). The feel-bad rate is a flat **0.00** across every config tested since. + +--- + +## 3. The balance-by-bot trajectory + +Each row is a tuning pass; the simulator drove every decision. Regressions are left in +on purpose — they're the method working (a change that *looked* good and the data +vetoed). + +| Iter | Lever changed | Combo spread | Weakest combo | WorthyEnd / CashOut strat | +|---|---|---:|---:|---| +| 1 | initial design | 77.8 | 0.5% | 41.6 / 1.8 | +| 2 | sub-linear depth; neuter `ignore_threat`; mixed combo field | 59.8 | — | 44.8 / 0.0 | +| 3 | Champion-recruit Wardancer; Cautious plays the frontier | 46.5 | — | 44.8 / 3.1 | +| 4 | half-strength Fallen markers | **56.2** ⚠ | — | 44.0 / 3.5 | +| 5 | revert Fallen; cap marker overflow; raise dividend | 47.2 | — | 37.0 / 5.0 | +| 6 | bots compare worthy-vs-bank payouts | **55.5** ⚠ | — | 38.5 / 4.2 | +| 7 | Wardancer +1 marker strength; revert dividend | 43.4 | 4.9% | 46.1 / 2.8 | +| 8 | Dvergar −1 Cunning; Wisp gets a Warrior | 35.9 | 5.0% | 45.4 / 2.8 | +| 9 | Dvergar −1 Faith (gentler); trim Reaver | **32.5** | **8.1%** | 46.7 / 2.8 | + +Net: combo spread **−58%** (77.8 → 32.5), weakest combo **×16** (0.5% → 8.1%), and the +dead cells (Sylphid ~1%, Wardancer ~0.3%) all revived into the playable band. + +The two ⚠ regressions taught real lessons: half-strength Fallen markers (iter 4) handed +Warden's ×2 markers an immortal hold and spiked it to 56%; and making bots *compare* +exit payouts (iter 6) — correct in itself — exposed that Wardancer's whole value was a +rare conditional, which is what forced the iter-7 fix. + +--- + +## 4. Final combo heatmap (focal combo as Balanced vs. a mixed random field) + +``` + Reaver Lorekeeper Wardancer Hierophant Warden Prospector + Dvergar 40.6% 33.7% 18.4% 32.5% 30.9% 31.6% + Wisp 20.6% 15.9% 11.9% 14.5% 14.4% 14.2% + Revenant 17.3% 13.9% 8.9% 12.2% 12.7% 11.7% + Gnoll 31.7% 28.7% 9.6% 23.9% 24.4% 24.8% + Sylphid 23.3% 18.8% 12.4% 15.0% 15.3% 14.6% + GolemBorn24.9% 20.4% 8.1% 19.6% 19.1% 32.0% + + Lineage avg : Dvergar 31.2 Gnoll 23.9 GolemBorn 20.7 Sylphid 16.6 Wisp 15.3 Revenant 12.8 + Calling avg : Reaver 26.4 Lorekeeper 21.9 Prospector 21.5 Hierophant 19.6 Warden 19.5 Wardancer 11.6 +``` + +(Mean focal win-rate sits below 25% because the focal always plays the generalist +Balanced bot while the field includes the more aggressive WorthyEnd/PressOn strategies — +so read the *vs-mean* column in `combo_winrates.csv`, not the raw 25% line.) + +--- + +## 5. What's still open (and why) + +### The Cash-Out vs. Worthy-End substitution (the deepest finding) +The strategy band stays lopsided (WorthyEnd 46.7%, CashOut 2.8%) for a structural +reason the sim made undeniable: **for a glory-maximizing agent, Cash-Out and Worthy-End +are substitutes** — both convert a doomed party into points. So *every* lever that helps +the safe banker (e.g. the depth dividend) directly nerfs the martyr, and vice-versa. +They sat on a trade-off frontier across iterations 5–10: I could balance the *strategies* +or the *Wardancer calling*, but not both with the same `worthy_factor`. + +The intended differentiator — Worthy-End's **denial** (the Maw salts your held rooms so +no rival inherits them) — is real in the rules but **invisible to the bots**, because a +win-rate maximizer doesn't model the opponent's loss, and you mostly salt rooms you were +soloing anyway. *This is the headline design lesson:* the two exits only become a true +triangle (not a binary) when denial is made to **matter to your own score** — e.g. score +denial explicitly (gain a cut of the value you deny), or make Worthy-End pay on +*cumulative* depth dived across the whole game (rewarding a play-pattern banking can't +copy). That's the first thing I'd prototype next. + +### The soft draft tilt +Dvergar (threat-reduction is just broadly useful) and Reaver (loot-on-claim is a +flywheel) sit on top; Revenant and Wardancer sit low. No combo is dead (worst is 8.1%), +so the draft is *spicy, not solved* — acceptable for a Small-World-style game where some +combos are stronger — but a shipping version would want Dvergar's hook costed harder and +Revenant's Dread-to-Mettle made to actually pay. + +### Pacing variance is too tight +Game length is 10–11 rounds with almost no spread. Real push-your-luck wants more +variance in *when* the Maw breaks through. The `maw_dread_push` and `press_dread_push` +knobs are the levers; I'd widen them and accept a 9–14 spread. + +--- + +## 6. Reproduce + +```bash +cd deepfall +cargo test # 11 tests: determinism, pacing, no-double-count +cargo run --release -p deepfall-sim -- --out out --seeds 2000 +python3 analysis/analyze.py out # the heatmap above, no dependencies +``` + +Every number here is a pure function of `(configs/default.json, seed)` — same input, +same output, on any machine. diff --git a/deepfall/README.md b/deepfall/README.md new file mode 100644 index 0000000..706688f --- /dev/null +++ b/deepfall/README.md @@ -0,0 +1,85 @@ +# DEEPFALL + +> *Reikhelm, the deep hold, is sinking into the Maw. Delve fast, hold ground, and +> know when to die. The deepest grave is the brightest glory.* + +A tabletop game design + a **balance-by-bot** simulator, built as a self-contained +experiment in the `reikhelm` repo (sibling to `combat/`). It mashes up nine games +the designer loves and tries to keep their best parts while filing off their worst. + +The dungeon you delve is not invented — it is a real **reikhelm** dungeon +(`reikhelm-core`'s room/theme/graph model), so a physical printing could ship +seeded, reproducible boards straight out of the generator. + +--- + +## The one-paragraph pitch + +You are rival **delve-companies** racing into a collapsing megadungeon. Each company +is a **Lineage × Calling** smash-up (your race × your class/faction) that defines a +**bag of dice** you build mid-game. You push your luck rolling that bag to descend, +fight, crack vaults, and seize **rooms** — which score for whoever holds them **when +their floor collapses**. The **Maw** devours the dungeon from the bottom up, so the +richest deep rooms die *first* and the clock is brutal. Every party faces one +decision, over and over: **cash out** (send them "to ground" — they keep their +territory as silent dead and you draft a fresh party), **press on** (greedy, keep the +engine, risk the Maw), or **go for a Worthy End** (dive suicidally deep and let the +Maw take you in a blaze for the single biggest payout in the game). Most glory wins. + +## The nine games, and what we took / what we fixed + +| Game | What we **kept** | What we **filed off** | +|---|---|---| +| **Smash Up** | Two-half faction mashup; control rooms (bases) that score at a breakpoint | The "take-that" hand hate & swing — conflict here is *positional*, never "discard your stuff" | +| **Small World** | Race×Power combo drafting; **the Decline mechanic** (its single best idea) | Crowded-map turn-order tyranny — the Maw, not opponents, is the pressure | +| **Blood Rage** | Card-drafted strategy per age; **escalating doom (Ragnarök)**; **dying is glorious** | Coin-flip combat reveals — outcomes here are dice you *build* and *mitigate* | +| **Quarriors** | **Dice-bag building**; in-game engine growth; push-your-luck | "Dice gods hate me" whiffs — the Decline safety valve + Faith mitigation soften variance | +| **Dragon Rampage** | Push-your-luck **escape** tension; one escalating shared threat | Player elimination — nobody is ever out; a dead party becomes glory | +| **Catan** | Tile **production** when you hold ground; spatial placement | The robber feel-bad & resource starvation — the Maw is the only "robber," and it's fair to all | +| **EverQuest** | Holy-trinity **party comp** (Steel/Cunning/Faith); faction reputation; loot chase | The grind & downtime — turns are fast, simultaneous-ish, no camping | +| **Daggerfall** | **Custom class** via Lineage×Calling with advantages **and** disadvantages; procedural world; faction web | Sprawl & getting lost — bounded floors, a shared revealed board | +| **Eye of the Beholder** | Grid dungeon of room tiles; party of four; **mechanism puzzles** gate the best loot | Mapping tedium — the board is shared and revealed, not memorized | + +The **load-bearing fusion**: *Small World's Decline + Blood Rage's glorious death.* +That one combination kills the runaway leader, abolishes player elimination, and +inverts loss-aversion — three of these games' worst problems, solved at once. + +## Layout (mirrors `combat/`) + +``` +deepfall/ + README.md — this file + DESIGN.md — the full rules + the simulation model (the spec the sim implements) + Cargo.toml — self-contained workspace (does not touch the root reikhelm graph) + configs/default.json — the tunable balance table; editing it IS the balance act + deepfall-core/ — pure, deterministic, headless game engine (ChaCha8, no I/O) + deepfall-sim/ — batch harness: runs sweeps → CSV/JSON + analysis/ — Python (pandas/matplotlib) over the exported data +``` + +## Build & run + +```bash +cd deepfall +cargo test # engine determinism + rules tests +cargo run -p deepfall-sim -- --out out # run the balance sweeps → out/*.csv, out/*.json +python analysis/analyze.py out # (optional) plots → out/plots/*.png +``` + +## What the simulator is for + +It is not the game — it is a **truth serum** for the design. Bots play the three +exit strategies (Cash-Out, Press-On, Worthy-End) plus an adaptive Balanced bot, +across all 36 Lineage×Calling combos, thousands of seeded games. We read the data to +answer the only questions that matter for a design like this: + +1. **Is there a dominant strategy?** (We want a rock-paper-scissors band, not a king.) +2. **Are any Lineage×Calling combos broken?** (Asymmetry should be *fair*, not solved.) +3. **Does the Maw end the game in a satisfying window?** (~8–14 rounds.) +4. **Is it a runaway?** (Margin of victory should be tight.) +5. **How often does a party die for *nothing*?** (The feel-bad rate — keep it low.) +6. **Is the Decline *decision* real?** (Do winners vary their cash-out timing, or is + there one provably-correct moment?) + +See `DESIGN.md` §"Simulation model" for exactly how the sim abstracts the rules, and +the committed sweep notes for what the data actually said. diff --git a/deepfall/analysis/analyze.py b/deepfall/analysis/analyze.py new file mode 100644 index 0000000..8b20bf4 --- /dev/null +++ b/deepfall/analysis/analyze.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""DEEPFALL sweep analyzer — pure stdlib, no dependencies. + +Reads the CSV/JSON written by `deepfall-sim` into `out/` and renders the headline +balance picture to the terminal: an ASCII Lineage x Calling heatmap, the strategy +band, the pacing/feel numbers, and the deep-content reachability. Runs anywhere +Python 3 does — no pandas/matplotlib needed. + + python3 analysis/analyze.py out +""" +import csv +import json +import sys +from pathlib import Path + +LINEAGES = ["Dvergar", "Wisp", "Revenant", "Gnoll", "Sylphid", "GolemBorn"] +CALLINGS = ["Reaver", "Lorekeeper", "Wardancer", "Hierophant", "Warden", "Prospector"] + +# Shading ramp from cold (weak) to hot (strong), keyed off winrate vs. the fair share. +RAMP = " .:-=+*#%@" + + +def shade(frac, lo=0.0, hi=0.45): + t = max(0.0, min(1.0, (frac - lo) / (hi - lo))) + return RAMP[min(len(RAMP) - 1, int(t * (len(RAMP) - 1)))] + + +def load_combo(out: Path): + grid = {} + with open(out / "combo_winrates.csv") as f: + for row in csv.DictReader(f): + grid[(row["lineage"], row["calling"])] = float(row["wins_frac"]) + return grid + + +def heatmap(grid): + print("\nCOMBO WIN-RATE HEATMAP (focal combo as Balanced vs. a mixed random field)") + print(" rows = Lineage, cols = Calling. Each cell: win% and a shade (fair share 25%).\n") + colw = 11 + header = " " * 11 + "".join(c[:colw].ljust(colw) for c in CALLINGS) + print(header) + for l in LINEAGES: + cells = [] + for c in CALLINGS: + wr = grid.get((l, c), 0.0) + cells.append(f"{shade(wr)*3} {wr*100:4.1f}%".ljust(colw)) + print(f" {l:<9}" + "".join(cells)) + # Marginals. + print() + lmean = {l: sum(grid[(l, c)] for c in CALLINGS) / len(CALLINGS) for l in LINEAGES} + cmean = {c: sum(grid[(l, c)] for l in LINEAGES) / len(LINEAGES) for c in CALLINGS} + print(" Lineage averages : " + " ".join(f"{l} {lmean[l]*100:4.1f}%" for l in LINEAGES)) + print(" Calling averages : " + " ".join(f"{c} {cmean[c]*100:4.1f}%" for c in CALLINGS)) + vals = list(grid.values()) + print(f"\n spread: {(max(vals)-min(vals))*100:.1f} pts " + f"best: {max(grid, key=grid.get)} {max(vals)*100:.1f}% " + f"worst: {min(grid, key=grid.get)} {min(vals)*100:.1f}%") + + +def bar(label, frac, width=44, scale=0.5): + n = int(min(1.0, frac / scale) * width) + return f" {label:<11}{frac*100:5.1f}% {'#'*n}" + + +def strategy(out: Path): + print("\nSTRATEGY WIN-RATES (fair share 25%)") + with open(out / "strategy_winrates.csv") as f: + for row in csv.DictReader(f): + print(bar(row["strategy"], float(row["winrate"]))) + + +def depth(out: Path): + print("\nDEEP-CONTENT REACHABILITY (claims per floor; deeper = richer + collapses first)") + rows = list(csv.DictReader(open(out / "depth_claims.csv"))) + mx = max(int(r["claims"]) for r in rows) or 1 + for r in rows: + n = int(int(r["claims"]) / mx * 40) + print(f" floor {r['depth']} {'#'*n} {r['claims']}") + + +def headline(out: Path): + s = json.load(open(out / "summary.json")) + print("\nHEADLINE") + print(f" seeds : {s['seeds']}") + print(f" game length : mean {s['mean_rounds']:.1f} rounds " + f"({s['rounds_min']}-{s['rounds_max']})") + print(f" victory margin : {s['mean_margin_frac']*100:.1f}% of winner (lower = tighter)") + print(f" feel-bad / game : {s['feelbad_per_game']:.3f} (Taken w/o Last Stand)") + print(f" deep claims by R1 : {s['deep_claim_drafted_round_share']*100:.1f}% " + f"(low = deep game is NOT decided at game start)") + print(f" dominant strategy : {'YES' if s['strategy_dominant'] else 'no'}") + print(f" combo spread : {s['combo_spread']*100:.1f} pts") + + +def main(): + out = Path(sys.argv[1] if len(sys.argv) > 1 else "out") + if not (out / "combo_winrates.csv").exists(): + sys.exit(f"no sweep data in {out}/ — run: cargo run -p deepfall-sim -- --out {out}") + print("=" * 78) + print("DEEPFALL — balance sweep analysis") + print("=" * 78) + headline(out) + strategy(out) + heatmap(load_combo(out)) + depth(out) + print("\n" + "=" * 78) + + +if __name__ == "__main__": + main() diff --git a/deepfall/configs/default.json b/deepfall/configs/default.json new file mode 100644 index 0000000..f8ec736 --- /dev/null +++ b/deepfall/configs/default.json @@ -0,0 +1,30 @@ +{ + "floors": 6, + "rooms_per_floor": 4, + "depth_coeff": 0.7, + + "hand_size": 4, + "dread_threshold": 3, + "setback_loot": 3, + "recruit_cost": 2, + + "loot_per_greed": 1.0, + "loot_depth_factor": 0.3, + + "maw_base_rate": 0.15, + "maw_accel": 0.06, + "maw_dread_push": 0.02, + "max_collapses_per_round": 1, + "press_dread_push": 0.5, + + "cashout_depth_dividend": 2.0, + "cashout_min_bank": 12.0, + "worthy_factor": 2.5, + "worthy_held_bonus": 0.0, + + "value_rise_frac": 0.5, + "reentry_gap": 1, + "comeback_bp": 0, + + "max_rounds": 40 +} diff --git a/deepfall/configs/sample_dungeon.md b/deepfall/configs/sample_dungeon.md new file mode 100644 index 0000000..5455aed --- /dev/null +++ b/deepfall/configs/sample_dungeon.md @@ -0,0 +1,97 @@ +# Sample Dungeon — concrete example board + +Generated by the reikhelm dungeon recipe +(`reikhelm-core/src/recipes/dungeon.rs`) and dumped by the throwaway example +`reikhelm-core/examples/sample_dungeon.rs`. + +Reproduce exactly: + +```sh +cargo run --example sample_dungeon -p reikhelm-core 42 48 32 +``` + +- **Seed:** `42` +- **Canvas:** 48 x 32 cells +- **Config:** `DungeonConfig::default()` (just resized) — BSP `min_leaf 8`, + `max_depth 5`; rooms `5..=11`, margin 1; `extra_edge_ratio 0.30` (loop edges, + not a strict tree); `door_chance 0.5`. +- **Result:** 12 rooms, 14 corridors, deterministic for this seed. + +## ASCII map + +Legend: `#` wall   `.` floor   `+` door   `o` pillar   `~` water   `!` lava + +``` +################################################ +#################.....####################...### +#################.....###################.....## +#...........#####.....###########.######.......# +#...........#####.....###########.+....+.......# +#.............................+.....####.......# +#...........#####.....###########.#######.....## +#...........#####.....###########.#######....### +####.##############.#############+#######.###### +####.##############.#############.#######.###### +####.#########...##.#############.#######+###### +####.########.....#.#############.####.......### +####.########.....#.#############.####.......### +####.########.....#.##########....####.......### +##...########.....#.##########...+...........### +###...+...........+.#######.........##....o..### +###.#.#######.....#########.........##.......### +#####.#######.....############...#####.......### +#####.#######.....############...########+###### +#####.#######.....#######################.###### +#####.########...########################.###### +#####.#########+#########################.###### +#####.#########.#########################.###### +###.....#######.#########################.###### +##.......######.########......###########.###### +#.........#####+#######........##########+###### +#.........+.+.....####..........####...........# +#.........###...................####...........# +##.......####.....####..........####...........# +###.....#####.....#####........#####...........# +#############.....######......######...........# +################################################ +``` + +## Regions + +Themes drive DEEPFALL room flavor. Only rooms carry a theme; corridors are +plain passages. `cells` is the room's floor area (a rough "size"). + +| Region | Kind | Theme | Cells (size) | Top-left (x,y) | W x H | +|-------:|------|-----------|-------------:|----------------|-------| +| 0 | Room | Stone | 55 | (1,3) | 11x5 | +| 1 | Room | Threshold | 35 | (17,1) | 5x7 | +| 2 | Room | Den | 9 | (31,3) | 5x5 | +| 3 | Room | Vault | 37 | (40,1) | 7x7 | +| 4 | Room | Den | 8 | (2,13) | 4x4 | +| 5 | Room | Library | 51 | (1,23) | 9x7 | +| 6 | Room | Crypt | 51 | (13,10) | 5x11 | +| 7 | Room | Den | 30 | (27,13) | 9x6 | +| 8 | Room | Hall | 49 | (38,11) | 7x7 | +| 9 | Room | Crypt | 25 | (13,26) | 5x5 | +| 10 | Room | Den | 58 | (22,24) | 10x7 | +| 11 | Room | Throne | 55 | (36,26) | 11x5 | + +Plus 14 corridor regions (ids 12–25) linking the rooms. + +### Entities (overlay layer) + +- **Entrance** @ (19,4) — sits in the `Threshold` room (region 1). +- **Exit** @ (41,28) — sits in the `Throne` room (region 11), the far end. +- **Treasure** x6 and **Monster** x14 scattered, biased by each room's theme + (e.g. `Vault`/`Library` loot-rich, `Den` monster-dense, `Throne` high-risk + high-reward, `Threshold` kept safe). + +## Mapping to DEEPFALL + +Each reikhelm **room** becomes a DEEPFALL room tile carrying its **theme** +(Vault, Library, Crypt, Den, Hall, Throne, Threshold...), which sets that tile's +loot/danger profile via the recipe's `(treasure_bias, monster_bias)`; corridors +are the connective passages between them. Depth = distance from the `Threshold` +entrance toward the `Throne` exit, so **deeper rooms are higher value** (and +higher risk) — the `Throne` boss seat at the far corner is the natural payoff +tile. diff --git a/deepfall/deepfall-core/Cargo.toml b/deepfall/deepfall-core/Cargo.toml new file mode 100644 index 0000000..bc26dce --- /dev/null +++ b/deepfall/deepfall-core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "deepfall-core" +version = "0.1.0" +edition = "2021" +rust-version = "1.96" +description = "Pure, deterministic, headless engine for the DEEPFALL dice-bag delve game." + +[dependencies] +rand_chacha = "0.10.0" +serde = { version = "1.0.228", features = ["derive"] } + +[dev-dependencies] +serde_json = "1.0.150" diff --git a/deepfall/deepfall-core/src/config.rs b/deepfall/deepfall-core/src/config.rs new file mode 100644 index 0000000..bd6cc86 --- /dev/null +++ b/deepfall/deepfall-core/src/config.rs @@ -0,0 +1,126 @@ +//! The tunable balance table. Editing `configs/default.json` (or this struct's +//! [`Default`]) **is** the balance act — the same philosophy as `combat/`. +//! +//! Floating point is used for the few rate/scaling knobs (the Maw clock, loot +//! scaling). Determinism is preserved because all *randomness* flows through the +//! integer ChaCha8 [`crate::rng::Rng`]; the float knobs are fixed per run and applied +//! identically everywhere, so a given (config, seed) reproduces bit-for-bit. + +use serde::{Deserialize, Serialize}; + +/// The complete set of balance knobs for one DEEPFALL game. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Config { + /// Number of floors. Floor 1 = Threshold (top); `floors` = deepest. + pub floors: u32, + /// Rooms per floor. + pub rooms_per_floor: u32, + /// Room collapse glory = `base_value * (1 + depth_coeff * (depth - 1))`. + /// `1.0` ≈ linear `base*depth`; lower softens the deep/shallow cliff. + pub depth_coeff: f64, + + /// Dice drawn per hand (before Lineage/Calling modifiers). + pub hand_size: u32, + /// Standing Dread at/above this triggers a Setback. + pub dread_threshold: i32, + /// Loot lost on a Setback when the bag is too small to lose a die. + pub setback_loot: f64, + /// Mettle needed to recruit one die. + pub recruit_cost: i32, + + /// Loot per Greed symbol before depth scaling. + pub loot_per_greed: f64, + /// Loot depth multiplier = `1 + loot_depth_factor * (depth - 1)`. + pub loot_depth_factor: f64, + + /// Maw advance per round = `maw_base_rate + maw_accel*round + maw_dread_push*total_dread`. + pub maw_base_rate: f64, + pub maw_accel: f64, + pub maw_dread_push: f64, + /// Hard cap on floors collapsed per round (overflow carries). Default 1 keeps + /// "one floor away" an honest promise (kills the multi-collapse feel-bad). + pub max_collapses_per_round: u32, + /// Dread pressure added by each Press-On (feeds the Maw that turn). + pub press_dread_push: f64, + + /// Cash-Out bank += `cashout_depth_dividend * deepest_reached`. + pub cashout_depth_dividend: f64, + /// The Cautious bot Cashes Out once its bankable value clears this bar. + pub cashout_min_bank: f64, + /// Worthy-End spike = `worthy_factor * deepest_floor` (+ optional held bonus). + pub worthy_factor: f64, + /// Extra Worthy-End credit per point of held room value. **0 by default** to + /// avoid double-counting rooms already scored at collapse. + pub worthy_held_bonus: f64, + + /// Fraction of a room's *unclaimed* value that rises one floor on collapse + /// ("refugee plunder"). Makes the Maw frontier the always-hot zone. + pub value_rise_frac: f64, + /// A re-entering party inserts at `max(1, frontier - reentry_gap)`. + pub reentry_gap: u32, + /// Rubber-band: a trailing party's exit payouts scale by + /// `1 + comeback_bp/10000 * (deficit / leader_score)`. 0 disables it. + pub comeback_bp: i64, + + /// Safety valve against a stuck game; a game should end well before this. + pub max_rounds: u32, +} + +impl Default for Config { + /// Mirrors `configs/default.json`. Keep the two in sync. + fn default() -> Self { + Config { + floors: 6, + rooms_per_floor: 4, + // v2 (iter 2): sub-linear depth (was 1.0). The linear base*depth cliff — + // a 36-pt floor-6 Vault vs a 2-pt floor-1 Stone — made "always dive deepest" + // strictly dominant and blew the combo spread wide open. + depth_coeff: 0.7, + + hand_size: 4, + dread_threshold: 3, + setback_loot: 3.0, + recruit_cost: 2, + + loot_per_greed: 1.0, + // v2 (iter 2): 0.5 -> 0.3, less depth snowball for aggressive divers. + loot_depth_factor: 0.3, + + maw_base_rate: 0.15, + maw_accel: 0.06, + maw_dread_push: 0.02, + max_collapses_per_round: 1, + press_dread_push: 0.5, + + cashout_depth_dividend: 2.0, // iter7: back to 2.0 — 3.0 made banking dominate worthy + // v2 (iter 2): 8 -> 12. At 8 the Cautious bot nickel-and-dimed (churned weak + // shallow parties) and cratered to a 1.8% win rate; banking richer hauls + // before retiring makes the safe line viable. + cashout_min_bank: 12.0, + // v2 (iter 3-4): 4 -> 2.5. At 4 the generic Worthy End (any deep party on a + // Throne) made the WorthyEnd strategy a 45% king; lowering the base while + // raising the Wardancer multiplier (now 2.2x) shifts the payoff onto the + // specialist instead of the strategy. + worthy_factor: 2.5, + worthy_held_bonus: 0.0, + + value_rise_frac: 0.5, + reentry_gap: 1, + comeback_bp: 0, + + max_rounds: 40, + } + } +} + +impl Config { + /// Glory awarded for a room of `base_value` on floor `depth` (1 = top). + pub fn room_glory(&self, base_value: f64, depth: u32) -> f64 { + base_value * (1.0 + self.depth_coeff * (depth as f64 - 1.0)) + } + + /// Loot multiplier for acting on floor `depth`. + pub fn loot_depth_mult(&self, depth: u32) -> f64 { + 1.0 + self.loot_depth_factor * (depth as f64 - 1.0) + } +} diff --git a/deepfall/deepfall-core/src/content.rs b/deepfall/deepfall-core/src/content.rs new file mode 100644 index 0000000..735699c --- /dev/null +++ b/deepfall/deepfall-core/src/content.rs @@ -0,0 +1,431 @@ +//! Static game content: the six dice faces, the die kinds and their face layouts, +//! the room themes (reikhelm's palette), and the Lineage × Calling asymmetry tables. +//! +//! v2 (post-critique): Lineages carry **behavioral** hooks, not just stat tweaks, and +//! the dead/redundant grid cells are fixed (see DESIGN.md Part C). The magnitudes that +//! the balance sweeps actually move live in [`crate::config::Config`]; the structural +//! asymmetry lives here. + +use serde::{Deserialize, Serialize}; + +/// The six symbols a die can show. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Face { + Steel, + Cunning, + Faith, + Greed, + Mettle, + Dread, +} + +/// A tally of rolled symbols for one turn. +#[derive(Clone, Copy, Debug, Default)] +pub struct Symbols { + pub steel: i32, + pub cunning: i32, + pub faith: i32, + pub greed: i32, + pub mettle: i32, + pub dread: i32, +} + +impl Symbols { + pub fn add_face(&mut self, f: Face) { + match f { + Face::Steel => self.steel += 1, + Face::Cunning => self.cunning += 1, + Face::Faith => self.faith += 1, + Face::Greed => self.greed += 1, + Face::Mettle => self.mettle += 1, + Face::Dread => self.dread += 1, + } + } +} + +/// The kinds of die a bag can hold. Each has a fixed six-face layout. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum DieKind { + Recruit, + Warrior, + Scholar, + Priest, + Rogue, + Champion, + Relic, +} + +impl DieKind { + /// The six faces of this die kind (index by a d6 roll). + pub fn faces(self) -> [Face; 6] { + use Face::*; + match self { + // Balanced starter: one of each. + DieKind::Recruit => [Steel, Cunning, Faith, Greed, Mettle, Dread], + DieKind::Warrior => [Steel, Steel, Steel, Greed, Mettle, Dread], + DieKind::Scholar => [Cunning, Cunning, Faith, Mettle, Greed, Dread], + DieKind::Priest => [Faith, Faith, Cunning, Mettle, Greed, Dread], + DieKind::Rogue => [Greed, Greed, Cunning, Steel, Mettle, Dread], + // Premium: no Dread. + DieKind::Champion => [Steel, Steel, Cunning, Faith, Greed, Mettle], + // Premium loot/wild engine die: no Dread, no martial faces. + DieKind::Relic => [Greed, Greed, Greed, Faith, Mettle, Mettle], + } + } + + /// A crude "quality" used when choosing which die to shed on a Setback (lowest first). + pub fn quality(self) -> i32 { + match self { + DieKind::Recruit => 0, + DieKind::Warrior | DieKind::Scholar | DieKind::Priest | DieKind::Rogue => 1, + DieKind::Champion => 2, + DieKind::Relic => 2, + } + } +} + +/// Room themes, lifted from `reikhelm-core`'s `RegionTheme`. Each carries the +/// board-game stats the design assigns it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Theme { + Threshold, + Stone, + Cistern, + Den, + Crypt, + Hall, + Library, + Forge, + Vault, + Throne, +} + +/// Stats for a room theme: glory base, combat threat, mechanism lock, what it produces +/// while held, whether it demands the trinity, and whether it's a "combat" room +/// (Gnoll's scavenge bonus). +#[derive(Clone, Copy, Debug)] +pub struct ThemeStats { + pub base_value: f64, + pub threat: i32, + pub lock: i32, + pub produces: Option, + pub needs_trinity: bool, + pub combat_room: bool, +} + +impl Theme { + pub fn stats(self) -> ThemeStats { + use Face::*; + let s = |base_value, threat, lock, produces, needs_trinity, combat_room| ThemeStats { + base_value, + threat, + lock, + produces, + needs_trinity, + combat_room, + }; + match self { + Theme::Threshold => s(1.0, 0, 0, None, false, false), + Theme::Stone => s(2.0, 1, 0, None, false, false), + Theme::Cistern => s(3.0, 1, 1, Some(Mettle), false, false), + Theme::Den => s(3.0, 3, 0, Some(Steel), false, true), + Theme::Crypt => s(3.0, 2, 1, Some(Faith), false, true), + Theme::Hall => s(4.0, 1, 1, Some(Mettle), false, false), + Theme::Library => s(4.0, 1, 2, Some(Cunning), false, false), + Theme::Forge => s(5.0, 4, 1, Some(Steel), false, true), + // v2: Vault lock 3 -> 2 (was a ~5% one-turn lottery). Still trinity-gated. + Theme::Vault => s(6.0, 2, 2, Some(Greed), true, false), + Theme::Throne => s(7.0, 5, 2, Some(Greed), false, true), + } + } +} + +/// Themes ordered from poorest to richest — the richness gradient used to weight a +/// floor's themes by depth. +pub const RICHNESS_ORDER: [Theme; 10] = [ + Theme::Threshold, + Theme::Stone, + Theme::Cistern, + Theme::Den, + Theme::Crypt, + Theme::Hall, + Theme::Library, + Theme::Forge, + Theme::Vault, + Theme::Throne, +]; + +// --------------------------------------------------------------------------------- +// Lineages (the "race" half) +// --------------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Lineage { + Dvergar, + Wisp, + Revenant, + Gnoll, + Sylphid, + GolemBorn, +} + +pub const ALL_LINEAGES: [Lineage; 6] = [ + Lineage::Dvergar, + Lineage::Wisp, + Lineage::Revenant, + Lineage::Gnoll, + Lineage::Sylphid, + Lineage::GolemBorn, +]; + +/// Resolved modifiers a Lineage contributes. Built once per party. +#[derive(Clone, Debug)] +pub struct LineageMods { + pub move_mod: i32, + pub hand_mod: i32, + pub steel_mod: i32, + pub cunning_mod: i32, + pub faith_mod: i32, + /// Dvergar: reduces a room's Steel threat when claiming (smash through). v2 made + /// this a *reduction* rather than a full ignore — total ignore was a 78%-winrate + /// blowout in the sweeps because it deleted one of the two claim gates outright. + pub threat_reduction: i32, + /// Revenant: convert leftover Dread into Mettle 1:1 (peril as fuel)... + pub dread_to_mettle: bool, + /// ...and Revenant cannot use Faith to cancel Dread (no living soul). + pub can_cancel_dread: bool, + /// Gnoll: bonus loot multiplier when claiming a combat room. + pub combat_loot_bonus: f64, + /// Golem-born: never loses dice to a Setback. + pub setback_immune: bool, + /// Wisp: a Setback sheds this many extra dice (fragile). + pub setback_extra_loss: i32, + pub starting_bag: Vec, +} + +impl Lineage { + pub fn name(self) -> &'static str { + match self { + Lineage::Dvergar => "Dvergar", + Lineage::Wisp => "Wisp", + Lineage::Revenant => "Revenant", + Lineage::Gnoll => "Gnoll", + Lineage::Sylphid => "Sylphid", + Lineage::GolemBorn => "GolemBorn", + } + } + + pub fn mods(self) -> LineageMods { + use DieKind::*; + // Base template; each arm overrides what it changes. + let base = LineageMods { + move_mod: 0, + hand_mod: 0, + steel_mod: 0, + cunning_mod: 0, + faith_mod: 0, + threat_reduction: 0, + dread_to_mettle: false, + can_cancel_dread: true, + combat_loot_bonus: 0.0, + setback_immune: false, + setback_extra_loss: 0, + starting_bag: vec![], + }; + match self { + // Smash-through bruiser: shrugs off 1 threat, but godless (poor at Dread). + // v2 iter9: -1 Cunning (iter8) over-nerfed it — Cunning gates locks *and* + // buys Moves, so Dvergar cratered to ~6%. The cost moved to -1 Faith: it + // still claims locks fine, but courts Setbacks. A gentler, real trade-off. + Lineage::Dvergar => LineageMods { + faith_mod: -1, + threat_reduction: 1, + starting_bag: vec![Recruit, Recruit, Recruit, Warrior, Warrior, Warrior], + ..base + }, + // Nimble caster: draws +1 die every hand. v2 iter8: an all-Scholar bag left + // it starved of Steel and unable to clear room threats (a 5–7% Lineage); a + // lone Warrior gives it just enough muscle to claim, while the extra die and + // its natural extra Dread stay the trade-off. + Lineage::Wisp => LineageMods { + hand_mod: 1, + starting_bag: vec![Recruit, Recruit, Recruit, Scholar, Scholar, Warrior], + ..base + }, + // Fearless dead: turns Dread into Mettle, but can't cancel it with Faith. + Lineage::Revenant => LineageMods { + dread_to_mettle: true, + can_cancel_dread: false, + starting_bag: vec![Recruit, Recruit, Recruit, Recruit, Warrior, Priest], + ..base + }, + // Scavenger: rich loot from combat rooms, but godless (poor at quelling + // Dread). v2 iter3: the cost moved off Cunning (which gated locks and tanked + // the whole Lineage) onto Faith, so Gnoll claims fine but courts Setbacks. + Lineage::Gnoll => LineageMods { + faith_mod: -1, + combat_loot_bonus: 0.6, + starting_bag: vec![Recruit, Recruit, Recruit, Warrior, Warrior, Rogue], + ..base + }, + // Fleet and deft at locks, but reckless. v2: dropped the -1 Steel (it was a + // ~1%-winrate death sentence — Steel is the primary claim gate); the cost is + // now fragility, paid only when the dice betray you. + Lineage::Sylphid => LineageMods { + move_mod: 1, + cunning_mod: 1, + setback_extra_loss: 1, + starting_bag: vec![Recruit, Recruit, Recruit, Scholar, Scholar, Rogue], + ..base + }, + // Immovable: setback-immune, but rigid (-1 hand). Big balanced bag. + // The -1 hand is orthogonal to every Calling and *cancels* Prospector's + // +1 hand rather than negating an identity (no dead cell). + Lineage::GolemBorn => LineageMods { + hand_mod: -1, + setback_immune: true, + starting_bag: vec![Recruit, Recruit, Recruit, Recruit, Recruit, Warrior, Warrior], + ..base + }, + } + } +} + +// --------------------------------------------------------------------------------- +// Callings (the "class/faction" half) +// --------------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Calling { + Reaver, + Lorekeeper, + Wardancer, + Hierophant, + Warden, + Prospector, +} + +pub const ALL_CALLINGS: [Calling; 6] = [ + Calling::Reaver, + Calling::Lorekeeper, + Calling::Wardancer, + Calling::Hierophant, + Calling::Warden, + Calling::Prospector, +]; + +/// Resolved modifiers a Calling contributes. +#[derive(Clone, Debug)] +pub struct CallingMods { + pub recruit_kind: DieKind, + pub recruit_discount: i32, + pub hand_mod: i32, + pub move_mod: i32, + /// Reaver: bonus loot multiplier on every claimed room. + pub claim_loot_bonus: f64, + /// Lorekeeper: open Vaults without the trinity, plus a lock bonus. + pub solo_vault: bool, + pub cunning_bonus: i32, + /// Wardancer: Worthy-End multiplier, and a free Last Stand (no deep room needed). + pub worthy_mult: f64, + pub free_last_stand: bool, + /// Flat bonus to every control marker's strength (a consistent edge so a Calling + /// isn't hostage to a rare event). Distinct from Warden's `marker_mult`. + pub claim_strength_bonus: i32, + /// Warden: control-marker strength multiplier. + pub marker_mult: i32, + /// Hierophant: extra Dread cancelled, heals Setbacks, richer held-room production. + pub extra_dread_cancel: i32, + pub heal_setback: bool, + pub hold_loot_mult: f64, + /// Prospector: may recruit Relic dice. + pub finds_relics: bool, +} + +impl Calling { + pub fn name(self) -> &'static str { + match self { + Calling::Reaver => "Reaver", + Calling::Lorekeeper => "Lorekeeper", + Calling::Wardancer => "Wardancer", + Calling::Hierophant => "Hierophant", + Calling::Warden => "Warden", + Calling::Prospector => "Prospector", + } + } + + pub fn mods(self) -> CallingMods { + let base = CallingMods { + recruit_kind: DieKind::Recruit, + recruit_discount: 0, + hand_mod: 0, + move_mod: 0, + claim_loot_bonus: 0.0, + solo_vault: false, + cunning_bonus: 0, + worthy_mult: 1.0, + free_last_stand: false, + claim_strength_bonus: 0, + marker_mult: 1, + extra_dread_cancel: 0, + heal_setback: false, + hold_loot_mult: 1.0, + finds_relics: false, + }; + match self { + // v2 iter4/9: claim loot 0.75 -> 0.5 -> 0.4. Loot-on-claim is a flywheel + // (every claim funds the next), so it stayed the strongest Calling across + // Lineages even after the first trim. + Calling::Reaver => CallingMods { + recruit_kind: DieKind::Warrior, + recruit_discount: 1, + claim_loot_bonus: 0.4, + ..base + }, + // v2: dropped the +1 lock Cunning bonus — solo-Vault access to the richest + // rooms is already its identity; stacking a general lock bonus on top made + // it strong with *every* Lineage. Now it's good at Vaults, not all locks. + Calling::Lorekeeper => CallingMods { + recruit_kind: DieKind::Scholar, + solo_vault: true, + ..base + }, + // v2 iter3: the worthy *specialist* was the worst Calling because anyone can + // martyr on a deep Throne — the bonus was redundant. Now it's genuinely the + // best at the martyr line: premium Champion dice (reach + hold + crack the + // Vault it dies on) and a 1.8x payout that clearly beats a generic 1.0x End. + // v2 iter7: added a consistent +1 marker strength. The sweeps proved that + // for a glory-maximizing bot, Worthy End and Cash Out are *substitutes*, so a + // purely worthy-conditional Calling is dead weight whenever banking pays more + // (which the depth dividend made common). The flat claim bonus makes Wardancer + // a solid area-control crew that *also* dies huge — viable either way. + Calling::Wardancer => CallingMods { + recruit_kind: DieKind::Champion, + move_mod: 1, + worthy_mult: 2.2, + free_last_stand: true, + claim_strength_bonus: 1, + ..base + }, + Calling::Hierophant => CallingMods { + recruit_kind: DieKind::Priest, + extra_dread_cancel: 1, + heal_setback: true, + hold_loot_mult: 1.25, + ..base + }, + Calling::Warden => CallingMods { + recruit_kind: DieKind::Champion, + marker_mult: 2, + ..base + }, + Calling::Prospector => CallingMods { + recruit_kind: DieKind::Relic, + recruit_discount: 1, + hand_mod: 1, + finds_relics: true, + ..base + }, + } + } +} diff --git a/deepfall/deepfall-core/src/controller.rs b/deepfall/deepfall-core/src/controller.rs new file mode 100644 index 0000000..505850a --- /dev/null +++ b/deepfall/deepfall-core/src/controller.rs @@ -0,0 +1,214 @@ +//! The bots. Each [`Strategy`] is a pure function of a [`View`] (a read-only snapshot +//! of the public game state). The four strategies are deliberately built around the +//! design's three exits, so the sweeps can answer "is there a dominant exit?". +//! +//! - [`Strategy::CashOut`] — Cautious: bank early and safe, the pure Decline player. +//! - [`Strategy::PressOn`] — Greedy: build the engine, hold ground, defend with a +//! late Cash-Out rather than risk the Maw. +//! - [`Strategy::WorthyEnd`] — Martyr: dive deepest and ride a Last Stand into a blaze. +//! - [`Strategy::Balanced`] — Adaptive: read the clock, bank good hauls, opportunistic +//! martyrdom when deep and doomed. The "good human" baseline. + +use serde::{Deserialize, Serialize}; + +/// A read-only snapshot handed to a [`Strategy`] for one decision. +#[derive(Clone, Debug)] +pub struct View { + pub round: u32, + pub frontier: u32, + pub floors_total: u32, + pub my_glory: f64, + pub leader_glory: f64, + pub rank: u32, + pub num_players: u32, + + pub has_party: bool, + pub floor: u32, + pub deepest: u32, + pub unbanked: f64, + pub bag_size: u32, + pub last_stand: bool, + pub held_count: u32, + pub held_value: f64, + pub holds_deep_room: bool, + pub free_last_stand: bool, + /// What a Worthy End would pay right now (`worthy_factor * deepest * worthy_mult`). + pub worthy_estimate: f64, + /// Number of collapse events until *this party's* floor is eaten (1 = next). + pub collapses_until_my_floor: u32, + + /// Tunable thresholds passed through from config so bots read the same table. + pub cashout_min_bank: f64, + pub cashout_depth_dividend: f64, +} + +impl View { + /// What a Cash-Out would bank right now (loot + depth dividend). + fn bankable(&self) -> f64 { + self.unbanked + self.cashout_depth_dividend * self.deepest as f64 + } + fn can_last_stand(&self) -> bool { + self.holds_deep_room || self.free_last_stand + } +} + +/// The party's plan for the action phase of a turn. +#[derive(Clone, Copy, Debug)] +pub struct Intent { + pub target_depth: u32, + pub press_target: u32, + pub want_recruit: bool, + pub dive_moves: bool, +} + +/// The exit decision at the end of a turn. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Exit { + Stay, + CashOut, + SetLastStand, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum Strategy { + CashOut, + PressOn, + WorthyEnd, + Balanced, +} + +pub const ALL_STRATEGIES: [Strategy; 4] = [ + Strategy::CashOut, + Strategy::PressOn, + Strategy::WorthyEnd, + Strategy::Balanced, +]; + +impl Strategy { + pub fn name(self) -> &'static str { + match self { + Strategy::CashOut => "CashOut", + Strategy::PressOn => "PressOn", + Strategy::WorthyEnd => "WorthyEnd", + Strategy::Balanced => "Balanced", + } + } + + pub fn intent(self, v: &View) -> Intent { + match self { + // Take-profits banker: contest the frontier like everyone else, but never + // gamble — bank gains fast. (v2 iter3: "play shallow" was just losing; the + // real cautious identity is *early exit timing*, not avoiding the action.) + Strategy::CashOut => { + let target = if v.collapses_until_my_floor <= 1 { + v.floor.saturating_sub(1).max(1) + } else { + (v.floor + 1).min(v.frontier).max(1) + }; + Intent { + target_depth: target, + press_target: 0, + want_recruit: v.bag_size < 9, + dive_moves: false, + } + } + // Build the engine; advance when safe, flee up when the Maw is one away. + Strategy::PressOn => { + let target = if v.collapses_until_my_floor <= 1 { + v.floor.saturating_sub(1).max(1) // flee up + } else { + (v.floor + 1).min(v.frontier).max(1) // press deeper + }; + Intent { + target_depth: target, + press_target: 2, + want_recruit: v.bag_size < 12, + dive_moves: false, + } + } + // Rush the deepest live floor and press hard for the claim + the dive. + Strategy::WorthyEnd => Intent { + target_depth: v.frontier.max(1), + press_target: 3, + want_recruit: v.bag_size < 8 && v.round <= 3, + dive_moves: true, + }, + // Adaptive: dive toward the mid-deep frontier early; ease off when close. + Strategy::Balanced => { + let target = if v.collapses_until_my_floor <= 1 { + v.floor.saturating_sub(1).max(1) + } else { + (v.floor + 1).min(v.frontier).max(1) + }; + Intent { + target_depth: target, + press_target: if v.collapses_until_my_floor <= 1 { 0 } else { 1 }, + want_recruit: v.bag_size < 10, + dive_moves: v.round <= 4, + } + } + } + } + + pub fn exit(self, v: &View) -> Exit { + if !v.has_party { + return Exit::Stay; + } + match self { + // Bank as soon as the haul is worthwhile, or the moment danger appears. + Strategy::CashOut => { + if v.collapses_until_my_floor <= 1 || v.bankable() >= v.cashout_min_bank { + Exit::CashOut + } else { + Exit::Stay + } + } + // Defend territory with a late Cash-Out rather than feed the Maw. + Strategy::PressOn => { + if v.collapses_until_my_floor <= 1 { + Exit::CashOut + } else { + Exit::Stay + } + } + // The gambler: commit to a Last Stand when deep and doomed, but only if it + // actually out-pays a Cash-Out (v2 iter6: blindly preferring martyrdom threw + // parties away for a worse payout — a Wardancer's *bigger* End is what makes + // it worth it). Otherwise salvage. + Strategy::WorthyEnd => { + if v.last_stand { + Exit::Stay + } else if v.can_last_stand() + && v.deepest >= 3 + && v.collapses_until_my_floor <= 2 + && v.worthy_estimate >= v.bankable() * 0.9 + { + Exit::SetLastStand + } else if v.collapses_until_my_floor <= 1 { + Exit::CashOut + } else { + Exit::Stay + } + } + // Lock in good hauls; martyr only when a Worthy End clearly beats banking — + // this is what separates Wardancer (2.2x End) from everyone else. + Strategy::Balanced => { + if v.last_stand { + Exit::Stay + } else if v.can_last_stand() + && v.deepest >= 3 + && v.collapses_until_my_floor <= 2 + && v.worthy_estimate > v.bankable() + { + Exit::SetLastStand + } else if v.collapses_until_my_floor <= 1 { + Exit::CashOut + } else if v.collapses_until_my_floor <= 2 && v.bankable() >= v.cashout_min_bank * 1.5 { + Exit::CashOut + } else { + Exit::Stay + } + } + } + } +} diff --git a/deepfall/deepfall-core/src/game.rs b/deepfall/deepfall-core/src/game.rs new file mode 100644 index 0000000..a5914d6 --- /dev/null +++ b/deepfall/deepfall-core/src/game.rs @@ -0,0 +1,853 @@ +//! The DEEPFALL engine: pure, deterministic, headless. One `(Config, seed, roster)` +//! plays one full game and yields a [`GameResult`]. No I/O, no rendering. +//! +//! The turn resolution implements DESIGN.md Part B as revised by Part C. The within-turn +//! order is fixed and load-bearing: **roll (+press) → resolve Dread → move → claim → +//! loot → recruit → exit**. The Maw advances once, after all players' turns, capped at +//! `max_collapses_per_round` collapses (overflow carries in `maw_accum`). + +use crate::config::Config; +use crate::content::*; +use crate::controller::{Exit, Strategy, View}; +use crate::rng::Rng; + +/// A control marker in a room. +#[derive(Clone, Copy, Debug)] +pub struct Marker { + pub owner: usize, + pub strength: i32, + pub fallen: bool, +} + +/// A player's accumulated, not-yet-completed assault on a room (carries across turns). +#[derive(Clone, Copy, Debug)] +struct Assault { + owner: usize, + steel: i32, + cunning: i32, +} + +#[derive(Clone, Debug)] +pub struct Room { + pub theme: Theme, + pub depth: u32, + /// Glory base in *room-value units*; collapse glory = `cfg.room_glory(base, depth)` + /// plus [`risen`]. + pub base_value: f64, + /// Extra glory (already in glory units) that rose here from a deeper collapse. + pub risen: f64, + pub markers: Vec, + assault: Vec, + pub collapsed: bool, +} + +impl Room { + fn glory(&self, cfg: &Config) -> f64 { + cfg.room_glory(self.base_value, self.depth) + self.risen + } + fn controlled(&self) -> bool { + self.markers.iter().any(|m| m.strength > 0) + } + fn assault_mut(&mut self, owner: usize) -> &mut Assault { + if let Some(i) = self.assault.iter().position(|a| a.owner == owner) { + &mut self.assault[i] + } else { + self.assault.push(Assault { owner, steel: 0, cunning: 0 }); + self.assault.last_mut().unwrap() + } + } +} + +#[derive(Clone, Debug)] +pub struct Floor { + pub depth: u32, + pub rooms: Vec, + pub collapsed: bool, +} + +/// An active delve-company. +#[derive(Clone, Debug)] +pub struct Party { + pub lineage: Lineage, + pub calling: Calling, + lmods: LineageMods, + cmods: CallingMods, + pub bag: Vec, + pub floor: u32, + pub unbanked: f64, + pub deepest: u32, + pub last_stand: bool, + /// (depth, room index) of every room this party holds an active marker in. + held: Vec<(u32, usize)>, + drafted_round: u32, +} + +impl Party { + fn new(lineage: Lineage, calling: Calling, floor: u32, round: u32) -> Self { + let lmods = lineage.mods(); + let cmods = calling.mods(); + Party { + bag: lmods.starting_bag.clone(), + lineage, + calling, + lmods, + cmods, + floor, + unbanked: 0.0, + deepest: floor, + last_stand: false, + held: vec![], + drafted_round: round, + } + } +} + +#[derive(Clone, Debug)] +pub struct Player { + pub id: usize, + pub strategy: Strategy, + /// If set, this player always drafts this exact combo (for combo sweeps). + pub fixed_combo: Option<(Lineage, Calling)>, + pub glory: f64, + party: Option, + // --- per-game metrics --- + pub setbacks: u32, + pub cashouts: u32, + pub worthy_ends: u32, + pub takes_no_laststand: u32, + pub cashout_depth_sum: u32, + /// Combo actually played most recently (for win attribution). + pub last_combo: Option<(Lineage, Calling)>, +} + +impl Player { + pub fn new(id: usize, strategy: Strategy, fixed_combo: Option<(Lineage, Calling)>) -> Self { + Player { + id, + strategy, + fixed_combo, + glory: 0.0, + party: None, + setbacks: 0, + cashouts: 0, + worthy_ends: 0, + takes_no_laststand: 0, + cashout_depth_sum: 0, + last_combo: None, + } + } +} + +/// A single claim, recorded for the deep-content-reachability metric. +#[derive(Clone, Copy, Debug)] +pub struct ClaimEvent { + pub depth: u32, + pub owner: usize, + pub round: u32, + pub drafted_round: u32, +} + +/// The outcome of one full game. +#[derive(Clone, Debug)] +pub struct GameResult { + pub rounds: u32, + pub players: Vec, + pub winner: usize, + pub margin: f64, + pub claims: Vec, +} + +#[derive(Clone, Debug)] +pub struct PlayerResult { + pub id: usize, + pub strategy: Strategy, + pub glory: f64, + pub setbacks: u32, + pub cashouts: u32, + pub worthy_ends: u32, + pub takes_no_laststand: u32, + pub mean_cashout_depth: f64, + pub combo: Option<(Lineage, Calling)>, +} + +pub struct Game { + cfg: Config, + rng: Rng, + floors: Vec, + maw_accum: f64, + /// Deepest uncollapsed floor; 0 once all have collapsed. + frontier: u32, + players: Vec, + round: u32, +} + +impl Game { + /// Build a game. `roster` is one (strategy, optional fixed combo) per player. + pub fn new(cfg: Config, seed: u64, roster: &[(Strategy, Option<(Lineage, Calling)>)]) -> Self { + let rng = Rng::from_seed(seed); + let mut g = Game { + floors: Vec::new(), + maw_accum: 0.0, + frontier: cfg.floors, + players: roster + .iter() + .enumerate() + .map(|(i, (s, c))| Player::new(i, *s, *c)) + .collect(), + round: 0, + rng, + cfg, + }; + g.build_dungeon(); + g + } + + fn build_dungeon(&mut self) { + let mut trng = self.rng.fork("dungeon"); + let floors = self.cfg.floors; + for depth in 1..=floors { + let mut rooms = Vec::new(); + for r in 0..self.cfg.rooms_per_floor { + let theme = roll_theme(depth, floors, r, self.cfg.rooms_per_floor, &mut trng); + rooms.push(Room { + theme, + depth, + base_value: theme.stats().base_value, + risen: 0.0, + markers: vec![], + assault: vec![], + collapsed: false, + }); + } + self.floors.push(Floor { depth, rooms, collapsed: false }); + } + } + + fn floor_idx(depth: u32) -> usize { + (depth - 1) as usize + } + + /// Play to completion. + pub fn run(&mut self) -> GameResult { + let mut claims: Vec = Vec::new(); + while self.frontier >= 1 && self.round < self.cfg.max_rounds { + self.round += 1; + let mut round_dread = 0.0; + for pi in 0..self.players.len() { + round_dread += self.take_turn(pi, &mut claims); + } + self.advance_maw(round_dread); + } + // Any party still active at the end is force-resolved under the Take rule. + for pi in 0..self.players.len() { + if self.players[pi].party.is_some() { + self.take_party(pi, self.players[pi].party.as_ref().unwrap().floor); + } + } + self.finish(claims) + } + + /// One player's turn. Returns the raw Dread this turn contributed to the Maw. + fn take_turn(&mut self, pi: usize, claims: &mut Vec) -> f64 { + // Draft a fresh company if needed (re-entry near the frontier). + if self.players[pi].party.is_none() { + if self.frontier < 1 { + return 0.0; + } + let combo = self.draft_combo(pi); + let entry = self + .frontier + .saturating_sub(self.cfg.reentry_gap) + .max(1) + .min(self.frontier); + let party = Party::new(combo.0, combo.1, entry, self.round); + self.players[pi].last_combo = Some(combo); + self.players[pi].party = Some(party); + } + + // --- ROLL (+ adaptive press-on) --- + let mut rng = self.rng.fork(&format!("p{pi}:r{}", self.round)); + let (lmods, cmods) = { + let p = self.players[pi].party.as_ref().unwrap(); + (p.lmods.clone(), p.cmods.clone()) + }; + let hand = (self.cfg.hand_size as i32 + lmods.hand_mod + cmods.hand_mod).max(1) as usize; + let mut bag = self.players[pi].party.as_ref().unwrap().bag.clone(); + rng.shuffle(&mut bag); + + let view = self.view(pi); + let intent = self.players[pi].strategy.intent(&view); + + let mut sym = Symbols::default(); + let mut cursor = 0usize; + let draw_hand = |sym: &mut Symbols, cursor: &mut usize, rng: &mut Rng| { + for _ in 0..hand { + if *cursor >= bag.len() { + break; + } + let die = bag[*cursor]; + *cursor += 1; + sym.add_face(die.faces()[rng.d6()]); + } + }; + draw_hand(&mut sym, &mut cursor, &mut rng); + let mut presses = 0; + while presses < intent.press_target + && cursor < bag.len() + && sym.dread < self.cfg.dread_threshold + { + draw_hand(&mut sym, &mut cursor, &mut rng); + presses += 1; + } + let raw_dread = sym.dread as f64 + presses as f64 * self.cfg.press_dread_push; + + // Lineage symbol mods (clamped to >= 0). + sym.steel = (sym.steel + lmods.steel_mod).max(0); + sym.cunning = (sym.cunning + lmods.cunning_mod + cmods.cunning_bonus).max(0); + sym.faith = (sym.faith + lmods.faith_mod).max(0); + + // Trinity is judged on what was produced this turn, before spending. + let trinity = sym.steel > 0 && sym.cunning > 0 && sym.faith > 0; + + // --- DREAD --- + self.resolve_dread(pi, &mut sym, &lmods, &cmods); + + // --- MOVE --- (may buy extra moves with Cunning then Mettle if diving) + let depth_before = self.players[pi].party.as_ref().unwrap().floor; + let base_moves = (1 + lmods.move_mod + cmods.move_mod).max(1); + let mut moves = base_moves; + let target = intent.target_depth.clamp(1, self.frontier); + if intent.dive_moves { + let dist = (depth_before as i32 - target as i32).abs(); + let want = (dist - moves).max(0); + let from_cunning = want.min(sym.cunning); + sym.cunning -= from_cunning; + let from_mettle = (want - from_cunning).min(sym.mettle); + sym.mettle -= from_mettle; + moves += from_cunning + from_mettle; + } + { + let p = self.players[pi].party.as_mut().unwrap(); + let mut f = p.floor as i32; + let t = target as i32; + let step = if t > f { 1 } else { -1 }; + let mut left = moves; + while f != t && left > 0 { + f += step; + left -= 1; + } + p.floor = (f.clamp(1, self.frontier as i32)) as u32; + p.deepest = p.deepest.max(p.floor); + } + + // --- CLAIM --- (assault accumulation + completion) + self.resolve_claim(pi, &mut sym, trinity, &lmods, &cmods, claims); + + // --- LOOT --- (Greed + held-room production) + self.resolve_loot(pi, &sym, &cmods); + + // --- RECRUIT --- + if intent.want_recruit { + let cost = (self.cfg.recruit_cost - cmods.recruit_discount).max(1); + if sym.mettle >= cost { + let p = self.players[pi].party.as_mut().unwrap(); + p.bag.push(cmods.recruit_kind); + // (Mettle is spent here; `sym` is discarded at turn end, so no decrement.) + } + } + + // --- EXIT --- + let view2 = self.view(pi); + match self.players[pi].strategy.exit(&view2) { + Exit::Stay => {} + Exit::SetLastStand => { + let elig = view2.holds_deep_room || cmods.free_last_stand; + if elig { + self.players[pi].party.as_mut().unwrap().last_stand = true; + } else { + // Ineligible: treat as a defensive Cash-Out instead of a no-op. + self.cash_out(pi); + } + } + Exit::CashOut => self.cash_out(pi), + } + + raw_dread + } + + fn resolve_dread(&mut self, pi: usize, sym: &mut Symbols, lmods: &LineageMods, cmods: &CallingMods) { + let mut dread = sym.dread; + if lmods.can_cancel_dread { + let cancel = dread.min(sym.faith + cmods.extra_dread_cancel); + // Faith is consumed first, then the Calling's free cancels. + let from_faith = cancel.min(sym.faith); + sym.faith -= from_faith; + dread -= cancel; + } + if lmods.dread_to_mettle { + sym.mettle += dread; // peril becomes fuel + dread = 0; + } + sym.dread = dread.max(0); + + if sym.dread >= self.cfg.dread_threshold && !lmods.setback_immune { + self.players[pi].setbacks += 1; + let mut to_lose = (1 + lmods.setback_extra_loss) - if cmods.heal_setback { 1 } else { 0 }; + to_lose = to_lose.max(0); + let p = self.players[pi].party.as_mut().unwrap(); + let mut shed = 0; + while shed < to_lose && p.bag.len() > 2 { + // Drop the lowest-quality die. + if let Some((idx, _)) = p + .bag + .iter() + .enumerate() + .min_by_key(|(_, k)| k.quality()) + { + p.bag.remove(idx); + shed += 1; + } else { + break; + } + } + if shed < to_lose { + p.unbanked = (p.unbanked - self.cfg.setback_loot).max(0.0); + } + } + } + + fn resolve_claim( + &mut self, + pi: usize, + sym: &mut Symbols, + trinity: bool, + lmods: &LineageMods, + cmods: &CallingMods, + claims: &mut Vec, + ) { + let depth = self.players[pi].party.as_ref().unwrap().floor; + let fi = Self::floor_idx(depth); + // Pick a target room: prefer the highest-glory room this party can complete + // this turn (counting carried assault); else invest in the richest room. + let (mut best_complete, mut best_complete_g) = (None, -1.0); + let (mut best_any, mut best_any_g) = (None, -1.0); + { + let floor = &self.floors[fi]; + for (ri, room) in floor.rooms.iter().enumerate() { + if room.collapsed { + continue; + } + let st = room.theme.stats(); + let prior = room.assault.iter().find(|a| a.owner == pi); + let have_steel = prior.map(|a| a.steel).unwrap_or(0) + sym.steel; + let have_cunning = prior.map(|a| a.cunning).unwrap_or(0) + sym.cunning; + let eff_threat = (st.threat - lmods.threat_reduction).max(0); + let steel_ok = have_steel >= eff_threat; + let cunning_ok = have_cunning >= st.lock; + let trinity_ok = !st.needs_trinity || cmods.solo_vault || trinity; + let g = room.glory(&self.cfg); + if g > best_any_g { + best_any_g = g; + best_any = Some(ri); + } + if steel_ok && cunning_ok && trinity_ok && g > best_complete_g { + best_complete_g = g; + best_complete = Some(ri); + } + } + } + + let target = best_complete.or(best_any); + let ri = match target { + Some(ri) => ri, + None => return, + }; + + let complete = best_complete == Some(ri); + let st = self.floors[fi].rooms[ri].theme.stats(); + + // Fold this turn's steel/cunning into the assault. + { + let room = &mut self.floors[fi].rooms[ri]; + let a = room.assault_mut(pi); + a.steel += sym.steel; + a.cunning += sym.cunning; + } + sym.steel = 0; + sym.cunning = 0; + + if !complete { + return; // progress carried; no marker yet + } + + // Complete the claim: drop/strengthen a marker, clear our assault, take loot. + let (overflow, room_g, combat_room) = { + let room = &mut self.floors[fi].rooms[ri]; + let a = room.assault_mut(pi); + let eff_threat = (st.threat - lmods.threat_reduction).max(0); + let steel_over = (a.steel - eff_threat).max(0); + let cunning_over = (a.cunning - st.lock).max(0); + a.steel = 0; + a.cunning = 0; + (steel_over + cunning_over, room.glory(&self.cfg), st.combat_room) + }; + + // Cap how far a single hard-pressed claim can snowball one marker (iter5: + // overflow cap 4 -> 2), so Warden's x2 stays strong without being a runaway. + let strength = (1 + overflow.min(2) + cmods.claim_strength_bonus) * cmods.marker_mult; + let already_held = { + let room = &mut self.floors[fi].rooms[ri]; + if let Some(m) = room.markers.iter_mut().find(|m| m.owner == pi && !m.fallen) { + m.strength += strength; + true + } else { + room.markers.push(Marker { owner: pi, strength, fallen: false }); + false + } + }; + + let drafted = self.players[pi].party.as_ref().unwrap().drafted_round; + if !already_held { + let p = self.players[pi].party.as_mut().unwrap(); + p.held.push((depth, ri)); + claims.push(ClaimEvent { depth, owner: pi, round: self.round, drafted_round: drafted }); + } + + // Pillage loot on claim (Reaver), scavenge bonus on combat rooms (Gnoll). + let mut bonus = cmods.claim_loot_bonus; + if combat_room { + bonus += lmods.combat_loot_bonus; + } + if bonus > 0.0 { + self.players[pi].party.as_mut().unwrap().unbanked += room_g * bonus * 0.25; + } + } + + fn resolve_loot(&mut self, pi: usize, sym: &Symbols, cmods: &CallingMods) { + let depth = self.players[pi].party.as_ref().unwrap().floor; + let mut loot = sym.greed as f64 * self.cfg.loot_per_greed * self.cfg.loot_depth_mult(depth); + // Held-room production (the Catan engine), richer for Hierophant. + let held: Vec<(u32, usize)> = self.players[pi].party.as_ref().unwrap().held.clone(); + for (d, ri) in held { + let fi = Self::floor_idx(d); + if let Some(room) = self.floors.get(fi).and_then(|f| f.rooms.get(ri)) { + if !room.collapsed { + loot += room.base_value * 0.15 * cmods.hold_loot_mult; + } + } + } + self.players[pi].party.as_mut().unwrap().unbanked += loot; + } + + /// Cash Out: bank loot + depth dividend, convert held markers to Fallen, retire. + fn cash_out(&mut self, pi: usize) { + let party = self.players[pi].party.take().unwrap(); + let raw = party.unbanked + self.cfg.cashout_depth_dividend * party.deepest as f64; + let banked = raw * self.comeback_mult(pi); + self.players[pi].glory += banked; + self.players[pi].cashouts += 1; + self.players[pi].cashout_depth_sum += party.deepest; + // Held active markers become permanent Fallen markers (strength 1). v2 iter5 + // reverted the half-strength experiment: it let Warden's x2 markers persist as + // full-strength dead men and spiked it to a 56% blowout. Cashing out is now + // rewarded through the depth dividend (above), not through immortal territory. + for (d, ri) in party.held { + let fi = Self::floor_idx(d); + if let Some(room) = self.floors.get_mut(fi).and_then(|f| f.rooms.get_mut(ri)) { + if let Some(m) = room.markers.iter_mut().find(|m| m.owner == pi && !m.fallen) { + m.fallen = true; + m.strength = 1; + } + } + } + } + + /// A party is Taken by the Maw on floor `f` (its floor collapsed). + fn take_party(&mut self, pi: usize, _f: u32) { + let party = match self.players[pi].party.take() { + Some(p) => p, + None => return, + }; + if party.last_stand { + let cmods = &party.cmods; + let held_value: f64 = party + .held + .iter() + .filter_map(|&(d, ri)| { + let fi = Self::floor_idx(d); + self.floors.get(fi).and_then(|fl| fl.rooms.get(ri)) + }) + .filter(|r| !r.collapsed) + .map(|r| r.glory(&self.cfg)) + .sum(); + let raw = self.cfg.worthy_factor * party.deepest as f64 * cmods.worthy_mult + + self.cfg.worthy_held_bonus * held_value; + let cm = self.comeback_mult(pi); + self.players[pi].glory += raw * cm; + self.players[pi].worthy_ends += 1; + // Denial: the Maw consumes the held rooms clean — salt them so no rival + // inherits the value at their later collapse. + for (d, ri) in &party.held { + let fi = Self::floor_idx(*d); + if let Some(room) = self.floors.get_mut(fi).and_then(|fl| fl.rooms.get_mut(*ri)) { + if !room.collapsed { + room.markers.clear(); + room.base_value = 0.0; + room.risen = 0.0; + } + } + } + } else { + // The one true feel-bad: lose unbanked loot and all active territory. + self.players[pi].takes_no_laststand += 1; + for (d, ri) in &party.held { + let fi = Self::floor_idx(*d); + if let Some(room) = self.floors.get_mut(fi).and_then(|fl| fl.rooms.get_mut(*ri)) { + room.markers.retain(|m| !(m.owner == pi && !m.fallen)); + } + } + } + } + + fn advance_maw(&mut self, round_dread: f64) { + let speed = self.cfg.maw_base_rate + + self.cfg.maw_accel * self.round as f64 + + self.cfg.maw_dread_push * round_dread; + self.maw_accum += speed; + let mut collapses = 0; + while self.maw_accum >= 1.0 + && self.frontier >= 1 + && collapses < self.cfg.max_collapses_per_round + { + self.maw_accum -= 1.0; + self.collapse(self.frontier); + self.frontier -= 1; + collapses += 1; + } + } + + /// Collapse floor `f`: score room majorities, rise unclaimed value, Take occupants. + fn collapse(&mut self, f: u32) { + let fi = Self::floor_idx(f); + // 1) Score each room; collect risen value from uncontrolled rooms. + let mut risen_total = 0.0; + let awards: Vec<(usize, f64)>; + { + let cfg = self.cfg.clone(); + let floor = &mut self.floors[fi]; + floor.collapsed = true; + let mut acc: Vec<(usize, f64)> = Vec::new(); + for room in floor.rooms.iter_mut() { + let g = room.glory(&cfg); + if !room.controlled() { + risen_total += g * cfg.value_rise_frac; + continue; + } + // Majority by summed marker strength; ties split (floor by count). + let mut tally: Vec<(usize, i32)> = Vec::new(); + for m in &room.markers { + if m.strength <= 0 { + continue; + } + if let Some(e) = tally.iter_mut().find(|(o, _)| *o == m.owner) { + e.1 += m.strength; + } else { + tally.push((m.owner, m.strength)); + } + } + let top = tally.iter().map(|(_, s)| *s).max().unwrap_or(0); + let winners: Vec = + tally.iter().filter(|(_, s)| *s == top).map(|(o, _)| *o).collect(); + if winners.is_empty() { + continue; + } + let share = g / winners.len() as f64; + for w in winners { + acc.push((w, share)); + } + } + awards = acc; + } + for (owner, g) in awards { + self.players[owner].glory += g; + } + + // 2) Risen plunder flows to the floor above (f-1), split across its rooms. + if risen_total > 0.0 && f >= 2 { + let up = Self::floor_idx(f - 1); + let live: Vec = self.floors[up] + .rooms + .iter() + .enumerate() + .filter(|(_, r)| !r.collapsed) + .map(|(i, _)| i) + .collect(); + if !live.is_empty() { + let per = risen_total / live.len() as f64; + for i in live { + self.floors[up].rooms[i].risen += per; + } + } + } + + // 3) Take any active party standing on this floor. + let occupants: Vec = self + .players + .iter() + .filter(|p| p.party.as_ref().map(|pt| pt.floor == f).unwrap_or(false)) + .map(|p| p.id) + .collect(); + for pi in occupants { + self.take_party(pi, f); + } + } + + fn comeback_mult(&self, pi: usize) -> f64 { + if self.cfg.comeback_bp == 0 { + return 1.0; + } + let leader = self + .players + .iter() + .map(|p| p.glory) + .fold(0.0_f64, f64::max) + .max(1.0); + let deficit = (leader - self.players[pi].glory).max(0.0); + 1.0 + (self.cfg.comeback_bp as f64 / 10_000.0) * (deficit / leader) + } + + fn draft_combo(&self, pi: usize) -> (Lineage, Calling) { + if let Some(c) = self.players[pi].fixed_combo { + return c; + } + let mut rng = self.rng.fork(&format!("draft:p{pi}:r{}", self.round)); + let l = *rng.choose(&ALL_LINEAGES).unwrap(); + let c = *rng.choose(&ALL_CALLINGS).unwrap(); + (l, c) + } + + fn view(&self, pi: usize) -> View { + let leader = self.players.iter().map(|p| p.glory).fold(0.0_f64, f64::max); + let my = self.players[pi].glory; + let rank = 1 + self.players.iter().filter(|p| p.glory > my).count() as u32; + let cmods = self.players[pi] + .party + .as_ref() + .map(|p| p.cmods.clone()); + let (has_party, floor, deepest, unbanked, bag_size, last_stand, held_count, held_value, holds_deep) = + if let Some(p) = self.players[pi].party.as_ref() { + let hv: f64 = p + .held + .iter() + .filter_map(|&(d, ri)| { + let fi = Self::floor_idx(d); + self.floors.get(fi).and_then(|fl| fl.rooms.get(ri)) + }) + .filter(|r| !r.collapsed) + .map(|r| r.glory(&self.cfg)) + .sum(); + let deep = p.held.iter().any(|&(d, ri)| { + let fi = Self::floor_idx(d); + self.floors + .get(fi) + .and_then(|fl| fl.rooms.get(ri)) + .map(|r| matches!(r.theme, Theme::Vault | Theme::Throne)) + .unwrap_or(false) + }); + (true, p.floor, p.deepest, p.unbanked, p.bag.len() as u32, p.last_stand, p.held.len() as u32, hv, deep) + } else { + (false, 0, 0, 0.0, 0, false, 0, 0.0, false) + }; + let collapses_until = if has_party && floor <= self.frontier { + self.frontier - floor + 1 + } else { + self.cfg.floors + 1 + }; + let worthy_mult = cmods.as_ref().map(|c| c.worthy_mult).unwrap_or(1.0); + let worthy_estimate = self.cfg.worthy_factor * deepest as f64 * worthy_mult; + View { + round: self.round, + frontier: self.frontier, + floors_total: self.cfg.floors, + my_glory: my, + leader_glory: leader, + rank, + num_players: self.players.len() as u32, + has_party, + floor, + deepest, + unbanked, + bag_size, + last_stand, + held_count, + held_value, + holds_deep_room: holds_deep, + free_last_stand: cmods.map(|c| c.free_last_stand).unwrap_or(false), + worthy_estimate, + collapses_until_my_floor: collapses_until, + cashout_min_bank: self.cfg.cashout_min_bank, + cashout_depth_dividend: self.cfg.cashout_depth_dividend, + } + } + + fn finish(&self, claims: Vec) -> GameResult { + let players: Vec = self + .players + .iter() + .map(|p| PlayerResult { + id: p.id, + strategy: p.strategy, + glory: p.glory, + setbacks: p.setbacks, + cashouts: p.cashouts, + worthy_ends: p.worthy_ends, + takes_no_laststand: p.takes_no_laststand, + mean_cashout_depth: if p.cashouts > 0 { + p.cashout_depth_sum as f64 / p.cashouts as f64 + } else { + 0.0 + }, + combo: p.last_combo, + }) + .collect(); + // Winner = highest glory; lowest id breaks ties deterministically. + let mut best = 0usize; + for i in 1..players.len() { + if players[i].glory > players[best].glory { + best = i; + } + } + let mut sorted: Vec = players.iter().map(|p| p.glory).collect(); + sorted.sort_by(|a, b| b.partial_cmp(a).unwrap()); + let margin = if sorted.len() >= 2 { sorted[0] - sorted[1] } else { 0.0 }; + GameResult { rounds: self.round, players, winner: best, margin, claims } + } +} + +/// Pick a room theme for floor `depth` of `floors`, biased richer with depth. +fn roll_theme(depth: u32, floors: u32, room_idx: u32, rooms_per_floor: u32, rng: &mut Rng) -> Theme { + // The deepest floor guarantees a Throne in its first room. + if depth == floors && room_idx == 0 { + return Theme::Throne; + } + // The top floor's first room is the safe Threshold mouth. + if depth == 1 && room_idx == 0 { + return Theme::Threshold; + } + let n = RICHNESS_ORDER.len() as i32; + // Target richness index scales with depth; jitter ±2 for variety. + let frac = if floors > 1 { + (depth as f64 - 1.0) / (floors as f64 - 1.0) + } else { + 0.5 + }; + let center = (frac * (n as f64 - 1.0)).round() as i32; + let jitter = rng.range(-2, 3); // [-2, 2] + let mut idx = (center + jitter).clamp(1, n - 1); // never re-roll Threshold here + // Avoid stacking too many Thrones on the deepest floor. + if RICHNESS_ORDER[idx as usize] as i32 == Theme::Throne as i32 && room_idx != 0 { + idx -= 1; + } + let _ = rooms_per_floor; + RICHNESS_ORDER[idx as usize] +} diff --git a/deepfall/deepfall-core/src/lib.rs b/deepfall/deepfall-core/src/lib.rs new file mode 100644 index 0000000..cead19e --- /dev/null +++ b/deepfall/deepfall-core/src/lib.rs @@ -0,0 +1,126 @@ +//! DEEPFALL — a pure, deterministic, headless engine for the dice-bag delve game. +//! +//! See `../DESIGN.md` for the full design and the simulation model. The crate is +//! consumed by `deepfall-sim` (the balance-by-bot harness). Determinism is +//! load-bearing: one `(Config, seed, roster)` reproduces one game bit-for-bit, so the +//! sweeps can attribute an outcome delta to the single knob that changed. + +pub mod config; +pub mod content; +pub mod controller; +pub mod game; +pub mod rng; + +pub use config::Config; +pub use content::{Calling, DieKind, Face, Lineage, Theme, ALL_CALLINGS, ALL_LINEAGES}; +pub use controller::{Strategy, ALL_STRATEGIES}; +pub use game::{Game, GameResult, PlayerResult}; + +/// Convenience: play one game from a roster of `(strategy, optional fixed combo)`. +pub fn simulate( + cfg: Config, + seed: u64, + roster: &[(Strategy, Option<(Lineage, Calling)>)], +) -> GameResult { + Game::new(cfg, seed, roster).run() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn roster4() -> Vec<(Strategy, Option<(Lineage, Calling)>)> { + vec![ + (Strategy::CashOut, None), + (Strategy::PressOn, None), + (Strategy::WorthyEnd, None), + (Strategy::Balanced, None), + ] + } + + #[test] + fn determinism_same_seed_same_result() { + let a = simulate(Config::default(), 12345, &roster4()); + let b = simulate(Config::default(), 12345, &roster4()); + assert_eq!(a.rounds, b.rounds); + assert_eq!(a.winner, b.winner); + for (x, y) in a.players.iter().zip(b.players.iter()) { + assert!((x.glory - y.glory).abs() < 1e-9, "glory diverged: {} vs {}", x.glory, y.glory); + } + } + + #[test] + fn different_seeds_vary() { + let mut winners = std::collections::HashSet::new(); + let mut lengths = std::collections::HashSet::new(); + for seed in 0..50 { + let r = simulate(Config::default(), seed, &roster4()); + winners.insert(r.winner); + lengths.insert(r.rounds); + } + assert!(winners.len() > 1, "RNG should produce different winners across seeds"); + assert!(lengths.len() > 1, "game length should vary across seeds"); + } + + #[test] + fn game_ends_in_sane_window() { + // The Maw must reliably end the game; never hit the safety cap. + for seed in 0..200 { + let r = simulate(Config::default(), seed, &roster4()); + assert!(r.rounds >= 3, "game ended too fast: {} rounds", r.rounds); + assert!( + r.rounds < Config::default().max_rounds, + "game hit the max_rounds safety cap at seed {seed} ({} rounds)", + r.rounds + ); + } + } + + #[test] + fn everyone_scores_something() { + // No player should be hard-locked out of all glory across many games. + let mut ever_scored = vec![false; 4]; + for seed in 0..100 { + let r = simulate(Config::default(), seed, &roster4()); + for (i, p) in r.players.iter().enumerate() { + if p.glory > 0.0 { + ever_scored[i] = true; + } + } + } + assert!(ever_scored.iter().all(|&b| b), "a seat never scored in 100 games"); + } + + #[test] + fn fixed_combo_is_honored() { + let roster = vec![ + (Strategy::Balanced, Some((Lineage::Dvergar, Calling::Reaver))), + (Strategy::Balanced, Some((Lineage::Wisp, Calling::Lorekeeper))), + ]; + let r = simulate(Config::default(), 7, &roster); + assert_eq!(r.players[0].combo, Some((Lineage::Dvergar, Calling::Reaver))); + assert_eq!(r.players[1].combo, Some((Lineage::Wisp, Calling::Lorekeeper))); + } + + #[test] + fn worthy_held_bonus_is_additive_and_off_by_default() { + // Turning worthy_held_bonus on must only ever *raise* total glory, and the two + // configs must be identical whenever no Worthy End occurs — proving the term is + // a clean additive bonus (not a hidden double-count baked into the default). + let base = Config::default(); + assert_eq!(base.worthy_held_bonus, 0.0, "default must not double-count held value"); + let mut hot = base.clone(); + hot.worthy_held_bonus = 1.0; + for seed in 0..300 { + let a = simulate(base.clone(), seed, &roster4()); + let b = simulate(hot.clone(), seed, &roster4()); + // Determinism: the only divergence allowed is extra glory for worthy-enders. + for (x, y) in a.players.iter().zip(b.players.iter()) { + assert!(y.glory >= x.glory - 1e-9, "held bonus must never reduce glory"); + if y.worthy_ends == 0 { + assert!((x.glory - y.glory).abs() < 1e-6, "no worthy end => identical glory"); + } + } + } + } +} diff --git a/deepfall/deepfall-core/src/rng.rs b/deepfall/deepfall-core/src/rng.rs new file mode 100644 index 0000000..daf8d95 --- /dev/null +++ b/deepfall/deepfall-core/src/rng.rs @@ -0,0 +1,190 @@ +//! Deterministic, reproducible RNG — **vendored** from `combat/combat-core/src/rng.rs` +//! (which in turn vendored `reikhelm-core/src/rng.rs`). Same seed → same game on +//! every machine and every run; we never touch `thread_rng`/`rand::random()`. +//! +//! The keystone is [`Rng::fork`]: it derives an *independent* child stream from the +//! parent's **basis seed** plus a label, without drawing from or mutating the parent. +//! A child is a pure function of `(basis_seed, label)` — independent of how many values +//! were pulled from the parent first. DEEPFALL keys every roll by +//! `"p{player}:r{round}:roll{n}"`, so adding a die kind or tweaking one Lineage never +//! reshuffles another player's or another round's stream. That isolation is what makes +//! "change one knob, observe the delta" hold across the balance sweeps. + +use rand_chacha::ChaCha8Rng; +// `rand_core` reached through the re-export on `rand_chacha` so the two stay +// version-locked. `Rng` carries `next_u64`; `SeedableRng` carries `seed_from_u64`. +use rand_chacha::rand_core::{Rng as _, SeedableRng}; + +/// A deterministic random number generator backed by ChaCha8. +#[derive(Clone, Debug)] +pub struct Rng { + /// The stable basis used for all forks: the seed this `Rng` was built from. + basis_seed: u64, + /// The live ChaCha8 stream that actually produces random words. + inner: ChaCha8Rng, +} + +impl Rng { + /// Create an `Rng` from a 64-bit seed, storing the seed as the fork basis. + pub fn from_seed(seed: u64) -> Self { + Self { + basis_seed: seed, + inner: ChaCha8Rng::seed_from_u64(seed), + } + } + + /// Derive an independent child `Rng` from this `Rng`'s basis seed and `label`. + /// Does not advance `self`; depends only on `(basis_seed, label)`. + pub fn fork(&self, label: &str) -> Rng { + Rng::from_seed(mix_seed(self.basis_seed, label)) + } + + /// Draw a `u64` of raw randomness from the live stream. + fn next_u64(&mut self) -> u64 { + self.inner.next_u64() + } + + /// Integer in the **half-open** range `[lo, hi)`. `range(0, 3)` yields `0,1,2`. + /// If `hi <= lo` the range is empty; returns `lo` rather than panicking. + pub fn range(&mut self, lo: i32, hi: i32) -> i32 { + if hi <= lo { + return lo; + } + let span = (hi as i64 - lo as i64) as u64; + lo + self.below(span) as i32 + } + + /// Uniform integer in `[0, span)` via rejection sampling (no modulo bias). + fn below(&mut self, span: u64) -> u64 { + let threshold = u64::MAX - (u64::MAX % span); + loop { + let x = self.next_u64(); + if x < threshold { + return x % span; + } + } + } + + /// Roll one six-sided die: a uniform value in `0..6`. + pub fn d6(&mut self) -> usize { + self.below(6) as usize + } + + /// Return `true` with probability `p_bp / 10_000` (basis points), integer-only. + pub fn chance_bp(&mut self, p_bp: i64) -> bool { + if p_bp <= 0 { + return false; + } + if p_bp >= 10_000 { + return true; + } + let span = 1u128 << 64; + let threshold = (span * p_bp as u128) / 10_000u128; + (self.next_u64() as u128) < threshold + } + + /// Reference to a uniformly chosen element of `items`, or `None` if empty. + pub fn choose<'a, T>(&mut self, items: &'a [T]) -> Option<&'a T> { + if items.is_empty() { + return None; + } + let idx = self.below(items.len() as u64) as usize; + items.get(idx) + } + + /// Fisher–Yates shuffle in place; slices of length < 2 are left unchanged. + pub fn shuffle(&mut self, items: &mut [T]) { + let len = items.len(); + if len < 2 { + return; + } + for i in (1..len).rev() { + let j = self.below((i + 1) as u64) as usize; + items.swap(i, j); + } + } +} + +/// Mix a 64-bit basis seed with a label into a fresh 64-bit seed for [`Rng::fork`]. +/// Explicit fixed integer arithmetic (splitmix64 + FNV-1a) — never `DefaultHasher`. +fn mix_seed(basis: u64, label: &str) -> u64 { + const FNV_PRIME: u64 = 0x0000_0100_0000_01B3; + let mut state = splitmix64(basis); + for &byte in label.as_bytes() { + state ^= byte as u64; + state = state.wrapping_mul(FNV_PRIME); + } + splitmix64(state) +} + +/// One round of the splitmix64 finalizer: a fixed, portable integer avalanche. +fn splitmix64(seed: u64) -> u64 { + let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn same_seed_same_stream() { + let mut a = Rng::from_seed(7); + let mut b = Rng::from_seed(7); + for _ in 0..100 { + assert_eq!(a.next_u64(), b.next_u64()); + } + } + + #[test] + fn fork_is_order_independent() { + let parent = Rng::from_seed(99); + // Draining one child must not change a sibling forked from the same parent: + // a fork depends only on (basis_seed, label), never on the live stream. + let mut drained = parent.fork("alpha"); + for _ in 0..1000 { + let _ = drained.next_u64(); + } + let mut c2a = parent.fork("beta"); + let mut c2b = parent.fork("beta"); + for _ in 0..50 { + assert_eq!(c2a.next_u64(), c2b.next_u64()); + } + } + + #[test] + fn distinct_labels_diverge() { + let parent = Rng::from_seed(1); + let mut a = parent.fork("x#0"); + let mut b = parent.fork("x#1"); + let mut differ = false; + for _ in 0..20 { + if a.next_u64() != b.next_u64() { + differ = true; + break; + } + } + assert!(differ, "distinct labels should yield distinct streams"); + } + + #[test] + fn d6_in_range() { + let mut r = Rng::from_seed(42); + for _ in 0..1000 { + let v = r.d6(); + assert!(v < 6); + } + } + + #[test] + fn range_respects_bounds() { + let mut r = Rng::from_seed(5); + for _ in 0..1000 { + let v = r.range(3, 7); + assert!((3..7).contains(&v)); + } + assert_eq!(r.range(4, 4), 4); // empty range returns lo + } +} diff --git a/deepfall/deepfall-sim/Cargo.toml b/deepfall/deepfall-sim/Cargo.toml new file mode 100644 index 0000000..a8bed61 --- /dev/null +++ b/deepfall/deepfall-sim/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "deepfall-sim" +version = "0.1.0" +edition = "2021" +rust-version = "1.96" +description = "Batch balance-by-bot harness over deepfall-core: sweeps, metrics, CSV/JSON export." + +[dependencies] +deepfall-core = { path = "../deepfall-core" } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" diff --git a/deepfall/deepfall-sim/src/main.rs b/deepfall/deepfall-sim/src/main.rs new file mode 100644 index 0000000..7a83296 --- /dev/null +++ b/deepfall/deepfall-sim/src/main.rs @@ -0,0 +1,383 @@ +//! DEEPFALL balance-by-bot harness. Runs sweeps over `deepfall-core`, writes CSV/JSON +//! to `--out`, and prints the headline findings. Mirrors `combat-sim`'s shape. +//! +//! Sweeps: +//! 1. strategy — all 24 seat-permutations of the 4 bots × seeds → exit-dominance. +//! 2. combo — each of the 36 Lineage×Calling combos as a focal "main" vs a +//! random field → asymmetry fairness. +//! 3. pacing/feel — game length, victory margin, feel-bad rate (from sweep 1). +//! 4. depth — which floors' rooms get claimed, and by parties drafted when. + +use std::collections::BTreeMap; +use std::fmt::Write as _; +use std::fs; +use std::path::Path; + +use deepfall_core::config::Config; +use deepfall_core::content::{Calling, Lineage, ALL_CALLINGS, ALL_LINEAGES}; +use deepfall_core::controller::{Strategy, ALL_STRATEGIES}; +use deepfall_core::game::Game; + +fn main() { + let args: Vec = std::env::args().collect(); + let out = arg_value(&args, "--out").unwrap_or_else(|| "out".to_string()); + let seeds: u64 = arg_value(&args, "--seeds").and_then(|s| s.parse().ok()).unwrap_or(400); + let cfg_path = arg_value(&args, "--config"); + + let cfg = match &cfg_path { + Some(p) => { + let txt = fs::read_to_string(p).expect("read config"); + serde_json::from_str(&txt).expect("parse config") + } + None => Config::default(), + }; + + fs::create_dir_all(&out).expect("create out dir"); + println!("DEEPFALL sweeps seeds={seeds} out={out} config={}", cfg_path.as_deref().unwrap_or("")); + println!("{}", "=".repeat(72)); + + let strat = strategy_sweep(&cfg, seeds, &out); + let combo = combo_sweep(&cfg, seeds, &out); + write_sample_game(&cfg, &out); + + // Headline summary JSON (consumed by analysis/analyze.py). + let summary = serde_json::json!({ + "seeds": seeds, + "config": cfg_value(&cfg), + "strategy_winrates": strat.winrates, + "strategy_dominant": strat.dominant, + "mean_rounds": strat.mean_rounds, + "rounds_min": strat.rounds_min, + "rounds_max": strat.rounds_max, + "mean_margin_frac": strat.mean_margin_frac, + "feelbad_per_game": strat.feelbad_per_game, + "exit_mix": strat.exit_mix, + "combo_best": combo.best, + "combo_worst": combo.worst, + "combo_spread": combo.spread, + "combo_flags": combo.flags, + "depth_claims": strat.depth_claims, + "deep_claim_drafted_round_share": strat.deep_drafted_share, + }); + fs::write(Path::new(&out).join("summary.json"), serde_json::to_string_pretty(&summary).unwrap()) + .unwrap(); + + print_headline(&strat, &combo); +} + +// --------------------------------------------------------------------------------- +// Sweep 1: strategy (exit-dominance) + pacing/feel/depth metrics +// --------------------------------------------------------------------------------- + +struct StratOut { + winrates: BTreeMap, + dominant: bool, + mean_rounds: f64, + rounds_min: u32, + rounds_max: u32, + mean_margin_frac: f64, + feelbad_per_game: f64, + exit_mix: BTreeMap, + depth_claims: BTreeMap, + deep_drafted_share: f64, +} + +fn strategy_sweep(cfg: &Config, seeds: u64, out: &str) -> StratOut { + let perms = permutations(&ALL_STRATEGIES); + let mut wins: BTreeMap = BTreeMap::new(); + let mut games_per_strat: BTreeMap = BTreeMap::new(); + let mut total_games = 0u64; + let mut sum_rounds = 0u64; + let mut rounds_min = u32::MAX; + let mut rounds_max = 0u32; + let mut sum_margin_frac = 0.0; + let mut sum_feelbad = 0u64; + let mut exits: BTreeMap<&str, u64> = BTreeMap::new(); + let mut depth_claims: BTreeMap = BTreeMap::new(); + let mut deep_claims_total = 0u64; + let mut deep_claims_round1 = 0u64; + + let mut games_csv = String::from( + "seed,perm,rounds,winner_strategy,winner_glory,margin,margin_frac,feelbad,worthy_ends,cashouts\n", + ); + + for seed in 0..seeds { + for (pi_perm, perm) in perms.iter().enumerate() { + let roster: Vec<(Strategy, Option<(Lineage, Calling)>)> = + perm.iter().map(|s| (*s, None)).collect(); + let r = Game::new(cfg.clone(), seed.wrapping_mul(1000) + pi_perm as u64, &roster).run(); + total_games += 1; + sum_rounds += r.rounds as u64; + rounds_min = rounds_min.min(r.rounds); + rounds_max = rounds_max.max(r.rounds); + + let win_strat = r.players[r.winner].strategy; + *wins.entry(win_strat).or_default() += 1; + for p in &r.players { + *games_per_strat.entry(p.strategy).or_default() += 1; + } + + let winner_glory = r.players[r.winner].glory; + let margin_frac = if winner_glory > 0.0 { r.margin / winner_glory } else { 0.0 }; + sum_margin_frac += margin_frac; + + let feelbad: u32 = r.players.iter().map(|p| p.takes_no_laststand).sum(); + let worthy: u32 = r.players.iter().map(|p| p.worthy_ends).sum(); + let cashouts: u32 = r.players.iter().map(|p| p.cashouts).sum(); + sum_feelbad += feelbad as u64; + *exits.entry("worthy_end").or_default() += worthy as u64; + *exits.entry("cash_out").or_default() += cashouts as u64; + *exits.entry("taken_feelbad").or_default() += feelbad as u64; + + for c in &r.claims { + *depth_claims.entry(c.depth).or_default() += 1; + if c.depth >= cfg.floors.saturating_sub(1) { + deep_claims_total += 1; + if c.drafted_round <= 1 { + deep_claims_round1 += 1; + } + } + } + + // Sample only a slice of permutations into the per-game CSV to keep it lean. + if pi_perm == 0 { + let _ = writeln!( + games_csv, + "{seed},{pi_perm},{},{},{:.2},{:.2},{:.3},{feelbad},{worthy},{cashouts}", + r.rounds, + win_strat.name(), + winner_glory, + r.margin, + margin_frac, + ); + } + } + } + + fs::write(Path::new(out).join("games.csv"), games_csv).unwrap(); + + // Win-rate table. + let mut winrates_csv = String::from("strategy,games,wins,winrate\n"); + let mut winrates = BTreeMap::new(); + for s in ALL_STRATEGIES { + let g = *games_per_strat.get(&s).unwrap_or(&0); + let w = *wins.get(&s).unwrap_or(&0); + let wr = if g > 0 { w as f64 / g as f64 } else { 0.0 }; + let _ = writeln!(winrates_csv, "{},{g},{w},{:.4}", s.name(), wr); + winrates.insert(s.name().to_string(), wr); + } + fs::write(Path::new(out).join("strategy_winrates.csv"), winrates_csv).unwrap(); + + // Depth-claims table. + let mut dc_csv = String::from("depth,claims\n"); + let mut depth_claims_named = BTreeMap::new(); + for (d, n) in &depth_claims { + let _ = writeln!(dc_csv, "{d},{n}"); + depth_claims_named.insert(format!("floor{d}"), *n); + } + fs::write(Path::new(out).join("depth_claims.csv"), dc_csv).unwrap(); + + let max_wr = winrates.values().cloned().fold(0.0_f64, f64::max); + let min_wr = winrates.values().cloned().fold(1.0_f64, f64::min); + // "Dominant" if any strategy clears 40% (1.6x the 25% fair share) or one is starved. + let dominant = max_wr > 0.40 || min_wr < 0.12; + + let exit_mix = exits.iter().map(|(k, v)| (k.to_string(), *v)).collect(); + let deep_drafted_share = if deep_claims_total > 0 { + deep_claims_round1 as f64 / deep_claims_total as f64 + } else { + 0.0 + }; + + StratOut { + winrates, + dominant, + mean_rounds: sum_rounds as f64 / total_games as f64, + rounds_min, + rounds_max, + mean_margin_frac: sum_margin_frac / total_games as f64, + feelbad_per_game: sum_feelbad as f64 / total_games as f64, + exit_mix, + depth_claims: depth_claims_named, + deep_drafted_share, + } +} + +// --------------------------------------------------------------------------------- +// Sweep 2: combo asymmetry +// --------------------------------------------------------------------------------- + +struct ComboOut { + best: (String, f64), + worst: (String, f64), + spread: f64, + flags: Vec, +} + +fn combo_sweep(cfg: &Config, seeds: u64, out: &str) -> ComboOut { + // Each combo "mains" (fixed) one focal seat vs a random field; all Balanced bots. + // Focal seat rotates by seed for turn-order fairness. 4 players. + let games_per_combo = seeds.max(200); + let mut rows: Vec<(Lineage, Calling, f64, u64)> = Vec::new(); + + for l in ALL_LINEAGES { + for c in ALL_CALLINGS { + let mut wins = 0u64; + for seed in 0..games_per_combo { + let focal_seat = (seed % 4) as usize; + // The focal "mains" its combo as a generalist (Balanced); the field is + // a realistic mix of the four strategies with random combos, rotated for + // turn-order fairness. (All-Balanced fields under-credit worthy-capable + // combos, because Balanced under-uses the Worthy End.) + let roster: Vec<(Strategy, Option<(Lineage, Calling)>)> = (0..4) + .map(|seat| { + if seat == focal_seat { + (Strategy::Balanced, Some((l, c))) + } else { + (ALL_STRATEGIES[(seat + seed as usize) % 4], None) + } + }) + .collect(); + let seed_full = seed + .wrapping_mul(7919) + .wrapping_add((l as u64) << 8) + .wrapping_add(c as u64); + let r = Game::new(cfg.clone(), seed_full, &roster).run(); + if r.winner == focal_seat { + wins += 1; + } + } + let wr = wins as f64 / games_per_combo as f64; + rows.push((l, c, wr, games_per_combo)); + } + } + + let mean = rows.iter().map(|r| r.2).sum::() / rows.len() as f64; + + let mut csv = String::from("lineage,calling,games,wins_frac,vs_mean\n"); + for (l, c, wr, g) in &rows { + let _ = writeln!(csv, "{},{},{g},{:.4},{:.2}", l.name(), c.name(), wr, wr / mean); + } + fs::write(Path::new(out).join("combo_winrates.csv"), csv).unwrap(); + + let best = rows.iter().cloned().fold(rows[0].clone(), |a, b| if b.2 > a.2 { b } else { a }); + let worst = rows.iter().cloned().fold(rows[0].clone(), |a, b| if b.2 < a.2 { b } else { a }); + + // Fair share in a 4p game is 0.25. Flag combos that beat 1.5x mean or fall under 0.5x. + let mut flags = Vec::new(); + for (l, c, wr, _) in &rows { + if *wr > mean * 1.5 { + flags.push(format!("STRONG {} {} ({:.1}% = {:.2}x mean)", l.name(), c.name(), wr * 100.0, wr / mean)); + } else if *wr < mean * 0.5 { + flags.push(format!("WEAK {} {} ({:.1}% = {:.2}x mean)", l.name(), c.name(), wr * 100.0, wr / mean)); + } + } + + ComboOut { + best: (format!("{} {}", best.0.name(), best.1.name()), best.2), + worst: (format!("{} {}", worst.0.name(), worst.1.name()), worst.2), + spread: best.2 - worst.2, + flags, + } +} + +// --------------------------------------------------------------------------------- +// One full game serialized for inspection +// --------------------------------------------------------------------------------- + +fn write_sample_game(cfg: &Config, out: &str) { + let roster: Vec<(Strategy, Option<(Lineage, Calling)>)> = + ALL_STRATEGIES.iter().map(|s| (*s, None)).collect(); + let r = Game::new(cfg.clone(), 7, &roster).run(); + let players: Vec<_> = r + .players + .iter() + .map(|p| { + serde_json::json!({ + "id": p.id, + "strategy": p.strategy.name(), + "combo": p.combo.map(|(l, c)| format!("{} {}", l.name(), c.name())), + "glory": p.glory, + "setbacks": p.setbacks, + "cashouts": p.cashouts, + "worthy_ends": p.worthy_ends, + "taken_feelbad": p.takes_no_laststand, + "mean_cashout_depth": p.mean_cashout_depth, + }) + }) + .collect(); + let j = serde_json::json!({ + "seed": 7, + "rounds": r.rounds, + "winner": r.winner, + "margin": r.margin, + "players": players, + "total_claims": r.claims.len(), + }); + fs::write(Path::new(out).join("sample_game.json"), serde_json::to_string_pretty(&j).unwrap()).unwrap(); +} + +// --------------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------------- + +fn print_headline(s: &StratOut, c: &ComboOut) { + println!("\nSTRATEGY WIN-RATES (fair share = 25%)"); + for (name, wr) in &s.winrates { + let bar = "#".repeat((*wr * 80.0) as usize); + println!(" {:<10} {:>5.1}% {}", name, wr * 100.0, bar); + } + println!(" dominant-strategy flag: {}", if s.dominant { "⚠ YES" } else { "no — balanced band" }); + + println!("\nPACING & FEEL"); + println!(" game length: mean {:.1} rounds (min {}, max {})", s.mean_rounds, s.rounds_min, s.rounds_max); + println!(" victory margin: mean {:.1}% of winner's score", s.mean_margin_frac * 100.0); + println!(" feel-bad (Taken w/o Last Stand): {:.2} per game", s.feelbad_per_game); + println!(" exit population: {:?}", s.exit_mix); + + println!("\nDEEP-CONTENT REACHABILITY"); + println!(" claims by floor: {:?}", s.depth_claims); + println!(" share of deep-floor claims by round-1 parties: {:.1}% (high = deep game is game-start-only)", s.deep_drafted_share * 100.0); + + println!("\nCOMBO ASYMMETRY (36 combos; fair ≈ 25%)"); + println!(" strongest: {} at {:.1}%", c.best.0, c.best.1 * 100.0); + println!(" weakest: {} at {:.1}%", c.worst.0, c.worst.1 * 100.0); + println!(" spread (best-worst): {:.1} pts", c.spread * 100.0); + if c.flags.is_empty() { + println!(" no combos flagged outside [0.5x, 1.5x] of mean ✓"); + } else { + println!(" flags:"); + for f in &c.flags { + println!(" {f}"); + } + } + println!("\n{}", "=".repeat(72)); +} + +fn arg_value(args: &[String], key: &str) -> Option { + args.iter().position(|a| a == key).and_then(|i| args.get(i + 1)).cloned() +} + +/// All permutations of a 4-element strategy array (24). +fn permutations(items: &[Strategy; 4]) -> Vec> { + let mut out = Vec::new(); + let v: Vec = items.to_vec(); + permute(&v, 0, &mut v.clone(), &mut out); + out +} + +fn permute(orig: &[Strategy], k: usize, cur: &mut Vec, out: &mut Vec>) { + if k == orig.len() { + out.push(cur.clone()); + return; + } + for i in k..cur.len() { + cur.swap(k, i); + permute(orig, k + 1, cur, out); + cur.swap(k, i); + } +} + +fn cfg_value(cfg: &Config) -> serde_json::Value { + serde_json::to_value(cfg).unwrap_or(serde_json::Value::Null) +} diff --git a/reikhelm-core/examples/sample_dungeon.rs b/reikhelm-core/examples/sample_dungeon.rs new file mode 100644 index 0000000..8a666b1 --- /dev/null +++ b/reikhelm-core/examples/sample_dungeon.rs @@ -0,0 +1,84 @@ +//! Generates ONE dungeon for a fixed seed and prints its ASCII map plus a +//! per-region table (id, kind, theme, cell count, bounds) and the entity list. +//! +//! A throwaway viewer used to produce a concrete example board for tabletop +//! game design (DEEPFALL). Deterministic: same seed → same dungeon. +//! +//! Usage: +//! ```text +//! cargo run --example sample_dungeon -p reikhelm-core [seed] [width] [height] +//! ``` + +use std::env; + +use reikhelm_core::recipes::dungeon::{dungeon, DungeonConfig}; + +fn main() { + let mut args = env::args().skip(1); + let seed: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(42); + let width: u32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(48); + let height: u32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(32); + + let cfg = DungeonConfig { + width, + height, + ..DungeonConfig::default() + }; + + let pipeline = match dungeon(cfg) { + Ok(p) => p, + Err(e) => { + eprintln!("config rejected: {e}"); + std::process::exit(1); + } + }; + + let map = pipeline.run(seed); + + println!("=== reikhelm dungeon — seed {seed}, {width}x{height} ==="); + println!(); + println!("Legend: # wall . floor + door o pillar ~ water ! lava"); + println!(); + println!("{}", map.to_ascii()); + println!(); + + // Per-region table. Only real rooms carry a theme; corridors stay None. + println!("--- regions (id | kind | theme | cells | bounds) ---"); + let mut rooms = 0usize; + let mut corridors = 0usize; + for r in &map.regions { + if r.cells.is_empty() { + continue; + } + let kind = format!("{:?}", r.kind); + let theme = match r.theme { + Some(t) => format!("{t:?}"), + None => "-".to_string(), + }; + let b = r.bounds; + println!( + "{:>3} | {:<8} | {:<10} | {:>4} | ({},{}) {}x{}", + r.id.0, + kind, + theme, + r.cells.len(), + b.x, + b.y, + b.w, + b.h, + ); + match r.kind { + reikhelm_core::region::RegionKind::Room => rooms += 1, + reikhelm_core::region::RegionKind::Corridor => corridors += 1, + } + } + println!(); + println!("totals: {rooms} rooms, {corridors} corridors"); + println!(); + + // Entities (entrance / exit / treasure / monsters) live as an overlay. + println!("--- entities (kind @ x,y) ---"); + for e in &map.entities { + println!("{:?} @ {},{}", e.kind, e.at.x, e.at.y); + } +}