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>
This commit is contained in:
Parley Hatch 2026-06-03 12:46:47 -06:00
parent 5055feb4c3
commit 902a233229
24 changed files with 1604 additions and 9 deletions

4
combat/.gitignore vendored
View file

@ -1,6 +1,10 @@
# Rust build output (this is its own workspace). # Rust build output (this is its own workspace).
/target/ /target/
# WASM build output (regenerated by wasm-pack). Portraits are committed assets.
combat-web/pkg/
combat-web/.playwright-mcp/
# Python analysis virtualenv + generated plots. # Python analysis virtualenv + generated plots.
analysis/.venv/ analysis/.venv/
analysis/plots/ analysis/plots/

79
combat/Cargo.lock generated
View file

@ -2,6 +2,18 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 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]] [[package]]
name = "combat-core" name = "combat-core"
version = "0.1.0" version = "0.1.0"
@ -21,6 +33,16 @@ dependencies = [
"serde_json", "serde_json",
] ]
[[package]]
name = "combat-wasm"
version = "0.1.0"
dependencies = [
"combat-core",
"serde",
"serde_json",
"wasm-bindgen",
]
[[package]] [[package]]
name = "csv" name = "csv"
version = "1.4.0" version = "1.4.0"
@ -54,6 +76,12 @@ version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.21" version = "0.2.21"
@ -97,6 +125,12 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]] [[package]]
name = "ryu" name = "ryu"
version = "1.0.23" version = "1.0.23"
@ -163,6 +197,51 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" 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]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.50" version = "0.8.50"

View file

@ -1,6 +1,10 @@
[workspace] [workspace]
resolver = "2" 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 # This is a SELF-CONTAINED workspace, deliberately separate from the root
# reikhelm workspace. The root `Cargo.toml` lists its members explicitly (no # reikhelm workspace. The root `Cargo.toml` lists its members explicitly (no

View file

@ -42,8 +42,11 @@ the next hit overrun your capacity and trigger the **stagger cascade**
combat/ combat/
combat-core/ pure deterministic engine — the single source of truth combat-core/ pure deterministic engine — the single source of truth
combat-sim/ batch harness: load config, run sweeps, export CSV/JSON 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 analysis/ Python (pandas/matplotlib) over the exported data
configs/ the tunable "table" (default.json) 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 `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. anti-turtle guardrail.
- **archetypes** — a skilled player against golem / rat / battlemage. - **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) ## What the data shows (default config)
With the shipped `configs/default.json` (these numbers are tuned placeholders — the With the shipped `configs/default.json` (these numbers are tuned placeholders — the
whole point is that they're a table you edit): whole point is that they're a table you edit):
- **Duration tent** — fights are trivially short at the con extremes (~15s) and - **Duration tent** — fights are trivially short at the con extremes (~15s) 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. lethal" shape.
- **Skill matters** — at even con, win rate climbs monotonically with policy: - **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 - **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. interesting verbs is worth it, which is the whole thesis.
- **Mirror matches are fair** — even-con skill-vs-skill is ≈ symmetric. - **Mirror matches are fair** — even-con skill-vs-skill is ≈ symmetric.

View file

@ -222,7 +222,12 @@ impl Rules {
} }
/// Fizzle chance (bp) for `difficulty` attempted at `competency` (§9). /// 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 { pub fn fizzle_chance_bp(&self, competency: i64, difficulty: i64) -> i64 {
if difficulty <= 0 {
return 0;
}
let gap = (difficulty - competency).max(0); let gap = (difficulty - competency).max(0);
let raw = self.fizzle.base_bp + gap * self.fizzle.per_gap_bp; let raw = self.fizzle.base_bp + gap * self.fizzle.per_gap_bp;
raw.clamp(0, self.fizzle.max_bp) raw.clamp(0, self.fizzle.max_bp)

View file

@ -349,3 +349,84 @@ impl Controller for HoldAndPunish {
"hold_and_punish" "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<Action>,
/// The actor the passive auto-attack swings at.
pub target: Option<ActorId>,
/// 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<RefCell<…>>` is right.
pub type HumanHandle = std::rc::Rc<std::cell::RefCell<HumanIntent>>;
/// 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"
}
}

View file

