reikhelm/combat/combat-core/src/config.rs
Parley Hatch 902a233229 feat(combat): playable browser front-end + AI monster portraits
A real-time consumer of combat-core (the front-end the spec designs for, §14):
pick a foe, manage your single Vigor pool, try not to spend your survival.

- combat-core: a HumanController (input-driven, spec §5) behind a shared intent
  cell, plus Encounter::tick_once / drain_events so a wall-clock front-end can
  drive the engine a tick at a time (§10). Trivial abilities (difficulty 0, e.g.
  auto-attack) no longer fizzle. Engine unchanged otherwise; 48 tests still green.
- combat-wasm: a wasm-bindgen Game bridge — tick-stepping, JSON world state, the
  player's ability list, and a flavorful MonsterAI that uses each monster's
  signature kit (the wraith curses your ceiling) on a coin-flip so big hits space
  into a readable duel. Own workspace member, kept out of the default host build.
- combat-web: an atmospheric battler. The signature dual Vigor bar shows fill,
  the regen-headroom ceiling, the ceiling marker, and the dark cracked fatigue
  zone in one bar — the squeeze made legible. Floating damage, cast telegraphs,
  combat log, result overlay. Verified end-to-end in a real browser.
- tools/portraits.py: stdlib text-to-image client reusing the proven LAN Z-Image
  stack (minus the depth ControlNet) to render the bestiary. 7 portraits committed.
- Added skeleton / troll / wraith monster stat blocks; bumped the duelist's
  durability so fights are readable (even-con ~30s) and active play beats every
  monster while passive auto-attacking loses the hard ones. Re-validated the sim:
  the skill gradient and anti-turtle guardrail still hold (auto ~1% → skill100 34%
  at even con). README findings updated to match.

Verified live: roster screen, win/lose, the dual bars, telegraphed casts, and a
3s active-play victory over the Skeleton Knight.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:46:47 -06:00

289 lines
9.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<ConAnchor>,
/// The ability library (§6) — data, not code.
#[serde(default)]
pub abilities: Vec<AbilitySpec>,
/// Optional named archetypes the sim can reference when assembling scenarios.
#[serde(default)]
pub stat_blocks: BTreeMap<String, crate::actor::StatBlock>,
}
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<String, Ability>,
}
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<Ability>, Vec<String>) {
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).
/// Trivial abilities (`difficulty <= 0`, e.g. a basic auto-attack) never
/// fizzle — the base floor only applies to real skills/spells.
pub fn fizzle_chance_bp(&self, competency: i64, difficulty: i64) -> i64 {
if difficulty <= 0 {
return 0;
}
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);
}
}