reikhelm/combat/combat-wasm/src/lib.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

441 lines
15 KiB
Rust

//! combat-wasm — a `wasm-bindgen` bridge that drives [`combat_core`] from a
//! browser front-end (the real-time consumer the spec designs for, §10/§14).
//!
//! The engine is unchanged: the player is a [`HumanController`] whose intent the
//! UI pushes between ticks, monsters run a small [`MonsterAI`], and the front-end
//! advances one tick per wall-clock frame via [`Game::tick`]. State crosses the
//! boundary as JSON strings (parsed with `JSON.parse` in JS) to keep the surface
//! tiny and dependency-light.
use combat_core::ability::Ability;
use combat_core::actor::{compile_actor, Actor, ActorId, StaggerState};
use combat_core::config::{CombatConfig, Rules};
use combat_core::controller::{Action, Controller, HumanController, HumanHandle, View};
use combat_core::effect::EffectKind;
use combat_core::engine::{Encounter, EncounterOptions};
use combat_core::event::{Event, Outcome};
use combat_core::rng::Rng;
use serde::Serialize;
use wasm_bindgen::prelude::*;
/// `1000` milli-units == 1 displayed unit.
fn to_units(milli: i64) -> f64 {
milli as f64 / 1000.0
}
// --------------------------------------------------------------------------- //
// Monster AI — flavor brain so each archetype uses its signature kit.
// --------------------------------------------------------------------------- //
/// A simple but characterful enemy brain: fire the most expensive ability that's
/// usable right now (a monster's signature move — the wraith's curse, the golem's
/// heavy strike), otherwise auto-attack the player. Lives here, not in core, so
/// the engine stays policy-free.
#[derive(Debug, Default)]
struct MonsterAI;
impl MonsterAI {
fn usable(view: &View, ab: &Ability) -> bool {
ab.cost.fill <= view.me.vigor && !view.me.on_cooldown(&ab.id, view.tick)
}
}
impl Controller for MonsterAI {
fn decide(&mut self, view: &View, rng: &mut Rng) -> Action {
let Some(target) = view.weakest_enemy() else {
return Action::Idle;
};
// Signature move: priciest usable hostile ability that isn't the free
// auto-attack (cost.fill == 0). Used on a coin-flip rather than on every
// cooldown, so a monster's big hits are spaced out into a readable duel
// instead of a relentless burst.
let use_signature = rng.chance_bp(5500);
let signature = view
.me
.loadout
.iter()
.filter(|a| a.is_offensive() && a.cost.fill > 0 && Self::usable(view, a))
.max_by_key(|a| a.cost.fill);
if let (true, Some(ab)) = (use_signature, signature) {
// Friendly-tagged effects (a self-buff) target self; here all are
// hostile, so aim at the player.
return Action::Use { ability_id: ab.id.clone(), targets: vec![target.id] };
}
// Else swing: cheapest offensive ability.
let auto = view
.me
.loadout
.iter()
.filter(|a| a.is_offensive())
.min_by_key(|a| a.cost.fill);
match auto {
Some(ab) if Self::usable(view, ab) => {
Action::Use { ability_id: ab.id.clone(), targets: vec![target.id] }
}
_ => Action::Idle,
}
}
fn label(&self) -> &str {
"monster"
}
}
// --------------------------------------------------------------------------- //
// Serializable views handed to JS.
// --------------------------------------------------------------------------- //
#[derive(Serialize)]
struct CastView {
ability: String,
remaining: u64,
}
#[derive(Serialize)]
struct CooldownView {
id: String,
remaining: u64,
}
#[derive(Serialize)]
struct ActorView {
id: ActorId,
name: String,
team: u8,
level: i32,
vigor: f64,
cap: f64,
fatigue: f64,
max_vigor: f64,
stagger: String,
casting: Option<CastView>,
statuses: Vec<String>,
cooldowns: Vec<CooldownView>,
}
#[derive(Serialize)]
#[serde(tag = "t", rename_all = "snake_case")]
enum EvView {
Hit { src: ActorId, tgt: ActorId, dmg: f64, outcome: String },
Ceiling { src: ActorId, tgt: ActorId, fatigue: f64 },
Recovery { tgt: ActorId, heal: f64, revived: bool },
Cast { actor: ActorId, ability: String },
Fizzle { actor: ActorId, ability: String },
Interrupt { actor: ActorId, ability: String },
Death { actor: ActorId },
}
#[derive(Serialize)]
struct StateView {
tick: u64,
outcome: Option<String>,
actors: Vec<ActorView>,
events: Vec<EvView>,
}
#[derive(Serialize)]
struct AbilityView {
id: String,
name: String,
school: String,
fill_cost: f64,
fatigue_cost: f64,
cast_time: u32,
cooldown: u32,
kind: String,
/// Rough magnitude as % of a target's pool (for a tooltip), 0 if N/A.
magnitude_pct: f64,
}
// --------------------------------------------------------------------------- //
// The game.
// --------------------------------------------------------------------------- //
/// A live battle the browser drives one tick at a time.
#[wasm_bindgen]
pub struct Game {
enc: Encounter,
human: HumanHandle,
rules: Rules,
player_id: ActorId,
}
#[wasm_bindgen]
impl Game {
/// Build a battle: the player (an archetype id like `"duelist"`) on team 0
/// against `enemies` (a comma-separated list of archetype ids) on team 1.
/// `config_json` is the combat `CombatConfig`.
#[wasm_bindgen(constructor)]
pub fn new(config_json: &str, player: &str, enemies: &str, seed: u64) -> Result<Game, JsValue> {
let config: CombatConfig = serde_json::from_str(config_json)
.map_err(|e| JsValue::from_str(&format!("bad config json: {e}")))?;
let rules = config.compile();
let build = |archetype: &str, id: ActorId, team: u8| -> Result<Actor, JsValue> {
let sb = config
.stat_blocks
.get(archetype)
.ok_or_else(|| JsValue::from_str(&format!("unknown archetype {archetype:?}")))?;
let mut sb = sb.clone();
sb.team = team;
let (loadout, missing) = rules.resolve_loadout(&sb.loadout);
if !missing.is_empty() {
return Err(JsValue::from_str(&format!(
"{archetype} loadout references unknown abilities: {missing:?}"
)));
}
Ok(compile_actor(id, &sb, loadout, rules.tick_rate))
};
let mut actors = Vec::new();
let mut controllers: Vec<Box<dyn Controller>> = Vec::new();
// Player is actor 0, team 0.
let player_actor = build(player, 0, 0)?;
actors.push(player_actor);
let human = HumanController::new();
let handle = human.handle();
// Default the auto-attack target to the first enemy (id 1).
handle.borrow_mut().target = Some(1);
controllers.push(Box::new(human));
// Enemies are team 1, ids 1..
let enemy_ids: Vec<&str> = enemies.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
if enemy_ids.is_empty() {
return Err(JsValue::from_str("no enemies specified"));
}
for (i, name) in enemy_ids.iter().enumerate() {
let id = (i + 1) as ActorId;
actors.push(build(name, id, 1)?);
controllers.push(Box::new(MonsterAI));
}
let opts = EncounterOptions { max_ticks: 100_000, log_events: true, sample_every: 0 };
let enc = Encounter::new(actors, controllers, rules.clone(), seed, opts);
Ok(Game { enc, human: handle, rules, player_id: 0 })
}
/// Advance one tick. Returns the new state as JSON, including any events that
/// fired this tick and an `outcome` once the fight resolves.
pub fn tick(&mut self) -> String {
let outcome = self.enc.tick_once();
let events = self.enc.drain_events();
self.state_with(outcome, &events)
}
/// Current state as JSON without advancing (for the initial render).
pub fn state(&self) -> String {
self.state_with(None, &[])
}
/// The player's slotted abilities as JSON, for building the action bar.
pub fn abilities(&self) -> String {
let me = &self.enc.actors[self.player_id as usize];
let views: Vec<AbilityView> = me.loadout.iter().map(ability_view).collect();
serde_json::to_string(&views).unwrap_or_else(|_| "[]".into())
}
/// The player's actor id.
#[wasm_bindgen(js_name = playerId)]
pub fn player_id(&self) -> u32 {
self.player_id
}
/// Queue an ability for the next decision. `target` < 0 picks a sensible
/// default (self for support, the current target otherwise).
#[wasm_bindgen(js_name = useAbility)]
pub fn use_ability(&mut self, ability_id: &str, target: i32) {
let Some(ability) = self.rules.abilities.get(ability_id).cloned() else {
return;
};
let targets = if ability.is_support() {
vec![self.player_id]
} else {
let t = if target >= 0 {
target as ActorId
} else {
self.human.borrow().target.unwrap_or(1)
};
vec![t]
};
self.human.borrow_mut().queued = Some(Action::Use {
ability_id: ability_id.to_string(),
targets,
});
}
/// Set the passive auto-attack target.
#[wasm_bindgen(js_name = setTarget)]
pub fn set_target(&mut self, target: u32) {
self.human.borrow_mut().target = Some(target);
}
/// Toggle whether the player auto-attacks when no ability is queued.
#[wasm_bindgen(js_name = setAutoAttack)]
pub fn set_auto_attack(&mut self, on: bool) {
self.human.borrow_mut().auto_attack = on;
}
fn state_with(&self, outcome: Option<Outcome>, events: &[Event]) -> String {
let actors = self.enc.actors.iter().map(|a| actor_view(a, self.enc.current_tick())).collect();
let view = StateView {
tick: self.enc.current_tick(),
outcome: outcome.map(|o| self.outcome_label(o)),
actors,
events: events.iter().filter_map(ev_view).collect(),
};
serde_json::to_string(&view).unwrap_or_else(|_| "{}".into())
}
fn outcome_label(&self, o: Outcome) -> String {
match o {
Outcome::Win { team } if team == self.enc.actors[self.player_id as usize].team => {
"win_player".into()
}
Outcome::Win { .. } => "win_enemy".into(),
Outcome::Draw => "draw".into(),
Outcome::Timeout => "timeout".into(),
}
}
}
fn actor_view(a: &Actor, tick: u64) -> ActorView {
let stagger = match a.stagger {
StaggerState::Normal => "normal",
StaggerState::Dazed { .. } => "dazed",
StaggerState::Unconscious => "unconscious",
StaggerState::Dead => "dead",
}
.to_string();
let casting = a.casting.as_ref().map(|c| CastView {
ability: c.ability_id.clone(),
remaining: c.completes_at.saturating_sub(tick),
});
let statuses = a
.statuses
.iter()
.map(|s| status_label(&s.kind).to_string())
.collect();
let cooldowns = a
.cooldowns
.iter()
.filter(|(_, &ready)| ready > tick)
.map(|(id, &ready)| CooldownView { id: id.clone(), remaining: ready - tick })
.collect();
ActorView {
id: a.id,
name: a.name.clone(),
team: a.team,
level: a.level,
vigor: to_units(a.vigor),
cap: to_units(a.cap()),
fatigue: to_units(a.fatigue),
max_vigor: to_units(a.max_vigor),
stagger,
casting,
statuses,
cooldowns,
}
}
fn status_label(kind: &combat_core::actor::StatusKind) -> &'static str {
use combat_core::actor::StatusKind::*;
match kind {
RegenSabotage { .. } => "regen_sabotage",
FatigueAmp { .. } => "fatigue_amp",
Dot { .. } => "dot",
Hot { .. } => "hot",
Mitigation { .. } => "mitigation",
}
}
fn ev_view(e: &Event) -> Option<EvView> {
Some(match e {
Event::Hit { source, target, landed, outcome, .. } => EvView::Hit {
src: *source,
tgt: *target,
dmg: to_units(*landed),
outcome: format!("{outcome:?}").to_lowercase(),
},
Event::Ceiling { source, target, fatigue_added, .. } => EvView::Ceiling {
src: *source,
tgt: *target,
fatigue: to_units(*fatigue_added),
},
Event::Recovery { target, fatigue_healed, revived, .. } => EvView::Recovery {
tgt: *target,
heal: to_units(*fatigue_healed),
revived: *revived,
},
Event::CastStarted { actor, ability, .. } => EvView::Cast {
actor: *actor,
ability: ability.clone(),
},
Event::Fail { actor, ability, reason, .. } => {
use combat_core::event::FailReason::*;
match reason {
CompetencyFizzle => EvView::Fizzle { actor: *actor, ability: ability.clone() },
Interrupted => EvView::Interrupt { actor: *actor, ability: ability.clone() },
}
}
Event::Death { actor, .. } => EvView::Death { actor: *actor },
// Action / Blocked / End are not surfaced as floaters.
_ => return None,
})
}
fn ability_view(ab: &Ability) -> AbilityView {
let kind = classify(ab).to_string();
let magnitude_pct = ab
.effects
.iter()
.filter_map(|e| match e.kind {
EffectKind::FillDamage { magnitude_bp } => Some(magnitude_bp),
EffectKind::CeilingDamage { magnitude_bp } => Some(magnitude_bp),
EffectKind::Recovery { magnitude_bp } => Some(magnitude_bp),
_ => None,
})
.map(|bp| bp as f64 / 100.0) // bp -> percent
.next()
.unwrap_or(0.0);
AbilityView {
id: ab.id.clone(),
name: ab.name.clone(),
school: ab.school.clone(),
fill_cost: to_units(ab.cost.fill),
fatigue_cost: to_units(ab.cost.fatigue),
cast_time: ab.cast_time,
cooldown: ab.cooldown,
kind,
magnitude_pct,
}
}
/// Classify an ability for the UI (icon / colour).
fn classify(ab: &Ability) -> &'static str {
if ab.is_support() {
if ab.effects.iter().any(|e| matches!(e.kind, EffectKind::MitigationBuff { .. })) {
return "defense";
}
return "recovery";
}
if ab.effects.iter().any(|e| matches!(e.kind, EffectKind::CeilingDamage { .. })) {
return "curse";
}
if ab.effects.iter().any(|e| matches!(e.kind, EffectKind::RegenSabotage { .. } | EffectKind::FatigueAmp { .. })) {
return "hex";
}
if ab.effects.iter().any(|e| matches!(e.kind, EffectKind::Dot { .. })) {
return "dot";
}
if ab.cost.fill == 0 {
return "auto";
}
if ab.cast_time > 0 {
return "spell";
}
"skill"
}