feat(deepfall): board-game design + balance-by-bot simulator

DEEPFALL: a tabletop game mashing up 9 loved games (SmashUp, Dragon
Rampage, Blood Rage, Catan, EverQuest, Daggerfall, Eye of the Beholder,
Quarriors, SmallWorld). Core fusion = SmallWorld decline + Blood Rage
glorious death -> three exits (Cash Out / Press On / Worthy End) under a
rising Maw that collapses a reikhelm dungeon floor-by-floor.

- deepfall-core: pure deterministic engine (vendored ChaCha8, 11 tests)
- deepfall-sim: balance-by-bot sweeps -> CSV/JSON
- analysis/analyze.py: stdlib ASCII heatmap (no deps)
- DESIGN.md (rules + post-critique revisions), FINDINGS.md (9-iteration
  balance journey), README.md
- reikhelm-core/examples/sample_dungeon.rs: dumps a real generated board

Method: adversarial design critics -> build -> simulate -> tune. 9
iterations compressed combo spread 77.8 -> 32.5 pts (all 36 combos
viable). Fixed deep-content lockout (value rises with the Maw), the
multi-collapse feel-bad (1 collapse/round cap; feel-bad now 0.00/game),
and dead combos. Open finding: Cash-Out and Worthy-End are substitutes
for a glory-maximizing bot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parley Hatch 2026-06-18 22:49:33 -06:00
parent a86b52b4c5
commit 277c158378
19 changed files with 3458 additions and 0 deletions

2
deepfall/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
/out

162
deepfall/Cargo.lock generated Normal file
View file

@ -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"

10
deepfall/Cargo.toml Normal file
View file

@ -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.

374
deepfall/DESIGN.md Normal file
View file

@ -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<Marker>`.
*(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<DieKind>, floor (starts at 1), unbanked_loot,
deepest_reached, last_stand: bool, held: Vec<RoomRef>`.
- **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 ~814).
- **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 × (depth1))` (`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`.

157
deepfall/FINDINGS.md Normal file
View file

@ -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 (1011) | ✅ in the 814 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 510: 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 1011 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 914 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.

85
deepfall/README.md Normal file
View file

@ -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?** (~814 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.

View file

@ -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()

View file

@ -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
}

View file

@ -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 &nbsp; `.` floor &nbsp; `+` door &nbsp; `o` pillar &nbsp; `~` water &nbsp; `!` 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 1225) 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.

View file

@ -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"

View file

@ -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)
}
}

View file

@ -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<Face>,
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<DieKind>,
}
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 57% 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
},
}
}
}

View file

@ -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
}
}
}
}
}

View file

@ -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<Marker>,
assault: Vec<Assault>,
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<Room>,
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<DieKind>,
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<Party>,
// --- 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<PlayerResult>,
pub winner: usize,
pub margin: f64,
pub claims: Vec<ClaimEvent>,
}
#[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<Floor>,
maw_accum: f64,
/// Deepest uncollapsed floor; 0 once all have collapsed.
frontier: u32,
players: Vec<Player>,
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<ClaimEvent> = 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<ClaimEvent>) -> 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<ClaimEvent>,
) {
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<usize> =
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<usize> = 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<usize> = 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<ClaimEvent>) -> GameResult {
let players: Vec<PlayerResult> = 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<f64> = 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]
}

View file

@ -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");
}
}
}
}
}

View file

@ -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)
}
/// FisherYates shuffle in place; slices of length < 2 are left unchanged.
pub fn shuffle<T>(&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
}
}

View file

@ -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"

View file

@ -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<String> = 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("<default>"));
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<String, f64>,
dominant: bool,
mean_rounds: f64,
rounds_min: u32,
rounds_max: u32,
mean_margin_frac: f64,
feelbad_per_game: f64,
exit_mix: BTreeMap<String, u64>,
depth_claims: BTreeMap<String, u64>,
deep_drafted_share: f64,
}
fn strategy_sweep(cfg: &Config, seeds: u64, out: &str) -> StratOut {
let perms = permutations(&ALL_STRATEGIES);
let mut wins: BTreeMap<Strategy, u64> = BTreeMap::new();
let mut games_per_strat: BTreeMap<Strategy, u64> = 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<u32, u64> = 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<String>,
}
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::<f64>() / 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<String> {
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<Vec<Strategy>> {
let mut out = Vec::new();
let v: Vec<Strategy> = items.to_vec();
permute(&v, 0, &mut v.clone(), &mut out);
out
}
fn permute(orig: &[Strategy], k: usize, cur: &mut Vec<Strategy>, out: &mut Vec<Vec<Strategy>>) {
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)
}

View file

@ -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);
}
}