diff --git a/README.md b/README.md index 005c38e..554bfc9 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,14 @@ primitive vocabulary (Partitioner / Shaper / Connector / Placer / Decorator). workstation) → Floyd–Steinberg dither through a 256-color palette → signature pixel art. See `tools/README.md`. +### Combined-pool combat system (Rust + Python) +- `combat/` — a deterministic, headless combat engine built on one combined + `Vigor` pool (HP + mana + stamina merged), so offense literally spends your + survival. Sim-first: a pure Rust core (`combat-core`) + a batch sweep harness + (`combat-sim`) that runs tens of thousands of fights and exports metrics, plus a + Python analysis layer that measures "feel" (difficulty curve, fight duration) + instead of eyeballing it. Its own Cargo workspace. See `combat/README.md`. + ## Building ```sh diff --git a/combat/.gitignore b/combat/.gitignore new file mode 100644 index 0000000..9dffa75 --- /dev/null +++ b/combat/.gitignore @@ -0,0 +1,11 @@ +# Rust build output (this is its own workspace). +/target/ + +# Python analysis virtualenv + generated plots. +analysis/.venv/ +analysis/plots/ + +# Exported sim data (regenerate with `combat-sim`). +out/ +*.csv +sample_fight.json diff --git a/combat/Cargo.lock b/combat/Cargo.lock new file mode 100644 index 0000000..f3ee9f4 --- /dev/null +++ b/combat/Cargo.lock @@ -0,0 +1,190 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "combat-core" +version = "0.1.0" +dependencies = [ + "rand_chacha", + "serde", + "serde_json", +] + +[[package]] +name = "combat-sim" +version = "0.1.0" +dependencies = [ + "combat-core", + "csv", + "serde", + "serde_json", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[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 = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[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.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +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.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/combat/Cargo.toml b/combat/Cargo.toml new file mode 100644 index 0000000..85367f3 --- /dev/null +++ b/combat/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] +resolver = "2" +members = ["combat-core", "combat-sim"] + +# This is a SELF-CONTAINED workspace, deliberately separate from the root +# reikhelm workspace. The root `Cargo.toml` lists its members explicitly (no +# globs) and this folder declares its own `[workspace]`, so `combat/` neither +# pollutes nor depends on the dungeon-generator crates. One experiment, one +# isolated build graph (see ../README.md). diff --git a/combat/README.md b/combat/README.md new file mode 100644 index 0000000..e5390d6 --- /dev/null +++ b/combat/README.md @@ -0,0 +1,137 @@ +# combat — a combined-pool combat system + +> A standalone experiment in the reikhelm sandbox. It borrows the repo's +> conventions (pure deterministic core, serde config, ChaCha8 RNG, headless-first, +> front-ends as consumers) but has **no dependency** on `reikhelm-core`. Its own +> Cargo workspace; one experiment, one isolated build graph. +> +> Design spec: [`../.dev/2026-06-02-combat-system/spec/design.md`](../.dev/2026-06-02-combat-system/spec/design.md). + +## The idea + +A real-time (tick-based) combat system in the EverQuest / Daggerfall lineage — +auto-attack + skills + magic — built on one deliberately unusual idea: **a single +combined resource pool, `Vigor`**, that replaces separate HP / mana / stamina. +Everything you do *and* everything done to you draws on the same bar. **Offense +literally spends your survival.** + +It is **a deterministic simulation first, a playable game second.** "Feel" (fight +duration, difficulty curve) is not eyeballed — it is *measured* across tens of +thousands of simulated fights and tuned to target shapes. + +### The spine — two axes and a squeeze + +Every combatant has one pool with three derived quantities: + +| Term | Meaning | +|------|---------| +| **Vigor (fill)** | Current value. **Also your absorb capacity** for the stagger cascade. | +| **Cap (ceiling)** | `max_vigor − fatigue`. The highest fill can regen to *right now*. | +| **Fatigue** | Fight-scoped ceiling loss. Every point that *leaves* the pool adds some. | + +Every combat verb is a point on two axes — cost to *fill* and cost to *ceiling*. +Offense costs both; auto-attack/defense cost a little ceiling; recovery is the +inverse. You don't lose by emptying fill once — you lose because **attrition +grinds your ceiling down**, a lower ceiling caps a lower fill, and a low fill lets +the next hit overrun your capacity and trigger the **stagger cascade** +(absorbed → dazed → unconscious → dead). Everything points at the same death. + +## Layout + +``` +combat/ + combat-core/ pure deterministic engine — the single source of truth + combat-sim/ batch harness: load config, run sweeps, export CSV/JSON + analysis/ Python (pandas/matplotlib) over the exported data + configs/ the tunable "table" (default.json) +``` + +`combat-core` has no I/O, no rendering, no real-time. A fight is a pure function +of `(rules, seed, actors, controllers)`, reproducible bit-for-bit. The sim and +(later) any visual front-end are just consumers. + +### Inside `combat-core` + +| Module | Role | +|--------|------| +| `fixed` | integer fixed-point math — the only place magnitudes round (banker's rounding) | +| `rng` | vendored order-independent, version-stable deterministic RNG (+ integer `chance_bp`) | +| `con` | the level-delta → multiplier con curve | +| `mitigation` | the ordered "how much lands" pipeline (armor → con → defense → stance → buff) | +| `effect` / `ability` | the composable effect library + unified ability model (skills == spells) | +| `actor` | the symmetric combatant: combined pool, stagger cascade, statuses, competency, loadout | +| `controller` | pluggable intent — `SwingOnCooldown`, `ScriptedPlayer { skill }`, `HoldAndPunish` | +| `config` | the full tunable surface, compiled to an integer-only `Rules` | +| `event` | the structured event stream + metrics recorder | +| `engine` | the fixed-tick loop + canonical within-tick resolution order | + +### Determinism is load-bearing + +"Change one knob, observe the isolated delta" only holds if unrelated rolls don't +reshuffle and arithmetic doesn't drift. Two guarantees make it true: + +- **Order-independent RNG forking** — every roll is keyed + `root.fork("actor{id}:{kind}:tick{t}")`, so adding an effect to one ability + never shifts another actor's or tick's stream. (Vendored verbatim; a hand-rolled + splitmix64 + FNV-1a mix, *not* `DefaultHasher`.) +- **Integer fixed-point magnitudes** — `vigor`/`cap`/`fatigue` and every formula + are integer milli-units with one documented rounding rule; `f64` is confined to + config parsing. Bit-exact across compilers / opt levels / machines. + +The within-tick order is itself an invariant (reordering changes outcomes), and +**decisions are simultaneous within a tick** — all controllers read the same +start-of-tick snapshot, then commit in id order — so a perfect mirror match is +fair. + +## Build & run + +```sh +# (cargo on PATH: export PATH="$HOME/.cargo/bin:$PATH") +cd combat +cargo test # unit + determinism integration tests +cargo build --release + +# Run the sweeps → out/{fights.csv, summary.csv, sample_fight.json} +./target/release/combat-sim --config configs/default.json --out out --seeds 300 + +# Analyze → plots/*.png + a text read-out +cd analysis +python3 -m venv .venv && ./.venv/bin/pip install -r requirements.txt +./.venv/bin/python analyze.py --data ../out --plots plots +``` + +`combat-sim` runs two sweeps: +- **con** — player vs. a fixed competent benchmark across level delta × player + policy (auto-only, skill 20/50/80/100). The auto-only policy is the §12 + anti-turtle guardrail. +- **archetypes** — a skilled player against golem / rat / battlemage. + +## What the data shows (default config) + +With the shipped `configs/default.json` (these numbers are tuned placeholders — the +whole point is that they're a table you edit): + +- **Duration tent** — fights are trivially short at the con extremes (~1–5s) and + longest/most-contested at even con (~22s). The intended "trivial → satisfying → + lethal" shape. +- **Skill matters** — at even con, win rate climbs monotonically with policy: + auto-only **~3%** → skill20 **29%** → skill80 **42%** → skill100 **44%**. +- **Anti-turtle guardrail is green** — in the contested band the full kit beats + auto-attack-only decisively (≈42% vs ≈3% at even con). Spending on the + interesting verbs is worth it, which is the whole thesis. +- **Mirror matches are fair** — even-con skill-vs-skill is ≈ symmetric. + +Getting there was the sim working as designed: the *first* default numbers +produced a one-tick cliff and 82% mutual-KO draws, and revealed the §15 deepest +risk live — a poke skill that was strictly worse than the free auto-attack, so +auto-only out-won the full kit. Tuning the table (con curve, competency coupling, +ability efficiency, damage variance) moved it to the curves above. **That loop — +sim → measure → tune → re-sim — is the deliverable.** + +## Tuning + +Everything balance-relevant lives in `configs/default.json`. Editing it is the +primary balancing act; only structural rule changes need a recompile. The levers +the feel rides on (spec §15): the **con curve** shape, the **regen vs. drain vs. +spend** rates (co-tuned or even-con fights are un-loseable or stagger-locked), and +keeping abilities ahead of auto-attack on damage-per-resource or utility. diff --git a/combat/analysis/analyze.py b/combat/analysis/analyze.py new file mode 100644 index 0000000..e39196d --- /dev/null +++ b/combat/analysis/analyze.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""Analysis layer for the combined-pool combat sim (spec §12). + +Consumes the CSV/JSON that ``combat-sim`` exports and renders the headline views +that make "feel" measurable instead of eyeballed: + + 1. Difficulty curve — player win-rate vs. con tier, one line per policy. + 2. Duration tent — time-to-resolution vs. con tier (trivial → contested → lethal). + 3. Auto-attack guardrail — auto-only win-rate vs. the full kit (§12 anti-turtle). + 4. Stagger frequency — staggers inflicted vs. con tier. + 5. One fight's ceiling-decay — the combined pool (fill) and ceiling (cap) over time. + +Run after a sweep: + + python analyze.py --data ../out --plots plots + +The file is also organized into ``# %%`` cells so it doubles as a notebook in +Jupyter / VS Code interactive. +""" +# %% +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") # headless: write PNGs, never block on a window +import matplotlib.pyplot as plt +import pandas as pd + + +# %% +def load(data_dir: Path): + """Load the three sim artifacts; tolerate a missing sample fight.""" + fights = pd.read_csv(data_dir / "fights.csv") + summary = pd.read_csv(data_dir / "summary.csv") + sample_path = data_dir / "sample_fight.json" + sample = json.loads(sample_path.read_text()) if sample_path.exists() else None + return fights, summary, sample + + +# %% +def plot_difficulty_curve(summary: pd.DataFrame, out: Path): + """Win-rate vs. con tier, one line per player policy — the difficulty curve + and the skill gradient in one panel.""" + con = summary[summary["sweep"] == "con"] + fig, ax = plt.subplots(figsize=(8, 5)) + for ctrl, grp in sorted(con.groupby("player_ctrl")): + grp = grp.sort_values("con_delta") + ax.plot(grp["con_delta"], grp["player_win_rate"] * 100, marker="o", label=ctrl) + ax.axvline(0, color="grey", lw=0.8, ls=":") + ax.set_xlabel("con tier (enemy_level − player_level)") + ax.set_ylabel("player win rate (%)") + ax.set_title("Difficulty curve — win rate by con tier and policy") + ax.set_ylim(-3, 103) + ax.grid(True, alpha=0.3) + ax.legend(title="player policy") + fig.tight_layout() + fig.savefig(out / "difficulty_curve.png", dpi=120) + plt.close(fig) + + +# %% +def plot_duration_tent(summary: pd.DataFrame, out: Path): + """Mean fight duration vs. con tier — the 'trivial → contested → lethal' tent.""" + con = summary[(summary["sweep"] == "con") & (summary["player_ctrl"] == "skill80")] + con = con.sort_values("con_delta") + fig, ax = plt.subplots(figsize=(8, 5)) + ax.plot(con["con_delta"], con["mean_seconds"], marker="o", color="darkorange") + ax.axvline(0, color="grey", lw=0.8, ls=":") + ax.set_xlabel("con tier (enemy_level − player_level)") + ax.set_ylabel("mean time-to-resolution (s)") + ax.set_title("Duration tent — fights are longest (most contested) at even con") + ax.grid(True, alpha=0.3) + fig.tight_layout() + fig.savefig(out / "duration_tent.png", dpi=120) + plt.close(fig) + + +# %% +def plot_guardrail(summary: pd.DataFrame, out: Path): + """The §12 anti-turtle guardrail: auto-attack-only win-rate against the best + full-kit policy. If auto-only ever matches the kit, the interesting verbs are + a tax and the thesis is broken.""" + con = summary[summary["sweep"] == "con"] + auto = con[con["player_ctrl"] == "auto_only"].sort_values("con_delta") + best = ( + con[con["player_ctrl"] != "auto_only"] + .groupby("con_delta")["player_win_rate"] + .max() + .reset_index() + .sort_values("con_delta") + ) + fig, ax = plt.subplots(figsize=(8, 5)) + ax.plot(auto["con_delta"], auto["player_win_rate"] * 100, marker="o", label="auto-attack only") + ax.plot(best["con_delta"], best["player_win_rate"] * 100, marker="s", label="best full kit") + ax.fill_between( + best["con_delta"], + auto.set_index("con_delta").reindex(best["con_delta"])["player_win_rate"].values * 100, + best["player_win_rate"] * 100, + alpha=0.15, + color="green", + label="value of abilities", + ) + ax.set_xlabel("con tier (enemy_level − player_level)") + ax.set_ylabel("player win rate (%)") + ax.set_title("Anti-turtle guardrail — abilities must beat auto-attack") + ax.grid(True, alpha=0.3) + ax.legend() + fig.tight_layout() + fig.savefig(out / "guardrail.png", dpi=120) + plt.close(fig) + + +# %% +def plot_stagger_frequency(summary: pd.DataFrame, out: Path): + """Staggers the player lands vs. con tier (a skilled player).""" + con = summary[(summary["sweep"] == "con") & (summary["player_ctrl"] == "skill80")] + con = con.sort_values("con_delta") + fig, ax = plt.subplots(figsize=(8, 5)) + ax.plot(con["con_delta"], con["mean_player_staggers_inflicted"], marker="o", color="purple") + ax.set_xlabel("con tier (enemy_level − player_level)") + ax.set_ylabel("mean staggers inflicted / fight") + ax.set_title("Stagger frequency by con tier (skilled player)") + ax.grid(True, alpha=0.3) + fig.tight_layout() + fig.savefig(out / "stagger_frequency.png", dpi=120) + plt.close(fig) + + +# %% +def plot_sample_fight(sample: dict, out: Path): + """One fight's combined pool over time: fill (the visible bar) and cap (the + ceiling that attrition grinds down). The squeeze made legible.""" + if sample is None: + print(" (no sample_fight.json — skipping time-series plot)") + return + metrics = sample["metrics"] + tick_rate = metrics.get("tick_rate", 10) + fig, ax = plt.subplots(figsize=(9, 5)) + for actor in metrics["actors"]: + fill = [v / 1000.0 for v in actor.get("fill_series", [])] + cap = [v / 1000.0 for v in actor.get("cap_series", [])] + if not fill: + continue + t = [i / tick_rate for i in range(len(fill))] + (line,) = ax.plot(t, fill, label=f'{actor["name"]} — vigor (fill)') + ax.plot(t, cap, ls="--", color=line.get_color(), alpha=0.7, label=f'{actor["name"]} — cap (ceiling)') + ax.set_xlabel("time (s)") + ax.set_ylabel("vigor") + ax.set_title("One fight — fill spent toward zero, ceiling ground down by attrition") + ax.grid(True, alpha=0.3) + ax.legend(fontsize=8) + fig.tight_layout() + fig.savefig(out / "sample_fight.png", dpi=120) + plt.close(fig) + + +# %% +def print_findings(summary: pd.DataFrame): + """A terse text read-out of the headline numbers.""" + con = summary[summary["sweep"] == "con"] + print("\n=== headline findings ===") + + even = con[con["con_delta"] == 0] + if not even.empty: + print("Even-con win rate by policy (skill should climb monotonically):") + for _, r in even.sort_values("player_ctrl").iterrows(): + print(f" {r['player_ctrl']:>10}: {r['player_win_rate'] * 100:5.1f}% " + f"(mean {r['mean_seconds']:.1f}s)") + + # The guardrail only means something in the CONTESTED band: at trivial + # (under-con) and lethal (over-con) tiers every policy wins 100% / 0%, so the + # kit can't add value to an already-decided fight. Averaging those in hides + # the signal. Restrict to tiers where the full kit is genuinely in play. + kit_by_delta = ( + con[con["player_ctrl"] == "skill80"].set_index("con_delta")["player_win_rate"] + ) + auto_by_delta = ( + con[con["player_ctrl"] == "auto_only"].set_index("con_delta")["player_win_rate"] + ) + contested = [d for d, w in kit_by_delta.items() if 0.05 < w < 0.95] + if contested: + auto_c = auto_by_delta.reindex(contested).mean() + kit_c = kit_by_delta.reindex(contested).mean() + print(f"\nContested tiers (kit win-rate in 5–95%): {sorted(contested)}") + print(f" auto-attack-only mean win rate: {auto_c * 100:5.1f}%") + print(f" full-kit (skill80) mean win rate: {kit_c * 100:5.1f}%") + verdict = ( + "GREEN — abilities beat turtling where it matters" + if kit_c > auto_c + 0.05 + else "RED — auto-attack dominates even contested fights" + ) + print(f" guardrail: {verdict}") + else: + print("\nNo contested tiers found (curve is a step) — widen the con curve.") + + arch = summary[summary["sweep"] == "archetypes"] + if not arch.empty: + print("\nArchetype matchups (skill80 player win rate):") + for _, r in arch.sort_values("matchup").iterrows(): + print(f" vs {r['matchup']:>10} (con {r['con_delta']:+d}): " + f"{r['player_win_rate'] * 100:5.1f}% mean {r['mean_seconds']:.1f}s") + + +# %% +def main(): + ap = argparse.ArgumentParser(description="Analyze combat-sim output.") + ap.add_argument("--data", default="../out", type=Path, help="dir with fights.csv/summary.csv") + ap.add_argument("--plots", default="plots", type=Path, help="dir to write PNGs") + args = ap.parse_args() + + fights, summary, sample = load(args.data) + args.plots.mkdir(parents=True, exist_ok=True) + + print(f"loaded {len(fights):,} fights across {len(summary)} cells from {args.data}") + plot_difficulty_curve(summary, args.plots) + plot_duration_tent(summary, args.plots) + plot_guardrail(summary, args.plots) + plot_stagger_frequency(summary, args.plots) + plot_sample_fight(sample, args.plots) + print_findings(summary) + print(f"\nplots written to {args.plots}/") + + +if __name__ == "__main__": + main() diff --git a/combat/analysis/requirements.txt b/combat/analysis/requirements.txt new file mode 100644 index 0000000..d4b4eec --- /dev/null +++ b/combat/analysis/requirements.txt @@ -0,0 +1,2 @@ +pandas>=2.0 +matplotlib>=3.7 diff --git a/combat/combat-core/Cargo.toml b/combat/combat-core/Cargo.toml new file mode 100644 index 0000000..a91af61 --- /dev/null +++ b/combat/combat-core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "combat-core" +version = "0.1.0" +edition = "2021" +rust-version = "1.96" +description = "Pure, deterministic, headless combined-pool combat engine." + +[dependencies] +rand_chacha = "0.10.0" +serde = { version = "1.0.228", features = ["derive"] } + +[dev-dependencies] +serde_json = "1.0.150" diff --git a/combat/combat-core/src/ability.rs b/combat/combat-core/src/ability.rs new file mode 100644 index 0000000..bae9245 --- /dev/null +++ b/combat/combat-core/src/ability.rs @@ -0,0 +1,330 @@ +//! Abilities (spec §6): skills and spells are **one thing**. "Kick" = melee +//! delivery + a fill-damage effect. "Fireball" = projectile delivery + a fire +//! (fill-damage) effect with a wind-up. Both resolve through the same composed +//! two-axis cost grid. +//! +//! An [`AbilitySpec`] is authored data; [`Ability`] is its compiled form with an +//! **absolute** [`AbilityCost`] baked in by [`compose_cost`]. The only structural +//! axis separating a skill from a spell is `cast_time` (instant vs. wind-up). + +use crate::effect::{EffectSpec, Valence}; +use crate::fixed::{apply_bp, units, Milli}; +use serde::{Deserialize, Serialize}; + +/// Delivery / shape — sets targeting and a base cost contribution. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Delivery { + /// Affects only the caster (recovery, self-buffs). + SelfCast, + /// One adjacent target, no wind-up cost premium. + Touch, + /// One target in melee — the auto-attack / weapon-skill shape. + Melee, + /// One target at range; the canonical spell shape. + Projectile, + /// One target, channel-flavored ranged. + Beam, + /// Up to `n` targets in an area. + Aoe { n: u32 }, +} + +impl Delivery { + /// Whether this delivery can reach more than one enemy (informational; the + /// authoritative target count is [`AbilitySpec::targets`]). + pub fn max_targets(&self) -> u32 { + match self { + Delivery::Aoe { n } => *n, + _ => 1, + } + } +} + +/// Coefficients turning a spec into a cost — "the table" for cost (spec §11). All +/// base costs are in **displayed units** of fill; fatigue defaults to a ratio of +/// the composed fill unless an ability overrides it. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CostModel { + pub base_fill_self: i64, + pub base_fill_touch: i64, + pub base_fill_melee: i64, + pub base_fill_projectile: i64, + pub base_fill_beam: i64, + pub base_fill_aoe: i64, + /// Default fatigue ("spend leaves a mark", §3): `fatigue = fill × this_bp`. + /// Applied when an ability does not author `fatigue_units`. + pub fatigue_ratio_bp: i64, + /// Difficulty per unit of composed fill cost (drives fizzle, §9), in bp. + /// `difficulty = round(fill_units × this_bp / 10_000)` when not authored. + pub difficulty_per_fill_bp: i64, +} + +impl Default for CostModel { + fn default() -> Self { + Self { + base_fill_self: 0, + base_fill_touch: 1, + base_fill_melee: 1, + base_fill_projectile: 2, + base_fill_beam: 3, + base_fill_aoe: 4, + fatigue_ratio_bp: 2_000, // 20% of every point spent becomes fatigue + difficulty_per_fill_bp: 10_000, // difficulty ≈ fill cost in units + } + } +} + +impl CostModel { + fn base_fill(&self, d: Delivery) -> i64 { + match d { + Delivery::SelfCast => self.base_fill_self, + Delivery::Touch => self.base_fill_touch, + Delivery::Melee => self.base_fill_melee, + Delivery::Projectile => self.base_fill_projectile, + Delivery::Beam => self.base_fill_beam, + Delivery::Aoe { .. } => self.base_fill_aoe, + } + } +} + +/// An authored, explicit cost in displayed units (used by `cost_override` for +/// shapes the composer can't express cleanly, e.g. auto-attack's `(0, small)`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RawCost { + pub fill: i64, + pub fatigue: i64, +} + +/// The compiled, absolute cost of an ability, in milli-units. `fatigue` is the +/// **net** ceiling change from paying (positive = a mark; recovery's net gain +/// comes from its `Recovery` effect, not from a negative cost here). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct AbilityCost { + pub fill: Milli, + pub fatigue: Milli, +} + +/// Authored ability data (config). Compiled to [`Ability`] via [`compile`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AbilitySpec { + pub id: String, + pub name: String, + /// Competency school this draws on for fizzle/effectiveness (§9). + pub school: String, + pub delivery: Delivery, + #[serde(default)] + pub effects: Vec, + /// Scalar on effect magnitude *and* cost. bp; `10_000` == ×1.0. + #[serde(default = "default_potency")] + pub potency_bp: i64, + #[serde(default = "default_targets")] + pub targets: u32, + /// Wind-up in ticks. `0` == instant (a skill); `>0` == a spell. + #[serde(default)] + pub cast_time: u32, + /// Ticks before this ability is reusable. + #[serde(default)] + pub cooldown: u32, + /// Explicit cost in displayed units; bypasses the composer when set. + #[serde(default)] + pub cost_override: Option, + /// Explicit fatigue in displayed units; overrides only the fatigue column. + #[serde(default)] + pub fatigue_units: Option, + /// Explicit difficulty (drives fizzle); derived from cost when absent. + #[serde(default)] + pub difficulty: Option, +} + +fn default_potency() -> i64 { + 10_000 +} +fn default_targets() -> u32 { + 1 +} + +/// A compiled ability: spec data plus its baked-in absolute cost and difficulty. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Ability { + pub id: String, + pub name: String, + pub school: String, + pub delivery: Delivery, + pub effects: Vec, + pub potency_bp: i64, + pub targets: u32, + pub cast_time: u32, + pub cooldown: u32, + pub cost: AbilityCost, + pub difficulty: i64, +} + +impl Ability { + /// Whether this ability only ever helps its targets (all effects friendly) — + /// used by controllers/targeting to route a heal at an ally rather than a foe. + pub fn is_support(&self) -> bool { + !self.effects.is_empty() + && self.effects.iter().all(|e| e.valence() == Valence::Friendly) + } + + /// Whether this ability has any hostile effect (it attacks someone). + pub fn is_offensive(&self) -> bool { + self.effects.iter().any(|e| e.valence() == Valence::Hostile) + } +} + +/// Compile a spec into an [`Ability`], composing its absolute cost. +pub fn compile(spec: &AbilitySpec, model: &CostModel) -> Ability { + let cost = compose_cost(spec, model); + let difficulty = spec.difficulty.unwrap_or_else(|| { + // Derived difficulty ≈ composed fill cost in units, scaled. + let fill_units = cost.fill / crate::fixed::UNIT; + crate::fixed::mul_div_round(fill_units, model.difficulty_per_fill_bp, 10_000) + }); + Ability { + id: spec.id.clone(), + name: spec.name.clone(), + school: spec.school.clone(), + delivery: spec.delivery, + effects: spec.effects.clone(), + potency_bp: spec.potency_bp, + targets: spec.targets.max(1), + cast_time: spec.cast_time, + cooldown: spec.cooldown, + cost, + difficulty: difficulty.max(0), + } +} + +/// Compose an absolute `(fill, fatigue)` cost (spec §6): base delivery cost plus +/// each effect's contribution, scaled by potency, multiplied by target count. +/// +/// `fill = (base_delivery + Σ effect.cost_fill) × potency × targets`, then +/// `fatigue = fill × fatigue_ratio` unless authored. An explicit `cost_override` +/// short-circuits the whole thing. +pub fn compose_cost(spec: &AbilitySpec, model: &CostModel) -> AbilityCost { + if let Some(o) = spec.cost_override { + return AbilityCost { + fill: units(o.fill), + fatigue: units(o.fatigue), + }; + } + + let targets = spec.targets.max(1) as i64; + let base_units: i64 = model.base_fill(spec.delivery) + + spec.effects.iter().map(|e| e.cost_fill).sum::(); + + // Scale by potency in milli-space, then multiply by target count. + let mut fill = apply_bp(units(base_units), spec.potency_bp); + fill *= targets; + + let fatigue = match spec.fatigue_units { + Some(f) => units(f) * targets, // authored fatigue still scales with targets + None => apply_bp(fill, model.fatigue_ratio_bp), + }; + + AbilityCost { fill, fatigue } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::effect::EffectKind; + use crate::fixed::UNIT; + + fn fill_dmg(mag: i64, cost: i64) -> EffectSpec { + EffectSpec::new(EffectKind::FillDamage { magnitude_bp: mag }, cost) + } + + #[test] + fn composes_a_simple_skill_cost() { + let model = CostModel::default(); + let spec = AbilitySpec { + id: "kick".into(), + name: "Kick".into(), + school: "martial".into(), + delivery: Delivery::Melee, + effects: vec![fill_dmg(1_000, 4)], // melee base 1 + effect 4 = 5 fill + potency_bp: 10_000, + targets: 1, + cast_time: 0, + cooldown: 0, + cost_override: None, + fatigue_units: None, + difficulty: None, + }; + let ab = compile(&spec, &model); + assert_eq!(ab.cost.fill, 5 * UNIT); + // 20% fatigue ratio -> 1.0 unit fatigue. + assert_eq!(ab.cost.fatigue, UNIT); + assert!(ab.is_offensive()); + } + + #[test] + fn potency_and_targets_scale_cost() { + let model = CostModel::default(); + let base = AbilitySpec { + id: "nuke".into(), + name: "Nuke".into(), + school: "invocation".into(), + delivery: Delivery::Aoe { n: 6 }, + effects: vec![fill_dmg(2_000, 6)], // aoe base 4 + 6 = 10 fill at 1x,1 target + potency_bp: 20_000, // ×2 potency + targets: 6, // ×6 targets + cast_time: 20, + cooldown: 0, + cost_override: None, + fatigue_units: None, + difficulty: None, + }; + let ab = compile(&base, &model); + // (10 units × 2.0) × 6 = 120 units fill. + assert_eq!(ab.cost.fill, 120 * UNIT); + // difficulty derived from fill units (120) × 1.0. + assert_eq!(ab.difficulty, 120); + } + + #[test] + fn cost_override_makes_auto_attack_shape() { + let model = CostModel::default(); + let spec = AbilitySpec { + id: "swing".into(), + name: "Swing".into(), + school: "martial".into(), + delivery: Delivery::Melee, + effects: vec![fill_dmg(1_000, 0)], + potency_bp: 10_000, + targets: 1, + cast_time: 0, + cooldown: 10, + cost_override: Some(RawCost { fill: 0, fatigue: 1 }), // (0 fill, +small) + fatigue_units: None, + difficulty: None, + }; + let ab = compile(&spec, &model); + assert_eq!(ab.cost.fill, 0); + assert_eq!(ab.cost.fatigue, UNIT); + } + + #[test] + fn support_ability_is_detected() { + let model = CostModel::default(); + let spec = AbilitySpec { + id: "mend".into(), + name: "Mend".into(), + school: "restoration".into(), + delivery: Delivery::SelfCast, + effects: vec![EffectSpec::new(EffectKind::Recovery { magnitude_bp: 1_500 }, 2)], + potency_bp: 10_000, + targets: 1, + cast_time: 0, + cooldown: 30, + cost_override: None, + fatigue_units: None, + difficulty: None, + }; + let ab = compile(&spec, &model); + assert!(ab.is_support()); + assert!(!ab.is_offensive()); + } +} diff --git a/combat/combat-core/src/actor.rs b/combat/combat-core/src/actor.rs new file mode 100644 index 0000000..d82928c --- /dev/null +++ b/combat/combat-core/src/actor.rs @@ -0,0 +1,627 @@ +//! The [`Actor`] (spec §5): every combatant — player or monster — is the *same* +//! struct with the *same* combined `Vigor` pool, stagger cascade, and ability +//! resolution. Variety is two independent dials: a [`StatBlock`] (archetype) and +//! a controller (intent, see `controller.rs`). The Actor never knows who drives +//! it. +//! +//! ## The combined pool (spec §3) +//! - `vigor` — current fill. **Also the absorb capacity** the stagger cascade +//! compares hits against (§4). +//! - `fatigue` — accumulated, fight-scoped ceiling loss. Every point that *leaves* +//! the pool (spend or damage) adds some. Recovers only out of combat or via +//! recovery effects. +//! - `cap = max_vigor − fatigue` — the highest fill can regen to right now. When +//! it hits zero, the actor has spent its whole life total and collapses. +//! +//! This module owns the pure state mutations (pay, take a hit, regen, collapse). +//! The engine (`engine.rs`) orchestrates *when* they happen; mitigation is +//! computed there and the post-mitigation magnitude handed to [`Actor::take_hit`]. + +use crate::ability::{Ability, AbilityCost}; +use crate::fixed::{apply_bp, clamp, mul_div_round, units, Milli, BP_ONE, UNIT}; +use std::collections::BTreeMap; + +pub type ActorId = u32; + +/// Where an actor sits on the stagger cascade (spec §4). `Dazed` and +/// `Unconscious` are reached by a hit overrunning capacity, or by the ceiling +/// collapsing to zero (§3). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StaggerState { + /// Acting normally. + Normal, + /// Knocked down until `until` (inclusive tick). Regen reduced; cannot act. + Dazed { until: u64 }, + /// Regen zeroed; cannot act. Revivable only by a friendly recovery; otherwise + /// follow-up hits grind to 0 → `Dead`. + Unconscious, + /// Terminal. + Dead, +} + +impl StaggerState { + pub fn can_act(&self) -> bool { + matches!(self, StaggerState::Normal) + } + pub fn is_down(&self) -> bool { + matches!(self, StaggerState::Unconscious | StaggerState::Dead) + } + pub fn is_dead(&self) -> bool { + matches!(self, StaggerState::Dead) + } +} + +/// What a single hit did to the target — returned by [`Actor::take_hit`] so the +/// engine can log a precise event and detect interruptions. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HitOutcome { + Absorbed, + Dazed, + Unconscious, + /// The hit (or the grind that followed an earlier one) reached `Dead`. + Killed, +} + +/// A timed status riding on an actor. `Dazed`/`Unconscious` live in +/// [`StaggerState`]; these are the effect-driven ones. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Status { + pub kind: StatusKind, + /// Inclusive last tick this status applies. Expired when `tick > expires_at`. + pub expires_at: u64, + pub source: ActorId, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StatusKind { + /// Reduce regen by `reduce_bp` (§6.1 regen sabotage). + RegenSabotage { reduce_bp: i64 }, + /// Multiply fatigue-per-spend/per-hit by `(1 + amp_bp)` (§6.1). + FatigueAmp { amp_bp: i64 }, + /// Fill drain per tick, bp of `max_vigor`, with the source's effective level + /// for con at tick time. + Dot { per_tick_bp: i64, source_eff_level: i32 }, + /// Fill restore per tick, bp of `max_vigor`. + Hot { per_tick_bp: i64 }, + /// A mitigation contribution at `stage` (block windows, stances, buffs, + /// vulnerability debuffs). `amount_bp` is "fraction removed"; negative + /// amplifies (a sunder). + Mitigation { + stage: crate::mitigation::MitigationStage, + amount_bp: i64, + }, +} + +/// An in-progress spell cast (spec §6 cast time). Cost is already paid; if the +/// caster is interrupted before `completes_at`, it is lost (paid for nothing). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Cast { + pub ability_id: String, + pub targets: Vec, + pub completes_at: u64, +} + +/// Authored archetype (spec §5, config). Compiled into an [`Actor`] at fight +/// setup. All magnitudes are **displayed units**; rates are **units per second** +/// (converted to per-tick milli at compile, given the tick rate). +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct StatBlock { + pub name: String, + pub team: u8, + pub level: i32, + pub max_vigor: i64, + pub regen_idle_per_sec: i64, + pub regen_combat_per_sec: i64, + #[serde(default)] + pub armor_bp: i64, + /// Per-school competency (§9). Missing schools default to 0. + #[serde(default)] + pub competency: BTreeMap, + /// Ability ids to slot for this fight (memorization, §9). Resolved against + /// the config ability library. + pub loadout: Vec, + /// Starting fill as bp of `max_vigor` (default full). + #[serde(default = "default_start_bp")] + pub start_vigor_bp: i64, + /// Starting fatigue as bp of `max_vigor` (default 0). + #[serde(default)] + pub start_fatigue_bp: i64, +} + +fn default_start_bp() -> i64 { + 10_000 +} + +/// The compiled, mutable combatant. +#[derive(Clone, Debug)] +pub struct Actor { + pub id: ActorId, + pub name: String, + pub team: u8, + pub level: i32, + + pub max_vigor: Milli, + pub vigor: Milli, + pub fatigue: Milli, + + /// Per-tick regen in milli-units, idle and in-combat. + pub regen_idle: Milli, + pub regen_combat: Milli, + + pub armor_bp: i64, + pub competency: BTreeMap, + + /// The slotted abilities usable this fight (memorization commit). + pub loadout: Vec, + /// ability id → tick it becomes usable again. + pub cooldowns: BTreeMap, + + pub statuses: Vec, + pub casting: Option, + pub stagger: StaggerState, +} + +impl Actor { + /// Current ceiling: `max_vigor − fatigue`, clamped to `[0, max_vigor]`. + #[inline] + pub fn cap(&self) -> Milli { + clamp(self.max_vigor - self.fatigue, 0, self.max_vigor) + } + + /// Absorb capacity the stagger cascade compares against — current fill (§4). + #[inline] + pub fn capacity(&self) -> Milli { + self.vigor + } + + pub fn is_alive(&self) -> bool { + !self.stagger.is_dead() + } + + /// "In the fight": conscious enough to matter. A team loses when none of its + /// actors satisfy this (spec §3/§4 — knocked out is out). + pub fn is_active(&self) -> bool { + !self.stagger.is_down() + } + + pub fn can_act(&self) -> bool { + self.stagger.can_act() && self.casting.is_none() + } + + /// Effective level for *outgoing* effectiveness in `school`: base level plus + /// competency folded in (spec §7/§9). `per_point_bp` is levels-per-competency- + /// point in bp (config). Raw incoming-damage uses plain `level`, not this. + pub fn effective_level(&self, school: &str, per_point_bp: i64) -> i32 { + let comp = self.competency.get(school).copied().unwrap_or(0); + let bonus = mul_div_round(comp, per_point_bp, BP_ONE); + self.level + bonus as i32 + } + + pub fn competency_in(&self, school: &str) -> i64 { + self.competency.get(school).copied().unwrap_or(0) + } + + /// Can this actor afford to *begin* `cost`? Only the fill side gates starting + /// (spec §6 affordability). Fatigue is paid regardless once committed. + #[inline] + pub fn can_afford(&self, cost: AbilityCost) -> bool { + cost.fill <= self.vigor + } + + pub fn ability(&self, id: &str) -> Option<&Ability> { + self.loadout.iter().find(|a| a.id == id) + } + + pub fn on_cooldown(&self, id: &str, tick: u64) -> bool { + self.cooldowns.get(id).is_some_and(|&ready| tick < ready) + } + + /// Pay an ability's absolute cost: spend fill, add the fatigue "mark" (§3), + /// scaled by any `FatigueAmp` status. Re-clamps and may trigger collapse. + pub fn pay(&mut self, cost: AbilityCost) { + self.vigor -= cost.fill; + let marked = self.amplified_fatigue(cost.fatigue); + self.fatigue += marked; + self.reconcile(); + } + + /// Apply a post-mitigation fill hit (spec §4 stagger cascade). Returns what + /// it did. `landed` is already through the mitigation pipeline. Adds the + /// damage-taken fatigue share (§3) and resolves the stagger band. + pub fn take_hit(&mut self, landed: Milli, tick: u64, p: &StaggerParams) -> HitOutcome { + if self.stagger.is_dead() { + return HitOutcome::Killed; + } + let landed = landed.max(0); + + // A hit landing on an *already* unconscious actor is the follow-up that + // grinds them out (spec §4): it kills. The hit that first knocks you + // unconscious does not — that's the line between the two. + let was_unconscious = matches!(self.stagger, StaggerState::Unconscious); + + // Fatigue share of every hit (the §3 "spend leaves a mark" for damage), + // scaled by FatigueAmp. Applied before re-clamping so a hit erodes the + // ceiling even if it's later floored. + let fat = self.amplified_fatigue(apply_bp(landed, p.fatigue_ratio_bp)); + self.fatigue += fat; + + // Band the hit against capacity *before* subtracting (capacity = fill). + let cap_now = self.capacity(); + let band = stagger_band(landed, cap_now, p); + + self.vigor -= landed; + + if was_unconscious { + self.stagger = StaggerState::Dead; + self.casting = None; + self.reconcile(); + return HitOutcome::Killed; + } + + let outcome = match band { + Band::Absorbed => HitOutcome::Absorbed, + Band::Dazed => { + self.enter_dazed(tick, p.daze_duration); + HitOutcome::Dazed + } + Band::Unconscious => { + self.enter_unconscious(); + HitOutcome::Unconscious + } + }; + self.reconcile(); + outcome + } + + /// Apply per-tick regen toward the cap (spec §3). Zero while unconscious; + /// reduced (never below a non-zero floor) while dazed or regen-sabotaged. + pub fn regen_tick(&mut self, in_combat: bool, p: &RegenParams) { + if self.stagger.is_down() { + return; // unconscious/dead: no regen + } + let base = if in_combat { self.regen_combat } else { self.regen_idle }; + let mut rate = base; + + if matches!(self.stagger, StaggerState::Dazed { .. }) { + rate = apply_bp(rate, p.dazed_regen_bp); + } + for s in &self.statuses { + if let StatusKind::RegenSabotage { reduce_bp } = s.kind { + rate = apply_bp(rate, (BP_ONE - reduce_bp).max(0)); + } + } + // Non-zero floor so an actor can always claw back toward affording an + // action — no soft-lock (spec §3/§6). + rate = rate.max(p.regen_floor); + + self.vigor = clamp(self.vigor + rate, 0, self.cap()); + } + + /// Reduce fatigue (raise the ceiling) by `amount` milli — a recovery effect + /// (§6.1). Revives an unconscious (not dead) actor to a brief daze if it + /// brings the cap back above zero, the clutch-save path (§12 near-death). + pub fn recover_fatigue(&mut self, amount: Milli, tick: u64, revive_daze: u32) { + if self.stagger.is_dead() { + return; + } + self.fatigue = (self.fatigue - amount).max(0); + if matches!(self.stagger, StaggerState::Unconscious) && self.cap() > 0 { + // Wake to a daze; the fight isn't over for them yet. Set directly — + // `enter_dazed` intentionally refuses to downgrade an unconscious + // actor, but a recovery is exactly the sanctioned way back. + self.revive_to_daze(tick, revive_daze); + } + self.reconcile(); + } + + /// Restore fill (a heal / HoT tick), clamped to the cap. Can also revive an + /// unconscious actor whose cap is intact. + pub fn restore_fill(&mut self, amount: Milli, tick: u64, revive_daze: u32) { + if self.stagger.is_dead() { + return; + } + self.vigor = clamp(self.vigor + amount, 0, self.cap()); + if matches!(self.stagger, StaggerState::Unconscious) && self.vigor > 0 { + self.revive_to_daze(tick, revive_daze); + } + } + + /// Add direct fatigue (a `CeilingDamage` curse — attacks the real life total, + /// §6.1). May collapse the ceiling. + pub fn add_fatigue(&mut self, amount: Milli) { + self.fatigue += self.amplified_fatigue(amount); + self.reconcile(); + } + + pub fn add_status(&mut self, status: Status) { + self.statuses.push(status); + } + + /// Drop expired statuses (spec §10 step 1). A status covering tick `t` is + /// inclusive of effects resolving on `t`, so it survives until `tick > expires_at`. + pub fn expire_statuses(&mut self, tick: u64) { + self.statuses.retain(|s| tick <= s.expires_at); + } + + /// Clear a dazed state that has elapsed (spec §10 step 1). + pub fn expire_daze(&mut self, tick: u64) { + if let StaggerState::Dazed { until } = self.stagger { + if tick > until { + self.stagger = StaggerState::Normal; + } + } + } + + /// Sum of mitigation contributions at `stage` from active statuses (block + /// windows, stances, buffs, debuffs). Caller adds armor/con separately. + pub fn mitigation_at(&self, stage: crate::mitigation::MitigationStage) -> i64 { + self.statuses + .iter() + .filter_map(|s| match s.kind { + StatusKind::Mitigation { stage: st, amount_bp } if st == stage => Some(amount_bp), + _ => None, + }) + .sum() + } + + // --- internals --------------------------------------------------------- + + /// Scale a fatigue amount by any active `FatigueAmp` statuses (§6.1). + fn amplified_fatigue(&self, base: Milli) -> Milli { + let amp: i64 = self + .statuses + .iter() + .filter_map(|s| match s.kind { + StatusKind::FatigueAmp { amp_bp } => Some(amp_bp), + _ => None, + }) + .sum(); + if amp == 0 { + base + } else { + apply_bp(base, BP_ONE + amp) + } + } + + fn enter_dazed(&mut self, tick: u64, duration: u32) { + // A fresh daze interrupts any cast (paid for nothing, §6) and refreshes + // the timer; never downgrades an unconscious actor. + if matches!(self.stagger, StaggerState::Unconscious | StaggerState::Dead) { + return; + } + self.stagger = StaggerState::Dazed { until: tick + duration as u64 }; + self.casting = None; + } + + fn enter_unconscious(&mut self) { + if !matches!(self.stagger, StaggerState::Dead) { + self.stagger = StaggerState::Unconscious; + self.casting = None; + } + } + + /// Revive an unconscious (not dead) actor to a brief daze. The one sanctioned + /// way to upgrade *out* of unconsciousness (recovery / heal), bypassing the + /// `enter_dazed` downgrade guard. + fn revive_to_daze(&mut self, tick: u64, duration: u32) { + if matches!(self.stagger, StaggerState::Unconscious) { + self.stagger = StaggerState::Dazed { until: tick + duration as u64 }; + } + } + + /// Re-clamp fatigue/vigor and apply ceiling collapse (§3/§4): if the cap has + /// reached zero, the actor self-overruns to unconscious. + fn reconcile(&mut self) { + self.fatigue = clamp(self.fatigue, 0, self.max_vigor); + let cap = self.cap(); + self.vigor = clamp(self.vigor, 0, cap); + if cap == 0 { + self.enter_unconscious(); + } + } +} + +/// Tunables for the stagger cascade (from config). +#[derive(Clone, Copy, Debug)] +pub struct StaggerParams { + /// `hit/capacity` above this (bp) is at least a daze. `10_000` == 1.0×. + pub daze_threshold_bp: i64, + /// `hit/capacity` at/above this (bp) is unconscious. `15_000` == 1.5×. + pub unconscious_threshold_bp: i64, + pub daze_duration: u32, + /// Fatigue share of every hit taken (§3), bp. + pub fatigue_ratio_bp: i64, +} + +/// Tunables for regen (from config). +#[derive(Clone, Copy, Debug)] +pub struct RegenParams { + pub dazed_regen_bp: i64, + pub regen_floor: Milli, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Band { + Absorbed, + Dazed, + Unconscious, +} + +/// Which stagger band a `landed` hit falls into against `capacity`, using +/// integer cross-multiplication (no division, no f64). At `capacity == 0` any +/// positive hit is an overrun → unconscious (the cap-collapse death path, §4). +fn stagger_band(landed: Milli, capacity: Milli, p: &StaggerParams) -> Band { + if landed <= 0 { + return Band::Absorbed; + } + let lhs = landed as i128 * BP_ONE as i128; + let daze_rhs = capacity as i128 * p.daze_threshold_bp as i128; + let unc_rhs = capacity as i128 * p.unconscious_threshold_bp as i128; + if lhs <= daze_rhs { + Band::Absorbed + } else if lhs < unc_rhs { + Band::Dazed + } else { + Band::Unconscious + } +} + +/// Build a runtime [`Actor`] from a [`StatBlock`], its resolved loadout, and the +/// tick rate (for unit/sec → milli/tick regen conversion). +pub fn compile_actor(id: ActorId, sb: &StatBlock, loadout: Vec, tick_rate: u32) -> Actor { + let max_vigor = units(sb.max_vigor); + let to_per_tick = |per_sec: i64| -> Milli { + // per_sec units → milli per tick = per_sec * 1000 / tick_rate. + mul_div_round(per_sec * UNIT, 1, tick_rate.max(1) as i64) + }; + let fatigue = apply_bp(max_vigor, sb.start_fatigue_bp); + let cap = clamp(max_vigor - fatigue, 0, max_vigor); + let vigor = clamp(apply_bp(max_vigor, sb.start_vigor_bp), 0, cap); + Actor { + id, + name: sb.name.clone(), + team: sb.team, + level: sb.level, + max_vigor, + vigor, + fatigue, + regen_idle: to_per_tick(sb.regen_idle_per_sec), + regen_combat: to_per_tick(sb.regen_combat_per_sec), + armor_bp: sb.armor_bp, + competency: sb.competency.clone(), + loadout, + cooldowns: BTreeMap::new(), + statuses: Vec::new(), + casting: None, + stagger: StaggerState::Normal, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn params() -> StaggerParams { + StaggerParams { + daze_threshold_bp: 10_000, + unconscious_threshold_bp: 15_000, + daze_duration: 5, + fatigue_ratio_bp: 2_000, + } + } + + fn dummy(max_units: i64) -> Actor { + let sb = StatBlock { + name: "T".into(), + team: 0, + level: 10, + max_vigor: max_units, + regen_idle_per_sec: 10, + regen_combat_per_sec: 5, + armor_bp: 0, + competency: BTreeMap::new(), + loadout: vec![], + start_vigor_bp: 10_000, + start_fatigue_bp: 0, + }; + compile_actor(1, &sb, vec![], 10) + } + + #[test] + fn fresh_actor_is_full() { + let a = dummy(100); + assert_eq!(a.vigor, units(100)); + assert_eq!(a.fatigue, 0); + assert_eq!(a.cap(), units(100)); + // 5 units/sec at 10 tps -> 500 milli/tick. + assert_eq!(a.regen_combat, 500); + } + + #[test] + fn small_hit_is_absorbed_and_marks_fatigue() { + let mut a = dummy(100); + let out = a.take_hit(units(10), 0, ¶ms()); + assert_eq!(out, HitOutcome::Absorbed); + assert_eq!(a.vigor, units(90)); + // 20% of 10 units = 2 units fatigue. + assert_eq!(a.fatigue, units(2)); + assert_eq!(a.cap(), units(98)); + } + + #[test] + fn overrun_between_1x_and_1_5x_dazes() { + let mut a = dummy(100); + a.vigor = units(10); // capacity 10 + let out = a.take_hit(units(12), 3, ¶ms()); // 1.2x capacity + assert_eq!(out, HitOutcome::Dazed); + assert!(matches!(a.stagger, StaggerState::Dazed { until: 8 })); + } + + #[test] + fn overrun_past_1_5x_knocks_unconscious() { + let mut a = dummy(100); + a.vigor = units(10); + let out = a.take_hit(units(20), 0, ¶ms()); // 2.0x capacity + assert_eq!(out, HitOutcome::Unconscious); + assert!(matches!(a.stagger, StaggerState::Unconscious)); + } + + #[test] + fn absorbed_to_exactly_zero_is_not_death() { + let mut a = dummy(100); + a.vigor = units(10); + let out = a.take_hit(units(10), 0, ¶ms()); // hit == capacity + assert_eq!(out, HitOutcome::Absorbed); + assert_eq!(a.vigor, 0); + assert!(a.is_alive()); + assert!(a.is_active()); + } + + #[test] + fn unconscious_then_follow_up_hit_dies() { + let mut a = dummy(100); + a.vigor = units(2); + // First hit (5x capacity) knocks unconscious but does NOT kill (§4). + let first = a.take_hit(units(10), 0, ¶ms()); + assert_eq!(first, HitOutcome::Unconscious); + assert!(matches!(a.stagger, StaggerState::Unconscious)); + assert!(a.is_alive()); + // Any follow-up hit while down grinds them out → dead. + let second = a.take_hit(units(1), 1, ¶ms()); + assert_eq!(second, HitOutcome::Killed); + assert!(a.stagger.is_dead()); + } + + #[test] + fn ceiling_collapse_knocks_unconscious() { + let mut a = dummy(100); + a.add_fatigue(units(100)); // cap -> 0 + assert_eq!(a.cap(), 0); + assert!(matches!(a.stagger, StaggerState::Unconscious)); + assert!(!a.is_active()); + } + + #[test] + fn recovery_revives_unconscious_to_daze() { + let mut a = dummy(100); + a.add_fatigue(units(100)); // collapse + assert!(matches!(a.stagger, StaggerState::Unconscious)); + a.recover_fatigue(units(40), 5, 3); // recovered 40 fatigue -> cap 40 + assert!(matches!(a.stagger, StaggerState::Dazed { until: 8 })); + assert_eq!(a.cap(), units(40)); + } + + #[test] + fn regen_floor_prevents_softlock() { + let mut a = dummy(100); + a.vigor = 0; + a.add_status(Status { + kind: StatusKind::RegenSabotage { reduce_bp: 10_000 }, // 100% sabotage + expires_at: 100, + source: 2, + }); + let rp = RegenParams { dazed_regen_bp: 5_000, regen_floor: 50 }; + a.regen_tick(true, &rp); + assert!(a.vigor >= 50, "regen floor must keep the actor un-stuck"); + } +} diff --git a/combat/combat-core/src/con.rs b/combat/combat-core/src/con.rs new file mode 100644 index 0000000..2ba6a45 --- /dev/null +++ b/combat/combat-core/src/con.rs @@ -0,0 +1,166 @@ +//! The con curve (spec §7): a single level-delta → magnitude-multiplier mapping, +//! reused in both directions. +//! +//! - **Incoming damage**: `delta = attacker_level − defender_level`. +//! - **Outgoing effect effectiveness / resist**: `delta = caster_effective_level +//! − target_level`, where `caster_effective_level` folds in competency (§9). +//! +//! Represented as a small **anchor table** with clamped linear interpolation in +//! integer space (basis points). Anchors come from config, so the whole feel of +//! over/under-con fights is a table you edit, not code. +//! +//! ## The "double duty" caveat (§7) +//! Both callers feed a *delta*, but their domains differ: raw level delta vs. a +//! competency-inflated effective delta that can run wider. [`ConCurve`] clamps +//! outside its anchor range and exposes [`ConCurve::covers`] so a caller can +//! detect when a delta falls past the tuned region. If competency inflation ever +//! pushes effective deltas past where the damage-tuned curve behaves, split this +//! into two `ConCurve`s — the call sites already pass their own delta, so that +//! split is a config change plus one field, not a rewrite. + +use crate::fixed::BP_ONE; +use serde::{Deserialize, Serialize}; + +/// One `(delta, multiplier)` anchor. `mult_bp` is basis points (`10_000` == 1.0×). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ConAnchor { + pub delta: i32, + pub mult_bp: i64, +} + +/// A monotone-ish anchor table mapping level delta to a magnitude multiplier. +/// +/// Anchors are kept sorted by `delta`. Between anchors the multiplier is linearly +/// interpolated; outside the first/last anchor it is clamped to the endpoint. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ConCurve { + /// Sorted (ascending `delta`) anchor points. Always non-empty after + /// [`ConCurve::new`]. + anchors: Vec, +} + +impl ConCurve { + /// Build from anchors, sorting by delta and de-duplicating exact-delta + /// collisions (last wins). Falls back to an even-con identity if empty so a + /// degenerate config can never produce a panicking or zero-length curve. + pub fn new(mut anchors: Vec) -> Self { + anchors.sort_by_key(|a| a.delta); + anchors.dedup_by_key(|a| a.delta); + if anchors.is_empty() { + anchors.push(ConAnchor { delta: 0, mult_bp: BP_ONE }); + } + Self { anchors } + } + + /// The default illustrative curve from the spec (§7): even-con `1.0×`, + /// `+5 ≈ 3.0×`, `−5 ≈ 0.2×`, monotone, with a couple of intermediate anchors + /// so the interpolation isn't a straight line through the extremes. + pub fn default_anchors() -> Vec { + vec![ + ConAnchor { delta: -8, mult_bp: 200 }, // 0.02× — hopelessly under-con + ConAnchor { delta: -5, mult_bp: 2_000 }, // 0.20× + ConAnchor { delta: -2, mult_bp: 6_000 }, // 0.60× + ConAnchor { delta: 0, mult_bp: 10_000 }, // 1.00× — even con + ConAnchor { delta: 2, mult_bp: 16_000 }, // 1.60× + ConAnchor { delta: 5, mult_bp: 30_000 }, // 3.00× + ConAnchor { delta: 8, mult_bp: 50_000 }, // 5.00× — brutally over-con + ] + } + + /// Multiplier in basis points for a given level `delta`, clamped to the + /// endpoint multipliers outside the anchored range. + pub fn multiplier_bp(&self, delta: i32) -> i64 { + let first = self.anchors.first().expect("ConCurve is never empty"); + let last = self.anchors.last().expect("ConCurve is never empty"); + if delta <= first.delta { + return first.mult_bp; + } + if delta >= last.delta { + return last.mult_bp; + } + // Find the bracketing pair [lo, hi] with lo.delta <= delta < hi.delta. + // Anchor counts are tiny (single digits), so a linear scan is clearest. + for pair in self.anchors.windows(2) { + let lo = pair[0]; + let hi = pair[1]; + if delta >= lo.delta && delta < hi.delta { + let span = (hi.delta - lo.delta) as i64; // > 0 (deltas are unique & sorted) + let into = (delta - lo.delta) as i64; + let rise = hi.mult_bp - lo.mult_bp; + // lo.mult + rise * into / span, rounded half-to-even. + return lo.mult_bp + crate::fixed::mul_div_round(rise, into, span); + } + } + // Unreachable given the clamps above, but stay total. + last.mult_bp + } + + /// Whether `delta` falls inside the anchored (tuned) range. A caller using a + /// competency-inflated delta can check this to log/escalate when it leaves + /// the region the curve was tuned for (the §7 caveat). + pub fn covers(&self, delta: i32) -> bool { + let first = self.anchors.first().expect("ConCurve is never empty"); + let last = self.anchors.last().expect("ConCurve is never empty"); + (first.delta..=last.delta).contains(&delta) + } +} + +impl Default for ConCurve { + fn default() -> Self { + ConCurve::new(ConCurve::default_anchors()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn anchors_are_exact() { + let c = ConCurve::default(); + assert_eq!(c.multiplier_bp(0), 10_000); + assert_eq!(c.multiplier_bp(5), 30_000); + assert_eq!(c.multiplier_bp(-5), 2_000); + } + + #[test] + fn clamps_outside_range() { + let c = ConCurve::default(); + assert_eq!(c.multiplier_bp(-100), 200); // floor anchor + assert_eq!(c.multiplier_bp(100), 50_000); // ceil anchor + assert!(!c.covers(-100)); + assert!(c.covers(0)); + } + + #[test] + fn interpolates_between_anchors() { + let c = ConCurve::default(); + // delta=1 sits halfway between 0 (10_000) and 2 (16_000) -> 13_000. + assert_eq!(c.multiplier_bp(1), 13_000); + // delta=3 between 2 (16_000) and 5 (30_000): 16000 + 14000*1/3 = 20666.67 + // round half-to-even of 20666.666... -> 20667. + assert_eq!(c.multiplier_bp(3), 20_667); + } + + #[test] + fn monotone_nondecreasing() { + let c = ConCurve::default(); + let mut prev = i64::MIN; + for d in -10..=10 { + let m = c.multiplier_bp(d); + assert!(m >= prev, "curve dipped at delta {d}: {m} < {prev}"); + prev = m; + } + } + + #[test] + fn unsorted_input_is_normalized() { + let c = ConCurve::new(vec![ + ConAnchor { delta: 5, mult_bp: 30_000 }, + ConAnchor { delta: 0, mult_bp: 10_000 }, + ConAnchor { delta: -5, mult_bp: 2_000 }, + ]); + assert_eq!(c.multiplier_bp(0), 10_000); + assert_eq!(c.multiplier_bp(5), 30_000); + } +} diff --git a/combat/combat-core/src/config.rs b/combat/combat-core/src/config.rs new file mode 100644 index 0000000..4493126 --- /dev/null +++ b/combat/combat-core/src/config.rs @@ -0,0 +1,284 @@ +//! Configuration — "the table" (spec §11). A single serde-loadable +//! [`CombatConfig`] holds every tunable; **nothing balance-relevant is +//! hardcoded**. It compiles once into [`Rules`], the integer-fixed-point form the +//! engine actually runs on (f64 is confined to this parse/compile boundary, §10). +//! +//! Editing config is the primary balancing act; only structural rule changes +//! require a recompile. + +use crate::ability::{compile as compile_ability, Ability, AbilitySpec, CostModel}; +use crate::actor::{RegenParams, StaggerParams}; +use crate::con::{ConAnchor, ConCurve}; +use crate::fixed::{mul_div_round, Milli, UNIT}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// Regen tunables (§3). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RegenConfig { + /// Fraction of normal regen while dazed. `5_000` == half. + pub dazed_regen_bp: i64, + /// The non-zero floor (units/sec) so an actor can always claw back toward + /// affording an action — no soft-lock (§3/§6). + pub floor_per_sec: i64, +} + +impl Default for RegenConfig { + fn default() -> Self { + Self { dazed_regen_bp: 5_000, floor_per_sec: 1 } + } +} + +/// Stagger cascade tunables (§4) plus the damage-taken fatigue share (§3). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct StaggerConfig { + /// `hit/capacity` above this is at least a daze. `10_000` == 1.0×. + pub daze_threshold_bp: i64, + /// `hit/capacity` at/above this is unconscious. `15_000` == 1.5×. + pub unconscious_threshold_bp: i64, + /// Daze duration in ticks. + pub daze_duration: u32, + /// Daze granted when a recovery revives an unconscious actor (ticks). + pub revive_daze: u32, + /// Fatigue share of every hit taken (§3), bp of the landed damage. + pub fatigue_ratio_bp: i64, +} + +impl Default for StaggerConfig { + fn default() -> Self { + Self { + daze_threshold_bp: 10_000, + unconscious_threshold_bp: 15_000, + daze_duration: 8, + revive_daze: 12, + fatigue_ratio_bp: 2_000, + } + } +} + +/// Competency → effectiveness coupling (§9). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CompetencyConfig { + /// Effective-level bonus per competency point, bp. `1_000` == +0.1 level/pt + /// (so 50 competency ≈ +5 effective levels). + pub level_per_point_bp: i64, +} + +impl Default for CompetencyConfig { + fn default() -> Self { + Self { level_per_point_bp: 1_000 } + } +} + +/// Fizzle model (§9): chance an ability fails to fire (paid for nothing). +/// `chance_bp = clamp(base + max(0, difficulty − competency) × per_gap, 0, max)`. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct FizzleConfig { + /// Floor fizzle chance even at/above competency (bp). + pub base_bp: i64, + /// Added fizzle per point that difficulty exceeds competency (bp). + pub per_gap_bp: i64, + /// Hard ceiling on fizzle chance (bp). + pub max_bp: i64, +} + +impl Default for FizzleConfig { + fn default() -> Self { + Self { base_bp: 200, per_gap_bp: 150, max_bp: 9_000 } + } +} + +/// The whole tunable surface (§11), serde-loadable from JSON. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CombatConfig { + /// Fixed tick rate (ticks/second, §10). + #[serde(default = "default_tick_rate")] + pub tick_rate: u32, + /// ± spread on every landed fill hit, bp. `1_500` == ±15%. The genre's damage + /// range; also the symmetry-breaker that turns a deterministic mirror match + /// from a coin-flip-of-draws into smooth win-rate curves. + #[serde(default)] + pub damage_variance_bp: i64, + #[serde(default)] + pub regen: RegenConfig, + #[serde(default)] + pub stagger: StaggerConfig, + #[serde(default)] + pub competency: CompetencyConfig, + #[serde(default)] + pub fizzle: FizzleConfig, + #[serde(default)] + pub cost_model: CostModel, + /// Con curve anchors (§7). Empty ⇒ the spec's default curve. + #[serde(default)] + pub con_curve: Vec, + /// The ability library (§6) — data, not code. + #[serde(default)] + pub abilities: Vec, + /// Optional named archetypes the sim can reference when assembling scenarios. + #[serde(default)] + pub stat_blocks: BTreeMap, +} + +fn default_tick_rate() -> u32 { + 10 +} + +impl Default for CombatConfig { + fn default() -> Self { + Self { + tick_rate: 10, + damage_variance_bp: 0, + regen: RegenConfig::default(), + stagger: StaggerConfig::default(), + competency: CompetencyConfig::default(), + fizzle: FizzleConfig::default(), + cost_model: CostModel::default(), + con_curve: ConCurve::default_anchors(), + abilities: Vec::new(), + stat_blocks: BTreeMap::new(), + } + } +} + +/// The compiled, integer-fixed-point ruleset the engine runs on. +#[derive(Clone, Debug)] +pub struct Rules { + pub tick_rate: u32, + pub damage_variance_bp: i64, + pub stagger: StaggerParams, + pub regen: RegenParams, + pub revive_daze: u32, + pub con: ConCurve, + pub competency_level_per_point_bp: i64, + pub fizzle: FizzleConfig, + pub cost_model: CostModel, + /// Default spend-mark ratio (§3), mirrored from the cost model for clarity. + pub spend_fatigue_ratio_bp: i64, + /// Compiled abilities keyed by id (resolution for loadouts). + pub abilities: BTreeMap, +} + +impl CombatConfig { + /// Compile config into [`Rules`]: convert units/sec → milli/tick, fold the + /// con anchors, and compose every ability's absolute cost up front. + pub fn compile(&self) -> Rules { + let tick_rate = self.tick_rate.max(1); + + let stagger = StaggerParams { + daze_threshold_bp: self.stagger.daze_threshold_bp, + unconscious_threshold_bp: self.stagger.unconscious_threshold_bp, + daze_duration: self.stagger.daze_duration, + fatigue_ratio_bp: self.stagger.fatigue_ratio_bp, + }; + + let regen = RegenParams { + dazed_regen_bp: self.regen.dazed_regen_bp, + regen_floor: per_sec_to_per_tick(self.regen.floor_per_sec, tick_rate), + }; + + let con = if self.con_curve.is_empty() { + ConCurve::default() + } else { + ConCurve::new(self.con_curve.clone()) + }; + + let abilities = self + .abilities + .iter() + .map(|spec| (spec.id.clone(), compile_ability(spec, &self.cost_model))) + .collect(); + + Rules { + tick_rate, + damage_variance_bp: self.damage_variance_bp.max(0), + stagger, + regen, + revive_daze: self.stagger.revive_daze, + con, + competency_level_per_point_bp: self.competency.level_per_point_bp, + fizzle: self.fizzle.clone(), + cost_model: self.cost_model.clone(), + spend_fatigue_ratio_bp: self.cost_model.fatigue_ratio_bp, + abilities, + } + } +} + +impl Rules { + /// Resolve a loadout of ability ids against the compiled library, dropping + /// (and reporting) any unknown ids so a typo in config fails loud-ish but the + /// rest of the actor still builds. + pub fn resolve_loadout(&self, ids: &[String]) -> (Vec, Vec) { + let mut out = Vec::new(); + let mut missing = Vec::new(); + for id in ids { + match self.abilities.get(id) { + Some(a) => out.push(a.clone()), + None => missing.push(id.clone()), + } + } + (out, missing) + } + + /// Fizzle chance (bp) for `difficulty` attempted at `competency` (§9). + pub fn fizzle_chance_bp(&self, competency: i64, difficulty: i64) -> i64 { + let gap = (difficulty - competency).max(0); + let raw = self.fizzle.base_bp + gap * self.fizzle.per_gap_bp; + raw.clamp(0, self.fizzle.max_bp) + } + + /// Con multiplier (bp) for an attacker/caster of `attacker_eff_level` against + /// `target_level` (§7). + pub fn con_mult_bp(&self, attacker_eff_level: i32, target_level: i32) -> i64 { + self.con.multiplier_bp(attacker_eff_level - target_level) + } +} + +/// units/sec → milli/tick: `per_sec × 1000 / tick_rate`, rounded half-to-even. +fn per_sec_to_per_tick(per_sec: i64, tick_rate: u32) -> Milli { + mul_div_round(per_sec * UNIT, 1, tick_rate.max(1) as i64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_compiles() { + let cfg = CombatConfig::default(); + let rules = cfg.compile(); + assert_eq!(rules.tick_rate, 10); + assert_eq!(rules.con.multiplier_bp(0), 10_000); + // floor 1 unit/sec at 10 tps -> 100 milli/tick. + assert_eq!(rules.regen.regen_floor, 100); + } + + #[test] + fn fizzle_grows_with_difficulty_gap() { + let rules = CombatConfig::default().compile(); + // competency >= difficulty -> just the base. + assert_eq!(rules.fizzle_chance_bp(50, 20), 200); + // a 10-point gap adds 10 * 150 = 1500. + assert_eq!(rules.fizzle_chance_bp(20, 30), 200 + 1_500); + // clamps at max. + assert_eq!(rules.fizzle_chance_bp(0, 1_000), 9_000); + } + + #[test] + fn config_roundtrips_json() { + let cfg = CombatConfig::default(); + let j = serde_json::to_string(&cfg).unwrap(); + let back: CombatConfig = serde_json::from_str(&j).unwrap(); + assert_eq!(back.tick_rate, cfg.tick_rate); + assert_eq!(back.con_curve.len(), cfg.con_curve.len()); + } + + #[test] + fn partial_json_fills_defaults() { + // Only override tick rate; everything else should default. + let cfg: CombatConfig = serde_json::from_str(r#"{ "tick_rate": 20 }"#).unwrap(); + assert_eq!(cfg.tick_rate, 20); + assert_eq!(cfg.stagger.unconscious_threshold_bp, 15_000); + } +} diff --git a/combat/combat-core/src/controller.rs b/combat/combat-core/src/controller.rs new file mode 100644 index 0000000..e6d1c04 --- /dev/null +++ b/combat/combat-core/src/controller.rs @@ -0,0 +1,351 @@ +//! Controllers (spec §5): the pluggable *intent* layer. The [`Actor`] is inert +//! state; a `Controller` decides what it tries to do each tick. Players and +//! monsters run the identical combat math — only the controller differs, so a +//! ladder of scripted policies of varying skill is exactly how we measure the +//! gradual→brutal difficulty curve (§12). +//! +//! A controller returns an [`Action`]; the engine validates affordability, +//! cooldown, and fizzle, then resolves it. Controllers only ever *read* the +//! world (`&View`) and draw from a deterministic, per-decision forked RNG, so the +//! whole decision step is reproducible (§10 step 0). + +use crate::actor::{Actor, ActorId}; +use crate::ability::Ability; +use crate::config::Rules; +use crate::effect::EffectKind; +use crate::fixed::{apply_bp, Milli, BP_ONE}; +use crate::mitigation::MitigationStage; +use crate::rng::Rng; + +/// What an actor tries to do this tick. The engine has final say (it may block an +/// unaffordable/on-cooldown choice and record the reason). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Action { + /// Wait — regen and watch. + Idle, + /// Begin `ability_id` against `targets` (resolved from the actor's loadout). + Use { ability_id: String, targets: Vec }, +} + +/// A read-only snapshot the controller reasons over. +pub struct View<'a> { + pub me: &'a Actor, + pub actors: &'a [Actor], + pub tick: u64, + pub rules: &'a Rules, +} + +impl<'a> View<'a> { + /// Conscious enemies (different team, still in the fight). + pub fn enemies(&self) -> Vec<&'a Actor> { + self.actors + .iter() + .filter(|a| a.team != self.me.team && a.is_active()) + .collect() + } + + /// Conscious allies other than me. + pub fn allies(&self) -> Vec<&'a Actor> { + self.actors + .iter() + .filter(|a| a.team == self.me.team && a.id != self.me.id && a.is_active()) + .collect() + } + + /// The enemy closest to going down (lowest cap, ties broken by lowest fill + /// then id) — the focus-fire / punish target. + pub fn weakest_enemy(&self) -> Option<&'a Actor> { + self.enemies() + .into_iter() + .min_by_key(|a| (a.cap(), a.vigor, a.id)) + } + + /// An enemy mid-cast (a wind-up worth reacting to defensively). + pub fn enemy_casting(&self) -> Option<&'a Actor> { + self.enemies().into_iter().find(|a| a.casting.is_some()) + } + + fn usable(&self, ab: &Ability) -> bool { + ab.cost.fill <= self.me.vigor && !self.me.on_cooldown(&ab.id, self.tick) + } +} + +// --- ability introspection (role inference, no manual tagging) ------------- + +fn is_recovery(ab: &Ability) -> bool { + ab.effects.iter().any(|e| { + matches!(e.kind, EffectKind::Recovery { .. } | EffectKind::Hot { .. }) + }) +} + +fn is_active_defense(ab: &Ability) -> bool { + ab.effects.iter().any(|e| { + matches!( + e.kind, + EffectKind::MitigationBuff { stage: MitigationStage::ActiveDefense, .. } + ) + }) +} + +/// Total fill-damage magnitude (bp of target max_vigor) this ability throws, +/// folding in potency — the rough "how big a hit" used to spot a punish. +fn fill_damage_bp(ab: &Ability) -> i64 { + let raw: i64 = ab + .effects + .iter() + .filter_map(|e| match e.kind { + EffectKind::FillDamage { magnitude_bp } => Some(magnitude_bp), + EffectKind::Dot { per_tick_bp, duration } => Some(per_tick_bp * duration as i64), + _ => None, + }) + .sum(); + apply_bp(raw, ab.potency_bp) +} + +/// The actor's auto-attack: the cheapest offensive ability (fill cost), ties to +/// the lowest id-stable order. Falls back to any offensive ability. +fn auto_attack(me: &Actor) -> Option<&Ability> { + me.loadout + .iter() + .filter(|a| a.is_offensive()) + .min_by_key(|a| (a.cost.fill, a.name.clone())) +} + +/// The biggest affordable, off-cooldown offensive hit. +fn best_heavy<'a>(view: &View<'a>) -> Option<&'a Ability> { + view.me + .loadout + .iter() + .filter(|a| a.is_offensive() && view.usable(a)) + .max_by_key(|a| (fill_damage_bp(a), a.cost.fill)) +} + +/// A usable recovery ability, if any. +fn usable_recovery<'a>(view: &View<'a>) -> Option<&'a Ability> { + view.me + .loadout + .iter() + .find(|a| is_recovery(a) && view.usable(a)) +} + +/// A usable active-defense ability, if any. +fn usable_defense<'a>(view: &View<'a>) -> Option<&'a Ability> { + view.me + .loadout + .iter() + .find(|a| is_active_defense(a) && view.usable(a)) +} + +/// Estimate the landed magnitude of `ab` against `target` for punish timing — +/// con + armor only (defenses/buffs are unknowable to the attacker). Good enough +/// to judge "would this overrun their capacity?". +fn estimate_landed(view: &View, ab: &Ability, target: &Actor) -> Milli { + let pre = apply_bp(target.max_vigor, fill_damage_bp(ab).max(0)); + let eff_level = view + .me + .effective_level(&ab.school, view.rules.competency_level_per_point_bp); + let con = view.rules.con_mult_bp(eff_level, target.level); + let after_con = apply_bp(pre, con); + apply_bp(after_con, (BP_ONE - target.armor_bp).max(0)) +} + +/// Whether `landed` would push `target` past the daze threshold (overruns +/// capacity) — the moment to strike (§4/§5). +fn would_overrun(view: &View, landed: Milli, target: &Actor) -> bool { + let lhs = landed as i128 * BP_ONE as i128; + let rhs = target.vigor as i128 * view.rules.stagger.daze_threshold_bp as i128; + lhs > rhs +} + +/// The decision interface. `decide` is pure w.r.t. the world (`&View`) and draws +/// only from the deterministic `rng`. +pub trait Controller: std::fmt::Debug { + fn decide(&mut self, view: &View, rng: &mut Rng) -> Action; + fn label(&self) -> &str; +} + +/// Dumb AI **and** the §12 auto-attack-only guardrail policy: only ever swings, +/// on cooldown, at the weakest enemy. Never touches an ability. If the fraction +/// of fights this wins is high, the interesting verbs are a tax (thesis broken). +#[derive(Debug, Clone)] +pub struct SwingOnCooldown; + +impl Controller for SwingOnCooldown { + fn decide(&mut self, view: &View, _rng: &mut Rng) -> Action { + let Some(target) = view.weakest_enemy() else { + return Action::Idle; + }; + match auto_attack(view.me) { + Some(ab) if view.usable(ab) => Action::Use { + ability_id: ab.id.clone(), + targets: vec![target.id], + }, + _ => Action::Idle, + } + } + fn label(&self) -> &str { + "swing_on_cooldown" + } +} + +/// A "player" policy with a **skill** knob (0..100). It knows the right play — +/// recover when low, defend a telegraphed cast, punish a spent enemy with the +/// heavy, poke otherwise — but executes the optimal line only `skill`% of the +/// time, otherwise falling back to a plain swing. Sweeping `skill` traces the +/// difficulty curve (§5/§12). +#[derive(Debug, Clone)] +pub struct ScriptedPlayer { + /// 0 = flails, 100 = always optimal. + pub skill: i64, + /// Recover when cap drops below this fraction of max (bp). + pub recover_below_bp: i64, +} + +impl ScriptedPlayer { + pub fn new(skill: i64) -> Self { + Self { skill: skill.clamp(0, 100), recover_below_bp: 3_500 } + } + + fn plays_well(&self, rng: &mut Rng) -> bool { + rng.chance_bp(self.skill * 100) // skill% as bp + } +} + +impl Controller for ScriptedPlayer { + fn decide(&mut self, view: &View, rng: &mut Rng) -> Action { + let Some(target) = view.weakest_enemy() else { + return Action::Idle; + }; + let optimal = self.plays_well(rng); + let swing = || -> Action { + match auto_attack(view.me) { + Some(ab) if view.usable(ab) => Action::Use { + ability_id: ab.id.clone(), + targets: vec![target.id], + }, + _ => Action::Idle, + } + }; + + if !optimal { + // Misplay: just swing (or idle if it can't). + return swing(); + } + + // 1. Survival first: low ceiling → recover. + let cap_frac = if view.me.max_vigor > 0 { + (view.me.cap() as i128 * BP_ONE as i128 / view.me.max_vigor as i128) as i64 + } else { + 0 + }; + if cap_frac < self.recover_below_bp { + if let Some(rec) = usable_recovery(view) { + return Action::Use { + ability_id: rec.id.clone(), + targets: vec![view.me.id], + }; + } + } + + // 2. React to a telegraphed cast: defend. + if view.enemy_casting().is_some() { + if let Some(def) = usable_defense(view) { + return Action::Use { + ability_id: def.id.clone(), + targets: vec![view.me.id], + }; + } + } + + // 3. Punish a spent enemy with the heavy if it would overrun them. + if let Some(heavy) = best_heavy(view) { + let est = estimate_landed(view, heavy, target); + if would_overrun(view, est, target) { + return Action::Use { + ability_id: heavy.id.clone(), + targets: vec![target.id], + }; + } + } + + // 4. Otherwise poke with the cheapest usable offensive ability. + swing() + } + + fn label(&self) -> &str { + "scripted_player" + } +} + +/// The hold-and-punish AI (§5): conserve, watch the target's fill, and unload the +/// biggest hit exactly when they've spent low enough to overrun their capacity. +/// Defends telegraphed casts. The timing meta-game, played by the machine. +#[derive(Debug, Clone)] +pub struct HoldAndPunish { + /// Pure-poke fallback below this cap fraction (don't sit at death's door). + pub panic_below_bp: i64, +} + +impl Default for HoldAndPunish { + fn default() -> Self { + Self { panic_below_bp: 3_000 } + } +} + +impl Controller for HoldAndPunish { + fn decide(&mut self, view: &View, _rng: &mut Rng) -> Action { + let Some(target) = view.weakest_enemy() else { + return Action::Idle; + }; + + // Self-preservation: if our own ceiling is collapsing, recover. + let cap_frac = if view.me.max_vigor > 0 { + (view.me.cap() as i128 * BP_ONE as i128 / view.me.max_vigor as i128) as i64 + } else { + 0 + }; + if cap_frac < self.panic_below_bp { + if let Some(rec) = usable_recovery(view) { + return Action::Use { + ability_id: rec.id.clone(), + targets: vec![view.me.id], + }; + } + } + + // Defend a telegraphed cast. + if view.enemy_casting().is_some() { + if let Some(def) = usable_defense(view) { + return Action::Use { + ability_id: def.id.clone(), + targets: vec![view.me.id], + }; + } + } + + // The haymaker: fire the heaviest hit the instant it would overrun. + if let Some(heavy) = best_heavy(view) { + let est = estimate_landed(view, heavy, target); + if would_overrun(view, est, target) { + return Action::Use { + ability_id: heavy.id.clone(), + targets: vec![target.id], + }; + } + } + + // Not the moment — chip with a cheap swing, conserving the bar for the + // punish window rather than burning the heavy early. + match auto_attack(view.me) { + Some(ab) if view.usable(ab) => Action::Use { + ability_id: ab.id.clone(), + targets: vec![target.id], + }, + _ => Action::Idle, + } + } + + fn label(&self) -> &str { + "hold_and_punish" + } +} diff --git a/combat/combat-core/src/effect.rs b/combat/combat-core/src/effect.rs new file mode 100644 index 0000000..561b354 --- /dev/null +++ b/combat/combat-core/src/effect.rs @@ -0,0 +1,143 @@ +//! The effect library (spec §6.1): the small, data-driven vocabulary every +//! ability is composed from. Adding an effect is a new enum variant + its +//! handling in the engine's apply step — a library entry, not an engine rewrite. +//! +//! Effects target *either* the fill or the ceiling. The ceiling-attacking ones +//! (`CeilingDamage`, `RegenSabotage`, `FatigueAmp`) are the most on-thesis +//! content the system has: they attack the real life total instead of the +//! visible bar. +//! +//! Magnitudes are authored as **basis points of the target's `max_vigor`** (so +//! content scales with whoever it hits) except mitigation amounts, which are +//! basis points of *reduction*. `cost_fill` is the resource (displayed units) +//! this effect contributes to its ability's composed cost. + +use crate::mitigation::MitigationStage; +use serde::{Deserialize, Serialize}; + +/// Whether an effect helps its target (route to self/ally) or harms it (route to +/// an enemy). Derived from the kind so targeting can be inferred without extra +/// authoring. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Valence { + Hostile, + Friendly, +} + +/// One effect, tagged by kind with its per-kind parameters. `duration` is in +/// ticks; `0`/absent means instant. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum EffectKind { + /// Damage to the fill bar, as bp of target `max_vigor`, routed through con + + /// the mitigation pipeline before it lands. + FillDamage { magnitude_bp: i64 }, + /// Direct fatigue gain on the target (lowers their cap) — a curse on the real + /// life total. bp of target `max_vigor`. Does **not** go through fill + /// mitigation; con effectiveness still gates whether it lands (§7). + CeilingDamage { magnitude_bp: i64 }, + /// Lower the target's fatigue (raise their cap), bp of target `max_vigor`. + /// The inverse of `CeilingDamage`; friendly. + Recovery { magnitude_bp: i64 }, + /// Reduce the target's regen by `reduce_bp` for `duration` ticks. + RegenSabotage { reduce_bp: i64, duration: u32 }, + /// Multiply the target's fatigue-per-spend by `(1 + amp_bp)` for `duration`. + FatigueAmp { amp_bp: i64, duration: u32 }, + /// Drain fill over time: `per_tick_bp` of target `max_vigor` each tick for + /// `duration` ticks. Each tick routes through mitigation. + Dot { per_tick_bp: i64, duration: u32 }, + /// Restore fill over time: `per_tick_bp` of target `max_vigor` per tick. + Hot { per_tick_bp: i64, duration: u32 }, + /// Add mitigation at `stage` for `duration` ticks (friendly). A `block` + /// ability is a short, strong `ActiveDefense` buff — defenses are just timed + /// mitigation, so the reactive-timing meta-game needs no separate concept. + MitigationBuff { stage: MitigationStage, amount_bp: i64, duration: u32 }, + /// Strip mitigation at `stage` for `duration` ticks (hostile). Negative + /// reduction at the `Buff` stage reads as a vulnerability/sunder. + MitigationDebuff { stage: MitigationStage, amount_bp: i64, duration: u32 }, +} + +impl EffectKind { + /// Who this effect is meant for. Drives target selection in the engine. + pub fn valence(&self) -> Valence { + match self { + EffectKind::Recovery { .. } + | EffectKind::Hot { .. } + | EffectKind::MitigationBuff { .. } => Valence::Friendly, + EffectKind::FillDamage { .. } + | EffectKind::CeilingDamage { .. } + | EffectKind::RegenSabotage { .. } + | EffectKind::FatigueAmp { .. } + | EffectKind::Dot { .. } + | EffectKind::MitigationDebuff { .. } => Valence::Hostile, + } + } + + /// Whether this effect resolves instantly (vs. installing a timed status). + pub fn is_instant(&self) -> bool { + matches!( + self, + EffectKind::FillDamage { .. } + | EffectKind::CeilingDamage { .. } + | EffectKind::Recovery { .. } + ) + } +} + +/// An authored effect: what it does (`kind`) plus what it adds to its ability's +/// composed fill cost (`cost_fill`, in displayed units — "more effects ⇒ more +/// expensive", §6). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EffectSpec { + #[serde(flatten)] + pub kind: EffectKind, + #[serde(default)] + pub cost_fill: i64, +} + +impl EffectSpec { + pub fn new(kind: EffectKind, cost_fill: i64) -> Self { + Self { kind, cost_fill } + } + + pub fn valence(&self) -> Valence { + self.kind.valence() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valence_is_derived_from_kind() { + assert_eq!(EffectKind::FillDamage { magnitude_bp: 1000 }.valence(), Valence::Hostile); + assert_eq!(EffectKind::Recovery { magnitude_bp: 500 }.valence(), Valence::Friendly); + assert_eq!( + EffectKind::MitigationBuff { + stage: MitigationStage::ActiveDefense, + amount_bp: 8000, + duration: 2 + } + .valence(), + Valence::Friendly + ); + } + + #[test] + fn instant_vs_timed_classification() { + assert!(EffectKind::FillDamage { magnitude_bp: 1000 }.is_instant()); + assert!(!EffectKind::Dot { per_tick_bp: 100, duration: 5 }.is_instant()); + } + + #[test] + fn effect_spec_roundtrips_json() { + let e = EffectSpec::new(EffectKind::FillDamage { magnitude_bp: 1000 }, 5); + let j = serde_json::to_string(&e).unwrap(); + let back: EffectSpec = serde_json::from_str(&j).unwrap(); + assert_eq!(e, back); + // flattened tag means the json is one flat object. + assert!(j.contains("\"kind\":\"fill_damage\"")); + assert!(j.contains("\"magnitude_bp\":1000")); + } +} diff --git a/combat/combat-core/src/engine.rs b/combat/combat-core/src/engine.rs new file mode 100644 index 0000000..41d1b60 --- /dev/null +++ b/combat/combat-core/src/engine.rs @@ -0,0 +1,803 @@ +//! The engine (spec §10): a fixed-tick, deterministic [`Encounter`] loop that +//! enforces the **canonical within-tick resolution order** — itself a determinism +//! invariant, because reordering changes outcomes (regen-before-damage yields +//! fewer staggers; sequential hits differ from batched). +//! +//! ```text +//! 0. Controllers decide (ascending actor-id) → pay cost, open cooldowns/windows, +//! enqueue instant hits / start casts. +//! 1. Expire finishing statuses, dazes, and defense windows (inclusive of tick t). +//! 2. Resolve casts completing this tick → enqueue their hits / install statuses. +//! 3. Apply queued hits sequentially in ascending source-id order — each followed +//! immediately by its stagger check and fatigue add. +//! 4. Tick DoT/HoT and other per-tick effects. +//! 5. Apply regen (clamped to cap). +//! ``` +//! +//! A fight is a pure function of `(rules, seed, actors, controllers)`. Every roll +//! is a `root_rng.fork("actor{id}:{kind}:tick{t}")`, so adding an effect to one +//! ability never reshuffles another actor's or tick's stream (§10). + +use crate::actor::{Actor, ActorId, HitOutcome, Status, StatusKind}; +use crate::ability::Ability; +use crate::config::Rules; +use crate::controller::{Action, Controller, View}; +use crate::effect::{EffectKind, EffectSpec, Valence}; +use crate::event::{Event, FightMetrics, FightRecorder, Outcome, BlockedReason}; +use crate::fixed::{apply_bp, Milli}; +use crate::mitigation::{self, MitigationInputs, MitigationStage}; +use crate::rng::Rng; + +/// Knobs for a single fight that aren't balance (those live in [`Rules`]). +#[derive(Clone, Copy, Debug)] +pub struct EncounterOptions { + /// Hard cap on ticks; reaching it is a `Timeout` outcome. + pub max_ticks: u64, + /// Retain the raw event log (for inspecting one fight; off for sweeps). + pub log_events: bool, + /// Sample cap/fill time series every N ticks (0 = off). + pub sample_every: u32, +} + +impl Default for EncounterOptions { + fn default() -> Self { + Self { max_ticks: 6_000, log_events: false, sample_every: 0 } + } +} + +/// A resolved instant magnitude waiting for sequential application at step 3. +#[derive(Clone, Debug)] +struct PendingHit { + source: ActorId, + source_eff_level: i32, + target: ActorId, + ability_id: String, + seq: u64, + payload: HitPayload, +} + +#[derive(Clone, Debug)] +enum HitPayload { + /// Fill damage, pre-con/pre-mitigation (potency already folded). + Fill { pre_mitigation: Milli }, + /// Direct fatigue (a curse), pre-con-resist. + Ceiling { magnitude: Milli }, + /// Fatigue healed (friendly, no resist). + Recovery { magnitude: Milli }, + /// Instant fill restore (friendly). + Heal { magnitude: Milli }, +} + +/// The fight. +pub struct Encounter { + pub actors: Vec, + controllers: Vec>, + rules: Rules, + root_rng: Rng, + tick: u64, + opts: EncounterOptions, + recorder: FightRecorder, + pending: Vec, + seq: u64, +} + +impl Encounter { + /// Build an encounter. `actors` must carry dense ids `0..n` matching their + /// index, paired with a controller each. + pub fn new( + actors: Vec, + controllers: Vec>, + rules: Rules, + seed: u64, + opts: EncounterOptions, + ) -> Self { + assert_eq!(actors.len(), controllers.len(), "one controller per actor"); + for (i, a) in actors.iter().enumerate() { + assert_eq!(a.id as usize, i, "actor ids must be dense 0..n in index order"); + } + let recorder = FightRecorder::new(&actors, seed, rules.tick_rate, opts.log_events, opts.sample_every); + Self { + actors, + controllers, + rules, + root_rng: Rng::from_seed(seed), + tick: 0, + opts, + recorder, + pending: Vec::new(), + seq: 0, + } + } + + /// Run to resolution and return the rolled-up metrics. + pub fn run(mut self) -> FightMetrics { + loop { + self.step(); + if let Some(outcome) = self.resolution() { + return self.recorder.finish(self.tick, outcome, &self.actors); + } + if self.tick >= self.opts.max_ticks { + return self.recorder.finish(self.tick, Outcome::Timeout, &self.actors); + } + self.tick += 1; + } + } + + /// Run and also return the raw event log (only populated if `log_events`). + pub fn run_with_events(mut self) -> (FightMetrics, Vec) { + let metrics = loop { + self.step(); + if let Some(outcome) = self.resolution() { + break self.recorder.clone().finish(self.tick, outcome, &self.actors); + } + if self.tick >= self.opts.max_ticks { + break self.recorder.clone().finish(self.tick, Outcome::Timeout, &self.actors); + } + self.tick += 1; + }; + let events = self.recorder.into_events(); + (metrics, events) + } + + /// One full tick through the canonical order. + fn step(&mut self) { + self.pending.clear(); + self.decide_and_commit(); // step 0 + self.expire(); // step 1 + self.resolve_casts(); // step 2 + self.apply_hits(); // step 3 + self.tick_over_time(); // step 4 + self.apply_regen(); // step 5 + self.sample(); + } + + // --- step 0 ------------------------------------------------------------ + + fn decide_and_commit(&mut self) { + let n = self.actors.len(); + + // Pass 1: ALL controllers decide against the *same* start-of-tick snapshot + // (no commits happen yet). Within one 0.1s tick combatants act + // simultaneously — a higher-id actor must not get sub-tick reaction to a + // lower-id actor's spend, or a perfect mirror match would be unfair. + let mut decisions: Vec<(ActorId, Action)> = Vec::with_capacity(n); + for id in 0..n as ActorId { + let idx = id as usize; + if !self.actors[idx].is_active() { + continue; // unconscious/dead: out of the fight + } + if !self.actors[idx].can_act() { + // Dazed or mid-cast: no fresh decision. Dazed = blocked; casting + // proceeds toward step 2. + if matches!(self.actors[idx].stagger, crate::actor::StaggerState::Dazed { .. }) { + self.recorder.blocked(self.tick, id, "", BlockedReason::Dazed); + } + continue; + } + let action = { + let view = View { + me: &self.actors[idx], + actors: &self.actors, + tick: self.tick, + rules: &self.rules, + }; + let mut rng = self.root_rng.fork(&format!("actor{id}:decide:tick{}", self.tick)); + self.controllers[idx].decide(&view, &mut rng) + }; + decisions.push((id, action)); + } + + // Pass 2: commit in ascending id order (payment + cast-start are still + // ordered, the §10 step-0 invariant — only the *decision* is simultaneous). + for (id, action) in decisions { + match action { + Action::Idle => self.recorder.idle(id), + Action::Use { ability_id, targets } => self.commit_use(id, &ability_id, targets), + } + } + } + + fn commit_use(&mut self, source: ActorId, ability_id: &str, targets: Vec) { + let idx = source as usize; + let Some(ability) = self.actors[idx].ability(ability_id).cloned() else { + // Controller named an ability not in the loadout — treat as a no-target + // misfire so it's visible rather than silent. + self.recorder.blocked(self.tick, source, ability_id, BlockedReason::NoTarget); + return; + }; + + if self.actors[idx].on_cooldown(&ability.id, self.tick) { + self.recorder.blocked(self.tick, source, &ability.id, BlockedReason::OnCooldown); + return; + } + if !self.actors[idx].can_afford(ability.cost) { + // The §6 affordability gate: heavy hitters fall away as the ceiling + // grinds down. This is a real "wanted to act but couldn't" signal. + self.recorder.blocked(self.tick, source, &ability.id, BlockedReason::CantAfford); + return; + } + + // Pay at cast-start (§6): fizzle/interrupt later = paid for nothing. + let fill_before = self.actors[idx].vigor; + self.actors[idx].pay(ability.cost); + self.recorder.spent(source, ability.cost.fill); + if ability.cooldown > 0 { + self.actors[idx] + .cooldowns + .insert(ability.id.clone(), self.tick + ability.cooldown as u64); + } + self.recorder.action(self.tick, &self.actors[idx], &ability.id, fill_before); + self.classify_action(source, &ability); + + if ability.cast_time == 0 { + // Instant: resolve now (step 0). Self-buffs install immediately so a + // block protects this very tick; hits queue for step 3. + self.resolve_ability(source, &ability, &targets); + } else { + let completes_at = self.tick + ability.cast_time as u64; + self.actors[idx].casting = Some(crate::actor::Cast { + ability_id: ability.id.clone(), + targets, + completes_at, + }); + self.recorder.cast_started(self.tick, source, &ability.id, completes_at); + } + } + + fn classify_action(&mut self, source: ActorId, ability: &Ability) { + if ability.is_support() { + self.recorder.recovery_used(source); + } else if ability.cost.fill == 0 && ability.is_offensive() { + self.recorder.auto_attack(source); + } else { + self.recorder.ability_used(source); + } + } + + // --- step 1 ------------------------------------------------------------ + + fn expire(&mut self) { + let tick = self.tick; + for a in &mut self.actors { + a.expire_statuses(tick); + a.expire_daze(tick); + } + } + + // --- step 2 ------------------------------------------------------------ + + fn resolve_casts(&mut self) { + let n = self.actors.len(); + for id in 0..n as ActorId { + let idx = id as usize; + let due = self.actors[idx] + .casting + .as_ref() + .filter(|c| c.completes_at == self.tick) + .map(|c| (c.ability_id.clone(), c.targets.clone())); + if let Some((ability_id, targets)) = due { + self.actors[idx].casting = None; + if let Some(ability) = self.actors[idx].ability(&ability_id).cloned() { + self.resolve_ability(id, &ability, &targets); + } + } + } + } + + /// Roll fizzle, then route every effect: install timed statuses immediately, + /// queue instant magnitudes for step 3. + fn resolve_ability(&mut self, source: ActorId, ability: &Ability, targets: &[ActorId]) { + // Fizzle (§9): competency vs difficulty. Paid for nothing on failure. + let comp = self.actors[source as usize].competency_in(&ability.school); + let chance = self.rules.fizzle_chance_bp(comp, ability.difficulty); + if chance > 0 { + let mut rng = self + .root_rng + .fork(&format!("actor{source}:fizzle:tick{}", self.tick)); + if rng.chance_bp(chance) { + self.recorder.fizzle(self.tick, source, &ability.id); + return; + } + } + + let eff_level = self.actors[source as usize] + .effective_level(&ability.school, self.rules.competency_level_per_point_bp); + + for (order, effect) in ability.effects.iter().enumerate() { + let chosen = self.select_targets(source, ability, effect, targets); + for target in chosen { + self.route_effect(source, eff_level, target, ability, effect, order as u64); + } + } + } + + /// Pick targets for one effect by valence, honoring the controller's request + /// and topping up to `ability.targets` from the appropriate side. + fn select_targets( + &self, + source: ActorId, + ability: &Ability, + effect: &EffectSpec, + requested: &[ActorId], + ) -> Vec { + let want = ability.targets.max(1) as usize; + let me = &self.actors[source as usize]; + let mut out: Vec = Vec::new(); + + match effect.valence() { + Valence::Friendly => { + // Self first, then requested allies, then nearest allies. + let push = |id: ActorId, out: &mut Vec| { + if out.len() < want && !out.contains(&id) { + out.push(id); + } + }; + // Honor explicit friendly requests (e.g. heal an ally), else self. + for &r in requested { + if let Some(t) = self.actors.get(r as usize) { + if t.team == me.team && t.is_alive() { + push(r, &mut out); + } + } + } + push(source, &mut out); + for a in &self.actors { + if a.team == me.team && a.is_alive() { + push(a.id, &mut out); + } + } + } + Valence::Hostile => { + let push = |id: ActorId, out: &mut Vec| { + if out.len() < want && !out.contains(&id) { + out.push(id); + } + }; + for &r in requested { + if let Some(t) = self.actors.get(r as usize) { + if t.team != me.team && t.is_active() { + push(r, &mut out); + } + } + } + // Top up with the most-vulnerable enemies (lowest cap). + let mut enemies: Vec<&Actor> = self + .actors + .iter() + .filter(|a| a.team != me.team && a.is_active()) + .collect(); + enemies.sort_by_key(|a| (a.cap(), a.vigor, a.id)); + for a in enemies { + push(a.id, &mut out); + } + } + } + out + } + + /// Apply a status immediately or queue an instant magnitude. + fn route_effect( + &mut self, + source: ActorId, + source_eff_level: i32, + target: ActorId, + ability: &Ability, + effect: &EffectSpec, + order: u64, + ) { + let potency = ability.potency_bp; + let tmax = self.actors[target as usize].max_vigor; + let scaled = |bp: i64| apply_bp(tmax, apply_bp(bp, potency)); + let _ = order; + + match effect.kind { + EffectKind::FillDamage { magnitude_bp } => { + let payload = HitPayload::Fill { pre_mitigation: scaled(magnitude_bp) }; + let seq = self.next_seq(); + self.queue(PendingHit { + source, + source_eff_level, + target, + ability_id: ability.id.clone(), + seq, + payload, + }); + } + EffectKind::CeilingDamage { magnitude_bp } => { + let payload = HitPayload::Ceiling { magnitude: scaled(magnitude_bp) }; + let seq = self.next_seq(); + self.queue(PendingHit { + source, + source_eff_level, + target, + ability_id: ability.id.clone(), + seq, + payload, + }); + } + EffectKind::Recovery { magnitude_bp } => { + let payload = HitPayload::Recovery { magnitude: scaled(magnitude_bp) }; + let seq = self.next_seq(); + self.queue(PendingHit { + source, + source_eff_level, + target, + ability_id: ability.id.clone(), + seq, + payload, + }); + } + EffectKind::Hot { per_tick_bp, duration: 0 } => { + let payload = HitPayload::Heal { magnitude: scaled(per_tick_bp) }; + let seq = self.next_seq(); + self.queue(PendingHit { + source, + source_eff_level, + target, + ability_id: ability.id.clone(), + seq, + payload, + }); + } + // --- timed statuses: install now, tick later --- + EffectKind::Dot { per_tick_bp, duration } => self.install( + target, + StatusKind::Dot { per_tick_bp: apply_bp(per_tick_bp, potency), source_eff_level }, + duration, + source, + ), + EffectKind::Hot { per_tick_bp, duration } => self.install( + target, + StatusKind::Hot { per_tick_bp: apply_bp(per_tick_bp, potency) }, + duration, + source, + ), + EffectKind::RegenSabotage { reduce_bp, duration } => { + self.install(target, StatusKind::RegenSabotage { reduce_bp }, duration, source) + } + EffectKind::FatigueAmp { amp_bp, duration } => { + self.install(target, StatusKind::FatigueAmp { amp_bp }, duration, source) + } + EffectKind::MitigationBuff { stage, amount_bp, duration } => self.install( + target, + StatusKind::Mitigation { stage, amount_bp: apply_bp(amount_bp, potency) }, + duration, + source, + ), + EffectKind::MitigationDebuff { stage, amount_bp, duration } => self.install( + target, + // A debuff strips mitigation → stored negative so the pipeline + // amplifies the hit (a vulnerability/sunder). + StatusKind::Mitigation { stage, amount_bp: -apply_bp(amount_bp, potency) }, + duration, + source, + ), + } + } + + fn install(&mut self, target: ActorId, kind: StatusKind, duration: u32, source: ActorId) { + if duration == 0 { + return; + } + let expires_at = self.tick + duration as u64 - 1; + self.actors[target as usize].add_status(Status { kind, expires_at, source }); + } + + fn queue(&mut self, hit: PendingHit) { + self.pending.push(hit); + } + + fn next_seq(&mut self) -> u64 { + self.seq += 1; + self.seq + } + + /// Apply deterministic ± damage variance to a magnitude. Returns the input + /// unchanged when variance is disabled. Keyed by `(source, target, seq, tick)` + /// so it never reshuffles another hit's stream (§10). + fn varied(&self, amount: Milli, source: ActorId, target: ActorId, seq: u64) -> Milli { + let var = self.rules.damage_variance_bp; + if var <= 0 { + return amount; + } + let mut rng = self.root_rng.fork(&format!( + "hit:src{source}:tgt{target}:seq{seq}:tick{}", + self.tick + )); + // Uniform factor in [BP_ONE - var, BP_ONE + var]. + let factor = (crate::fixed::BP_ONE - var) + rng.range(0, (2 * var + 1) as i32) as i64; + apply_bp(amount, factor) + } + + // --- step 3 ------------------------------------------------------------ + + fn apply_hits(&mut self) { + // Sequential in ascending source-id order, stable within a source: hit 1 + // lowers fill before hit 2's stagger check (§10). + let mut pending = std::mem::take(&mut self.pending); + pending.sort_by_key(|h| (h.source, h.seq)); + for h in pending { + self.apply_one(h); + } + } + + fn apply_one(&mut self, h: PendingHit) { + let tidx = h.target as usize; + if self.actors[tidx].stagger.is_dead() { + return; + } + match h.payload { + HitPayload::Fill { pre_mitigation } => { + // Per-hit damage variance (deterministic, keyed by the hit). The + // genre's damage range; also what desyncs a mirror match. + let pre_mitigation = self.varied(pre_mitigation, h.source, h.target, h.seq); + let con = self + .rules + .con_mult_bp(h.source_eff_level, self.actors[tidx].level); + let inp = MitigationInputs { + armor_reduce_bp: self.actors[tidx].armor_bp, + con_mult_bp: con, + active_defense_reduce_bp: self.actors[tidx].mitigation_at(MitigationStage::ActiveDefense), + stance_reduce_bp: self.actors[tidx].mitigation_at(MitigationStage::Stance), + buff_reduce_bp: self.actors[tidx].mitigation_at(MitigationStage::Buff), + }; + let mit = mitigation::run(pre_mitigation, inp); + + // Capture cast-in-progress to detect interruption. + let was_casting = self.actors[tidx] + .casting + .as_ref() + .map(|c| c.ability_id.clone()); + + let outcome = self.actors[tidx].take_hit(mit.landed, self.tick, &self.rules.stagger); + self.recorder + .hit(self.tick, h.source, &h.ability_id, &self.actors[tidx], mit, outcome); + + if matches!(outcome, HitOutcome::Dazed | HitOutcome::Unconscious | HitOutcome::Killed) { + if let Some(cast_id) = was_casting { + if self.actors[tidx].casting.is_none() { + self.recorder.interrupted(self.tick, h.target, &cast_id); + } + } + } + if matches!(outcome, HitOutcome::Killed) { + self.recorder.death(self.tick, h.target); + } + } + HitPayload::Ceiling { magnitude } => { + // Con effectiveness gates how much of the curse lands (§7). + let con = self + .rules + .con_mult_bp(h.source_eff_level, self.actors[tidx].level); + let landed = apply_bp(magnitude, con); + let before_down = !self.actors[tidx].is_active(); + self.actors[tidx].add_fatigue(landed); + self.recorder + .ceiling_damage(self.tick, h.source, h.target, &h.ability_id, landed); + if !before_down && !self.actors[tidx].is_active() { + // Collapse from the curse counts as a death-adjacent event only + // if it actually killed; unconscious is logged via state. + if self.actors[tidx].stagger.is_dead() { + self.recorder.death(self.tick, h.target); + } + } + } + HitPayload::Recovery { magnitude } => { + let was_down = !self.actors[tidx].is_active(); + self.actors[tidx] + .recover_fatigue(magnitude, self.tick, self.rules.revive_daze); + let revived = was_down && self.actors[tidx].is_active(); + self.recorder + .recovery(self.tick, h.source, h.target, &h.ability_id, magnitude, revived); + } + HitPayload::Heal { magnitude } => { + let was_down = !self.actors[tidx].is_active(); + self.actors[tidx] + .restore_fill(magnitude, self.tick, self.rules.revive_daze); + let revived = was_down && self.actors[tidx].is_active(); + self.recorder.fill_restored(h.target, magnitude); + if revived { + self.recorder + .recovery(self.tick, h.source, h.target, &h.ability_id, 0, true); + } + } + } + } + + // --- step 4 ------------------------------------------------------------ + + fn tick_over_time(&mut self) { + let n = self.actors.len(); + for tidx in 0..n { + if self.actors[tidx].stagger.is_dead() { + continue; + } + // Snapshot the over-time statuses to apply (avoid borrow conflicts). + let tmax = self.actors[tidx].max_vigor; + let tlevel = self.actors[tidx].level; + let mut dots: Vec<(Milli, i32, ActorId)> = Vec::new(); + let mut hots: Vec = Vec::new(); + for s in &self.actors[tidx].statuses { + match s.kind { + StatusKind::Dot { per_tick_bp, source_eff_level } => { + dots.push((apply_bp(tmax, per_tick_bp), source_eff_level, s.source)); + } + StatusKind::Hot { per_tick_bp } => { + hots.push(apply_bp(tmax, per_tick_bp)); + } + _ => {} + } + } + for (pre, src_level, src) in dots { + let con = self.rules.con_mult_bp(src_level, tlevel); + let inp = MitigationInputs { + armor_reduce_bp: self.actors[tidx].armor_bp, + con_mult_bp: con, + active_defense_reduce_bp: 0, // a poison isn't parried + stance_reduce_bp: self.actors[tidx].mitigation_at(MitigationStage::Stance), + buff_reduce_bp: self.actors[tidx].mitigation_at(MitigationStage::Buff), + }; + let mit = mitigation::run(pre, inp); + let outcome = self.actors[tidx].take_hit(mit.landed, self.tick, &self.rules.stagger); + self.recorder + .hit(self.tick, src, "dot", &self.actors[tidx], mit, outcome); + if matches!(outcome, HitOutcome::Killed) { + self.recorder.death(self.tick, self.actors[tidx].id); + } + } + for amount in hots { + self.actors[tidx] + .restore_fill(amount, self.tick, self.rules.revive_daze); + self.recorder.fill_restored(self.actors[tidx].id, amount); + } + } + } + + // --- step 5 ------------------------------------------------------------ + + fn apply_regen(&mut self) { + for a in &mut self.actors { + let before = a.vigor; + a.regen_tick(true, &self.rules.regen); + let gained = a.vigor - before; + if gained > 0 { + self.recorder.regen(a.id, gained); + } + } + } + + fn sample(&mut self) { + let tick = self.tick; + // Snapshot ids first to avoid borrowing actors while recorder mutates. + for i in 0..self.actors.len() { + let snapshot = self.actors[i].clone(); + self.recorder.sample(tick, &snapshot); + } + } + + // --- resolution -------------------------------------------------------- + + /// `Some(outcome)` once the fight is decided: one team holds the only + /// conscious actor(s) (win), or none are conscious (draw). + fn resolution(&self) -> Option { + let mut teams_up: Vec = Vec::new(); + for a in &self.actors { + if a.is_active() && !teams_up.contains(&a.team) { + teams_up.push(a.team); + } + } + match teams_up.len() { + 0 => Some(Outcome::Draw), + 1 => Some(Outcome::Win { team: teams_up[0] }), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ability::{AbilitySpec, Delivery, RawCost}; + use crate::actor::{compile_actor, StatBlock}; + use crate::config::CombatConfig; + use crate::effect::EffectSpec; + use std::collections::BTreeMap; + + fn swing_spec() -> AbilitySpec { + AbilitySpec { + id: "swing".into(), + name: "Swing".into(), + school: "martial".into(), + delivery: Delivery::Melee, + effects: vec![EffectSpec::new(EffectKind::FillDamage { magnitude_bp: 1_000 }, 0)], + potency_bp: 10_000, + targets: 1, + cast_time: 0, + cooldown: 10, + cost_override: Some(RawCost { fill: 0, fatigue: 1 }), + fatigue_units: None, + difficulty: None, + } + } + + fn fighter(name: &str, team: u8, level: i32) -> StatBlock { + StatBlock { + name: name.into(), + team, + level, + max_vigor: 100, + regen_idle_per_sec: 10, + regen_combat_per_sec: 5, + armor_bp: 0, + competency: BTreeMap::new(), + loadout: vec!["swing".into()], + start_vigor_bp: 10_000, + start_fatigue_bp: 0, + } + } + + fn build(cfg: &CombatConfig, blocks: &[StatBlock]) -> (Vec, Rules) { + let rules = cfg.compile(); + let actors = blocks + .iter() + .enumerate() + .map(|(i, sb)| { + let (loadout, missing) = rules.resolve_loadout(&sb.loadout); + assert!(missing.is_empty(), "missing abilities: {missing:?}"); + compile_actor(i as ActorId, sb, loadout, rules.tick_rate) + }) + .collect(); + (actors, rules) + } + + #[test] + fn deterministic_even_fight_resolves() { + let cfg = CombatConfig { abilities: vec![swing_spec()], ..Default::default() }; + let blocks = vec![fighter("A", 0, 10), fighter("B", 1, 10)]; + let (actors, rules) = build(&cfg, &blocks); + + let controllers: Vec> = vec![ + Box::new(crate::controller::SwingOnCooldown), + Box::new(crate::controller::SwingOnCooldown), + ]; + let enc = Encounter::new(actors, controllers, rules, 42, EncounterOptions::default()); + let m = enc.run(); + // Two identical swingers: it ends (someone goes down) and doesn't time out. + assert!(matches!(m.outcome, Outcome::Win { .. } | Outcome::Draw)); + assert!(m.ticks < 6_000); + } + + #[test] + fn same_seed_same_outcome() { + let cfg = CombatConfig { abilities: vec![swing_spec()], ..Default::default() }; + let blocks = vec![fighter("A", 0, 10), fighter("B", 1, 12)]; + + let run_once = |seed: u64| { + let (actors, rules) = build(&cfg, &blocks); + let controllers: Vec> = vec![ + Box::new(crate::controller::SwingOnCooldown), + Box::new(crate::controller::SwingOnCooldown), + ]; + Encounter::new(actors, controllers, rules, seed, EncounterOptions::default()).run() + }; + let a = run_once(7); + let b = run_once(7); + assert_eq!(a.ticks, b.ticks); + assert_eq!(format!("{:?}", a.outcome), format!("{:?}", b.outcome)); + assert_eq!(a.actors[0].fill_damage_dealt, b.actors[0].fill_damage_dealt); + } + + #[test] + fn higher_con_attacker_wins() { + let cfg = CombatConfig { abilities: vec![swing_spec()], ..Default::default() }; + // +5 levels of con advantage should win reliably. + let blocks = vec![fighter("Strong", 0, 15), fighter("Weak", 1, 10)]; + let (actors, rules) = build(&cfg, &blocks); + let controllers: Vec> = vec![ + Box::new(crate::controller::SwingOnCooldown), + Box::new(crate::controller::SwingOnCooldown), + ]; + let m = Encounter::new(actors, controllers, rules, 1, EncounterOptions::default()).run(); + assert_eq!(m.outcome, Outcome::Win { team: 0 }); + } +} diff --git a/combat/combat-core/src/event.rs b/combat/combat-core/src/event.rs new file mode 100644 index 0000000..7c63785 --- /dev/null +++ b/combat/combat-core/src/event.rs @@ -0,0 +1,524 @@ +//! Instrumentation (spec §12): a structured event stream plus a recorder that +//! rolls it into per-fight metrics. Outcomes alone can't tell you *why* a config +//! feels bad, so the schema records **causes** — pre-mitigation damage and each +//! mitigation stage's contribution (you can't tune the con curve from `landed` +//! alone), `blocked` reasons (a "wanted to act but couldn't" signal that makes +//! soft-lock/turtle modes visible), and a fizzle-vs-interrupt split. + +use crate::actor::{Actor, ActorId, HitOutcome, StaggerState}; +use crate::fixed::Milli; +use serde::{Deserialize, Serialize}; + +/// Why an attempted action didn't happen (§12 `action_blocked_reason`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BlockedReason { + CantAfford, + OnCooldown, + Dazed, + NoTarget, +} + +/// Why a committed ability produced nothing (§12 `fail_reason`). Kept separate so +/// "competency too low" and "wind-ups too long" never collapse into one signal. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FailReason { + CompetencyFizzle, + Interrupted, +} + +/// How a fight ended. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Outcome { + /// A single team had the last conscious actor(s) standing. + Win { team: u8 }, + /// No conscious actors on any team (mutual collapse). + Draw, + /// Tick budget exhausted with multiple teams still up. + Timeout, +} + +/// The raw event stream (opt-in; not stored for million-fight sweeps). A tagged +/// enum so it serializes cleanly to JSON for inspecting a single fight. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum Event { + Action { + tick: u64, + actor: ActorId, + ability: String, + fill_before: Milli, + fill_after: Milli, + fatigue: Milli, + cap: Milli, + }, + CastStarted { + tick: u64, + actor: ActorId, + ability: String, + completes_at: u64, + }, + Blocked { + tick: u64, + actor: ActorId, + ability: String, + reason: BlockedReason, + }, + Fail { + tick: u64, + actor: ActorId, + ability: String, + reason: FailReason, + }, + Hit { + tick: u64, + source: ActorId, + target: ActorId, + ability: String, + pre_mitigation: Milli, + armor: Milli, + con: Milli, + defense: Milli, + stance: Milli, + buff: Milli, + landed: Milli, + outcome: HitKind, + target_fill_after: Milli, + target_cap_after: Milli, + }, + Ceiling { + tick: u64, + source: ActorId, + target: ActorId, + ability: String, + fatigue_added: Milli, + }, + Recovery { + tick: u64, + source: ActorId, + target: ActorId, + ability: String, + fatigue_healed: Milli, + revived: bool, + }, + Death { + tick: u64, + actor: ActorId, + }, + End { + tick: u64, + outcome: Outcome, + }, +} + +/// Serializable mirror of [`HitOutcome`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HitKind { + Absorbed, + Dazed, + Unconscious, + Killed, +} + +impl From for HitKind { + fn from(o: HitOutcome) -> Self { + match o { + HitOutcome::Absorbed => HitKind::Absorbed, + HitOutcome::Dazed => HitKind::Dazed, + HitOutcome::Unconscious => HitKind::Unconscious, + HitOutcome::Killed => HitKind::Killed, + } + } +} + +/// Serializable final state of an actor. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FinalState { + Alive, + Dazed, + Unconscious, + Dead, +} + +impl From for FinalState { + fn from(s: StaggerState) -> Self { + match s { + StaggerState::Normal => FinalState::Alive, + StaggerState::Dazed { .. } => FinalState::Dazed, + StaggerState::Unconscious => FinalState::Unconscious, + StaggerState::Dead => FinalState::Dead, + } + } +} + +/// Per-actor rolled-up metrics for one fight (§12). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ActorMetrics { + pub id: ActorId, + pub name: String, + pub team: u8, + pub level: i32, + + // Throughput. + pub fill_damage_dealt: Milli, + pub fill_damage_taken: Milli, + pub ceiling_damage_dealt: Milli, + pub ceiling_damage_taken: Milli, + + // Fill-flow accounting (§12 make-or-break co-tuning): where every point went. + pub fill_gained_regen: Milli, + pub fill_spent_actions: Milli, + pub fill_lost_damage: Milli, + pub fill_restored: Milli, + + // Stagger ledger. + pub dazes_inflicted: u32, + pub unconscious_inflicted: u32, + pub dazes_taken: u32, + pub unconscious_taken: u32, + + // Reliability. + pub fizzles: u32, + pub interruptions_suffered: u32, + + // Blocked-action breakdown. + pub blocked_cant_afford: u32, + pub blocked_on_cooldown: u32, + pub blocked_dazed: u32, + pub blocked_no_target: u32, + + // Action mix — feeds the auto-attack-only guardrail (§12). + pub auto_attacks: u32, + pub abilities_used: u32, + pub recoveries_used: u32, + pub idle_ticks: u32, + + pub near_death_recoveries: u32, + + // Lows over the fight. + pub min_fill: Milli, + pub min_cap: Milli, + + pub final_state: FinalState, + + /// Sampled `cap` over time (the ceiling-decay curve). Empty unless sampling + /// is enabled. + pub cap_series: Vec, + /// Sampled `vigor` over time. Empty unless sampling is enabled. + pub fill_series: Vec, +} + +impl ActorMetrics { + fn new(a: &Actor) -> Self { + Self { + id: a.id, + name: a.name.clone(), + team: a.team, + level: a.level, + fill_damage_dealt: 0, + fill_damage_taken: 0, + ceiling_damage_dealt: 0, + ceiling_damage_taken: 0, + fill_gained_regen: 0, + fill_spent_actions: 0, + fill_lost_damage: 0, + fill_restored: 0, + dazes_inflicted: 0, + unconscious_inflicted: 0, + dazes_taken: 0, + unconscious_taken: 0, + fizzles: 0, + interruptions_suffered: 0, + blocked_cant_afford: 0, + blocked_on_cooldown: 0, + blocked_dazed: 0, + blocked_no_target: 0, + auto_attacks: 0, + abilities_used: 0, + recoveries_used: 0, + idle_ticks: 0, + near_death_recoveries: 0, + min_fill: a.vigor, + min_cap: a.cap(), + final_state: FinalState::Alive, + cap_series: Vec::new(), + fill_series: Vec::new(), + } + } +} + +/// Whole-fight metrics (§12). The sim exports one row per fight from this. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct FightMetrics { + pub seed: u64, + pub ticks: u64, + pub tick_rate: u32, + pub outcome: Outcome, + pub actors: Vec, +} + +impl FightMetrics { + /// Fight duration in seconds (derived from ticks and rate). + pub fn seconds(&self) -> f64 { + self.ticks as f64 / self.tick_rate.max(1) as f64 + } + + /// Look up an actor's metrics by id. + pub fn actor(&self, id: ActorId) -> Option<&ActorMetrics> { + self.actors.iter().find(|m| m.id == id) + } +} + +/// Accumulates events into [`FightMetrics`]. Always tracks the rollups; only +/// retains the raw [`Event`] log when `log_events` is set (cheap for a single +/// inspected fight, skipped for million-fight sweeps). Sampling `cap`/`fill` +/// series is gated by `sample_every` (0 = off). +#[derive(Clone, Debug)] +pub struct FightRecorder { + metrics: Vec, + events: Vec, + log_events: bool, + sample_every: u32, + tick_rate: u32, + seed: u64, +} + +impl FightRecorder { + pub fn new(actors: &[Actor], seed: u64, tick_rate: u32, log_events: bool, sample_every: u32) -> Self { + Self { + metrics: actors.iter().map(ActorMetrics::new).collect(), + events: Vec::new(), + log_events, + sample_every, + tick_rate, + seed, + } + } + + fn m(&mut self, id: ActorId) -> &mut ActorMetrics { + // Actor ids are dense 0..n in encounters; fall back to a search if not. + let idx = self + .metrics + .iter() + .position(|x| x.id == id) + .expect("metrics exist for every actor"); + &mut self.metrics[idx] + } + + fn push(&mut self, e: Event) { + if self.log_events { + self.events.push(e); + } + } + + // --- engine-facing recording API -------------------------------------- + + pub fn action(&mut self, tick: u64, a: &Actor, ability: &str, fill_before: Milli) { + self.push(Event::Action { + tick, + actor: a.id, + ability: ability.to_string(), + fill_before, + fill_after: a.vigor, + fatigue: a.fatigue, + cap: a.cap(), + }); + } + + pub fn cast_started(&mut self, tick: u64, actor: ActorId, ability: &str, completes_at: u64) { + self.push(Event::CastStarted { + tick, + actor, + ability: ability.to_string(), + completes_at, + }); + } + + pub fn idle(&mut self, actor: ActorId) { + self.m(actor).idle_ticks += 1; + } + + pub fn auto_attack(&mut self, actor: ActorId) { + self.m(actor).auto_attacks += 1; + } + + pub fn ability_used(&mut self, actor: ActorId) { + self.m(actor).abilities_used += 1; + } + + pub fn recovery_used(&mut self, actor: ActorId) { + self.m(actor).recoveries_used += 1; + } + + pub fn spent(&mut self, actor: ActorId, fill: Milli) { + self.m(actor).fill_spent_actions += fill; + } + + pub fn blocked(&mut self, tick: u64, actor: ActorId, ability: &str, reason: BlockedReason) { + { + let m = self.m(actor); + match reason { + BlockedReason::CantAfford => m.blocked_cant_afford += 1, + BlockedReason::OnCooldown => m.blocked_on_cooldown += 1, + BlockedReason::Dazed => m.blocked_dazed += 1, + BlockedReason::NoTarget => m.blocked_no_target += 1, + } + } + self.push(Event::Blocked { + tick, + actor, + ability: ability.to_string(), + reason, + }); + } + + pub fn fizzle(&mut self, tick: u64, actor: ActorId, ability: &str) { + self.m(actor).fizzles += 1; + self.push(Event::Fail { + tick, + actor, + ability: ability.to_string(), + reason: FailReason::CompetencyFizzle, + }); + } + + pub fn interrupted(&mut self, tick: u64, actor: ActorId, ability: &str) { + self.m(actor).interruptions_suffered += 1; + self.push(Event::Fail { + tick, + actor, + ability: ability.to_string(), + reason: FailReason::Interrupted, + }); + } + + /// Record a resolved hit with its full mitigation breakdown. + #[allow(clippy::too_many_arguments)] + pub fn hit( + &mut self, + tick: u64, + source: ActorId, + ability: &str, + target: &Actor, + mit: crate::mitigation::MitigationResult, + outcome: HitOutcome, + ) { + { + let m = self.m(source); + m.fill_damage_dealt += mit.landed; + match outcome { + HitOutcome::Dazed => m.dazes_inflicted += 1, + HitOutcome::Unconscious | HitOutcome::Killed => m.unconscious_inflicted += 1, + HitOutcome::Absorbed => {} + } + } + { + let m = self.m(target.id); + m.fill_damage_taken += mit.landed; + m.fill_lost_damage += mit.landed; + match outcome { + HitOutcome::Dazed => m.dazes_taken += 1, + HitOutcome::Unconscious => m.unconscious_taken += 1, + _ => {} + } + } + self.push(Event::Hit { + tick, + source, + target: target.id, + ability: ability.to_string(), + pre_mitigation: mit.pre_mitigation, + armor: mit.armor_delta, + con: mit.con_delta, + defense: mit.defense_delta, + stance: mit.stance_delta, + buff: mit.buff_delta, + landed: mit.landed, + outcome: outcome.into(), + target_fill_after: target.vigor, + target_cap_after: target.cap(), + }); + } + + pub fn ceiling_damage(&mut self, tick: u64, source: ActorId, target: ActorId, ability: &str, fatigue: Milli) { + self.m(source).ceiling_damage_dealt += fatigue; + self.m(target).ceiling_damage_taken += fatigue; + self.push(Event::Ceiling { + tick, + source, + target, + ability: ability.to_string(), + fatigue_added: fatigue, + }); + } + + pub fn recovery( + &mut self, + tick: u64, + source: ActorId, + target: ActorId, + ability: &str, + fatigue_healed: Milli, + revived: bool, + ) { + if revived { + self.m(target).near_death_recoveries += 1; + } + self.push(Event::Recovery { + tick, + source, + target, + ability: ability.to_string(), + fatigue_healed, + revived, + }); + } + + pub fn fill_restored(&mut self, target: ActorId, amount: Milli) { + self.m(target).fill_restored += amount; + } + + pub fn regen(&mut self, actor: ActorId, amount: Milli) { + self.m(actor).fill_gained_regen += amount; + } + + pub fn death(&mut self, tick: u64, actor: ActorId) { + self.push(Event::Death { tick, actor }); + } + + /// Per-tick sampling of lows + (optional) time series. + pub fn sample(&mut self, tick: u64, a: &Actor) { + let store_series = self.sample_every > 0 && tick.is_multiple_of(self.sample_every as u64); + let m = self.m(a.id); + m.min_fill = m.min_fill.min(a.vigor); + m.min_cap = m.min_cap.min(a.cap()); + if store_series { + m.cap_series.push(a.cap()); + m.fill_series.push(a.vigor); + } + } + + /// Finalize into [`FightMetrics`], stamping each actor's terminal state. + pub fn finish(mut self, tick: u64, outcome: Outcome, actors: &[Actor]) -> FightMetrics { + for a in actors { + self.m(a.id).final_state = a.stagger.into(); + } + self.push(Event::End { tick, outcome }); + FightMetrics { + seed: self.seed, + ticks: tick, + tick_rate: self.tick_rate, + outcome, + actors: self.metrics, + } + } + + /// The raw event log (empty unless `log_events` was set). + pub fn into_events(self) -> Vec { + self.events + } +} diff --git a/combat/combat-core/src/fixed.rs b/combat/combat-core/src/fixed.rs new file mode 100644 index 0000000..e43a191 --- /dev/null +++ b/combat/combat-core/src/fixed.rs @@ -0,0 +1,138 @@ +//! Integer fixed-point arithmetic — the *only* place magnitude math rounds. +//! +//! Determinism (spec §10) requires bit-exact results across compilers, opt +//! levels, and machines. IEEE-754 `f64` multiply *chains* do **not** give that, +//! so every balance-relevant magnitude is an integer and every formula routes +//! through the helpers here. +//! +//! ## Units +//! - **Magnitudes** (vigor, cap, fatigue, damage, regen) are **milli-units**: +//! one displayed "unit" of Vigor = [`UNIT`] (`1000`) internal points. The ×1000 +//! headroom means per-tick regen and small percentages don't round to zero. +//! - **Ratios / percentages / multipliers** are **basis points**: [`BP_ONE`] +//! (`10_000`) == `1.0`, so `2_500` == `25%`, `12_457` == `1.2457×`. +//! +//! ## Rounding +//! Every conversion rounds **half to even** (banker's rounding). Always-floor +//! or always-up accumulates a systematic skew over a long fight — e.g. +//! always-floor on mitigation quietly favors the defender (spec §15). Half-to- +//! even is symmetric around zero and unbiased on a stream of ties, so it is the +//! single documented rule for *every* pipeline stage. + +/// A magnitude in milli-units (vigor, fatigue, damage, …). Signed so that +/// recovery (negative fatigue cost) and over-spend are representable. +pub type Milli = i64; + +/// One displayed Vigor unit, in milli-units. +pub const UNIT: Milli = 1_000; + +/// `1.0` expressed in basis points. +pub const BP_ONE: i64 = 10_000; + +/// Multiply a magnitude by a basis-point ratio, rounding half-to-even. +/// +/// `apply_bp(20_000, 5_000)` = `20_000 × 0.5` = `10_000`. +/// `apply_bp(1_000, 12_457)` = `1_000 × 1.2457` ≈ `1_246`. +#[inline] +pub fn apply_bp(amount: Milli, ratio_bp: i64) -> Milli { + mul_div_round(amount, ratio_bp, BP_ONE) +} + +/// Convert displayed units to internal milli-units. +#[inline] +pub fn units(n: i64) -> Milli { + n * UNIT +} + +/// `a * b / den`, computed in `i128` to avoid overflow, rounded half-to-even. +/// `den` must be positive. +#[inline] +pub fn mul_div_round(a: i64, b: i64, den: i64) -> i64 { + debug_assert!(den > 0, "mul_div_round denominator must be positive"); + let num = (a as i128) * (b as i128); + div_round_even(num, den as i128) as i64 +} + +/// Integer division `num / den` rounding half **to even**. `den > 0`. +/// +/// Built on Euclidean division so the floor base and non-negative remainder are +/// well-defined for negative `num` (recovery / over-spend produce negatives). +#[inline] +fn div_round_even(num: i128, den: i128) -> i128 { + let q = num.div_euclid(den); // floor(num/den) + let r = num.rem_euclid(den); // 0 <= r < den + let twice = r * 2; + if twice < den { + q + } else if twice > den { + q + 1 + } else { + // Exactly halfway: round toward the even neighbor. + if q % 2 == 0 { + q + } else { + q + 1 + } + } +} + +/// Clamp `x` into `[lo, hi]`. Panics in debug if `lo > hi`. +#[inline] +pub fn clamp(x: Milli, lo: Milli, hi: Milli) -> Milli { + debug_assert!(lo <= hi); + x.max(lo).min(hi) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn apply_bp_basic() { + assert_eq!(apply_bp(20_000, 5_000), 10_000); // 50% + assert_eq!(apply_bp(20_000, 10_000), 20_000); // 100% + assert_eq!(apply_bp(20_000, 0), 0); + assert_eq!(apply_bp(1_000, 12_457), 1_246); // 1.2457×, rounds 1245.7 -> 1246 + } + + #[test] + fn rounds_half_to_even() { + // 2.5 -> 2 (even), 3.5 -> 4 (even), 1.5 -> 2, 0.5 -> 0. + assert_eq!(mul_div_round(5, 1, 2), 2); + assert_eq!(mul_div_round(7, 1, 2), 4); + assert_eq!(mul_div_round(3, 1, 2), 2); + assert_eq!(mul_div_round(1, 1, 2), 0); + } + + #[test] + fn rounds_negatives_symmetrically() { + // -2.5 -> -2 (even), -3.5 -> -4, -0.5 -> 0, -1.5 -> -2. + assert_eq!(mul_div_round(-5, 1, 2), -2); + assert_eq!(mul_div_round(-7, 1, 2), -4); + assert_eq!(mul_div_round(-1, 1, 2), 0); + assert_eq!(mul_div_round(-3, 1, 2), -2); + } + + /// Over a symmetric stream of half-ties the even rule introduces no net + /// drift — the property that protects long-fight balance (spec §15). + #[test] + fn no_systematic_drift_on_ties() { + // Sum of round(k + 0.5) for k in -100..100 should equal the sum of the + // exact values (the +0.5 ties cancel under round-half-to-even). + let mut rounded = 0i128; + let mut exact = 0i128; + for k in -100..100 { + // value = (2k+1)/2 -> numerator 2k+1, den 2 + rounded += div_round_even((2 * k + 1) as i128, 2); + exact += (2 * k + 1) as i128; // times 2; compare scaled below + } + // exact average is sum/2; rounded should equal that with no bias. + assert_eq!(rounded * 2, exact); + } + + #[test] + fn mul_div_no_overflow_near_limits() { + // Large but realistic: 10^9 milli * 30000 bp / 10000 = 3 * 10^9. + assert_eq!(mul_div_round(1_000_000_000, 30_000, 10_000), 3_000_000_000); + } +} diff --git a/combat/combat-core/src/lib.rs b/combat/combat-core/src/lib.rs new file mode 100644 index 0000000..bba458f --- /dev/null +++ b/combat/combat-core/src/lib.rs @@ -0,0 +1,54 @@ +//! # combat-core +//! +//! A pure, deterministic, headless **combined-pool** combat engine. Every +//! combatant has a single `Vigor` pool that replaces HP / mana / stamina: +//! everything you do *and* everything done to you draws on the same bar, so +//! offense literally spends your survival. +//! +//! The crate is the single source of truth for combat math. It has no I/O, no +//! rendering, no real-time — a fight is a pure function of `(rules, seed, actors, +//! controllers)`, reproducible bit-for-bit (see [`rng`] and [`fixed`]). A batch +//! sim harness and (later) a real-time front-end are both just consumers. +//! +//! ## The shape of a fight +//! 1. Author a [`config::CombatConfig`] (the tunable "table") and `compile()` it +//! to [`config::Rules`]. +//! 2. Build [`actor::Actor`]s from [`actor::StatBlock`]s + resolved loadouts. +//! 3. Pair each with a [`controller::Controller`] (intent). +//! 4. Run an [`engine::Encounter`] → [`event::FightMetrics`]. +//! +//! ## Module map +//! - [`fixed`] — integer fixed-point arithmetic; the only place magnitudes round. +//! - [`rng`] — vendored order-independent, version-stable deterministic RNG. +//! - [`con`] — the level-delta → multiplier con curve. +//! - [`mitigation`] — the ordered "how much lands" pipeline. +//! - [`effect`] / [`ability`] — the composable effect library and unified ability +//! model (skills == spells) with its two-axis cost composer. +//! - [`actor`] — the symmetric combatant: combined pool, stagger cascade, statuses. +//! - [`config`] — the full tunable surface and its compiled [`config::Rules`]. +//! - [`controller`] — pluggable decision policies (dumb AI, scripted player, +//! hold-and-punish). +//! - [`event`] — the structured event stream + metrics recorder. +//! - [`engine`] — the fixed-tick loop and canonical within-tick resolution order. + +pub mod ability; +pub mod actor; +pub mod con; +pub mod config; +pub mod controller; +pub mod effect; +pub mod engine; +pub mod event; +pub mod fixed; +pub mod mitigation; +pub mod rng; + +// Convenience re-exports for the common surface. +pub use ability::{Ability, AbilitySpec, AbilityCost, Delivery}; +pub use actor::{Actor, ActorId, StatBlock, StaggerState}; +pub use config::{CombatConfig, Rules}; +pub use controller::{Controller, HoldAndPunish, ScriptedPlayer, SwingOnCooldown}; +pub use effect::{EffectKind, EffectSpec}; +pub use engine::{Encounter, EncounterOptions}; +pub use event::{ActorMetrics, FightMetrics, Outcome}; +pub use rng::Rng; diff --git a/combat/combat-core/src/mitigation.rs b/combat/combat-core/src/mitigation.rs new file mode 100644 index 0000000..f96e5c8 --- /dev/null +++ b/combat/combat-core/src/mitigation.rs @@ -0,0 +1,184 @@ +//! The mitigation pipeline (spec §8): one ordered place where "how much of this +//! hit actually lands" is decided, so new sources are additive contributors and +//! never reworks. +//! +//! Order is fixed: **Armor → Con → Active defense → Stance → Buff/debuff**. +//! Armor / defenses / stances / buffs are *reductions* (a fraction passes +//! through). Con is a *multiplier* drawn from the con curve (§7) — it can +//! amplify (over-con) as well as shrink (under-con). The pipeline reports each +//! stage's contribution because you cannot tune the con curve, the balance +//! lever, from the final `landed` number alone (spec §12). + +use crate::fixed::{apply_bp, Milli, BP_ONE}; +use serde::{Deserialize, Serialize}; + +/// The ordered stages of the pipeline. `ActiveDefense`, `Stance`, and `Buff` are +/// the stages that timed/temporary effects feed into. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MitigationStage { + Armor, + Con, + ActiveDefense, + Stance, + Buff, +} + +/// Per-stage reduction/multiplier inputs for a single hit, gathered by the +/// engine from the defender's stat block + active statuses. +/// +/// All `*_reduce_bp` are "fraction removed" in basis points (`10_000` == removes +/// everything). `con_mult_bp` is a straight multiplier (`10_000` == ×1.0, +/// `30_000` == ×3.0). `buff_reduce_bp` may be **negative** — a vulnerability +/// debuff that *amplifies* incoming damage. +#[derive(Clone, Copy, Debug)] +pub struct MitigationInputs { + pub armor_reduce_bp: i64, + pub con_mult_bp: i64, + pub active_defense_reduce_bp: i64, + pub stance_reduce_bp: i64, + pub buff_reduce_bp: i64, +} + +/// The landed damage plus the per-stage breakdown (each is `before − after` at +/// that stage, so they sum — with sign — to `pre_mitigation − landed`). The con +/// delta is negative when over-con amplifies the hit. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct MitigationResult { + pub pre_mitigation: Milli, + pub landed: Milli, + pub armor_delta: Milli, + pub con_delta: Milli, + pub defense_delta: Milli, + pub stance_delta: Milli, + pub buff_delta: Milli, +} + +/// Run a pre-mitigation magnitude through the full ordered pipeline. +/// +/// Each reduction multiplies the running value by `(1 − reduce)`, clamped so a +/// single reduction stage can null a hit but never make it negative. Con +/// multiplies (and may amplify). The final `landed` is floored at zero. +pub fn run(pre_mitigation: Milli, inp: MitigationInputs) -> MitigationResult { + let mut cur = pre_mitigation; + + // Armor — passive attrition reduction. + let after_armor = reduce(cur, inp.armor_reduce_bp); + let armor_delta = cur - after_armor; + cur = after_armor; + + // Con — level-delta multiplier (the amplify-capable stage). + let after_con = apply_bp(cur, inp.con_mult_bp.max(0)); + let con_delta = cur - after_con; // negative when amplified + cur = after_con; + + // Active defense — block/parry/evade windows. + let after_def = reduce(cur, inp.active_defense_reduce_bp); + let defense_delta = cur - after_def; + cur = after_def; + + // Stance — toggled mitigation trade. + let after_stance = reduce(cur, inp.stance_reduce_bp); + let stance_delta = cur - after_stance; + cur = after_stance; + + // Buff/debuff — temporary contributors. Negative reduce amplifies. + let after_buff = reduce_signed(cur, inp.buff_reduce_bp); + let buff_delta = cur - after_buff; + cur = after_buff.max(0); + + MitigationResult { + pre_mitigation, + landed: cur, + armor_delta, + con_delta, + defense_delta, + stance_delta, + buff_delta, + } +} + +/// Apply a non-negative reduction (`reduce_bp` clamped to `[0, BP_ONE]`). +#[inline] +fn reduce(amount: Milli, reduce_bp: i64) -> Milli { + let keep = BP_ONE - reduce_bp.clamp(0, BP_ONE); + apply_bp(amount, keep) +} + +/// Apply a signed reduction: positive shrinks (clamped to full negation), +/// negative amplifies (a vulnerability debuff). `keep = 1 − reduce` is clamped +/// at the low end to 0 (can't go negative) but may exceed 1 to amplify. +#[inline] +fn reduce_signed(amount: Milli, reduce_bp: i64) -> Milli { + let keep = (BP_ONE - reduce_bp).max(0); + apply_bp(amount, keep) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn no_mit(con_mult_bp: i64) -> MitigationInputs { + MitigationInputs { + armor_reduce_bp: 0, + con_mult_bp, + active_defense_reduce_bp: 0, + stance_reduce_bp: 0, + buff_reduce_bp: 0, + } + } + + #[test] + fn even_con_no_mitigation_is_identity() { + let r = run(10_000, no_mit(BP_ONE)); + assert_eq!(r.landed, 10_000); + assert_eq!(r.con_delta, 0); + } + + #[test] + fn over_con_amplifies_via_con_stage() { + let r = run(10_000, no_mit(30_000)); // ×3.0 + assert_eq!(r.landed, 30_000); + assert_eq!(r.con_delta, -20_000); // amplification reads as negative mitigation + } + + #[test] + fn armor_then_con_order_matters() { + // 50% armor, then ×2.0 con: 10000 -> 5000 -> 10000. + let inp = MitigationInputs { armor_reduce_bp: 5_000, ..no_mit(20_000) }; + let r = run(10_000, inp); + assert_eq!(r.armor_delta, 5_000); + assert_eq!(r.landed, 10_000); + } + + #[test] + fn active_defense_can_negate() { + let inp = MitigationInputs { active_defense_reduce_bp: 10_000, ..no_mit(BP_ONE) }; + let r = run(10_000, inp); + assert_eq!(r.landed, 0); + assert_eq!(r.defense_delta, 10_000); + } + + #[test] + fn vulnerability_debuff_amplifies() { + // buff_reduce_bp = -5000 => keep 1.5x. + let inp = MitigationInputs { buff_reduce_bp: -5_000, ..no_mit(BP_ONE) }; + let r = run(10_000, inp); + assert_eq!(r.landed, 15_000); + assert_eq!(r.buff_delta, -5_000); + } + + #[test] + fn deltas_reconcile_with_landed() { + let inp = MitigationInputs { + armor_reduce_bp: 2_000, + con_mult_bp: 16_000, + active_defense_reduce_bp: 3_000, + stance_reduce_bp: 1_000, + buff_reduce_bp: -1_000, + }; + let r = run(12_345, inp); + let sum = r.armor_delta + r.con_delta + r.defense_delta + r.stance_delta + r.buff_delta; + assert_eq!(r.pre_mitigation - r.landed, sum); + } +} diff --git a/combat/combat-core/src/rng.rs b/combat/combat-core/src/rng.rs new file mode 100644 index 0000000..c19a849 --- /dev/null +++ b/combat/combat-core/src/rng.rs @@ -0,0 +1,282 @@ +//! Deterministic, reproducible RNG — **vendored verbatim** from +//! `reikhelm-core/src/rng.rs` (spec §10), with one additive local extension: +//! [`Rng::chance_bp`], an integer-threshold probability check so that *no* +//! `f64` ever touches the combat hot path. +//! +//! [`Rng`] wraps [`rand_chacha::ChaCha8Rng`] — a portable, byte-stable stream +//! cipher RNG. The same seed yields the same sequence on every machine and +//! every run; we never touch `thread_rng`/`rand::random()`. +//! +//! The keystone feature is [`Rng::fork`]: it derives an *independent* child +//! stream from the parent's **basis seed** plus a label. Forking does not draw +//! from or otherwise mutate the parent (`fork(&self)`), so a child stream is a +//! pure function of `(basis_seed, label)` — independent of how many values were +//! pulled from the parent first. The combat engine leans on this to key every +//! roll by `"actor{id}:{roll_kind}:tick{t}"`, which is what makes "change one +//! knob, observe the isolated delta" hold: adding an effect to one ability +//! never reshuffles another actor's or another tick's stream. + +use rand_chacha::ChaCha8Rng; +// `rand_core` is not a direct dependency; reach its traits 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. +/// +/// Construct one with [`Rng::from_seed`], draw values with [`range`](Rng::range), +/// [`chance`](Rng::chance), [`chance_bp`](Rng::chance_bp), [`choose`](Rng::choose) +/// and [`shuffle`](Rng::shuffle), and derive isolated child streams with +/// [`fork`](Rng::fork). +#[derive(Clone, Debug)] +pub struct Rng { + /// The stable basis used for all forks: the seed this `Rng` was built from. + /// Forking mixes *this* (never the live ChaCha state) with the label, so a + /// fork is independent of how many draws the parent has taken. + 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. + /// + /// The seed is expanded into ChaCha8's 256-bit key by ChaCha8's own + /// portable `seed_from_u64`, so two `Rng`s built from the same `u64` + /// produce identical streams everywhere. + pub fn from_seed(seed: u64) -> Self { + Self { + basis_seed: seed, + inner: ChaCha8Rng::seed_from_u64(seed), + } + } + + /// Derive an independent child `Rng` from a stable basis (this `Rng`'s + /// originating seed) combined with `label`. + /// + /// This **does not** advance or otherwise mutate `self` (note `&self`), and + /// the result depends only on `(self.basis_seed, label)` — never on how many + /// values have already been drawn from `self`. Distinct labels (including + /// `"name#0"` vs `"name#1"`) yield distinct streams. + /// + /// The label→seed mix is the explicit, fixed integer routine in + /// [`mix_seed`] (splitmix64 + FNV-1a), chosen because it is stable across + /// runs and crate versions. We deliberately avoid + /// `std::collections::hash_map::DefaultHasher`, whose output is + /// unspecified and version-unstable (spec §10). + 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() + } + + /// Return an integer in the **half-open** range `[lo, hi)` — `lo` inclusive, + /// `hi` exclusive (so `range(0, 3)` yields one of `0, 1, 2`). + /// + /// If `hi <= lo` the range is empty; we return `lo` rather than panicking. + /// Uniformity is achieved with Lemire-style rejection sampling over the + /// `u64` span, so the result is unbiased across the whole range. + pub fn range(&mut self, lo: i32, hi: i32) -> i32 { + if hi <= lo { + return lo; + } + // Width fits in u64: (hi - lo) where hi > lo and both are i32. + let span = (hi as i64 - lo as i64) as u64; + lo + self.below(span) as i32 + } + + /// Uniform integer in `[0, span)` for `span > 0`, via rejection sampling to + /// avoid modulo bias. `span` is assumed non-zero by callers. + fn below(&mut self, span: u64) -> u64 { + // Largest multiple of `span` that fits in u64; anything at or above the + // threshold would skew the distribution, so we reject and redraw. + let threshold = u64::MAX - (u64::MAX % span); + loop { + let x = self.next_u64(); + if x < threshold { + return x % span; + } + } + } + + /// Return `true` with probability `p`. + /// + /// `p` is clamped to `[0.0, 1.0]`. The roll is a single `u64 → f64` + /// unit-interval divide-and-compare, which *is* IEEE-deterministic across + /// platforms (one division, one compare). Combat code prefers + /// [`chance_bp`](Rng::chance_bp) to keep the hot path integer-only, but this + /// remains available and identical to the upstream contract. + pub fn chance(&mut self, p: f64) -> bool { + if p <= 0.0 { + return false; + } + if p >= 1.0 { + return true; + } + // Map the next u64 into a uniform f64 in [0, 1) and compare. + // 53 bits gives the full mantissa precision of an f64. + let bits = self.next_u64() >> 11; // top 53 bits + let unit = bits as f64 / (1u64 << 53) as f64; + unit < p + } + + /// **Local extension (combat-core):** integer-only probability check. + /// + /// Returns `true` with probability `p_bp / 10_000` (basis points). This is + /// the integer-threshold `chance` variant the spec sanctions (§10): it + /// compares a fresh `u64` against `p_bp` scaled across the full `u64` span, + /// so no `f64` is involved at all. `p_bp <= 0` is always `false`, + /// `p_bp >= 10_000` is always `true`. + /// + /// Scaling: a draw is a "hit" when `draw < p_bp * (2^64 / 10_000)`. We use + /// `u128` for the threshold product so it cannot overflow, then truncate the + /// comparison back into `u64` space. The mapping is monotonic and exact at + /// the endpoints, which is all a fair Bernoulli trial needs. + pub fn chance_bp(&mut self, p_bp: i64) -> bool { + if p_bp <= 0 { + return false; + } + if p_bp >= 10_000 { + return true; + } + // threshold = floor(p_bp / 10_000 * 2^64), computed without overflow. + let span = 1u128 << 64; + let threshold = (span * p_bp as u128) / 10_000u128; // in [0, 2^64) + (self.next_u64() as u128) < threshold + } + + /// Return a reference to a uniformly chosen element of `items`, or `None` + /// if the slice is 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) + } + + /// Shuffle `items` in place into a uniformly random permutation + /// (in-place Fisher–Yates over our own [`range`]). Slices of length < 2 are + /// left unchanged. + pub fn shuffle(&mut self, items: &mut [T]) { + let len = items.len(); + if len < 2 { + return; + } + for i in (1..len).rev() { + let j = self.below((i + 1) as u64) as usize; + items.swap(i, j); + } + } +} + +/// Mix a 64-bit basis seed with a label into a fresh 64-bit seed for [`Rng::fork`]. +/// +/// This is the load-bearing determinism primitive (spec §10), spelled out +/// explicitly with fixed integer arithmetic — no `DefaultHasher`. +/// +/// Recipe: (1) splitmix64-scramble the basis; (2) fold each label byte with the +/// FNV-1a step (xor then multiply by the 64-bit FNV prime), making the result +/// order-sensitive so `"name#0"`/`"name#1"` diverge; (3) one final splitmix64 +/// finalizer to avalanche. +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_reproduces_sequence() { + let mut a = Rng::from_seed(0xABCD_1234); + let mut b = Rng::from_seed(0xABCD_1234); + let seq_a: Vec = (0..64).map(|_| a.range(0, 1000)).collect(); + let seq_b: Vec = (0..64).map(|_| b.range(0, 1000)).collect(); + assert_eq!(seq_a, seq_b); + } + + #[test] + fn fork_is_order_independent() { + let parent = Rng::from_seed(99); + let mut early = parent.fork("a"); + let mut drained = parent.clone(); + for _ in 0..50 { + let _ = drained.range(0, 100); + } + let mut late = drained.fork("a"); + let early_seq: Vec = (0..32).map(|_| early.range(0, 10_000)).collect(); + let late_seq: Vec = (0..32).map(|_| late.range(0, 10_000)).collect(); + assert_eq!(early_seq, late_seq, "fork must depend only on basis seed + label"); + } + + #[test] + fn fork_does_not_mutate_parent() { + let mut parent = Rng::from_seed(7); + let before: Vec = { + let mut probe = parent.clone(); + (0..16).map(|_| probe.range(0, 1000)).collect() + }; + let _child = parent.fork("whatever"); + let after: Vec = (0..16).map(|_| parent.range(0, 1000)).collect(); + assert_eq!(before, after); + } + + #[test] + fn distinct_labels_diverge() { + let parent = Rng::from_seed(42); + let mut a = parent.fork("a"); + let mut b = parent.fork("b"); + let seq_a: Vec = (0..32).map(|_| a.range(0, 1_000_000)).collect(); + let seq_b: Vec = (0..32).map(|_| b.range(0, 1_000_000)).collect(); + assert_ne!(seq_a, seq_b); + } + + /// The integer-threshold `chance_bp` saturates at the endpoints and lands + /// near its nominal rate over many draws. + #[test] + fn chance_bp_saturates_and_is_fair() { + let mut rng = Rng::from_seed(9); + assert!(!rng.chance_bp(0)); + assert!(!rng.chance_bp(-100)); + assert!(rng.chance_bp(10_000)); + assert!(rng.chance_bp(20_000)); + + let mut a = Rng::from_seed(2); + let hits = (0..10_000).filter(|_| a.chance_bp(5_000)).count(); + assert!((4_700..5_300).contains(&hits), "0.5 chance_bp produced {hits}/10000"); + + // A low rate should be rare but non-zero over enough draws. + let mut b = Rng::from_seed(3); + let rare = (0..100_000).filter(|_| b.chance_bp(100)).count(); // 1% + assert!((700..1_300).contains(&rare), "1% chance_bp produced {rare}/100000"); + } + + /// `chance_bp` is reproducible under a fixed seed. + #[test] + fn chance_bp_reproduces() { + let mut a = Rng::from_seed(123); + let mut b = Rng::from_seed(123); + let sa: Vec = (0..200).map(|_| a.chance_bp(3_333)).collect(); + let sb: Vec = (0..200).map(|_| b.chance_bp(3_333)).collect(); + assert_eq!(sa, sb); + } +} diff --git a/combat/combat-core/tests/determinism.rs b/combat/combat-core/tests/determinism.rs new file mode 100644 index 0000000..b5f07a3 --- /dev/null +++ b/combat/combat-core/tests/determinism.rs @@ -0,0 +1,115 @@ +//! End-to-end determinism + public-API smoke tests (spec §10). +//! +//! The whole analysis story rests on one guarantee: a fight is a bit-reproducible +//! function of `(rules, seed, actors, controllers)`. These tests drive the crate +//! exactly as `combat-sim` does and assert that guarantee at the event-log level. + +use combat_core::actor::{compile_actor, StatBlock}; +use combat_core::config::CombatConfig; +use combat_core::controller::{Controller, HoldAndPunish, ScriptedPlayer}; +use combat_core::engine::{Encounter, EncounterOptions}; +use combat_core::{Actor, ActorId}; +use std::collections::BTreeMap; + +fn duelist(name: &str, team: u8, level: i32) -> StatBlock { + let mut competency = BTreeMap::new(); + competency.insert("martial".to_string(), 50); + competency.insert("restoration".to_string(), 30); + StatBlock { + name: name.into(), + team, + level, + max_vigor: 120, + regen_idle_per_sec: 12, + regen_combat_per_sec: 6, + armor_bp: 1500, + competency, + loadout: vec![ + "auto_attack".into(), + "quick_jab".into(), + "heavy_strike".into(), + "second_wind".into(), + "guard".into(), + ], + start_vigor_bp: 10_000, + start_fatigue_bp: 0, + } +} + +fn config() -> CombatConfig { + let json = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../configs/default.json")) + .expect("default config readable"); + serde_json::from_str(&json).expect("default config parses") +} + +fn build(cfg: &CombatConfig, blocks: &[StatBlock]) -> Vec { + let rules = cfg.compile(); + blocks + .iter() + .enumerate() + .map(|(i, sb)| { + let (loadout, missing) = rules.resolve_loadout(&sb.loadout); + assert!(missing.is_empty(), "config loadout has unknown abilities: {missing:?}"); + compile_actor(i as ActorId, sb, loadout, rules.tick_rate) + }) + .collect() +} + +fn controllers() -> Vec> { + vec![Box::new(ScriptedPlayer::new(80)), Box::new(HoldAndPunish::default())] +} + +#[test] +fn full_fight_event_log_is_bit_reproducible() { + let cfg = config(); + let blocks = vec![duelist("P", 0, 10), duelist("E", 1, 12)]; + let opts = EncounterOptions { max_ticks: 6_000, log_events: true, sample_every: 0 }; + + let run = || { + let actors = build(&cfg, &blocks); + Encounter::new(actors, controllers(), cfg.compile(), 12345, opts).run_with_events() + }; + let (m1, e1) = run(); + let (m2, e2) = run(); + + assert_eq!(m1.ticks, m2.ticks); + assert_eq!(format!("{:?}", m1.outcome), format!("{:?}", m2.outcome)); + // Event logs must be identical event-for-event — the strongest determinism + // claim the crate makes. + let j1 = serde_json::to_string(&e1).unwrap(); + let j2 = serde_json::to_string(&e2).unwrap(); + assert_eq!(j1, j2, "event logs diverged under identical inputs"); + assert!(!e1.is_empty(), "a real fight should emit events"); +} + +#[test] +fn different_seeds_can_diverge() { + let cfg = config(); + let blocks = vec![duelist("P", 0, 10), duelist("E", 1, 10)]; + let opts = EncounterOptions::default(); + + let outcome_for = |seed: u64| { + let actors = build(&cfg, &blocks); + let m = Encounter::new(actors, controllers(), cfg.compile(), seed, opts).run(); + (m.ticks, format!("{:?}", m.outcome)) + }; + // Across many seeds an even-con fight produces a spread of durations (not a + // single fixed value), evidence the RNG actually drives variety. + let durations: std::collections::BTreeSet = + (0..40).map(|s| outcome_for(s).0).collect(); + assert!(durations.len() > 3, "expected varied fight lengths across seeds"); +} + +#[test] +fn config_round_trips_and_compiles() { + let cfg = config(); + let rules = cfg.compile(); + // The shipped library resolves and the headline abilities exist. + for id in ["auto_attack", "quick_jab", "heavy_strike", "second_wind", "guard", "fireball"] { + assert!(rules.abilities.contains_key(id), "missing ability {id}"); + } + // Re-serialize and re-parse to confirm the config is stable through serde. + let j = serde_json::to_string(&cfg).unwrap(); + let back: CombatConfig = serde_json::from_str(&j).unwrap(); + assert_eq!(back.compile().abilities.len(), rules.abilities.len()); +} diff --git a/combat/combat-sim/Cargo.toml b/combat/combat-sim/Cargo.toml new file mode 100644 index 0000000..f9a8d6e --- /dev/null +++ b/combat/combat-sim/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "combat-sim" +version = "0.1.0" +edition = "2021" +rust-version = "1.96" +description = "Batch simulation harness over combat-core: sweeps, metrics, CSV/JSON export." + +[dependencies] +combat-core = { path = "../combat-core" } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +csv = "1.3" diff --git a/combat/combat-sim/src/main.rs b/combat/combat-sim/src/main.rs new file mode 100644 index 0000000..1f8029a --- /dev/null +++ b/combat/combat-sim/src/main.rs @@ -0,0 +1,476 @@ +//! combat-sim: the batch harness over `combat-core` (spec §12). +//! +//! Loads the config "table", runs N fights across a matchup/parameter sweep, and +//! exports per-fight metrics so "feel" (fight duration, difficulty curve) is +//! *measured and tuned*, not eyeballed. The two headline sweeps: +//! +//! - **con** — player vs. an equal-kit enemy across level delta × player-skill, +//! with auto-attack-only included as one player policy so the §12 anti-turtle +//! guardrail (fraction of fights won by auto-only) falls out of the same table. +//! - **archetypes** — a skilled player against golem / rat / battlemage. +//! +//! Outputs (under `--out`, default `out/`): `fights.csv` (one row per fight), +//! `summary.csv` (aggregated per scenario cell), `sample_fight.json` (one fight's +//! full event log + metrics for plotting a single fight). + +use combat_core::actor::{compile_actor, StatBlock}; +use combat_core::config::Rules; +use combat_core::controller::{Controller, HoldAndPunish, ScriptedPlayer, SwingOnCooldown}; +use combat_core::engine::{Encounter, EncounterOptions}; +use combat_core::event::{FightMetrics, FinalState, Outcome}; +use combat_core::{Actor, CombatConfig}; +use serde::Serialize; +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::process::ExitCode; + +/// `1000` milli-units == 1 displayed unit. +fn u(milli: i64) -> f64 { + milli as f64 / 1000.0 +} + +/// Which controller drives the player in a cell. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PlayerCtrl { + /// The §12 guardrail policy: only ever auto-attacks. + AutoOnly, + /// Skill-tunable scripted "player". + Skill(i64), +} + +impl PlayerCtrl { + fn label(&self) -> String { + match self { + PlayerCtrl::AutoOnly => "auto_only".to_string(), + PlayerCtrl::Skill(s) => format!("skill{s}"), + } + } + fn skill(&self) -> i64 { + match self { + PlayerCtrl::AutoOnly => 0, + PlayerCtrl::Skill(s) => *s, + } + } + fn make(&self) -> Box { + match self { + PlayerCtrl::AutoOnly => Box::new(SwingOnCooldown), + PlayerCtrl::Skill(s) => Box::new(ScriptedPlayer::new(*s)), + } + } +} + +/// One flattened fight record (a `fights.csv` row). +#[derive(Serialize)] +struct FightRow { + sweep: String, + matchup: String, + con_delta: i32, + player_ctrl: String, + player_skill: i64, + seed: u64, + + player_won: u8, + outcome: String, + ticks: u64, + seconds: f64, + player_final: String, + enemy_final: String, + + // Player-side throughput + flow accounting (§12). + p_fill_dmg_dealt: f64, + p_fill_dmg_taken: f64, + p_ceiling_dmg_dealt: f64, + p_ceiling_dmg_taken: f64, + p_regen_gained: f64, + p_spent_actions: f64, + p_lost_damage: f64, + p_restored: f64, + + // Stagger ledger. + p_dazes_inflicted: u32, + p_unconscious_inflicted: u32, + p_dazes_taken: u32, + p_unconscious_taken: u32, + + // Reliability. + p_fizzles: u32, + p_interruptions: u32, + + // "Wanted to act but couldn't" (§12). + p_blocked_cant_afford: u32, + p_blocked_on_cooldown: u32, + p_blocked_dazed: u32, + + // Action mix (auto-attack-only guardrail signal). + p_auto_attacks: u32, + p_abilities_used: u32, + p_recoveries_used: u32, + p_idle_ticks: u32, + + p_near_death_recoveries: u32, + p_min_fill: f64, + p_min_cap: f64, +} + +/// Aggregated per-cell summary (a `summary.csv` row). +#[derive(Serialize)] +struct SummaryRow { + sweep: String, + matchup: String, + con_delta: i32, + player_ctrl: String, + fights: u32, + player_win_rate: f64, + draw_rate: f64, + timeout_rate: f64, + mean_seconds: f64, + median_seconds: f64, + mean_player_dazes_taken: f64, + mean_player_staggers_inflicted: f64, + mean_player_fizzles: f64, + mean_player_blocked_cant_afford: f64, + mean_player_min_cap: f64, +} + +struct Args { + config: PathBuf, + out: PathBuf, + seeds: u64, + max_ticks: u64, +} + +fn parse_args() -> Args { + let mut config = PathBuf::from("configs/default.json"); + let mut out = PathBuf::from("out"); + let mut seeds = 60u64; + let mut max_ticks = 6_000u64; + + let mut it = std::env::args().skip(1); + while let Some(a) = it.next() { + match a.as_str() { + "--config" => config = PathBuf::from(it.next().unwrap_or_default()), + "--out" => out = PathBuf::from(it.next().unwrap_or_default()), + "--seeds" => seeds = it.next().and_then(|s| s.parse().ok()).unwrap_or(seeds), + "--max-ticks" => max_ticks = it.next().and_then(|s| s.parse().ok()).unwrap_or(max_ticks), + "--help" | "-h" => { + eprintln!( + "combat-sim [--config PATH] [--out DIR] [--seeds N] [--max-ticks N]\n\ + Runs the con-tier and archetype sweeps and writes CSV + a sample fight." + ); + std::process::exit(0); + } + other => eprintln!("warning: ignoring unknown arg {other:?}"), + } + } + Args { config, out, seeds, max_ticks } +} + +fn main() -> ExitCode { + let args = parse_args(); + + let config = match std::fs::read_to_string(&args.config) { + Ok(s) => s, + Err(e) => { + eprintln!("error: cannot read config {:?}: {e}", args.config); + return ExitCode::FAILURE; + } + }; + let config: CombatConfig = match serde_json::from_str(&config) { + Ok(c) => c, + Err(e) => { + eprintln!("error: invalid config JSON: {e}"); + return ExitCode::FAILURE; + } + }; + let rules = config.compile(); + + if let Err(e) = std::fs::create_dir_all(&args.out) { + eprintln!("error: cannot create out dir {:?}: {e}", args.out); + return ExitCode::FAILURE; + } + + let mut fights: Vec = Vec::new(); + con_sweep(&config, &rules, args.seeds, args.max_ticks, &mut fights); + archetype_sweep(&config, &rules, args.seeds, args.max_ticks, &mut fights); + + let summary = summarize(&fights); + + if let Err(e) = write_csv(&args.out.join("fights.csv"), &fights) { + eprintln!("error writing fights.csv: {e}"); + return ExitCode::FAILURE; + } + if let Err(e) = write_csv(&args.out.join("summary.csv"), &summary) { + eprintln!("error writing summary.csv: {e}"); + return ExitCode::FAILURE; + } + + // A single inspectable fight (even-con, skilled player) with its full event log. + dump_sample_fight(&config, &rules, args.max_ticks, &args.out); + + println!( + "combat-sim: {} fights across {} cells → {:?}", + fights.len(), + summary.len(), + args.out + ); + println!(" fights.csv one row per fight"); + println!(" summary.csv aggregated per scenario cell"); + println!(" sample_fight.json one even-con fight, full event log"); + ExitCode::SUCCESS +} + +/// Build a fighter from a named archetype, overriding team/level. +fn make_fighter( + config: &CombatConfig, + rules: &Rules, + archetype: &str, + id: u32, + team: u8, + level: i32, +) -> Actor { + let base = config + .stat_blocks + .get(archetype) + .unwrap_or_else(|| panic!("config missing stat_block {archetype:?}")); + let mut sb: StatBlock = base.clone(); + sb.team = team; + sb.level = level; + let (loadout, missing) = rules.resolve_loadout(&sb.loadout); + if !missing.is_empty() { + eprintln!("warning: {archetype} loadout missing abilities: {missing:?}"); + } + compile_actor(id, &sb, loadout, rules.tick_rate) +} + +/// Run one fight and flatten to a row. +#[allow(clippy::too_many_arguments)] +fn run_fight( + rules: &Rules, + sweep: &str, + matchup: &str, + con_delta: i32, + player_ctrl: PlayerCtrl, + player: Actor, + enemy: Actor, + enemy_ctrl: Box, + seed: u64, + max_ticks: u64, +) -> FightRow { + let controllers: Vec> = vec![player_ctrl.make(), enemy_ctrl]; + let opts = EncounterOptions { max_ticks, log_events: false, sample_every: 0 }; + let m = Encounter::new(vec![player, enemy], controllers, rules.clone(), seed, opts).run(); + flatten(&m, sweep, matchup, con_delta, player_ctrl, seed) +} + +fn flatten( + m: &FightMetrics, + sweep: &str, + matchup: &str, + con_delta: i32, + player_ctrl: PlayerCtrl, + seed: u64, +) -> FightRow { + let p = m.actor(0).expect("player metrics"); + let e = m.actor(1).expect("enemy metrics"); + let (outcome, player_won) = match m.outcome { + Outcome::Win { team: 0 } => ("win_player".to_string(), 1), + Outcome::Win { team } => (format!("win_team{team}"), 0), + Outcome::Draw => ("draw".to_string(), 0), + Outcome::Timeout => ("timeout".to_string(), 0), + }; + FightRow { + sweep: sweep.to_string(), + matchup: matchup.to_string(), + con_delta, + player_ctrl: player_ctrl.label(), + player_skill: player_ctrl.skill(), + seed, + player_won, + outcome, + ticks: m.ticks, + seconds: m.seconds(), + player_final: final_label(p.final_state), + enemy_final: final_label(e.final_state), + p_fill_dmg_dealt: u(p.fill_damage_dealt), + p_fill_dmg_taken: u(p.fill_damage_taken), + p_ceiling_dmg_dealt: u(p.ceiling_damage_dealt), + p_ceiling_dmg_taken: u(p.ceiling_damage_taken), + p_regen_gained: u(p.fill_gained_regen), + p_spent_actions: u(p.fill_spent_actions), + p_lost_damage: u(p.fill_lost_damage), + p_restored: u(p.fill_restored), + p_dazes_inflicted: p.dazes_inflicted, + p_unconscious_inflicted: p.unconscious_inflicted, + p_dazes_taken: p.dazes_taken, + p_unconscious_taken: p.unconscious_taken, + p_fizzles: p.fizzles, + p_interruptions: p.interruptions_suffered, + p_blocked_cant_afford: p.blocked_cant_afford, + p_blocked_on_cooldown: p.blocked_on_cooldown, + p_blocked_dazed: p.blocked_dazed, + p_auto_attacks: p.auto_attacks, + p_abilities_used: p.abilities_used, + p_recoveries_used: p.recoveries_used, + p_idle_ticks: p.idle_ticks, + p_near_death_recoveries: p.near_death_recoveries, + p_min_fill: u(p.min_fill), + p_min_cap: u(p.min_cap), + } +} + +fn final_label(s: FinalState) -> String { + match s { + FinalState::Alive => "alive", + FinalState::Dazed => "dazed", + FinalState::Unconscious => "unconscious", + FinalState::Dead => "dead", + } + .to_string() +} + +/// The headline sweep: player (team 0) vs. an equal-kit duelist enemy (team 1), +/// across level delta × player policy. Enemy plays hold-and-punish. +fn con_sweep(config: &CombatConfig, rules: &Rules, seeds: u64, max_ticks: u64, out: &mut Vec) { + let player_level = 10; + let player_ctrls = [ + PlayerCtrl::AutoOnly, + PlayerCtrl::Skill(20), + PlayerCtrl::Skill(50), + PlayerCtrl::Skill(80), + PlayerCtrl::Skill(100), + ]; + // A fixed, competent benchmark opponent isolates the con + player-skill + // signal (vs. a different enemy AI, which would inject matchup asymmetry). + for delta in -8..=8 { + let enemy_level = player_level + delta; + for ctrl in player_ctrls { + for seed in 0..seeds { + let player = make_fighter(config, rules, "duelist", 0, 0, player_level); + let enemy = make_fighter(config, rules, "duelist", 1, 1, enemy_level); + out.push(run_fight( + rules, + "con", + "duelist_vs_duelist", + delta, + ctrl, + player, + enemy, + Box::new(ScriptedPlayer::new(80)), + seed, + max_ticks, + )); + } + } + } +} + +/// A skilled player against the three archetypes, to feel out asymmetric matchups. +fn archetype_sweep(config: &CombatConfig, rules: &Rules, seeds: u64, max_ticks: u64, out: &mut Vec) { + let player_level = 10; + let enemies = ["golem", "rat", "battlemage"]; + let ctrl = PlayerCtrl::Skill(80); + for enemy_name in enemies { + let enemy_level = config.stat_blocks.get(enemy_name).map(|b| b.level).unwrap_or(player_level); + let delta = enemy_level - player_level; + for seed in 0..seeds { + let player = make_fighter(config, rules, "duelist", 0, 0, player_level); + let enemy = make_fighter(config, rules, enemy_name, 1, 1, enemy_level); + out.push(run_fight( + rules, + "archetypes", + enemy_name, + delta, + ctrl, + player, + enemy, + Box::new(HoldAndPunish::default()), + seed, + max_ticks, + )); + } + } +} + +/// Roll fights up into per-cell summary rows. +fn summarize(fights: &[FightRow]) -> Vec { + // Group by (sweep, matchup, con_delta, player_ctrl). + let mut groups: BTreeMap<(String, String, i32, String), Vec<&FightRow>> = BTreeMap::new(); + for f in fights { + groups + .entry((f.sweep.clone(), f.matchup.clone(), f.con_delta, f.player_ctrl.clone())) + .or_default() + .push(f); + } + + let mut rows = Vec::new(); + for ((sweep, matchup, con_delta, player_ctrl), fs) in groups { + let n = fs.len() as f64; + let wins = fs.iter().filter(|f| f.player_won == 1).count() as f64; + let draws = fs.iter().filter(|f| f.outcome == "draw").count() as f64; + let timeouts = fs.iter().filter(|f| f.outcome == "timeout").count() as f64; + let mean = |xs: &[f64]| xs.iter().sum::() / xs.len().max(1) as f64; + let mut secs: Vec = fs.iter().map(|f| f.seconds).collect(); + secs.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let median = if secs.is_empty() { 0.0 } else { secs[secs.len() / 2] }; + + rows.push(SummaryRow { + sweep, + matchup, + con_delta, + player_ctrl, + fights: fs.len() as u32, + player_win_rate: wins / n, + draw_rate: draws / n, + timeout_rate: timeouts / n, + mean_seconds: mean(&secs), + median_seconds: median, + mean_player_dazes_taken: mean(&fs.iter().map(|f| f.p_dazes_taken as f64).collect::>()), + mean_player_staggers_inflicted: mean( + &fs.iter() + .map(|f| (f.p_dazes_inflicted + f.p_unconscious_inflicted) as f64) + .collect::>(), + ), + mean_player_fizzles: mean(&fs.iter().map(|f| f.p_fizzles as f64).collect::>()), + mean_player_blocked_cant_afford: mean( + &fs.iter().map(|f| f.p_blocked_cant_afford as f64).collect::>(), + ), + mean_player_min_cap: mean(&fs.iter().map(|f| f.p_min_cap).collect::>()), + }); + } + rows +} + +/// Dump one even-con, skilled fight with its full event log for the notebook. +fn dump_sample_fight(config: &CombatConfig, rules: &Rules, max_ticks: u64, out: &std::path::Path) { + let player = make_fighter(config, rules, "duelist", 0, 0, 10); + let enemy = make_fighter(config, rules, "duelist", 1, 1, 10); + let controllers: Vec> = + vec![Box::new(ScriptedPlayer::new(80)), Box::new(HoldAndPunish::default())]; + let opts = EncounterOptions { max_ticks, log_events: true, sample_every: 1 }; + let (metrics, events) = Encounter::new(vec![player, enemy], controllers, rules.clone(), 7, opts) + .run_with_events(); + + #[derive(Serialize)] + struct Sample<'a> { + metrics: &'a FightMetrics, + events: &'a [combat_core::event::Event], + } + let sample = Sample { metrics: &metrics, events: &events }; + match serde_json::to_string_pretty(&sample) { + Ok(j) => { + if let Err(e) = std::fs::write(out.join("sample_fight.json"), j) { + eprintln!("warning: cannot write sample_fight.json: {e}"); + } + } + Err(e) => eprintln!("warning: cannot serialize sample fight: {e}"), + } +} + +fn write_csv(path: &std::path::Path, rows: &[T]) -> Result<(), Box> { + let mut w = csv::Writer::from_path(path)?; + for r in rows { + w.serialize(r)?; + } + w.flush()?; + Ok(()) +} diff --git a/combat/configs/default.json b/combat/configs/default.json new file mode 100644 index 0000000..c2a684b --- /dev/null +++ b/combat/configs/default.json @@ -0,0 +1,187 @@ +{ + "_comment": "The combat 'table' (spec §11). Magnitudes are displayed units; ratios/percentages are basis points (10000 = 1.0 = 100%); durations are ticks. Editing this file is the primary balancing act. tick_rate ticks = 1 second.", + + "tick_rate": 10, + "damage_variance_bp": 1500, + + "regen": { + "dazed_regen_bp": 5000, + "floor_per_sec": 1 + }, + + "stagger": { + "daze_threshold_bp": 10000, + "unconscious_threshold_bp": 15000, + "daze_duration": 8, + "revive_daze": 12, + "fatigue_ratio_bp": 2000 + }, + + "competency": { + "level_per_point_bp": 200 + }, + + "fizzle": { + "base_bp": 200, + "per_gap_bp": 150, + "max_bp": 9000 + }, + + "cost_model": { + "base_fill_self": 0, + "base_fill_touch": 1, + "base_fill_melee": 1, + "base_fill_projectile": 2, + "base_fill_beam": 3, + "base_fill_aoe": 4, + "fatigue_ratio_bp": 2000, + "difficulty_per_fill_bp": 10000 + }, + + "con_curve": [ + { "delta": -8, "mult_bp": 4000 }, + { "delta": -5, "mult_bp": 6000 }, + { "delta": -2, "mult_bp": 8500 }, + { "delta": 0, "mult_bp": 10000 }, + { "delta": 2, "mult_bp": 11500 }, + { "delta": 5, "mult_bp": 15000 }, + { "delta": 8, "mult_bp": 19000 } + ], + + "abilities": [ + { + "id": "auto_attack", + "name": "Auto-Attack", + "school": "martial", + "delivery": "melee", + "effects": [ { "kind": "fill_damage", "magnitude_bp": 800, "cost_fill": 0 } ], + "cooldown": 10, + "cost_override": { "fill": 0, "fatigue": 1 } + }, + { + "id": "quick_jab", + "name": "Quick Jab", + "school": "martial", + "delivery": "melee", + "effects": [ { "kind": "fill_damage", "magnitude_bp": 1700, "cost_fill": 6 } ], + "cooldown": 6 + }, + { + "id": "heavy_strike", + "name": "Heavy Strike", + "school": "martial", + "delivery": "melee", + "effects": [ { "kind": "fill_damage", "magnitude_bp": 4500, "cost_fill": 26 } ], + "cooldown": 22 + }, + { + "id": "fireball", + "name": "Fireball", + "school": "invocation", + "delivery": "projectile", + "effects": [ { "kind": "fill_damage", "magnitude_bp": 3500, "cost_fill": 24 } ], + "potency_bp": 10000, + "cast_time": 12, + "cooldown": 20 + }, + { + "id": "curse_of_weariness", + "name": "Curse of Weariness", + "school": "affliction", + "delivery": "projectile", + "effects": [ { "kind": "ceiling_damage", "magnitude_bp": 1400, "cost_fill": 16 } ], + "cast_time": 8, + "cooldown": 40 + }, + { + "id": "venom", + "name": "Venom", + "school": "affliction", + "delivery": "touch", + "effects": [ { "kind": "dot", "per_tick_bp": 250, "duration": 20, "cost_fill": 12 } ], + "cooldown": 30 + }, + { + "id": "enervate", + "name": "Enervate", + "school": "affliction", + "delivery": "projectile", + "effects": [ { "kind": "regen_sabotage", "reduce_bp": 6000, "duration": 30, "cost_fill": 10 } ], + "cast_time": 6, + "cooldown": 35 + }, + { + "id": "second_wind", + "name": "Second Wind", + "school": "restoration", + "delivery": "self_cast", + "effects": [ { "kind": "recovery", "magnitude_bp": 3000, "cost_fill": 3 } ], + "cooldown": 45 + }, + { + "id": "mend", + "name": "Mend", + "school": "restoration", + "delivery": "self_cast", + "effects": [ { "kind": "hot", "per_tick_bp": 200, "duration": 15, "cost_fill": 6 } ], + "cast_time": 10, + "cooldown": 45 + }, + { + "id": "guard", + "name": "Guard", + "school": "martial", + "delivery": "self_cast", + "effects": [ { "kind": "mitigation_buff", "stage": "active_defense", "amount_bp": 8000, "duration": 3, "cost_fill": 0 } ], + "cooldown": 15, + "cost_override": { "fill": 0, "fatigue": 2 } + } + ], + + "stat_blocks": { + "duelist": { + "name": "Duelist", + "team": 0, + "level": 10, + "max_vigor": 120, + "regen_idle_per_sec": 12, + "regen_combat_per_sec": 6, + "armor_bp": 1500, + "competency": { "martial": 50, "restoration": 30 }, + "loadout": ["auto_attack", "quick_jab", "heavy_strike", "second_wind", "guard"] + }, + "battlemage": { + "name": "Battlemage", + "team": 0, + "level": 10, + "max_vigor": 100, + "regen_idle_per_sec": 12, + "regen_combat_per_sec": 7, + "armor_bp": 500, + "competency": { "invocation": 50, "affliction": 40, "restoration": 25, "martial": 15 }, + "loadout": ["auto_attack", "fireball", "curse_of_weariness", "second_wind", "guard"] + }, + "golem": { + "name": "Golem", + "team": 1, + "level": 10, + "max_vigor": 260, + "regen_idle_per_sec": 6, + "regen_combat_per_sec": 3, + "armor_bp": 3500, + "competency": { "martial": 40 }, + "loadout": ["auto_attack", "heavy_strike"] + }, + "rat": { + "name": "Rat", + "team": 1, + "level": 8, + "max_vigor": 45, + "regen_idle_per_sec": 16, + "regen_combat_per_sec": 9, + "armor_bp": 0, + "competency": { "martial": 20 }, + "loadout": ["auto_attack", "quick_jab"] + } + } +}