//! 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, statuses: Vec, cooldowns: Vec, } #[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, actors: Vec, events: Vec, } #[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 { 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 { 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> = 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 = 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, 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 { 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" }