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>
289 lines
9.9 KiB
Rust
289 lines
9.9 KiB
Rust
//! 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);
|
||
}
|
||
}
|