@ -139,6 +139,37 @@ impl Encounter {
(metrics, events) (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<Outcome> {
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<Event> {
self.recorder.take_events()
}
/// One full tick through the canonical order. /// One full tick through the canonical order.
fn step(&mut self) { fn step(&mut self) {
self.pending.clear(); self.pending.clear();

View file

@ -521,4 +521,10 @@ impl FightRecorder {
pub fn into_events(self) -> Vec<Event> { pub fn into_events(self) -> Vec<Event> {
self.events 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<Event> {
std::mem::take(&mut self.events)
}
} }

View file

@ -47,7 +47,10 @@ pub mod rng;
pub use ability::{Ability, AbilitySpec, AbilityCost, Delivery}; pub use ability::{Ability, AbilitySpec, AbilityCost, Delivery};
pub use actor::{Actor, ActorId, StatBlock, StaggerState}; pub use actor::{Actor, ActorId, StatBlock, StaggerState};
pub use config::{CombatConfig, Rules}; 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 effect::{EffectKind, EffectSpec};
pub use engine::{Encounter, EncounterOptions}; pub use engine::{Encounter, EncounterOptions};
pub use event::{ActorMetrics, FightMetrics, Outcome}; pub use event::{ActorMetrics, FightMetrics, Outcome};

View file

@ -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"

View file

@ -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<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"
}

View file

@ -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.

327
combat/combat-web/game.js Normal file
View file

@ -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 = `
<img src="./portraits/${foe.id}.png" alt="${foe.name}" loading="lazy" />
<div class="fmeta"><div class="fname">${foe.name}</div>
<div class="fblurb">${foe.blurb}</div></div>`;
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 = `
<div class="vbar">
<div class="vbar-cap"></div>
<div class="vbar-fill"></div>
<div class="vbar-ceiling"></div>
<div class="vbar-num"></div>
</div>`;
}
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]) => `<span class="chip ${c}">${t}</span>`).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 = `<span class="akey">${i + 1}</span>
<span class="aname">${ab.name}</span>
<span class="acost">${cost}${cast}</span>
<span class="acd hidden"></span>`;
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();

View file

@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vigor — combined-pool combat</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div id="app">
<!-- ── Start: choose your foe ─────────────────────────────────────── -->
<section id="start" class="screen">
<header class="masthead">
<h1>VIGOR</h1>
<p class="tagline">One pool. Offense spends your survival. Choose a foe.</p>
</header>
<div id="roster" class="roster"><!-- filled by JS --></div>
<footer class="legend">
<span><b class="swatch fill"></b> Vigor (fill) — your energy &amp; your absorb capacity</span>
<span><b class="swatch cap"></b> Cap (ceiling) — regen headroom</span>
<span><b class="swatch fatigue"></b> Fatigue — ceiling lost this fight</span>
</footer>
</section>
<!-- ── Battle ─────────────────────────────────────────────────────── -->
<section id="battle" class="screen hidden">
<div class="arena">
<!-- enemy -->
<div id="enemy-card" class="combatant enemy">
<div class="portrait-wrap">
<img id="enemy-portrait" class="portrait" alt="" />
<div id="enemy-cast" class="cast hidden"><span class="cast-label"></span><div class="cast-fill"></div></div>
</div>
<div class="nameplate">
<span id="enemy-name" class="cname"></span>
<span id="enemy-level" class="clevel"></span>
</div>
<div class="vbar-mount" id="enemy-vbar"></div>
<div id="enemy-status" class="statusrow"></div>
</div>
<div class="midline">
<div id="floaters" class="floaters"></div>
<div class="vs"></div>
</div>
<!-- player -->
<div id="player-card" class="combatant player">
<div class="nameplate">
<span id="player-name" class="cname"></span>
<span id="player-level" class="clevel"></span>
</div>
<div class="vbar-mount" id="player-vbar"></div>
<div id="player-status" class="statusrow"></div>
</div>
</div>
<div class="hud">
<div id="actionbar" class="actionbar"><!-- ability buttons --></div>
<div class="hud-side">
<button id="auto-toggle" class="auto on" title="Auto-attack (space)">AUTO ⚔</button>
<div id="log" class="log"></div>
</div>
</div>
<!-- result overlay -->
<div id="result" class="overlay hidden">
<div class="result-card">
<h2 id="result-title"></h2>
<p id="result-sub"></p>
<div class="result-actions">
<button id="again">Fight again</button>
<button id="choose">Choose foe</button>
</div>
</div>
</div>
</section>
</div>
<script type="module" src="./game.js"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 808 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 757 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 KiB

266
combat/combat-web/style.css Normal file
View file

@ -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; }

View file

@ -143,10 +143,10 @@
"name": "Duelist", "name": "Duelist",
"team": 0, "team": 0,
"level": 10, "level": 10,
"max_vigor": 120, "max_vigor": 150,
"regen_idle_per_sec": 12, "regen_idle_per_sec": 12,
"regen_combat_per_sec": 6, "regen_combat_per_sec": 7,
"armor_bp": 1500, "armor_bp": 2500,
"competency": { "martial": 50, "restoration": 30 }, "competency": { "martial": 50, "restoration": 30 },
"loadout": ["auto_attack", "quick_jab", "heavy_strike", "second_wind", "guard"] "loadout": ["auto_attack", "quick_jab", "heavy_strike", "second_wind", "guard"]
}, },
@ -173,7 +173,7 @@
"loadout": ["auto_attack", "heavy_strike"] "loadout": ["auto_attack", "heavy_strike"]
}, },
"rat": { "rat": {
"name": "Rat", "name": "Dire Rat",
"team": 1, "team": 1,
"level": 8, "level": 8,
"max_vigor": 45, "max_vigor": 45,
@ -182,6 +182,39 @@
"armor_bp": 0, "armor_bp": 0,
"competency": { "martial": 20 }, "competency": { "martial": 20 },
"loadout": ["auto_attack", "quick_jab"] "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"]
} }
} }
} }

151
combat/tools/portraits.py Normal file
View file

@ -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 textimage
portraits, not depth-conditioned renders.
Pure stdlib (no pip). Writes PNGs to ``combat-web/portraits/<id>.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()