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>
441 lines
15 KiB
Rust
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"
|
|
}
|