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:
parent
a86b52b4c5
commit
277c158378
19 changed files with 3458 additions and 0 deletions
2
deepfall/.gitignore
vendored
Normal file
2
deepfall/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/target
|
||||||
|
/out
|
||||||
162
deepfall/Cargo.lock
generated
Normal file
162
deepfall/Cargo.lock
generated
Normal 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
10
deepfall/Cargo.toml
Normal 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
374
deepfall/DESIGN.md
Normal 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 ~8–14).
|
||||||
|
- **Victory margin** as a fraction of the winner's score → runaway check (lower = tighter).
|
||||||
|
- **Feel-bad rate**: Takes-without-Last-Stand per game → keep low.
|
||||||
|
- **Cash-Out timing spread**: distribution of the depth/round at which winners cashed
|
||||||
|
out → is the Decline decision genuinely live, or is there one correct moment?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part C — Revisions after adversarial review (v2, what the engine implements)
|
||||||
|
|
||||||
|
Two independent reviewers (a "fun/decisions" lens and a "balance/exploits" lens) tore
|
||||||
|
into v1 before any code was written. They converged on the same structural flaw, so v2
|
||||||
|
folds the high-leverage fixes in. **The engine implements v2.** The deltas:
|
||||||
|
|
||||||
|
1. **Value rises with the Maw (the big one).** *Problem:* `glory = base×depth` +
|
||||||
|
bottom-up collapse correlated value with time — the climax landed mid-game, the
|
||||||
|
endgame fizzled on cheap floors, and the Worthy-End catch-up valve closed in the
|
||||||
|
back half (no deep floors left to dive into) → a back-half runaway, contradicting
|
||||||
|
the whole pitch. *Fix:* on collapse, a fraction (`value_rise_frac`) of each room's
|
||||||
|
**unclaimed** value rises to the floor above as "refugee plunder." The Maw's
|
||||||
|
frontier is therefore always the swelling hot zone, all the way up — the **final
|
||||||
|
Floor-1 collapse is now the biggest scoring event**, the arc crescendos, and a
|
||||||
|
trailing player can always dive into the (now-shallow) frontier for a real comeback.
|
||||||
|
|
||||||
|
2. **Re-entry near the frontier.** *Problem:* a fresh party from the Cash-Out loop
|
||||||
|
starts at Floor 1 and can never reach the deep live floor before it collapses →
|
||||||
|
Cash-Out self-limited to shallow farming; deep game decided once, early. *Fix:*
|
||||||
|
re-entering parties insert at `max(1, frontier − reentry_gap)` — the upper floors
|
||||||
|
are eaten anyway, so new delvers drop in near the action. Keeps every party relevant.
|
||||||
|
|
||||||
|
3. **One collapse per round (carry the remainder).** *Problem:* `while accum ≥ 1.0`
|
||||||
|
could eat two floors in a round late-game, Taking "I'll flee next turn" parties with
|
||||||
|
no Last Stand — a systematic feel-bad. *Fix:* at most `max_collapses_per_round`
|
||||||
|
(default 1) collapses per round; the overflow carries in `accum`. "One floor away"
|
||||||
|
is now an honest promise.
|
||||||
|
|
||||||
|
4. **Worthy End = burst + denial, not a bigger Cash-Out.** *Problem:* Cash-Out did
|
||||||
|
almost everything Worthy-End did, safely and repeatably, so the triangle was a
|
||||||
|
binary and Worthy-End read as the loser's button; also a `held_value` double-count
|
||||||
|
risk. *Fix:* the two exits now have **non-overlapping** upside. **Cash-Out = the long
|
||||||
|
game** (loot + depth dividend + Fallen markers that keep earning). **Worthy-End =
|
||||||
|
burst + denial**: a guaranteed `worthy_factor × deepest_floor` spike *and the Maw
|
||||||
|
consumes your held rooms clean* — their value is removed from the board so no rival
|
||||||
|
inherits it. Now a *leader* takes a Worthy-End to deny a chaser a 36-point Vault.
|
||||||
|
`worthy_held_bonus` is **0 by default** (no double-count); it's a separate knob.
|
||||||
|
|
||||||
|
5. **Variable-strength claims + assault progress.** *Problem:* contest-by-marker-visit
|
||||||
|
was solitaire and un-reactable; deep claims were a ~5% one-turn lottery with no
|
||||||
|
floor. *Fix:* a claim's marker **strength = 1 + symbols invested** (Warden ×2), so
|
||||||
|
majority is a live bid; and unmet Steel/Cunning **accumulates as assault progress**
|
||||||
|
on the room across turns, so a near-miss is never a total zero.
|
||||||
|
|
||||||
|
6. **Lineages get behavioral hooks; dead cells fixed.** *Problem:* Lineages were flat
|
||||||
|
stat tweaks (no "two halves surprise you"), and some combos negated themselves
|
||||||
|
(Golem-born Prospector locked out Relics; Gnoll Reaver / Revenant Hierophant stacked
|
||||||
|
the same axis). *Fix:* Dvergar **ignores room threat** on its claim (smash-through —
|
||||||
|
now Dvergar Lorekeeper cracks guarded Vaults); Revenant **converts Dread→Mettle**
|
||||||
|
(peril as fuel, a decision, not redundant immunity); Golem-born's cost is now **−1
|
||||||
|
hand size** (orthogonal — and it *cancels* Prospector's +hand, no dead cell).
|
||||||
|
See `content.rs` for the final tables.
|
||||||
|
|
||||||
|
7. **Sub-linear depth + comeback rubber-band, both as knobs.** Room glory is
|
||||||
|
`base × (1 + depth_coeff × (depth−1))` (`depth_coeff` tunes the cliff), and
|
||||||
|
`comeback_bp` optionally scales a trailing party's exit payouts by its glory deficit.
|
||||||
|
Both default conservatively and are what the sweeps actually move.
|
||||||
|
|
||||||
|
These are the hypotheses the sim must now confirm: that the triangle is live (no exit
|
||||||
|
> ~45% of winners' choices), that deep rooms are *contestable but not free*, that the
|
||||||
|
runaway is dead (tight margins; real comeback rate), and that the feel-bad is rare.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B8. What the sim deliberately does *not* model
|
||||||
|
|
||||||
|
Intra-floor geometry & corridors; denizen reputation as a full subsystem (folded into a
|
||||||
|
threat modifier); trading/negotiation (no table talk to simulate); the exact card/draft
|
||||||
|
UI. These don't move the six questions above, and adding them would trade clarity for
|
||||||
|
false precision. The balance levers that *do* matter all live in `configs/default.json`
|
||||||
|
and in the Lineage/Calling tables in `deepfall-core/src/content.rs`.
|
||||||
157
deepfall/FINDINGS.md
Normal file
157
deepfall/FINDINGS.md
Normal 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 (10–11) | ✅ in the 8–14 target band; the Maw ends it cleanly, every time |
|
||||||
|
| **Victory margin** | 13.7% of winner's score | ✅ tight — **no runaway leader** |
|
||||||
|
| **Feel-bad rate** (Taken w/o Last Stand) | **0.00 / game** | ✅ the one true feel-bad is fully designed out |
|
||||||
|
| **Deep content reachability** | only **6.5%** of deep-floor claims by round-1 parties | ✅ the deep game is *not* decided at game start |
|
||||||
|
| **Combo spread** (best − worst of 36) | **32.5 pts** (8.1%–40.6%) | 🟡 every combo viable; still a soft Lineage/Calling tilt |
|
||||||
|
| **Strategy band** | WorthyEnd 46.7 / PressOn 25.8 / Balanced 24.8 / CashOut 2.8 | 🟡 aggression is the strongest line; pure passivity is weak |
|
||||||
|
|
||||||
|
Three of the design's central promises held up under simulation — **no runaway, no
|
||||||
|
elimination feel-bad, and a living deep game**. Two things stayed stubborn — the
|
||||||
|
draft has a soft power tilt, and the four play-styles aren't a flat band. Both are
|
||||||
|
understood (below), not mysterious.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The three biggest things the sim caught (and the fixes)
|
||||||
|
|
||||||
|
### A. Value and time were correlated → the arc fizzled and the back half ran away
|
||||||
|
Room glory was `base × depth` and the Maw eats deepest-first, so the richest rooms
|
||||||
|
scored *earliest*; the game crescendo'd in the middle and ended on its cheapest floors,
|
||||||
|
and the Worthy-End catch-up valve closed once the deep floors were gone. Both critics
|
||||||
|
flagged it before a line of code ran.
|
||||||
|
|
||||||
|
**Fix (the single best rule in the game):** on collapse, a fraction of each room's
|
||||||
|
*unclaimed* value **rises to the floor above** as "refugee plunder." The Maw's frontier
|
||||||
|
is now always the swelling hot zone, all the way to Floor 1. The data confirms it: the
|
||||||
|
claim distribution is **non-monotonic** — Floor 4 (162k claims) draws *more* than Floor 3
|
||||||
|
(96k), because value pools wherever the frontier sits, and Floor 1 draws the most of all
|
||||||
|
as everyone converges for the finale. The endgame crescendos instead of fizzling, and
|
||||||
|
margins stayed tight (13.7%).
|
||||||
|
|
||||||
|
### B. The deep game was nearly a game-start-only contest
|
||||||
|
A fresh party from the Cash-Out loop entered at Floor 1 and physically could not reach
|
||||||
|
the deep live floor before it collapsed (the balance critic walked the move-math). So
|
||||||
|
the Decline loop would have fed you parties that could only farm shallow rooms.
|
||||||
|
|
||||||
|
**Fix:** re-entering parties insert at `max(1, frontier − 1)`. Result: only **6.5%** of
|
||||||
|
deep-floor claims come from round-1 parties — i.e. **93.5% of the deep game is played by
|
||||||
|
later-drafted crews.** The Decline loop stays relevant the whole game.
|
||||||
|
|
||||||
|
### C. The multi-collapse feel-bad
|
||||||
|
`while accum ≥ 1.0` could eat two floors in one round late-game and Take a "flee next
|
||||||
|
turn" party with no Last Stand. **Fix:** cap one collapse per round (carry the
|
||||||
|
remainder). The feel-bad rate is a flat **0.00** across every config tested since.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The balance-by-bot trajectory
|
||||||
|
|
||||||
|
Each row is a tuning pass; the simulator drove every decision. Regressions are left in
|
||||||
|
on purpose — they're the method working (a change that *looked* good and the data
|
||||||
|
vetoed).
|
||||||
|
|
||||||
|
| Iter | Lever changed | Combo spread | Weakest combo | WorthyEnd / CashOut strat |
|
||||||
|
|---|---|---:|---:|---|
|
||||||
|
| 1 | initial design | 77.8 | 0.5% | 41.6 / 1.8 |
|
||||||
|
| 2 | sub-linear depth; neuter `ignore_threat`; mixed combo field | 59.8 | — | 44.8 / 0.0 |
|
||||||
|
| 3 | Champion-recruit Wardancer; Cautious plays the frontier | 46.5 | — | 44.8 / 3.1 |
|
||||||
|
| 4 | half-strength Fallen markers | **56.2** ⚠ | — | 44.0 / 3.5 |
|
||||||
|
| 5 | revert Fallen; cap marker overflow; raise dividend | 47.2 | — | 37.0 / 5.0 |
|
||||||
|
| 6 | bots compare worthy-vs-bank payouts | **55.5** ⚠ | — | 38.5 / 4.2 |
|
||||||
|
| 7 | Wardancer +1 marker strength; revert dividend | 43.4 | 4.9% | 46.1 / 2.8 |
|
||||||
|
| 8 | Dvergar −1 Cunning; Wisp gets a Warrior | 35.9 | 5.0% | 45.4 / 2.8 |
|
||||||
|
| 9 | Dvergar −1 Faith (gentler); trim Reaver | **32.5** | **8.1%** | 46.7 / 2.8 |
|
||||||
|
|
||||||
|
Net: combo spread **−58%** (77.8 → 32.5), weakest combo **×16** (0.5% → 8.1%), and the
|
||||||
|
dead cells (Sylphid ~1%, Wardancer ~0.3%) all revived into the playable band.
|
||||||
|
|
||||||
|
The two ⚠ regressions taught real lessons: half-strength Fallen markers (iter 4) handed
|
||||||
|
Warden's ×2 markers an immortal hold and spiked it to 56%; and making bots *compare*
|
||||||
|
exit payouts (iter 6) — correct in itself — exposed that Wardancer's whole value was a
|
||||||
|
rare conditional, which is what forced the iter-7 fix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Final combo heatmap (focal combo as Balanced vs. a mixed random field)
|
||||||
|
|
||||||
|
```
|
||||||
|
Reaver Lorekeeper Wardancer Hierophant Warden Prospector
|
||||||
|
Dvergar 40.6% 33.7% 18.4% 32.5% 30.9% 31.6%
|
||||||
|
Wisp 20.6% 15.9% 11.9% 14.5% 14.4% 14.2%
|
||||||
|
Revenant 17.3% 13.9% 8.9% 12.2% 12.7% 11.7%
|
||||||
|
Gnoll 31.7% 28.7% 9.6% 23.9% 24.4% 24.8%
|
||||||
|
Sylphid 23.3% 18.8% 12.4% 15.0% 15.3% 14.6%
|
||||||
|
GolemBorn24.9% 20.4% 8.1% 19.6% 19.1% 32.0%
|
||||||
|
|
||||||
|
Lineage avg : Dvergar 31.2 Gnoll 23.9 GolemBorn 20.7 Sylphid 16.6 Wisp 15.3 Revenant 12.8
|
||||||
|
Calling avg : Reaver 26.4 Lorekeeper 21.9 Prospector 21.5 Hierophant 19.6 Warden 19.5 Wardancer 11.6
|
||||||
|
```
|
||||||
|
|
||||||
|
(Mean focal win-rate sits below 25% because the focal always plays the generalist
|
||||||
|
Balanced bot while the field includes the more aggressive WorthyEnd/PressOn strategies —
|
||||||
|
so read the *vs-mean* column in `combo_winrates.csv`, not the raw 25% line.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. What's still open (and why)
|
||||||
|
|
||||||
|
### The Cash-Out vs. Worthy-End substitution (the deepest finding)
|
||||||
|
The strategy band stays lopsided (WorthyEnd 46.7%, CashOut 2.8%) for a structural
|
||||||
|
reason the sim made undeniable: **for a glory-maximizing agent, Cash-Out and Worthy-End
|
||||||
|
are substitutes** — both convert a doomed party into points. So *every* lever that helps
|
||||||
|
the safe banker (e.g. the depth dividend) directly nerfs the martyr, and vice-versa.
|
||||||
|
They sat on a trade-off frontier across iterations 5–10: I could balance the *strategies*
|
||||||
|
or the *Wardancer calling*, but not both with the same `worthy_factor`.
|
||||||
|
|
||||||
|
The intended differentiator — Worthy-End's **denial** (the Maw salts your held rooms so
|
||||||
|
no rival inherits them) — is real in the rules but **invisible to the bots**, because a
|
||||||
|
win-rate maximizer doesn't model the opponent's loss, and you mostly salt rooms you were
|
||||||
|
soloing anyway. *This is the headline design lesson:* the two exits only become a true
|
||||||
|
triangle (not a binary) when denial is made to **matter to your own score** — e.g. score
|
||||||
|
denial explicitly (gain a cut of the value you deny), or make Worthy-End pay on
|
||||||
|
*cumulative* depth dived across the whole game (rewarding a play-pattern banking can't
|
||||||
|
copy). That's the first thing I'd prototype next.
|
||||||
|
|
||||||
|
### The soft draft tilt
|
||||||
|
Dvergar (threat-reduction is just broadly useful) and Reaver (loot-on-claim is a
|
||||||
|
flywheel) sit on top; Revenant and Wardancer sit low. No combo is dead (worst is 8.1%),
|
||||||
|
so the draft is *spicy, not solved* — acceptable for a Small-World-style game where some
|
||||||
|
combos are stronger — but a shipping version would want Dvergar's hook costed harder and
|
||||||
|
Revenant's Dread-to-Mettle made to actually pay.
|
||||||
|
|
||||||
|
### Pacing variance is too tight
|
||||||
|
Game length is 10–11 rounds with almost no spread. Real push-your-luck wants more
|
||||||
|
variance in *when* the Maw breaks through. The `maw_dread_push` and `press_dread_push`
|
||||||
|
knobs are the levers; I'd widen them and accept a 9–14 spread.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Reproduce
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd deepfall
|
||||||
|
cargo test # 11 tests: determinism, pacing, no-double-count
|
||||||
|
cargo run --release -p deepfall-sim -- --out out --seeds 2000
|
||||||
|
python3 analysis/analyze.py out # the heatmap above, no dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
Every number here is a pure function of `(configs/default.json, seed)` — same input,
|
||||||
|
same output, on any machine.
|
||||||
85
deepfall/README.md
Normal file
85
deepfall/README.md
Normal 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?** (~8–14 rounds.)
|
||||||
|
4. **Is it a runaway?** (Margin of victory should be tight.)
|
||||||
|
5. **How often does a party die for *nothing*?** (The feel-bad rate — keep it low.)
|
||||||
|
6. **Is the Decline *decision* real?** (Do winners vary their cash-out timing, or is
|
||||||
|
there one provably-correct moment?)
|
||||||
|
|
||||||
|
See `DESIGN.md` §"Simulation model" for exactly how the sim abstracts the rules, and
|
||||||
|
the committed sweep notes for what the data actually said.
|
||||||
110
deepfall/analysis/analyze.py
Normal file
110
deepfall/analysis/analyze.py
Normal 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()
|
||||||
30
deepfall/configs/default.json
Normal file
30
deepfall/configs/default.json
Normal 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
|
||||||
|
}
|
||||||
97
deepfall/configs/sample_dungeon.md
Normal file
97
deepfall/configs/sample_dungeon.md
Normal 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 `.` floor `+` door `o` pillar `~` water `!` lava
|
||||||
|
|
||||||
|
```
|
||||||
|
################################################
|
||||||
|
#################.....####################...###
|
||||||
|
#################.....###################.....##
|
||||||
|
#...........#####.....###########.######.......#
|
||||||
|
#...........#####.....###########.+....+.......#
|
||||||
|
#.............................+.....####.......#
|
||||||
|
#...........#####.....###########.#######.....##
|
||||||
|
#...........#####.....###########.#######....###
|
||||||
|
####.##############.#############+#######.######
|
||||||
|
####.##############.#############.#######.######
|
||||||
|
####.#########...##.#############.#######+######
|
||||||
|
####.########.....#.#############.####.......###
|
||||||
|
####.########.....#.#############.####.......###
|
||||||
|
####.########.....#.##########....####.......###
|
||||||
|
##...########.....#.##########...+...........###
|
||||||
|
###...+...........+.#######.........##....o..###
|
||||||
|
###.#.#######.....#########.........##.......###
|
||||||
|
#####.#######.....############...#####.......###
|
||||||
|
#####.#######.....############...########+######
|
||||||
|
#####.#######.....#######################.######
|
||||||
|
#####.########...########################.######
|
||||||
|
#####.#########+#########################.######
|
||||||
|
#####.#########.#########################.######
|
||||||
|
###.....#######.#########################.######
|
||||||
|
##.......######.########......###########.######
|
||||||
|
#.........#####+#######........##########+######
|
||||||
|
#.........+.+.....####..........####...........#
|
||||||
|
#.........###...................####...........#
|
||||||
|
##.......####.....####..........####...........#
|
||||||
|
###.....#####.....#####........#####...........#
|
||||||
|
#############.....######......######...........#
|
||||||
|
################################################
|
||||||
|
```
|
||||||
|
|
||||||
|
## Regions
|
||||||
|
|
||||||
|
Themes drive DEEPFALL room flavor. Only rooms carry a theme; corridors are
|
||||||
|
plain passages. `cells` is the room's floor area (a rough "size").
|
||||||
|
|
||||||
|
| Region | Kind | Theme | Cells (size) | Top-left (x,y) | W x H |
|
||||||
|
|-------:|------|-----------|-------------:|----------------|-------|
|
||||||
|
| 0 | Room | Stone | 55 | (1,3) | 11x5 |
|
||||||
|
| 1 | Room | Threshold | 35 | (17,1) | 5x7 |
|
||||||
|
| 2 | Room | Den | 9 | (31,3) | 5x5 |
|
||||||
|
| 3 | Room | Vault | 37 | (40,1) | 7x7 |
|
||||||
|
| 4 | Room | Den | 8 | (2,13) | 4x4 |
|
||||||
|
| 5 | Room | Library | 51 | (1,23) | 9x7 |
|
||||||
|
| 6 | Room | Crypt | 51 | (13,10) | 5x11 |
|
||||||
|
| 7 | Room | Den | 30 | (27,13) | 9x6 |
|
||||||
|
| 8 | Room | Hall | 49 | (38,11) | 7x7 |
|
||||||
|
| 9 | Room | Crypt | 25 | (13,26) | 5x5 |
|
||||||
|
| 10 | Room | Den | 58 | (22,24) | 10x7 |
|
||||||
|
| 11 | Room | Throne | 55 | (36,26) | 11x5 |
|
||||||
|
|
||||||
|
Plus 14 corridor regions (ids 12–25) linking the rooms.
|
||||||
|
|
||||||
|
### Entities (overlay layer)
|
||||||
|
|
||||||
|
- **Entrance** @ (19,4) — sits in the `Threshold` room (region 1).
|
||||||
|
- **Exit** @ (41,28) — sits in the `Throne` room (region 11), the far end.
|
||||||
|
- **Treasure** x6 and **Monster** x14 scattered, biased by each room's theme
|
||||||
|
(e.g. `Vault`/`Library` loot-rich, `Den` monster-dense, `Throne` high-risk
|
||||||
|
high-reward, `Threshold` kept safe).
|
||||||
|
|
||||||
|
## Mapping to DEEPFALL
|
||||||
|
|
||||||
|
Each reikhelm **room** becomes a DEEPFALL room tile carrying its **theme**
|
||||||
|
(Vault, Library, Crypt, Den, Hall, Throne, Threshold...), which sets that tile's
|
||||||
|
loot/danger profile via the recipe's `(treasure_bias, monster_bias)`; corridors
|
||||||
|
are the connective passages between them. Depth = distance from the `Threshold`
|
||||||
|
entrance toward the `Throne` exit, so **deeper rooms are higher value** (and
|
||||||
|
higher risk) — the `Throne` boss seat at the far corner is the natural payoff
|
||||||
|
tile.
|
||||||
13
deepfall/deepfall-core/Cargo.toml
Normal file
13
deepfall/deepfall-core/Cargo.toml
Normal 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"
|
||||||
126
deepfall/deepfall-core/src/config.rs
Normal file
126
deepfall/deepfall-core/src/config.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
431
deepfall/deepfall-core/src/content.rs
Normal file
431
deepfall/deepfall-core/src/content.rs
Normal 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 5–7% Lineage); a
|
||||||
|
// lone Warrior gives it just enough muscle to claim, while the extra die and
|
||||||
|
// its natural extra Dread stay the trade-off.
|
||||||
|
Lineage::Wisp => LineageMods {
|
||||||
|
hand_mod: 1,
|
||||||
|
starting_bag: vec![Recruit, Recruit, Recruit, Scholar, Scholar, Warrior],
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
// Fearless dead: turns Dread into Mettle, but can't cancel it with Faith.
|
||||||
|
Lineage::Revenant => LineageMods {
|
||||||
|
dread_to_mettle: true,
|
||||||
|
can_cancel_dread: false,
|
||||||
|
starting_bag: vec![Recruit, Recruit, Recruit, Recruit, Warrior, Priest],
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
// Scavenger: rich loot from combat rooms, but godless (poor at quelling
|
||||||
|
// Dread). v2 iter3: the cost moved off Cunning (which gated locks and tanked
|
||||||
|
// the whole Lineage) onto Faith, so Gnoll claims fine but courts Setbacks.
|
||||||
|
Lineage::Gnoll => LineageMods {
|
||||||
|
faith_mod: -1,
|
||||||
|
combat_loot_bonus: 0.6,
|
||||||
|
starting_bag: vec![Recruit, Recruit, Recruit, Warrior, Warrior, Rogue],
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
// Fleet and deft at locks, but reckless. v2: dropped the -1 Steel (it was a
|
||||||
|
// ~1%-winrate death sentence — Steel is the primary claim gate); the cost is
|
||||||
|
// now fragility, paid only when the dice betray you.
|
||||||
|
Lineage::Sylphid => LineageMods {
|
||||||
|
move_mod: 1,
|
||||||
|
cunning_mod: 1,
|
||||||
|
setback_extra_loss: 1,
|
||||||
|
starting_bag: vec![Recruit, Recruit, Recruit, Scholar, Scholar, Rogue],
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
// Immovable: setback-immune, but rigid (-1 hand). Big balanced bag.
|
||||||
|
// The -1 hand is orthogonal to every Calling and *cancels* Prospector's
|
||||||
|
// +1 hand rather than negating an identity (no dead cell).
|
||||||
|
Lineage::GolemBorn => LineageMods {
|
||||||
|
hand_mod: -1,
|
||||||
|
setback_immune: true,
|
||||||
|
starting_bag: vec![Recruit, Recruit, Recruit, Recruit, Recruit, Warrior, Warrior],
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------
|
||||||
|
// Callings (the "class/faction" half)
|
||||||
|
// ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum Calling {
|
||||||
|
Reaver,
|
||||||
|
Lorekeeper,
|
||||||
|
Wardancer,
|
||||||
|
Hierophant,
|
||||||
|
Warden,
|
||||||
|
Prospector,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const ALL_CALLINGS: [Calling; 6] = [
|
||||||
|
Calling::Reaver,
|
||||||
|
Calling::Lorekeeper,
|
||||||
|
Calling::Wardancer,
|
||||||
|
Calling::Hierophant,
|
||||||
|
Calling::Warden,
|
||||||
|
Calling::Prospector,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Resolved modifiers a Calling contributes.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct CallingMods {
|
||||||
|
pub recruit_kind: DieKind,
|
||||||
|
pub recruit_discount: i32,
|
||||||
|
pub hand_mod: i32,
|
||||||
|
pub move_mod: i32,
|
||||||
|
/// Reaver: bonus loot multiplier on every claimed room.
|
||||||
|
pub claim_loot_bonus: f64,
|
||||||
|
/// Lorekeeper: open Vaults without the trinity, plus a lock bonus.
|
||||||
|
pub solo_vault: bool,
|
||||||
|
pub cunning_bonus: i32,
|
||||||
|
/// Wardancer: Worthy-End multiplier, and a free Last Stand (no deep room needed).
|
||||||
|
pub worthy_mult: f64,
|
||||||
|
pub free_last_stand: bool,
|
||||||
|
/// Flat bonus to every control marker's strength (a consistent edge so a Calling
|
||||||
|
/// isn't hostage to a rare event). Distinct from Warden's `marker_mult`.
|
||||||
|
pub claim_strength_bonus: i32,
|
||||||
|
/// Warden: control-marker strength multiplier.
|
||||||
|
pub marker_mult: i32,
|
||||||
|
/// Hierophant: extra Dread cancelled, heals Setbacks, richer held-room production.
|
||||||
|
pub extra_dread_cancel: i32,
|
||||||
|
pub heal_setback: bool,
|
||||||
|
pub hold_loot_mult: f64,
|
||||||
|
/// Prospector: may recruit Relic dice.
|
||||||
|
pub finds_relics: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Calling {
|
||||||
|
pub fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Calling::Reaver => "Reaver",
|
||||||
|
Calling::Lorekeeper => "Lorekeeper",
|
||||||
|
Calling::Wardancer => "Wardancer",
|
||||||
|
Calling::Hierophant => "Hierophant",
|
||||||
|
Calling::Warden => "Warden",
|
||||||
|
Calling::Prospector => "Prospector",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mods(self) -> CallingMods {
|
||||||
|
let base = CallingMods {
|
||||||
|
recruit_kind: DieKind::Recruit,
|
||||||
|
recruit_discount: 0,
|
||||||
|
hand_mod: 0,
|
||||||
|
move_mod: 0,
|
||||||
|
claim_loot_bonus: 0.0,
|
||||||
|
solo_vault: false,
|
||||||
|
cunning_bonus: 0,
|
||||||
|
worthy_mult: 1.0,
|
||||||
|
free_last_stand: false,
|
||||||
|
claim_strength_bonus: 0,
|
||||||
|
marker_mult: 1,
|
||||||
|
extra_dread_cancel: 0,
|
||||||
|
heal_setback: false,
|
||||||
|
hold_loot_mult: 1.0,
|
||||||
|
finds_relics: false,
|
||||||
|
};
|
||||||
|
match self {
|
||||||
|
// v2 iter4/9: claim loot 0.75 -> 0.5 -> 0.4. Loot-on-claim is a flywheel
|
||||||
|
// (every claim funds the next), so it stayed the strongest Calling across
|
||||||
|
// Lineages even after the first trim.
|
||||||
|
Calling::Reaver => CallingMods {
|
||||||
|
recruit_kind: DieKind::Warrior,
|
||||||
|
recruit_discount: 1,
|
||||||
|
claim_loot_bonus: 0.4,
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
// v2: dropped the +1 lock Cunning bonus — solo-Vault access to the richest
|
||||||
|
// rooms is already its identity; stacking a general lock bonus on top made
|
||||||
|
// it strong with *every* Lineage. Now it's good at Vaults, not all locks.
|
||||||
|
Calling::Lorekeeper => CallingMods {
|
||||||
|
recruit_kind: DieKind::Scholar,
|
||||||
|
solo_vault: true,
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
// v2 iter3: the worthy *specialist* was the worst Calling because anyone can
|
||||||
|
// martyr on a deep Throne — the bonus was redundant. Now it's genuinely the
|
||||||
|
// best at the martyr line: premium Champion dice (reach + hold + crack the
|
||||||
|
// Vault it dies on) and a 1.8x payout that clearly beats a generic 1.0x End.
|
||||||
|
// v2 iter7: added a consistent +1 marker strength. The sweeps proved that
|
||||||
|
// for a glory-maximizing bot, Worthy End and Cash Out are *substitutes*, so a
|
||||||
|
// purely worthy-conditional Calling is dead weight whenever banking pays more
|
||||||
|
// (which the depth dividend made common). The flat claim bonus makes Wardancer
|
||||||
|
// a solid area-control crew that *also* dies huge — viable either way.
|
||||||
|
Calling::Wardancer => CallingMods {
|
||||||
|
recruit_kind: DieKind::Champion,
|
||||||
|
move_mod: 1,
|
||||||
|
worthy_mult: 2.2,
|
||||||
|
free_last_stand: true,
|
||||||
|
claim_strength_bonus: 1,
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
Calling::Hierophant => CallingMods {
|
||||||
|
recruit_kind: DieKind::Priest,
|
||||||
|
extra_dread_cancel: 1,
|
||||||
|
heal_setback: true,
|
||||||
|
hold_loot_mult: 1.25,
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
Calling::Warden => CallingMods {
|
||||||
|
recruit_kind: DieKind::Champion,
|
||||||
|
marker_mult: 2,
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
Calling::Prospector => CallingMods {
|
||||||
|
recruit_kind: DieKind::Relic,
|
||||||
|
recruit_discount: 1,
|
||||||
|
hand_mod: 1,
|
||||||
|
finds_relics: true,
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
214
deepfall/deepfall-core/src/controller.rs
Normal file
214
deepfall/deepfall-core/src/controller.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
853
deepfall/deepfall-core/src/game.rs
Normal file
853
deepfall/deepfall-core/src/game.rs
Normal 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]
|
||||||
|
}
|
||||||
126
deepfall/deepfall-core/src/lib.rs
Normal file
126
deepfall/deepfall-core/src/lib.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
190
deepfall/deepfall-core/src/rng.rs
Normal file
190
deepfall/deepfall-core/src/rng.rs
Normal 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fisher–Yates 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
|
||||||
|
}
|
||||||
|
}
|
||||||
11
deepfall/deepfall-sim/Cargo.toml
Normal file
11
deepfall/deepfall-sim/Cargo.toml
Normal 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"
|
||||||
383
deepfall/deepfall-sim/src/main.rs
Normal file
383
deepfall/deepfall-sim/src/main.rs
Normal 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)
|
||||||
|
}
|
||||||
84
reikhelm-core/examples/sample_dungeon.rs
Normal file
84
reikhelm-core/examples/sample_dungeon.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue