diff --git a/combat/.gitignore b/combat/.gitignore index 9dffa75..70d489d 100644 --- a/combat/.gitignore +++ b/combat/.gitignore @@ -1,6 +1,10 @@ # Rust build output (this is its own workspace). /target/ +# WASM build output (regenerated by wasm-pack). Portraits are committed assets. +combat-web/pkg/ +combat-web/.playwright-mcp/ + # Python analysis virtualenv + generated plots. analysis/.venv/ analysis/plots/ diff --git a/combat/Cargo.lock b/combat/Cargo.lock index f3ee9f4..005cc23 100644 --- a/combat/Cargo.lock +++ b/combat/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "combat-core" version = "0.1.0" @@ -21,6 +33,16 @@ dependencies = [ "serde_json", ] +[[package]] +name = "combat-wasm" +version = "0.1.0" +dependencies = [ + "combat-core", + "serde", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "csv" version = "1.4.0" @@ -54,6 +76,12 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -97,6 +125,12 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.23" @@ -163,6 +197,51 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + [[package]] name = "zerocopy" version = "0.8.50" diff --git a/combat/Cargo.toml b/combat/Cargo.toml index 85367f3..183d28c 100644 --- a/combat/Cargo.toml +++ b/combat/Cargo.toml @@ -1,6 +1,10 @@ [workspace] resolver = "2" -members = ["combat-core", "combat-sim"] +members = ["combat-core", "combat-sim", "combat-wasm"] +# A bare `cargo build`/`test` operates only on the host-native crates; +# `combat-wasm` is built deliberately for wasm32 via wasm-pack (it stays a member +# so it shares the lockfile and `cargo build -p combat-wasm` works). +default-members = ["combat-core", "combat-sim"] # This is a SELF-CONTAINED workspace, deliberately separate from the root # reikhelm workspace. The root `Cargo.toml` lists its members explicitly (no diff --git a/combat/README.md b/combat/README.md index e5390d6..983536e 100644 --- a/combat/README.md +++ b/combat/README.md @@ -42,8 +42,11 @@ the next hit overrun your capacity and trigger the **stagger cascade** combat/ combat-core/ pure deterministic engine — the single source of truth combat-sim/ batch harness: load config, run sweeps, export CSV/JSON + combat-wasm/ wasm-bindgen bridge — drive the engine from a browser + combat-web/ a playable real-time battler (the front-end consumer, §14) analysis/ Python (pandas/matplotlib) over the exported data configs/ the tunable "table" (default.json) + tools/ portraits.py — AI monster art via the LAN Z-Image stack ``` `combat-core` has no I/O, no rendering, no real-time. A fight is a pure function @@ -106,18 +109,31 @@ python3 -m venv .venv && ./.venv/bin/pip install -r requirements.txt anti-turtle guardrail. - **archetypes** — a skilled player against golem / rat / battlemage. +### Play it (browser) + +The same engine, real-time, with AI-rendered monster portraits — pick a foe and +fight. See [`combat-web/README.md`](combat-web/README.md) for details. + +```sh +wasm-pack build combat-wasm --dev --target web \ + --out-dir ../combat-web/pkg --out-name combat_wasm # build the bridge → pkg/ +python3 -m http.server 8013 --directory . # serve combat/ as root +open http://127.0.0.1:8013/combat-web/ +``` + ## What the data shows (default config) With the shipped `configs/default.json` (these numbers are tuned placeholders — the whole point is that they're a table you edit): - **Duration tent** — fights are trivially short at the con extremes (~1–5s) and - longest/most-contested at even con (~22s). The intended "trivial → satisfying → + longest/most-contested at even con (~30s). The intended "trivial → satisfying → lethal" shape. - **Skill matters** — at even con, win rate climbs monotonically with policy: - auto-only **~3%** → skill20 **29%** → skill80 **42%** → skill100 **44%**. + auto-only **~1%** → skill20 **10%** → skill50 **24%** → skill80 **29%** → + skill100 **34%**. - **Anti-turtle guardrail is green** — in the contested band the full kit beats - auto-attack-only decisively (≈42% vs ≈3% at even con). Spending on the + auto-attack-only decisively (≈29% vs ≈1% at even con). Spending on the interesting verbs is worth it, which is the whole thesis. - **Mirror matches are fair** — even-con skill-vs-skill is ≈ symmetric. diff --git a/combat/combat-core/src/config.rs b/combat/combat-core/src/config.rs index 4493126..8e8773a 100644 --- a/combat/combat-core/src/config.rs +++ b/combat/combat-core/src/config.rs @@ -222,7 +222,12 @@ impl Rules { } /// 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) diff --git a/combat/combat-core/src/controller.rs b/combat/combat-core/src/controller.rs index e6d1c04..e9b5559 100644 --- a/combat/combat-core/src/controller.rs +++ b/combat/combat-core/src/controller.rs @@ -349,3 +349,84 @@ impl Controller for HoldAndPunish { "hold_and_punish" } } + +/// Input-driven controller for a real-time front-end (spec §5 "player controller"). +/// +/// Intent lives behind a shared [`HumanHandle`] the UI mutates between ticks: a +/// queued one-shot action (a clicked ability) takes priority; otherwise, if +/// auto-attack is engaged, it swings at the current target. The engine drives it +/// like any other controller, so the player runs the identical combat math. +#[derive(Debug, Default)] +pub struct HumanIntent { + /// A one-shot action set by the UI; consumed on the next decision. + pub queued: Option, + /// The actor the passive auto-attack swings at. + pub target: Option, + /// Whether auto-attack is engaged (classic always-on melee). + pub auto_attack: bool, +} + +/// A shared handle to a player's intent. The UI holds one clone, the controller +/// the other. Single-threaded (front-end / WASM), so `Rc>` is right. +pub type HumanHandle = std::rc::Rc>; + +/// The player's controller. Build with [`HumanController::new`], then hand its +/// [`HumanController::handle`] to the UI to push intent. +#[derive(Debug, Clone)] +pub struct HumanController { + handle: HumanHandle, +} + +impl Default for HumanController { + fn default() -> Self { + Self::new() + } +} + +impl HumanController { + pub fn new() -> Self { + Self { + handle: std::rc::Rc::new(std::cell::RefCell::new(HumanIntent { + queued: None, + target: None, + auto_attack: true, + })), + } + } + + /// A clone of the shared intent handle for the UI to mutate. + pub fn handle(&self) -> HumanHandle { + self.handle.clone() + } +} + +impl Controller for HumanController { + fn decide(&mut self, view: &View, _rng: &mut Rng) -> Action { + let mut intent = self.handle.borrow_mut(); + if let Some(action) = intent.queued.take() { + return action; + } + if intent.auto_attack { + // Prefer the explicitly chosen target if it's still a live enemy, + // else fall back to the weakest enemy. + let target = intent + .target + .and_then(|t| view.actors.get(t as usize)) + .filter(|a| a.team != view.me.team && a.is_active()) + .map(|a| a.id) + .or_else(|| view.weakest_enemy().map(|a| a.id)); + if let Some(t) = target { + if let Some(ab) = auto_attack(view.me) { + if view.usable(ab) { + return Action::Use { ability_id: ab.id.clone(), targets: vec![t] }; + } + } + } + } + Action::Idle + } + + fn label(&self) -> &str { + "human" + } +} diff --git a/combat/combat-core/src/engine.rs b/combat/combat-core/src/engine.rs index 41d1b60..7febaaf 100644 --- a/combat/combat-core/src/engine.rs +++ b/combat/combat-core/src/engine.rs @@ -139,6 +139,37 @@ impl Encounter { (metrics, events) } + /// Advance exactly one tick, for a real-time front-end driving the engine on + /// a wall clock (spec §10). Returns `Some(outcome)` the tick the fight + /// resolves (or times out); `None` while it continues. Idempotent once + /// resolved — calling again past resolution is a no-op returning the outcome. + pub fn tick_once(&mut self) -> Option { + if let Some(o) = self.resolution() { + return Some(o); + } + self.step(); + if let Some(o) = self.resolution() { + return Some(o); + } + if self.tick >= self.opts.max_ticks { + return Some(Outcome::Timeout); + } + self.tick += 1; + None + } + + /// The current tick index (for a front-end clock / display). + pub fn current_tick(&self) -> u64 { + self.tick + } + + /// Drain events recorded since the last drain (empty unless `log_events`). + /// Lets a front-end pull per-tick events for damage numbers / log lines + /// without retaining the whole fight. + pub fn drain_events(&mut self) -> Vec { + self.recorder.take_events() + } + /// One full tick through the canonical order. fn step(&mut self) { self.pending.clear(); diff --git a/combat/combat-core/src/event.rs b/combat/combat-core/src/event.rs index 7c63785..92533e5 100644 --- a/combat/combat-core/src/event.rs +++ b/combat/combat-core/src/event.rs @@ -521,4 +521,10 @@ impl FightRecorder { pub fn into_events(self) -> Vec { self.events } + + /// Take the events buffered since the last drain, clearing it. For a + /// real-time front-end pulling per-tick events without retaining the fight. + pub fn take_events(&mut self) -> Vec { + std::mem::take(&mut self.events) + } } diff --git a/combat/combat-core/src/lib.rs b/combat/combat-core/src/lib.rs index bba458f..615db4c 100644 --- a/combat/combat-core/src/lib.rs +++ b/combat/combat-core/src/lib.rs @@ -47,7 +47,10 @@ pub mod rng; pub use ability::{Ability, AbilitySpec, AbilityCost, Delivery}; pub use actor::{Actor, ActorId, StatBlock, StaggerState}; pub use config::{CombatConfig, Rules}; -pub use controller::{Controller, HoldAndPunish, ScriptedPlayer, SwingOnCooldown}; +pub use controller::{ + Action, Controller, HoldAndPunish, HumanController, HumanHandle, HumanIntent, ScriptedPlayer, + SwingOnCooldown, +}; pub use effect::{EffectKind, EffectSpec}; pub use engine::{Encounter, EncounterOptions}; pub use event::{ActorMetrics, FightMetrics, Outcome}; diff --git a/combat/combat-wasm/Cargo.toml b/combat/combat-wasm/Cargo.toml new file mode 100644 index 0000000..4fa9269 --- /dev/null +++ b/combat/combat-wasm/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "combat-wasm" +version = "0.1.0" +edition = "2021" +rust-version = "1.96" +description = "wasm-bindgen bridge: drive combat-core from a browser front-end." + +# `cdylib` is what wasm-pack/wasm-bindgen emit; `rlib` lets host tooling +# (cargo check/clippy) link the crate too. +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +combat-core = { path = "../combat-core" } +wasm-bindgen = "0.2" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" diff --git a/combat/combat-wasm/src/lib.rs b/combat/combat-wasm/src/lib.rs new file mode 100644 index 0000000..860060e --- /dev/null +++ b/combat/combat-wasm/src/lib.rs @@ -0,0 +1,441 @@ +//! 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" +} diff --git a/combat/combat-web/README.md b/combat/combat-web/README.md new file mode 100644 index 0000000..9257853 --- /dev/null +++ b/combat/combat-web/README.md @@ -0,0 +1,47 @@ +# Vigor — playable combat front-end + +A browser battler that drives `combat-core` (via `combat-wasm`) in real time: pick +a foe, manage your single Vigor pool, and try not to spend your survival. The +engine is authoritative and unchanged — this is just a presentation layer and a +[`HumanController`] pushing your intent between ticks. + +## What's here +- `index.html` / `style.css` / `game.js` — the UI (torch-lit dark fantasy). +- `portraits/` — AI-generated monster art (Z-Image on the LAN workstation; see + `../tools/portraits.py`). Committed so the game runs offline. +- `pkg/` — the wasm-bindgen output (gitignored; rebuild with the command below). + +## Build & play +```sh +# 1. Build the wasm bridge into ./pkg (cargo on PATH; from combat/) +export PATH="$HOME/.cargo/bin:$PATH" +wasm-pack build combat-wasm --dev --target web \ + --out-dir ../combat-web/pkg --out-name combat_wasm + +# 2. Serve combat/ as the web root (so the page AND configs/ resolve) +python3 -m http.server 8013 --directory . + +# 3. Open the game +open http://127.0.0.1:8013/combat-web/ +``` + +(Re)generate portraits — needs the LAN ComfyUI / Z-Image server up: +```sh +python3 tools/portraits.py --url http://192.168.1.26:8188 --size 768 +``` + +## How to play +- **Click a foe** to start. The fight runs in real time (10 ticks/second). +- **Auto-attack** is on by default (toggle with the AUTO button or `space`). +- **Abilities**: click a card or press `1`–`5`. Each shows its **fill cost** — + spending fill lowers your **capacity**, so a big hit leaves you fragile. +- Watch the **dual bar**: bright = current fill, dim = regen headroom, the gold + line is your **ceiling** (`cap`), and the dark cracked zone on the right is + **fatigue** — ceiling lost for the rest of the fight. When the ceiling collapses + to your fill, the next hit overruns you (daze → KO). +- **Second Wind** restores ceiling; **Guard** opens a brief block window. Passive + auto-attacking beats the weak foes (Dire Rat, Skeleton); the Golem, Troll, and + Wraith want real play — recover, punish when they're spent, don't over-commit. + +Difficulty is a table: edit `../configs/default.json` (monster stats, ability +numbers, the con curve) to taste — the same file the sim tunes against. diff --git a/combat/combat-web/game.js b/combat/combat-web/game.js new file mode 100644 index 0000000..22496de --- /dev/null +++ b/combat/combat-web/game.js @@ -0,0 +1,327 @@ +// Vigor — browser front-end driving combat-core (via combat-wasm) one tick per +// wall-clock frame. The engine is authoritative; this file is pure presentation: +// build a Game, push the player's intent, render the state it hands back. + +import init, { Game } from "./pkg/combat_wasm.js"; + +// Monster roster for the start screen. ids match the config stat_blocks. +const ROSTER = [ + { id: "rat", name: "Dire Rat", blurb: "Fast and fragile — a flurry of filthy bites." }, + { id: "skeleton", name: "Skeleton Knight", blurb: "Brittle bone, a wicked notched blade." }, + { id: "battlemage", name: "Dark Sorcerer", blurb: "Fireballs with a long, punishable wind-up." }, + { id: "golem", name: "Stone Golem", blurb: "A slow wall of stone. Immense pool, heavy blows." }, + { id: "troll", name: "Cave Troll", blurb: "Huge, regenerates, hits like a landslide." }, + { id: "wraith", name: "Wraith", blurb: "Curses your ceiling — it drains your maximum away." }, +]; + +const PLAYER = "duelist"; + +let cfgText = null; // raw config JSON string (handed to the engine) +let abilityIndex = {}; // id -> { name, cast_time } from the config (for any caster) +let tickMs = 100; // ms per tick (1000 / tick_rate) + +let game = null; +let loopId = null; +let over = false; +let enemyId = 1; +let curFoe = null; +let playerAbilities = []; +let prevDown = {}; // id -> was-down, to fire the KO shake once +let casts = {}; // actorId -> { id, total } to drive the telegraph bar + +const $ = (sel) => document.querySelector(sel); +const round = (x) => Math.round(x); + +async function boot() { + await init(); + cfgText = await (await fetch("../configs/default.json")).text(); + const cfg = JSON.parse(cfgText); + tickMs = Math.max(20, Math.round(1000 / (cfg.tick_rate || 10))); + for (const a of cfg.abilities || []) { + abilityIndex[a.id] = { name: a.name, cast_time: a.cast_time || 0 }; + } + buildRoster(); + $("#again").onclick = () => startBattle(curFoe); + $("#choose").onclick = () => { + $("#result").classList.add("hidden"); + $("#battle").classList.add("hidden"); + $("#start").classList.remove("hidden"); + }; + $("#auto-toggle").onclick = toggleAuto; + window.addEventListener("keydown", onKey); +} + +function buildRoster() { + const r = $("#roster"); + r.innerHTML = ""; + for (const foe of ROSTER) { + const el = document.createElement("div"); + el.className = "foe"; + el.innerHTML = ` + ${foe.name} +
${foe.name}
+
${foe.blurb}
`; + el.onclick = () => startBattle(foe); + r.appendChild(el); + } +} + +function startBattle(foe) { + curFoe = foe; + over = false; + prevDown = {}; + casts = {}; + if (loopId) clearInterval(loopId); + + const seed = Math.floor(Math.random() * 1e9); + try { + game = new Game(cfgText, PLAYER, foe.id, BigInt(seed)); + } catch (e) { + alert("Could not start battle: " + e); + return; + } + enemyId = 1; + playerAbilities = JSON.parse(game.abilities()); + game.setTarget(enemyId); + + // Scene chrome. + $("#start").classList.add("hidden"); + $("#result").classList.add("hidden"); + $("#battle").classList.remove("hidden"); + $("#enemy-portrait").src = `./portraits/${foe.id}.png`; + $("#enemy-name").textContent = foe.name; + $("#player-name").textContent = "Duelist"; + $("#floaters").innerHTML = ""; + $("#log").innerHTML = ""; + mountVbar("#enemy-vbar"); + mountVbar("#player-vbar"); + buildActionBar(); + + applyState(JSON.parse(game.state())); + loopId = setInterval(step, tickMs); +} + +function step() { + if (over) return; + const s = JSON.parse(game.tick()); + applyState(s); + for (const ev of s.events) handleEvent(ev, s); + if (s.outcome) endBattle(s.outcome); +} + +// ── rendering ────────────────────────────────────────────────────────── +function mountVbar(sel) { + $(sel).innerHTML = ` +
+
+
+
+
+
`; +} + +function updateVbar(sel, a) { + const bar = $(sel).querySelector(".vbar"); + const pct = (v) => `${Math.max(0, Math.min(100, (v / a.max_vigor) * 100))}%`; + bar.querySelector(".vbar-cap").style.width = pct(a.cap); + bar.querySelector(".vbar-fill").style.width = pct(a.vigor); + bar.querySelector(".vbar-ceiling").style.left = pct(a.cap); + bar.querySelector(".vbar-num").textContent = `${round(a.vigor)} · cap ${round(a.cap)} / ${round(a.max_vigor)}`; + bar.classList.toggle("dazed", a.stagger === "dazed"); + bar.classList.toggle("down", a.stagger === "unconscious" || a.stagger === "dead"); +} + +function applyState(s) { + for (const a of s.actors) { + const isEnemy = a.team !== 0; + const card = isEnemy ? "#enemy-card" : "#player-card"; + updateVbar(isEnemy ? "#enemy-vbar" : "#player-vbar", a); + $(isEnemy ? "#enemy-level" : "#player-level").textContent = `Lv ${a.level}`; + renderStatuses(isEnemy ? "#enemy-status" : "#player-status", a); + $(card).classList.toggle("down", a.stagger === "unconscious" || a.stagger === "dead"); + + // Enemy cast telegraph. + if (isEnemy) renderCast(a); + + if (!isEnemy) updateActionBar(a); + } +} + +function renderStatuses(sel, a) { + const row = $(sel); + const chips = []; + if (a.stagger === "dazed") chips.push(["dazed", "DAZED"]); + for (const st of a.statuses) chips.push([st, label(st)]); + row.innerHTML = chips.map(([c, t]) => `${t}`).join(""); +} + +function label(st) { + return ({ + dot: "poison", hot: "mending", regen_sabotage: "enervated", + fatigue_amp: "weary", mitigation: "warded", + })[st] || st; +} + +function renderCast(a) { + const wrap = $("#enemy-cast"); + const card = $("#enemy-card"); + if (a.casting) { + const info = abilityIndex[a.casting.ability] || { name: a.casting.ability, cast_time: a.casting.remaining }; + if (!casts[a.id]) casts[a.id] = { id: a.casting.ability, total: Math.max(1, a.casting.remaining) }; + const total = abilityIndex[a.casting.ability]?.cast_time || casts[a.id].total; + const frac = Math.max(0, Math.min(1, (total - a.casting.remaining) / total)); + wrap.classList.remove("hidden"); + wrap.querySelector(".cast-label").textContent = `casting ${info.name}…`; + wrap.querySelector(".cast-fill").style.width = `${frac * 100}%`; + card.classList.add("casting"); + } else { + delete casts[a.id]; + wrap.classList.add("hidden"); + card.classList.remove("casting"); + } +} + +// ── action bar ───────────────────────────────────────────────────────── +function buildActionBar() { + const bar = $("#actionbar"); + bar.innerHTML = ""; + playerAbilities.forEach((ab, i) => { + const b = document.createElement("button"); + b.className = "ability"; + b.dataset.kind = ab.kind; + b.dataset.id = ab.id; + const cost = ab.fill_cost > 0 ? `${round(ab.fill_cost)} vigor` : "free"; + const cast = ab.cast_time > 0 ? ` · ${(ab.cast_time / 10).toFixed(1)}s cast` : ""; + b.innerHTML = `${i + 1} + ${ab.name} + ${cost}${cast} + `; + b.onclick = () => useAbility(ab.id); + bar.appendChild(b); + }); +} + +function updateActionBar(player) { + const cdMap = {}; + for (const c of player.cooldowns) cdMap[c.id] = c.remaining; + for (const b of $("#actionbar").children) { + const ab = playerAbilities.find((x) => x.id === b.dataset.id); + const cd = cdMap[ab.id] || 0; + const unaffordable = ab.fill_cost > player.vigor; + const cdEl = b.querySelector(".acd"); + if (cd > 0) { + cdEl.classList.remove("hidden"); + cdEl.textContent = (cd / 10).toFixed(1); + } else { + cdEl.classList.add("hidden"); + } + b.disabled = cd > 0 || unaffordable || player.stagger !== "normal"; + } +} + +function useAbility(id) { + if (!game || over) return; + game.useAbility(id, enemyId); +} + +function toggleAuto() { + const btn = $("#auto-toggle"); + const on = !btn.classList.contains("on"); + btn.classList.toggle("on", on); + if (game) game.setAutoAttack(on); +} + +function onKey(e) { + if ($("#battle").classList.contains("hidden")) return; + if (e.key === " ") { e.preventDefault(); toggleAuto(); return; } + const n = parseInt(e.key, 10); + if (n >= 1 && n <= playerAbilities.length) { + useAbility(playerAbilities[n - 1].id); + } +} + +// ── events → juice ───────────────────────────────────────────────────── +function nameOf(s, id) { + const a = s.actors.find((x) => x.id === id); + return a ? a.name : `#${id}`; +} + +function handleEvent(ev, s) { + switch (ev.t) { + case "hit": { + const toPlayer = ev.tgt === 0; + const big = ev.outcome === "dazed" || ev.outcome === "unconscious" || ev.outcome === "killed"; + const tag = ev.outcome === "dazed" ? " DAZE!" : ev.outcome === "unconscious" ? " KO!" : ev.outcome === "killed" ? " ✶" : ""; + floater(`${round(ev.dmg)}${tag}`, toPlayer ? "down" : "up", "hit", big); + if (ev.dmg > 0) shake(toPlayer ? "#player-card" : "#enemy-card"); + logLine(`${nameOf(s, ev.src)} hits ${nameOf(s, ev.tgt)} for ${round(ev.dmg)}${tag}`, big ? "lbig" : "lhit"); + break; + } + case "ceiling": { + floater(`-${round(ev.fatigue)} cap`, ev.tgt === 0 ? "down" : "up", "curse", true); + logLine(`${nameOf(s, ev.src)} curses ${nameOf(s, ev.tgt)}: ceiling −${round(ev.fatigue)}`, "lcurse"); + break; + } + case "recovery": { + if (ev.heal > 0 || ev.revived) floater(ev.revived ? "REVIVE" : `+${round(ev.heal)} cap`, ev.tgt === 0 ? "down" : "up", "heal", ev.revived); + logLine(`${nameOf(s, ev.tgt)} recovers ${round(ev.heal)} ceiling${ev.revived ? " (revived!)" : ""}`, "lheal"); + break; + } + case "cast": + logLine(`${nameOf(s, ev.actor)} begins ${abilityIndex[ev.ability]?.name || ev.ability}…`, "lcast"); + break; + case "fizzle": + floater("fizzle", ev.actor === 0 ? "down" : "up", "curse", false); + logLine(`${nameOf(s, ev.actor)}'s ${abilityIndex[ev.ability]?.name || ev.ability} fizzles`, ""); + break; + case "interrupt": + logLine(`${nameOf(s, ev.actor)}'s ${abilityIndex[ev.ability]?.name || ev.ability} is interrupted!`, "lbig"); + break; + case "death": + logLine(`${nameOf(s, ev.actor)} falls.`, "lbig"); + break; + } +} + +let floatSeq = 0; +function floater(text, dir, kind, big) { + const f = document.createElement("div"); + f.className = `floater ${dir} ${kind}${big ? " crit" : ""}`; + f.textContent = text; + f.style.left = `${50 + (floatSeq++ % 5 - 2) * 12}%`; + $("#floaters").appendChild(f); + setTimeout(() => f.remove(), 1200); +} + +function shake(sel) { + const el = $(sel); + el.classList.remove("hurt"); + void el.offsetWidth; // reflow to restart the animation + el.classList.add("hurt"); +} + +function logLine(text, cls) { + const log = $("#log"); + const line = document.createElement("div"); + if (cls) line.className = cls; + line.textContent = text; + log.appendChild(line); + while (log.children.length > 40) log.removeChild(log.firstChild); + log.scrollTop = log.scrollHeight; +} + +function endBattle(outcome) { + over = true; + if (loopId) clearInterval(loopId); + const win = outcome === "win_player"; + const card = $("#result .result-card"); + card.classList.toggle("win", win); + card.classList.toggle("lose", !win); + $("#result-title").textContent = win ? "VICTORY" : outcome === "draw" ? "MUTUAL RUIN" : "DEFEAT"; + $("#result-sub").textContent = win + ? `${curFoe.name} falls before you.` + : outcome === "draw" ? "You fell together." : `${curFoe.name} has bested you.`; + $("#result").classList.remove("hidden"); +} + +boot(); diff --git a/combat/combat-web/index.html b/combat/combat-web/index.html new file mode 100644 index 0000000..716b3a4 --- /dev/null +++ b/combat/combat-web/index.html @@ -0,0 +1,84 @@ + + + + + + Vigor — combined-pool combat + + + +
+ + +
+
+

VIGOR

+

One pool. Offense spends your survival. Choose a foe.

+
+
+
+ Vigor (fill) — your energy & your absorb capacity + Cap (ceiling) — regen headroom + Fatigue — ceiling lost this fight +
+
+ + + +
+ + + + diff --git a/combat/combat-web/portraits/battlemage.png b/combat/combat-web/portraits/battlemage.png new file mode 100644 index 0000000..a4a277b Binary files /dev/null and b/combat/combat-web/portraits/battlemage.png differ diff --git a/combat/combat-web/portraits/duelist.png b/combat/combat-web/portraits/duelist.png new file mode 100644 index 0000000..e147311 Binary files /dev/null and b/combat/combat-web/portraits/duelist.png differ diff --git a/combat/combat-web/portraits/golem.png b/combat/combat-web/portraits/golem.png new file mode 100644 index 0000000..3c552d5 Binary files /dev/null and b/combat/combat-web/portraits/golem.png differ diff --git a/combat/combat-web/portraits/rat.png b/combat/combat-web/portraits/rat.png new file mode 100644 index 0000000..306c08f Binary files /dev/null and b/combat/combat-web/portraits/rat.png differ diff --git a/combat/combat-web/portraits/skeleton.png b/combat/combat-web/portraits/skeleton.png new file mode 100644 index 0000000..787bea4 Binary files /dev/null and b/combat/combat-web/portraits/skeleton.png differ diff --git a/combat/combat-web/portraits/troll.png b/combat/combat-web/portraits/troll.png new file mode 100644 index 0000000..a258fab Binary files /dev/null and b/combat/combat-web/portraits/troll.png differ diff --git a/combat/combat-web/portraits/wraith.png b/combat/combat-web/portraits/wraith.png new file mode 100644 index 0000000..e86aa08 Binary files /dev/null and b/combat/combat-web/portraits/wraith.png differ diff --git a/combat/combat-web/style.css b/combat/combat-web/style.css new file mode 100644 index 0000000..aaeb3bf --- /dev/null +++ b/combat/combat-web/style.css @@ -0,0 +1,266 @@ +/* Vigor — combined-pool combat front-end. Torch-lit dark fantasy. */ + +:root { + --bg: #0a0807; + --panel: #15110d; + --panel-edge: #2a2018; + --ink: #e8dcc8; + --ink-dim: #9c8a72; + --gold: #e8a13a; + --ember: #ff7a2a; + + /* Vigor bar zones */ + --fill: #6fd08a; /* current vigor — bright life */ + --fill-glow: #b9ffce; + --cap: #3a5d50; /* regen headroom (cap - fill) */ + --fatigue: #43160f; /* lost ceiling — dark, cracked */ + --ceiling: #ffd98a; + + --hit: #ff5b4a; + --heal: #7dffb0; + --curse: #c77dff; + --daze: #ffd24a; + font-size: 16px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + height: 100%; + background: radial-gradient(120% 90% at 50% -10%, #1c150e 0%, var(--bg) 60%); + color: var(--ink); + font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif; + -webkit-font-smoothing: antialiased; +} + +#app { height: 100%; } +.screen { min-height: 100%; display: flex; flex-direction: column; } +.hidden { display: none !important; } + +/* ── Masthead / start ─────────────────────────────────────────────── */ +.masthead { text-align: center; padding: 3rem 1rem 1rem; } +.masthead h1 { + margin: 0; + font-size: 4rem; + letter-spacing: 0.4rem; + color: var(--gold); + text-shadow: 0 0 18px rgba(232, 161, 58, 0.45), 0 2px 0 #000; +} +.tagline { color: var(--ink-dim); font-style: italic; margin: 0.3rem 0 0; } + +.roster { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 1.1rem; + max-width: 1080px; + margin: 1.5rem auto; + padding: 0 1.5rem; + width: 100%; +} +.foe { + background: var(--panel); + border: 1px solid var(--panel-edge); + border-radius: 10px; + overflow: hidden; + cursor: pointer; + transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease; + display: flex; flex-direction: column; +} +.foe:hover { + transform: translateY(-4px); + border-color: var(--gold); + box-shadow: 0 10px 30px rgba(0,0,0,0.6), 0 0 22px rgba(232,161,58,0.18); +} +.foe img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; filter: saturate(0.92) contrast(1.04); } +.foe .fmeta { padding: 0.6rem 0.75rem 0.85rem; } +.foe .fname { font-size: 1.15rem; color: var(--gold); } +.foe .fblurb { color: var(--ink-dim); font-size: 0.82rem; margin-top: 0.2rem; line-height: 1.3; } + +.legend { + display: flex; flex-wrap: wrap; gap: 1.2rem; justify-content: center; + color: var(--ink-dim); font-size: 0.8rem; padding: 1rem 1rem 2rem; +} +.swatch { display: inline-block; width: 0.9rem; height: 0.9rem; border-radius: 2px; vertical-align: -1px; margin-right: 0.3rem; } +.swatch.fill { background: var(--fill); } +.swatch.cap { background: var(--cap); } +.swatch.fatigue { background: var(--fatigue); } + +/* ── Battle arena ─────────────────────────────────────────────────── */ +#battle { padding: 1rem; gap: 1rem; } +.arena { + flex: 1; + display: grid; + grid-template-rows: 1fr auto 1fr; + gap: 0.5rem; + max-width: 760px; + margin: 0 auto; + width: 100%; +} +.combatant { display: flex; flex-direction: column; gap: 0.5rem; } +.combatant.enemy { align-items: center; justify-content: flex-end; } +.combatant.player { align-items: center; justify-content: flex-start; } + +.portrait-wrap { position: relative; } +.portrait { + width: 184px; height: 184px; object-fit: cover; border-radius: 10px; + border: 2px solid var(--panel-edge); + box-shadow: 0 8px 28px rgba(0,0,0,0.7), inset 0 0 40px rgba(0,0,0,0.5); + filter: saturate(0.95) contrast(1.05); + transition: filter 0.2s, transform 0.08s; +} +.combatant.hurt .portrait { animation: shake 0.18s; } +.combatant.down .portrait { filter: grayscale(0.85) brightness(0.5); } +@keyframes shake { + 0%,100% { transform: translateX(0); } + 25% { transform: translateX(-6px); } + 75% { transform: translateX(6px); } +} + +.nameplate { display: flex; align-items: baseline; gap: 0.5rem; } +.cname { font-size: 1.3rem; color: var(--gold); } +.clevel { color: var(--ink-dim); font-size: 0.85rem; } + +/* The signature dual Vigor bar */ +.vbar-mount { width: min(440px, 90vw); } +.vbar { + position: relative; + height: 26px; + background: var(--fatigue); /* exposed base = lost ceiling */ + border: 1px solid #000; + border-radius: 5px; + overflow: hidden; + box-shadow: inset 0 2px 6px rgba(0,0,0,0.6); + background-image: repeating-linear-gradient( /* cracked texture on fatigue zone */ + -45deg, transparent 0 6px, rgba(0,0,0,0.18) 6px 7px); +} +.vbar-cap { + position: absolute; left: 0; top: 0; bottom: 0; + background: linear-gradient(var(--cap), #24403a); + transition: width 0.12s linear; +} +.vbar-fill { + position: absolute; left: 0; top: 0; bottom: 0; + background: linear-gradient(180deg, var(--fill-glow), var(--fill) 55%, #2f8f57); + box-shadow: 0 0 10px rgba(111,208,138,0.5); + transition: width 0.12s linear; +} +.vbar-ceiling { + position: absolute; top: -2px; bottom: -2px; width: 2px; + background: var(--ceiling); + box-shadow: 0 0 6px var(--ceiling); + transition: left 0.12s linear; +} +.vbar-num { + position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; + font-size: 0.8rem; color: #06140c; font-weight: 700; + text-shadow: 0 1px 0 rgba(255,255,255,0.25); + font-family: ui-monospace, monospace; +} +.vbar.dazed .vbar-fill { background: linear-gradient(180deg, #ffe9a0, var(--daze) 60%, #b9821a); } +.vbar.down .vbar-fill { background: #555; box-shadow: none; } + +.statusrow { display: flex; gap: 0.3rem; min-height: 1.5rem; flex-wrap: wrap; justify-content: center; } +.chip { + font-size: 0.68rem; padding: 0.12rem 0.45rem; border-radius: 999px; + background: #241a12; border: 1px solid var(--panel-edge); color: var(--ink-dim); +} +.chip.dazed { background: #4a3a12; color: var(--daze); border-color: var(--daze); } +.chip.dot { color: #9fd06f; } +.chip.regen_sabotage, .chip.fatigue_amp { color: var(--curse); } +.chip.mitigation { color: #7fd4ff; } +.chip.hot { color: var(--heal); } + +/* cast / telegraph */ +.cast { + position: absolute; left: 50%; transform: translateX(-50%); bottom: -10px; + width: 160px; background: rgba(0,0,0,0.7); border: 1px solid var(--ember); + border-radius: 4px; padding: 2px 4px; text-align: center; +} +.cast-label { font-size: 0.72rem; color: var(--ember); } +.cast-fill { height: 4px; background: var(--ember); width: 0%; margin-top: 2px; border-radius: 2px; box-shadow: 0 0 8px var(--ember); } +.combatant.enemy.casting .portrait { box-shadow: 0 0 28px rgba(255,122,42,0.6), 0 8px 28px rgba(0,0,0,0.7); } + +/* ── Midline: floaters + log ──────────────────────────────────────── */ +.midline { position: relative; display: flex; align-items: center; justify-content: center; } +.vs { color: #3a2c1c; font-size: 2rem; } +.floaters { position: absolute; inset: -60px 0; pointer-events: none; } +.floater { + position: absolute; left: 50%; font-weight: 700; font-family: ui-monospace, monospace; + font-size: 1.4rem; text-shadow: 0 2px 3px #000; animation: float-up 1.1s ease-out forwards; +} +.floater.up { transform: translateX(-50%); top: 10%; } /* damage to enemy (player dealt) */ +.floater.down { transform: translateX(-50%); bottom: 10%; } /* damage to player */ +.floater.hit { color: var(--hit); } +.floater.heal { color: var(--heal); } +.floater.curse { color: var(--curse); } +.floater.crit { font-size: 2rem; color: var(--daze); } +@keyframes float-up { + 0% { opacity: 0; transform: translate(-50%, 10px) scale(0.8); } + 15% { opacity: 1; transform: translate(-50%, 0) scale(1.1); } + 100% { opacity: 0; transform: translate(-50%, -50px) scale(1); } +} + +/* ── HUD ──────────────────────────────────────────────────────────── */ +.hud { display: flex; gap: 1rem; max-width: 900px; margin: 0 auto; width: 100%; align-items: stretch; } +.actionbar { display: flex; gap: 0.5rem; flex-wrap: wrap; flex: 1; align-content: flex-start; } +.ability { + min-width: 92px; padding: 0.5rem 0.6rem; border-radius: 8px; cursor: pointer; + background: var(--panel); border: 1px solid var(--panel-edge); color: var(--ink); + text-align: left; position: relative; transition: border-color 0.1s, transform 0.06s; + font-family: inherit; +} +.ability:hover:not(:disabled) { border-color: var(--gold); transform: translateY(-2px); } +.ability:disabled { opacity: 0.4; cursor: not-allowed; } +.ability .akey { position: absolute; top: 3px; right: 6px; font-size: 0.7rem; color: var(--ink-dim); } +.ability .aname { font-size: 0.9rem; display: block; } +.ability .acost { font-size: 0.72rem; color: var(--ink-dim); font-family: ui-monospace, monospace; } +.ability .acd { + position: absolute; inset: 0; background: rgba(0,0,0,0.6); border-radius: 8px; + display: flex; align-items: center; justify-content: center; font-family: ui-monospace, monospace; + font-size: 1.1rem; color: var(--ink); +} +.ability[data-kind="auto"] { border-left: 3px solid #8a8a8a; } +.ability[data-kind="skill"] { border-left: 3px solid #b9c6d6; } +.ability[data-kind="spell"] { border-left: 3px solid #5aa6ff; } +.ability[data-kind="curse"] { border-left: 3px solid var(--curse); } +.ability[data-kind="dot"] { border-left: 3px solid #9fd06f; } +.ability[data-kind="recovery"] { border-left: 3px solid var(--heal); } +.ability[data-kind="defense"] { border-left: 3px solid #7fd4ff; } + +.hud-side { width: 260px; display: flex; flex-direction: column; gap: 0.5rem; } +.auto { + padding: 0.35rem; border-radius: 6px; cursor: pointer; font-family: inherit; + background: var(--panel); border: 1px solid var(--panel-edge); color: var(--ink-dim); +} +.auto.on { color: var(--gold); border-color: var(--gold); } +.log { + flex: 1; min-height: 84px; max-height: 130px; overflow-y: auto; + background: #0d0a07; border: 1px solid var(--panel-edge); border-radius: 6px; + padding: 0.4rem 0.55rem; font-size: 0.78rem; color: var(--ink-dim); line-height: 1.45; +} +.log .lhit { color: #e8b98a; } +.log .lcurse { color: var(--curse); } +.log .lheal { color: var(--heal); } +.log .lbig { color: var(--daze); font-weight: 700; } +.log .lcast { color: var(--ember); } + +/* ── Result overlay ───────────────────────────────────────────────── */ +.overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.78); + display: flex; align-items: center; justify-content: center; z-index: 10; +} +.result-card { + background: var(--panel); border: 1px solid var(--panel-edge); border-radius: 12px; + padding: 2rem 2.5rem; text-align: center; box-shadow: 0 20px 60px rgba(0,0,0,0.8); +} +.result-card h2 { margin: 0 0 0.4rem; font-size: 2.4rem; letter-spacing: 0.1rem; } +.result-card.win h2 { color: var(--fill); text-shadow: 0 0 20px rgba(111,208,138,0.4); } +.result-card.lose h2 { color: var(--hit); text-shadow: 0 0 20px rgba(255,91,74,0.4); } +.result-card p { color: var(--ink-dim); margin: 0 0 1.4rem; } +.result-actions { display: flex; gap: 0.75rem; justify-content: center; } +.result-actions button { + padding: 0.6rem 1.2rem; border-radius: 8px; cursor: pointer; font-family: inherit; font-size: 1rem; + background: #241a12; border: 1px solid var(--gold); color: var(--gold); +} +.result-actions button:hover { background: var(--gold); color: #1a1109; } diff --git a/combat/configs/default.json b/combat/configs/default.json index c2a684b..aeaa0a5 100644 --- a/combat/configs/default.json +++ b/combat/configs/default.json @@ -143,10 +143,10 @@ "name": "Duelist", "team": 0, "level": 10, - "max_vigor": 120, + "max_vigor": 150, "regen_idle_per_sec": 12, - "regen_combat_per_sec": 6, - "armor_bp": 1500, + "regen_combat_per_sec": 7, + "armor_bp": 2500, "competency": { "martial": 50, "restoration": 30 }, "loadout": ["auto_attack", "quick_jab", "heavy_strike", "second_wind", "guard"] }, @@ -173,7 +173,7 @@ "loadout": ["auto_attack", "heavy_strike"] }, "rat": { - "name": "Rat", + "name": "Dire Rat", "team": 1, "level": 8, "max_vigor": 45, @@ -182,6 +182,39 @@ "armor_bp": 0, "competency": { "martial": 20 }, "loadout": ["auto_attack", "quick_jab"] + }, + "skeleton": { + "name": "Skeleton Knight", + "team": 1, + "level": 10, + "max_vigor": 90, + "regen_idle_per_sec": 8, + "regen_combat_per_sec": 4, + "armor_bp": 1000, + "competency": { "martial": 35 }, + "loadout": ["auto_attack", "quick_jab", "heavy_strike"] + }, + "troll": { + "name": "Cave Troll", + "team": 1, + "level": 11, + "max_vigor": 220, + "regen_idle_per_sec": 20, + "regen_combat_per_sec": 12, + "armor_bp": 2000, + "competency": { "martial": 30, "restoration": 20 }, + "loadout": ["auto_attack", "heavy_strike", "second_wind"] + }, + "wraith": { + "name": "Wraith", + "team": 1, + "level": 10, + "max_vigor": 110, + "regen_idle_per_sec": 12, + "regen_combat_per_sec": 7, + "armor_bp": 500, + "competency": { "affliction": 45, "martial": 15 }, + "loadout": ["auto_attack", "curse_of_weariness", "enervate", "venom"] } } } diff --git a/combat/tools/portraits.py b/combat/tools/portraits.py new file mode 100644 index 0000000..24628d9 --- /dev/null +++ b/combat/tools/portraits.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Generate monster portraits for the combat front-end via the LAN Z-Image stack. + +Reuses the proven Z-Image-Turbo text-to-image path from +``../../tools/comfy-spike/comfy.py`` (same unet/clip/vae + ModelSamplingAuraFlow +shift + KSampler), minus the depth-ControlNet nodes — these are free text→image +portraits, not depth-conditioned renders. + +Pure stdlib (no pip). Writes PNGs to ``combat-web/portraits/.png``. + + python3 tools/portraits.py [--url http://192.168.1.26:8188] [--size 768] + [--only wraith,golem] [--seed 7] +""" +import argparse +import json +import os +import time +import urllib.error +import urllib.parse +import urllib.request + +REPO_COMBAT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +OUT_DIR = os.path.join(REPO_COMBAT, "combat-web", "portraits") + +# Z-Image-Turbo model names (from the proven comfy.py zrender defaults). +UNET = "z_image_turbo_bf16.safetensors" +CLIP = "qwen_3_4b.safetensors" +CLIP_TYPE = "qwen_image" +VAE = "ae.safetensors" + +# A byte-identical style anchor across every portrait keeps lighting + palette +# coherent so the roster reads as one bestiary, not seven unrelated images. +STYLE = ( + "dark fantasy character portrait, head and shoulders bust, centered, facing the viewer, " + "dramatic warm torchlight, deep black background, volumetric haze, painterly digital art, " + "ominous, highly detailed, sharp focus, " +) +NEGATIVE = ( + "text, watermark, signature, letters, words, ui, hud, frame, border, blurry, " + "deformed, extra limbs, duplicate, low quality, modern, cartoon, cute, bright, washed out" +) + +# id → the creature description. ids match the config stat_blocks so the front-end +# can map a chosen monster to its portrait. +ROSTER = { + "duelist": "a grizzled human duelist, worn leather armor and a steel pauldron, scarred jaw, " + "short beard, steady determined eyes, a sheathed blade, a hardened adventurer", + "rat": "an enormous dire rat, matted greasy brown fur, gleaming red eyes, bared yellow fangs, " + "twitching whiskers, ragged ears, a diseased sewer beast", + "skeleton": "an undead skeleton knight, pitted rusted plate armor, cold blue flames in hollow " + "eye sockets, cracked yellowed bone, a notched broadsword, grave-rot and cobwebs", + "golem": "a massive ancient stone golem, cracked granite and basalt body, molten orange runes " + "glowing deep in the fissures, moss and lichen, blunt boulder fists, a slow construct", + "troll": "a hulking cave troll, warty grey-green hide, hunched massive shoulders, a tusked " + "underbite, small cruel eyes, knotted muscle, club-like fists, brutish and dripping", + "wraith": "a spectral wraith, tattered black hooded burial shroud billowing, a hollow dark void " + "where a face should be, two faint points of cold blue light, wisps of freezing mist, " + "skeletal grasping hands reaching forward, incorporeal and dreadful", + "battlemage": "a gaunt dark sorcerer, deep hooded robe, sunken shadowed eyes, crackling violet " + "arcane lightning coiling around raised bony hands, glowing runic sigils, malice", +} + + +def post(base, path, payload): + req = urllib.request.Request( + base + path, data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, method="POST") + return json.loads(urllib.request.urlopen(req, timeout=30).read()) + + +def get(base, path, timeout=120): + with urllib.request.urlopen(base + path, timeout=timeout) as r: + return r.read() + + +def t2i_graph(prompt, negative, size, seed, prefix): + """Z-Image-Turbo text-to-image graph (no ControlNet).""" + return { + "unet": {"class_type": "UNETLoader", "inputs": {"unet_name": UNET, "weight_dtype": "default"}}, + "clip": {"class_type": "CLIPLoader", "inputs": {"clip_name": CLIP, "type": CLIP_TYPE}}, + "vae": {"class_type": "VAELoader", "inputs": {"vae_name": VAE}}, + "shift": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["unet", 0], "shift": 3.0}}, + "pos": {"class_type": "CLIPTextEncode", "inputs": {"text": prompt, "clip": ["clip", 0]}}, + "neg": {"class_type": "CLIPTextEncode", "inputs": {"text": negative, "clip": ["clip", 0]}}, + "latent": {"class_type": "EmptySD3LatentImage", "inputs": {"width": size, "height": size, "batch_size": 1}}, + "ks": {"class_type": "KSampler", "inputs": { + "seed": seed, "steps": 8, "cfg": 1.0, "sampler_name": "res_multistep", + "scheduler": "simple", "denoise": 1.0, "model": ["shift", 0], + "positive": ["pos", 0], "negative": ["neg", 0], "latent_image": ["latent", 0]}}, + "dec": {"class_type": "VAEDecode", "inputs": {"samples": ["ks", 0], "vae": ["vae", 0]}}, + "save": {"class_type": "SaveImage", "inputs": {"images": ["dec", 0], "filename_prefix": prefix}}, + } + + +def render_one(base, mid, prompt, size, seed, wait): + graph = t2i_graph(STYLE + prompt, NEGATIVE, size, seed, f"portrait_{mid}") + try: + pid = post(base, "/prompt", {"prompt": graph})["prompt_id"] + except urllib.error.HTTPError as e: + print(f" {mid}: /prompt rejected:\n{e.read().decode()[:1500]}") + return False + deadline = time.time() + wait + while time.time() < deadline: + hist = json.loads(get(base, "/history/" + pid)) + if pid in hist: + entry = hist[pid] + if entry.get("status", {}).get("status_str") == "error": + print(f" {mid}: execution error:\n{json.dumps(entry['status'].get('messages'), indent=1)[:1500]}") + return False + for node_out in entry.get("outputs", {}).values(): + for im in node_out.get("images", []): + q = urllib.parse.urlencode({"filename": im["filename"], + "subfolder": im.get("subfolder", ""), + "type": im.get("type", "output")}) + blob = get(base, "/view?" + q) + out = os.path.join(OUT_DIR, f"{mid}.png") + with open(out, "wb") as f: + f.write(blob) + print(f" {mid}: -> {os.path.relpath(out, REPO_COMBAT)} ({len(blob)//1024} KB)") + return True + print(f" {mid}: finished but no image") + return False + time.sleep(1.5) + print(f" {mid}: timed out after {wait}s") + return False + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--url", default="http://192.168.1.26:8188") + ap.add_argument("--size", type=int, default=768) + ap.add_argument("--seed", type=int, default=7, help="base seed; each monster offsets from it") + ap.add_argument("--only", default="", help="comma-separated ids to (re)render") + ap.add_argument("--wait", type=int, default=180) + a = ap.parse_args() + + os.makedirs(OUT_DIR, exist_ok=True) + base = a.url.rstrip("/") + only = {x.strip() for x in a.only.split(",") if x.strip()} or None + roster = {k: v for k, v in ROSTER.items() if only is None or k in only} + + print(f"rendering {len(roster)} portraits at {a.size}px -> {os.path.relpath(OUT_DIR, REPO_COMBAT)}") + ok = 0 + for i, (mid, prompt) in enumerate(roster.items()): + if render_one(base, mid, prompt, a.size, a.seed + i * 1000, a.wait): + ok += 1 + print(f"done: {ok}/{len(roster)} portraits") + + +if __name__ == "__main__": + main()