diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d98eb1..34e5095 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -329,9 +329,17 @@ jobs: # slice 4 its 400 units are KayKit character models (about 2.2 M # triangles in the main pass), which lavapipe draws at about 1.4 s a # frame, so tick 400 comes about 120 s in: it gets a 180 s deadline - # (the sixth argument; docs/BUILD_TIMES.md). winit - # loads libxkbcommon-x11 and the X libraries at run time; the runner - # image lacks the first. The screenshots are uploaded for review. + # (the sixth argument; docs/BUILD_TIMES.md). Since art slice 6 it + # also renders `art_bench`, the art pass's stress scene (two full + # Towns, 400 units fighting, all of P0's units selected at tick 40), + # in its own 60 m framing: on lavapipe it draws about one frame a + # second, so on the 4-vCPU VM tick 200 came 64 s in and the run's + # 120 s reached tick 493, past the whole fight (ticks 47 to 388); + # tick 300 came 78 to 87 s in, too close to 120 s for a runner of + # unknown speed. + # winit loads libxkbcommon-x11 and the X libraries at run time; the + # runner image lacks the first. The screenshots are uploaded for + # review. - name: Render smoke test (Xvfb, lavapipe) run: | sudo apt-get install -y --no-install-recommends xvfb mesa-vulkan-drivers libxkbcommon-x11-0 libx11-xcb1 libxcursor1 libxi6 libxrandr2 @@ -339,6 +347,21 @@ jobs: scripts/render_smoke.sh target/ci/eonmark "$RUNNER_TEMP/render-smoke" scripts/render_smoke.sh target/ci/eonmark "$RUNNER_TEMP/render-smoke/unit_looks" unit_looks 20,32,40 100 scripts/render_smoke.sh target/ci/eonmark "$RUNNER_TEMP/render-smoke/enemy_outpost" enemy_outpost '' 400 180 + scripts/render_smoke.sh target/ci/eonmark "$RUNNER_TEMP/render-smoke/art_bench" art_bench '' 200 120 + + # Art pass AP4: every render smoke runs with --draw-stats and prints + # `frame_stats: draws ... max= ...`; fail unless every run's maximum + # is under 500 draw calls a frame (scripts/check_draws.sh; `mode=none` + # fails, since the counter does not count it). For `art_bench` (and + # `enemy_outpost`) --fight also requires that the fight was measured: + # a non-zero `fight_max` and a `frame_stats: fight` line with fight + # frames and ticks, so a run whose armies never fought fails. + - name: Draw-call gate (under 500 a frame) + run: | + scripts/check_draws.sh "$RUNNER_TEMP/render-smoke/render_smoke.log" + scripts/check_draws.sh "$RUNNER_TEMP/render-smoke/unit_looks/render_smoke.log" + scripts/check_draws.sh --fight "$RUNNER_TEMP/render-smoke/enemy_outpost/render_smoke.log" + scripts/check_draws.sh --fight "$RUNNER_TEMP/render-smoke/art_bench/render_smoke.log" - uses: actions/upload-artifact@v7 if: always() diff --git a/Cargo.lock b/Cargo.lock index cf0486a..f63cfc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3215,6 +3215,7 @@ dependencies = [ "objc2-app-kit 0.3.2", "rules", "sim", + "skrifa 0.42.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4ba74a6..83fef5a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,9 @@ png = "0.18" # dev-dependencies pathfinding = "4.16" +# The game's HUD fit tests measure Inter's advances (art pass); the version +# parley 0.9 (Bevy's text layout) already locks, without its defaults. +skrifa = { version = "=0.42.1", default-features = false, features = ["std"] } proptest = "1.11" criterion = "0.8" diff --git a/crates/game/Cargo.toml b/crates/game/Cargo.toml index 2605731..c90dd87 100644 --- a/crates/game/Cargo.toml +++ b/crates/game/Cargo.toml @@ -51,5 +51,10 @@ objc2-app-kit = { workspace = true, features = [ "NSWorkspace", ] } +[dev-dependencies] +# HUD fit tests: Inter's advance widths (`fonts::measure`), the skrifa that +# parley already locks. +skrifa.workspace = true + [lints] workspace = true diff --git a/crates/game/src/age_hud.rs b/crates/game/src/age_hud.rs index b4dbd0e..5a2dc88 100644 --- a/crates/game/src/age_hud.rs +++ b/crates/game/src/age_hud.rs @@ -28,7 +28,6 @@ use bevy::picking::events::{Click, Pointer}; use bevy::picking::pointer::PointerButton; use bevy::prelude::*; -use bevy::text::FontSize; use rules::Rules; use rules::strings::fill; use sim::{Command, RejectReason, SimView}; @@ -145,10 +144,7 @@ impl Plugin for AgeHudPlugin { fn text(value: &str, size: f32) -> impl Bundle { ( Text::new(value), - TextFont { - font_size: FontSize::Px(size), - ..default() - }, + crate::fonts::hud_font(size), TextColor(palette::HUD_TEXT), TextLayout::no_wrap(), ) @@ -287,6 +283,7 @@ mod tests { use super::*; use crate::economy_hud::PopReadout; use crate::hud::spawn_hud; + use bevy::text::FontSize; fn data_dir() -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data") @@ -493,14 +490,10 @@ mod tests { })); } - /// The glyph advance of Bevy 0.19.1's default font, the only one the - /// game uses until M7 (`bevy_text-0.19.1/src/FiraMono-subset.ttf`, a - /// monospace font: every glyph advances 600 of 1000 units per em). - const GLYPH_ADVANCE_EM: f32 = 0.6; - /// The width `e` lays out to, logical px, with `text_of` the text of a - /// readout (else its current text): a text, `GLYPH_ADVANCE_EM` per - /// character of its font size; a node, its children side by side with + /// readout (else its current text): a text, the sum of the UI font's + /// advances at its size (Inter since the art pass, + /// `crate::fonts::measure`); a node, its children side by side with /// its column gap, plus its horizontal padding; nothing when hidden. fn laid_out_width(world: &World, e: Entity, text_of: &dyn Fn(Entity) -> Option) -> f32 { let px = |v: Val| if let Val::Px(x) = v { x } else { 0.0 }; @@ -514,7 +507,7 @@ mod tests { panic!("HUD text is sized in px"); }; let shown = text_of(e).unwrap_or_else(|| text.0.clone()); - return padding + shown.chars().count() as f32 * GLYPH_ADVANCE_EM * font; + return padding + crate::fonts::measure::text_width_px(&shown, font); } let children: Vec = world .get::(e) diff --git a/crates/game/src/app.rs b/crates/game/src/app.rs index 7d6d5cb..68a4e81 100644 --- a/crates/game/src/app.rs +++ b/crates/game/src/app.rs @@ -388,6 +388,11 @@ pub fn run(cli: &Cli) -> AppExit { // tracing subscriber, and an event before it is dropped. // `scripts/render_smoke.sh` requires this line. info!("assets: {}", assets.display()); + // Art pass: Inter replaces Bevy's built-in FiraMono before the first + // text layout (`crate::fonts`). + app.add_plugins(crate::fonts::FontsPlugin { + assets: assets.clone(), + }); // Display sleep or a screen lock must not end the session. app.add_plugins(MonitorLossPlugin); if background { @@ -437,17 +442,28 @@ pub fn run(cli: &Cli) -> AppExit { crate::terrain_props::TerrainPropsPlugin, )); // M5a: the attrition indicator, supply ring, annexation timer and - // readout, and the outcome overlay over the frozen world. + // readout, and the outcome overlay over the frozen world. Art pass: + // arrows, stones and melee lunges for every hit (presentation only). app.add_plugins(( crate::markers::MarkersPlugin, crate::outcome::OutcomeOverlayPlugin, + crate::combat_fx::CombatFxPlugin, )); + // Art pass AP6 (owner decision 7): the minimap bottom-left over the + // bottom bar; it prints `frame_stats: minimap ...` on exit. + app.add_plugins(crate::minimap::MinimapPlugin); + // Art pass AP10: every unit selectable by a real click (a driver // clicks the printed targets; `crate::click_check`). if cli.click_check { app.add_plugins(crate::click_check::ClickCheckPlugin); } + // Art pass AP6: the minimap's input with real X input (a driver + // performs the printed steps; `crate::minimap_check`). + if cli.minimap_check { + app.add_plugins(crate::minimap_check::MinimapCheckPlugin); + } // Presentation only: frames and selects the walkthrough's beats, or // selects `art_bench`'s army. diff --git a/crates/game/src/cli.rs b/crates/game/src/cli.rs index 4577503..678bf32 100644 --- a/crates/game/src/cli.rs +++ b/crates/game/src/cli.rs @@ -14,8 +14,9 @@ //! The art pass adds `--assets-dir ` ([`Cli::resolve_assets_dir`], also //! `EONMARK_ASSETS`), `--window-size x`, `--grid` (the debug grid, //! off by default; G toggles it), `--draw-stats` (the draw-call counter, -//! `crate::draw_stats`) and `--click-check` (every unit selectable by a -//! real click, `crate::click_check`). +//! `crate::draw_stats`), `--click-check` (every unit selectable by a +//! real click, `crate::click_check`) and `--minimap-check` (the minimap's +//! input with real clicks, `crate::minimap_check`). //! Parsing is complete and tested here. use std::path::{Path, PathBuf}; @@ -87,6 +88,12 @@ Options: each primary click against the next target, then print `click_check: ok /` (exit 0) or `click_check: FAIL ...` (exit 1). A driver clicks with real X input. + --minimap-check With --scenario economy_walkthrough: from tick 40 print + the minimap's rect and four steps (`minimap_check: step + ...`: press, drag, right click, A then left click), + judge each, then print `minimap_check: ok` (exit 0) or + `minimap_check: FAIL ...` (exit 1). A driver performs the + steps with real X input. -h, --help Show this text. Data directory resolution order: @@ -225,6 +232,9 @@ pub struct Cli { /// `--click-check` (art pass): print every unit's click target and /// judge real clicks against them (`crate::click_check`). pub click_check: bool, + /// `--minimap-check` (art pass): print the minimap's steps and judge + /// real input against them (`crate::minimap_check`). + pub minimap_check: bool, } impl Default for Cli { @@ -254,6 +264,7 @@ impl Default for Cli { grid: false, draw_stats: false, click_check: false, + minimap_check: false, } } } @@ -343,6 +354,7 @@ impl Cli { "--grid" => cli.grid = true, "--draw-stats" => cli.draw_stats = true, "--click-check" => cli.click_check = true, + "--minimap-check" => cli.minimap_check = true, "--scenario" => { let name = args.next().ok_or_else(|| format!("{arg}: missing value"))?; if name.is_empty() || name.starts_with("--") { @@ -426,6 +438,7 @@ impl Cli { (cli.grid, "--grid"), (cli.draw_stats, "--draw-stats"), (cli.click_check, "--click-check"), + (cli.minimap_check, "--minimap-check"), (cli.fast_forward_to.is_some(), "--fast-forward-to"), ( cli.new_game_after_seconds.is_some(), @@ -443,6 +456,12 @@ impl Cli { .into(), ); } + if cli.minimap_check && cli.headless_run.is_none() && cli.scenario.is_none() { + return Err( + "--minimap-check drives a scenario's minimap; add --scenario economy_walkthrough" + .into(), + ); + } if cli.spectate && cli.scenario.is_some() { return Err("--spectate builds its own match; drop --scenario".into()); } @@ -696,6 +715,7 @@ mod tests { grid: false, draw_stats: false, click_check: false, + minimap_check: false, }) ); let Parsed::Run(cli) = parsed else { @@ -983,11 +1003,22 @@ mod tests { ); let err = parse(&["--click-check"]).unwrap_err(); assert!(err.contains("--scenario unit_looks"), "{err}"); + assert_eq!( + parse(&["--scenario", "economy_walkthrough", "--minimap-check"]), + Ok(Parsed::Run(Cli { + scenario: Some("economy_walkthrough".into()), + minimap_check: true, + ..Cli::default() + })) + ); + let err = parse(&["--minimap-check"]).unwrap_err(); + assert!(err.contains("--scenario economy_walkthrough"), "{err}"); for flag in [ vec!["--window-size", "1280x720"], vec!["--grid"], vec!["--draw-stats"], vec!["--click-check"], + vec!["--minimap-check"], ] { let mut args = vec!["--headless-run", "10"]; args.extend(flag.iter().copied()); diff --git a/crates/game/src/combat_fx.rs b/crates/game/src/combat_fx.rs new file mode 100644 index 0000000..3b1ee52 --- /dev/null +++ b/crates/game/src/combat_fx.rs @@ -0,0 +1,613 @@ +//! Hit feedback (art pass, owner decision 11; the first thing cut on an +//! overrun): fights read as more than hit points dropping. Presentation +//! only: [`spawn_hit_fx`] reads `SimEvent::Hit` after the sim step and +//! never writes to the sim, its commands or its state. +//! +//! For every hit ([`hit_fx`], from the attacker's data, not its name): +//! +//! - a ranged attacker (a unit kind whose range is over one tile, the +//! Bowman; or a building, the Town and the Watchtower) looses an arrow, +//! a [`ARROW_LENGTH_M`] dark streak flying from it to the target in +//! [`FLIGHT_SECS`] on a low arc ([`ARROW_ARC_M`]) that clears the heads +//! in between, pointing along its path; +//! - a Siege kind (the Mangonel) lobs a [`STONE_SIDE_M`] stone on an arc +//! [`STONE_ARC_M`] high in the same time; +//! - a melee attacker lunges [`LUNGE_M`] toward its target and back in +//! [`LUNGE_SECS`] ([`Lunge`], added to the unit's interpolated pose after +//! `present::sync_transforms`). +//! +//! Arrows and stones are two shared meshes with two unlit materials +//! (`palette::ARROW`, `palette::STONE_SHOT`; no emissive, nothing glows or +//! pulses) and cast no shadow, so they add two draw bins. They come from a +//! pool of at most [`POOL_CAP`] top-level entities, hidden and reused when +//! they land; a hit while all are in flight draws nothing +//! ([`FxPool::dropped`]). A hit whose attacker is gone (killed in the same +//! tick) draws nothing; a target killed by the hit is aimed at where it +//! was drawn last. + +use bevy::light::NotShadowCaster; +use bevy::prelude::*; +use rules::{Rules, UnitClass}; +use sim::{SimEvent, SimView, TargetId}; + +use crate::buildings::{building_top_y, footprint_centre}; +use crate::models::ModelLibrary; +use crate::palette; +use crate::present::{UnitEntities, UnitRef, sync_lifecycle, sync_transforms, world_from_sim}; +use crate::sim_driver::{SimEventMsg, SimHandle, SimSystems, sim_stepped}; + +/// Most arrows and stones in flight at once. +pub const POOL_CAP: usize = 256; +/// How long an arrow or a stone flies, seconds. +pub const FLIGHT_SECS: f32 = 0.25; +/// An arrow's length, metres. +pub const ARROW_LENGTH_M: f32 = 0.5; +/// An arrow's thickness, metres. +pub const ARROW_THICKNESS_M: f32 = 0.06; +/// A stone's side, metres. +pub const STONE_SIDE_M: f32 = 0.25; +/// A stone's arc above the straight line at its middle, metres. +pub const STONE_ARC_M: f32 = 2.0; +/// An arrow's arc at its middle, metres: low, but enough to carry it over +/// the heads between the archer and the target (figures stand 1.3-1.45 m; +/// a straight shot at chest height flew inside them and could not be seen +/// in the 40 m shots). +pub const ARROW_ARC_M: f32 = 0.8; +/// How far a melee attacker lunges toward its target, metres. +pub const LUNGE_M: f32 = 0.2; +/// How long a lunge lasts, out and back, seconds. +pub const LUNGE_SECS: f32 = 0.15; +/// Where a unit looses an arrow and where one strikes, metres above the +/// ground (chest height of a 1.3-1.45 m figure). +pub const SHOT_Y: f32 = 0.9; +/// A unit kind with a longer range than this (tiles x 100) shoots; up to +/// it, it strikes in melee (the melee kinds reach 25). +pub const MELEE_RANGE_X100: u32 = 100; +/// Share of a building's top a shot leaves from. +pub const BUILDING_SHOT_TOP: f32 = 0.8; + +/// What a hit looks like. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HitFx { + /// A dark streak from a ranged attacker. + Arrow, + /// A stone on an arc from a Siege kind. + Stone, + /// A melee lunge. + Lunge, +} + +/// The look of a hit by `attacker`, from its data: a building shoots +/// arrows; a unit of the Siege class throws stones, one with a range over +/// [`MELEE_RANGE_X100`] shoots arrows, anything else lunges. `None` when +/// the attacker is gone or has no attack. +pub fn hit_fx(view: &SimView<'_>, rules: &Rules, attacker: TargetId) -> Option { + match attacker { + TargetId::Building(id) => view.building(id).map(|_| HitFx::Arrow), + TargetId::Unit(id) => { + let kind = rules.unit_kind(view.unit(id)?.kind.0)?; + let attack = kind.attack.as_ref()?; + Some(if kind.class == UnitClass::Siege { + HitFx::Stone + } else if attack.range_tiles_x100 > MELEE_RANGE_X100 { + HitFx::Arrow + } else { + HitFx::Lunge + }) + } + } +} + +/// One arrow or stone in flight. +#[derive(Component, Debug, Clone, Copy, PartialEq)] +pub struct Flight { + /// Where it left, world metres. + pub from: Vec3, + /// Where it lands. + pub to: Vec3, + /// Its arc's height above the straight line at the middle. + pub arc: f32, + /// The fixed clock's time of the tick that launched it ([`fx_now`]). + pub started: f32, +} + +impl Flight { + /// Its position at `now`, and whether it has landed. + pub fn at(&self, now: f32) -> (Vec3, bool) { + let t = ((now - self.started) / FLIGHT_SECS).max(0.0); + let s = t.min(1.0); + let pos = self.from.lerp(self.to, s) + Vec3::Y * (self.arc * 4.0 * s * (1.0 - s)); + (pos, t >= 1.0) + } +} + +/// A melee lunge on a unit's entity, removed when it ends. +#[derive(Component, Debug, Clone, Copy, PartialEq)] +pub struct Lunge { + /// Toward the target, unit length, on the ground plane. + pub dir: Vec3, + /// The fixed clock's time of the tick that started it ([`fx_now`]). + pub started: f32, +} + +impl Lunge { + /// The offset at `now`: out to [`LUNGE_M`] and back (half a sine), and + /// whether it has ended. + pub fn offset(&self, now: f32) -> (Vec3, bool) { + let t = (now - self.started) / LUNGE_SECS; + if !(0.0..1.0).contains(&t) { + return (Vec3::ZERO, t >= 1.0); + } + (self.dir * LUNGE_M * (t * std::f32::consts::PI).sin(), false) + } +} + +/// The two meshes and materials every arrow and stone shares. +#[derive(Resource, Debug, Clone)] +pub struct FxAssets { + /// An arrow, long along its local Z. + pub arrow: Handle, + /// A stone. + pub stone: Handle, + /// Unlit `palette::ARROW`. + pub arrow_material: Handle, + /// Unlit `palette::STONE_SHOT`. + pub stone_material: Handle, +} + +/// The projectile pool. +#[derive(Resource, Debug, Default, Clone)] +pub struct FxPool { + /// Hidden entities ready to fly again. + pub free: Vec, + /// Entities spawned so far (at most [`POOL_CAP`]). + pub spawned: usize, + /// Hits that drew nothing because every projectile was in flight. + pub dropped: u64, +} + +/// Arrows, stones and lunges from `SimEvent::Hit`. Added by `app::run`. +pub struct CombatFxPlugin; + +impl Plugin for CombatFxPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(Startup, create_fx_assets) + .add_systems( + FixedUpdate, + spawn_hit_fx + .after(SimSystems::Step) + .before(sync_lifecycle) + .run_if(sim_stepped), + ) + .add_systems(Update, fly) + .add_systems( + PostUpdate, + apply_lunges + .after(sync_transforms) + .before(bevy::transform::TransformSystems::Propagate), + ); + } +} + +/// The arrow (a thin box along Z) and the stone (a cube), each with an +/// unlit material. +pub fn create_fx_assets( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + let unlit = |color: Color| StandardMaterial { + base_color: color, + unlit: true, + ..default() + }; + commands.insert_resource(FxAssets { + arrow: meshes.add(Cuboid::new( + ARROW_THICKNESS_M, + ARROW_THICKNESS_M, + ARROW_LENGTH_M, + )), + stone: meshes.add(Cuboid::from_length(STONE_SIDE_M)), + arrow_material: materials.add(unlit(palette::ARROW)), + stone_material: materials.add(unlit(palette::STONE_SHOT)), + }); +} + +/// Where a target of a hit is: a unit's position (or, if the hit killed it, +/// where its entity was drawn), a building's footprint centre, at +/// [`SHOT_Y`]. +fn target_pos( + view: &SimView<'_>, + rules: &Rules, + target: TargetId, + drawn: &dyn Fn(sim::UnitId) -> Option, +) -> Option { + match target { + TargetId::Unit(id) => view + .unit(id) + .map(|u| world_from_sim(u.pos, SHOT_Y)) + .or_else(|| drawn(id).map(|p| Vec3::new(p.x, SHOT_Y, p.z))), + TargetId::Building(id) => view + .building(id) + .map(|b| footprint_centre(rules, b) + Vec3::Y * SHOT_Y), + } +} + +/// After each stepped tick, before the presenter despawns the dead: for +/// every `Hit`, an arrow or a stone from the pool, or a lunge on the +/// attacker's entity. +#[allow(clippy::too_many_arguments)] // Bevy system: each parameter is one resource or query +pub fn spawn_hit_fx( + sim: NonSend, + mut events: MessageReader, + assets: Option>, + models: Option>, + entities: Res, + units: Query<&Transform, With>, + mut pool: ResMut, + fixed: Res>, + mut commands: Commands, +) { + let Some(assets) = assets else { + events.clear(); + return; + }; + let view = sim.view(); + let rules = sim.rules(); + // Inside the fixed loop this is the time of the tick being presented, + // so the hits of several ticks run in one frame start where their + // ticks put them. + let now = fixed.elapsed_secs(); + let drawn = |id: sim::UnitId| { + entities + .0 + .get(&id) + .and_then(|e| units.get(*e).ok()) + .map(|t| t.translation) + }; + for msg in events.read() { + let SimEvent::Hit { + attacker, target, .. + } = msg.event + else { + continue; + }; + let Some(fx) = hit_fx(&view, rules, attacker) else { + continue; + }; + let Some(to) = target_pos(&view, rules, target, &drawn) else { + continue; + }; + let from = match attacker { + TargetId::Unit(id) => view.unit(id).map(|u| world_from_sim(u.pos, SHOT_Y)), + TargetId::Building(id) => view.building(id).map(|b| { + footprint_centre(rules, b) + + Vec3::Y * (building_top_y(rules, b, models.as_deref()) * BUILDING_SHOT_TOP) + }), + }; + let Some(from) = from else { + continue; + }; + match fx { + HitFx::Lunge => { + let TargetId::Unit(id) = attacker else { + continue; + }; + let dir = Vec3::new(to.x - from.x, 0.0, to.z - from.z).normalize_or_zero(); + if let Some(entity) = entities.0.get(&id) { + commands.entity(*entity).insert(Lunge { dir, started: now }); + } + } + HitFx::Arrow | HitFx::Stone => { + let flight = Flight { + from, + to, + arc: if fx == HitFx::Stone { + STONE_ARC_M + } else { + ARROW_ARC_M + }, + started: now, + }; + let (mesh, material) = if fx == HitFx::Stone { + (assets.stone.clone(), assets.stone_material.clone()) + } else { + (assets.arrow.clone(), assets.arrow_material.clone()) + }; + let transform = flight_transform(&flight, now); + let bundle = ( + flight, + Mesh3d(mesh), + MeshMaterial3d(material), + transform, + Visibility::Visible, + ); + if let Some(entity) = pool.free.pop() { + commands.entity(entity).insert(bundle); + } else if pool.spawned < POOL_CAP { + pool.spawned += 1; + commands.spawn((Name::new("Projectile"), NotShadowCaster, bundle)); + } else { + pool.dropped += 1; + } + } + } + } +} + +/// The presentation time of hit feedback, seconds: the fixed clock's last +/// tick plus its overstep, the same clock `present::sync_transforms` +/// interpolates units with. A projectile started at its tick's fixed time +/// is then where it should be even when a slow frame runs several ticks +/// (on lavapipe one frame runs five), and dropped ticks never desync it. +pub fn fx_now(fixed: &Time) -> f32 { + fixed.elapsed_secs() + fixed.overstep().as_secs_f32() +} + +/// A projectile's transform at `now`: on its path, pointing along it. +pub fn flight_transform(flight: &Flight, now: f32) -> Transform { + let (pos, _) = flight.at(now); + let (ahead, _) = flight.at(now + FLIGHT_SECS * 0.05); + let dir = if (ahead - pos).length_squared() > 1e-8 { + ahead - pos + } else { + flight.to - flight.from + }; + let mut t = Transform::from_translation(pos); + if dir.length_squared() > 1e-8 { + t.look_to(dir, Vec3::Y); + } + t +} + +/// Every frame: move each projectile along its path; a landed one is +/// hidden, loses its [`Flight`] and goes back to the pool. +pub fn fly( + fixed: Res>, + mut flights: Query<(Entity, &Flight, &mut Transform, &mut Visibility)>, + mut pool: ResMut, + mut commands: Commands, +) { + let now = fx_now(&fixed); + for (entity, flight, mut transform, mut visibility) in &mut flights { + if flight.at(now).1 { + *visibility = Visibility::Hidden; + commands.entity(entity).remove::(); + pool.free.push(entity); + } else { + *transform = flight_transform(flight, now); + } + } +} + +/// After the presenter placed every unit this frame: offset the lunging +/// ones, and drop each lunge once it ended. +pub fn apply_lunges( + fixed: Res>, + mut lunges: Query<(Entity, &Lunge, &mut Transform)>, + mut commands: Commands, +) { + let now = fx_now(&fixed); + for (entity, lunge, mut transform) in &mut lunges { + let (offset, ended) = lunge.offset(now); + transform.translation += offset; + if ended { + commands.entity(entity).remove::(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rules::Rules; + use sim::{Command, PlayerCommand, PlayerId, UnitId, UnitKindId}; + + fn rules() -> Rules { + Rules::load(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data")) + .expect("rules load") + } + + fn kind(rules: &Rules, id: &str) -> UnitKindId { + UnitKindId(u16::try_from(rules.units.iter().position(|k| k.id == id).unwrap()).unwrap()) + } + + /// A standard start with one P0 Bowman, Skirmisher and Mangonel spawned + /// (ids in that order after the start's units). + fn sim_with_attackers() -> (SimHandle, [UnitId; 3]) { + let rules = rules(); + let mut setup = sim::MatchSetup::scenario(&rules, 1); + setup.start = sim::StartKind::Standard; + let mut sim = SimHandle::from_setup(setup, rules.clone()); + let at = sim.view().unit(UnitId(1)).unwrap().pos; + let cmds: Vec<_> = ["bowman", "skirmisher", "mangonel"] + .iter() + .enumerate() + .map(|(i, k)| { + PlayerCommand::new( + PlayerId(0), + u32::try_from(i).unwrap(), + Command::DebugSpawn { + owner: PlayerId(0), + kind: kind(&rules, k), + at, + count: 1, + }, + ) + }) + .collect(); + sim.step(&cmds); + for _ in 0..sim.setup().cmd_delay { + sim.step_once(); + } + let ids: Vec = { + let view = sim.view(); + let mut ids: Vec = view.units().map(|u| u.id).collect(); + ids.sort(); + ids.split_off(ids.len() - 3) + }; + (sim, [ids[0], ids[1], ids[2]]) + } + + /// An app with the fx assets, the pool and `spawn_hit_fx` and `fly` in + /// `Update`, over `sim`, with one presenter entity per unit. + fn fx_app(sim: SimHandle) -> App { + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_message::() + .init_resource::>() + .init_resource::>() + .init_resource::() + .init_resource::() + .add_systems(Startup, create_fx_assets) + .add_systems(Update, (spawn_hit_fx, fly).chain()); + let ids: Vec = sim.view().units().map(|u| u.id).collect(); + for id in ids { + let e = app + .world_mut() + .spawn((UnitRef(id), Transform::default())) + .id(); + app.world_mut() + .resource_mut::() + .0 + .insert(id, e); + } + app.world_mut().insert_non_send(sim); + app.update(); + app + } + + fn hit(app: &mut App, attacker: UnitId, target: TargetId) { + app.world_mut().write_message(SimEventMsg { + tick: 1, + event: SimEvent::Hit { + attacker: TargetId::Unit(attacker), + target, + damage: 1, + }, + }); + } + + fn flights(app: &mut App) -> Vec<(Entity, Flight)> { + app.world_mut() + .query::<(Entity, &Flight)>() + .iter(app.world()) + .map(|(e, f)| (e, *f)) + .collect() + } + + /// The Bowman's hit flies an arrow on its low arc, the Mangonel's a + /// stone on its high one, both from the attacker to the target; the + /// Skirmisher's lunges toward the target and flies nothing. Data + /// decides, not names: the kinds' classes and ranges. + #[test] + fn hits_spawn_projectiles_for_ranged_and_lunges_for_melee() { + let (sim, [bowman, skirmisher, mangonel]) = sim_with_attackers(); + { + let view = sim.view(); + let rules = sim.rules(); + let fx = |id| hit_fx(&view, rules, TargetId::Unit(id)); + assert_eq!(fx(bowman), Some(HitFx::Arrow)); + assert_eq!(fx(skirmisher), Some(HitFx::Lunge)); + assert_eq!(fx(mangonel), Some(HitFx::Stone)); + assert_eq!(fx(UnitId(1)), None, "a Yeoman has no attack"); + let town = view.buildings().next().unwrap().id; + assert_eq!( + hit_fx(&view, rules, TargetId::Building(town)), + Some(HitFx::Arrow) + ); + } + // A P1 Yeoman at the other Seat, far from the attackers. + let enemy = sim + .view() + .units() + .find(|u| u.owner == PlayerId(1)) + .unwrap() + .id; + let target = TargetId::Unit(enemy); + let mut app = fx_app(sim); + let assets = app.world().resource::().clone(); + + hit(&mut app, bowman, target); + app.update(); + let f = flights(&mut app); + assert_eq!(f.len(), 1, "one arrow"); + let (arrow, flight) = f[0]; + assert_eq!(flight.arc, ARROW_ARC_M); + assert!(flight.from.distance(flight.to) > 50.0, "Seat to Seat"); + assert_eq!(flight.from.y, SHOT_Y); + let mesh = app.world().get::(arrow).unwrap().0.clone(); + assert_eq!(mesh, assets.arrow); + assert!(app.world().get::(arrow).is_none(), "top-level"); + assert!(app.world().get::(arrow).is_some()); + + hit(&mut app, mangonel, target); + app.update(); + let stones: Vec<_> = flights(&mut app) + .into_iter() + .filter(|(_, f)| f.arc == STONE_ARC_M) + .collect(); + assert_eq!(stones.len(), 1, "one stone"); + let stone = app.world().get::(stones[0].0).unwrap().0.clone(); + assert_eq!(stone, assets.stone); + let (mid, _) = stones[0].1.at(stones[0].1.started + FLIGHT_SECS / 2.0); + let line = stones[0].1.from.lerp(stones[0].1.to, 0.5); + assert!((mid.y - line.y - STONE_ARC_M).abs() < 1e-4, "the arc's top"); + + let before = flights(&mut app).len(); + hit(&mut app, skirmisher, target); + app.update(); + assert_eq!(flights(&mut app).len(), before, "melee flies nothing"); + let entity = app.world().resource::().0[&skirmisher]; + let lunge = *app + .world() + .get::(entity) + .expect("the Skirmisher lunges"); + assert!((lunge.dir.length() - 1.0).abs() < 1e-4, "{lunge:?}"); + assert!(lunge.dir.x > 0.9, "east, toward P1: {lunge:?}"); + let (peak, ended) = lunge.offset(lunge.started + LUNGE_SECS / 2.0); + assert!(!ended); + assert!((peak.length() - LUNGE_M).abs() < 1e-4); + assert!(lunge.offset(lunge.started + LUNGE_SECS).1, "it ends"); + } + + /// At most `POOL_CAP` projectiles exist: hits past that draw nothing + /// and are counted; a landed one is hidden and flies again, so the + /// entity count never grows past the cap. + #[test] + fn pool_caps_at_256() { + let (sim, [bowman, ..]) = sim_with_attackers(); + let mut app = fx_app(sim); + for _ in 0..POOL_CAP + 44 { + hit(&mut app, bowman, TargetId::Unit(UnitId(1))); + } + app.update(); + assert_eq!(flights(&mut app).len(), POOL_CAP); + assert_eq!(app.world().resource::().dropped, 44); + let projectiles = |app: &mut App| { + app.world_mut() + .query_filtered::>() + .iter(app.world()) + .count() + }; + assert_eq!(projectiles(&mut app), POOL_CAP); + + // Let them land: every one hides and returns to the pool. + app.world_mut() + .resource_mut::>() + .advance_by(std::time::Duration::from_secs_f32(FLIGHT_SECS * 2.0)); + app.update(); + assert!(flights(&mut app).is_empty()); + assert_eq!(app.world().resource::().free.len(), POOL_CAP); + + // New hits reuse them. + for _ in 0..10 { + hit(&mut app, bowman, TargetId::Unit(UnitId(1))); + } + app.update(); + assert_eq!(flights(&mut app).len(), 10); + assert_eq!(projectiles(&mut app), POOL_CAP, "no new entity"); + assert_eq!(app.world().resource::().dropped, 44); + } +} diff --git a/crates/game/src/command_card.rs b/crates/game/src/command_card.rs index e3db341..c9847bf 100644 --- a/crates/game/src/command_card.rs +++ b/crates/game/src/command_card.rs @@ -53,7 +53,6 @@ use bevy::picking::events::{Click, Pointer}; use bevy::picking::pointer::PointerButton; use bevy::prelude::*; -use bevy::text::FontSize; use rules::Rules; use rules::strings::fill; use sim::{ @@ -72,10 +71,12 @@ use crate::selection::Selection; use crate::sim_driver::{LOCAL_PLAYER, PendingCommands, SimHandle}; /// Card button size, logical px. Every line a button can show fits one -/// line of it at [`CARD_FONT_PX`] (7.2 px a character): the longest are -/// "Needs Statecraft II" (19 characters; [`research_actions`] never offers -/// a level whose level before is missing, but the blocker exists) and the -/// Bowman at the military ramp's cap, "45 Grain 90 Lumber" (18). +/// line of it at [`CARD_FONT_PX`] in Inter (art pass; measured with its +/// advances): the longest are the Bowman at the military ramp's cap, "45 +/// Grain 90 Lumber" (114 px), and "Needs Statecraft II" (106 px; +/// [`research_actions`] never offers a level whose level before is +/// missing, but the blocker exists). With M4b's monospace Fira Mono they +/// were 18 and 19 characters of 7.2 px. pub const CARD_BUTTON_PX: Vec2 = Vec2::new(140.0, 36.0); /// Gap between card buttons, logical px. pub const CARD_GAP_PX: f32 = 4.0; @@ -88,8 +89,9 @@ pub const CARD_FONT_PX: f32 = 12.0; /// window does). Every line a Scriptorium shows fits one row of it, the /// longest being its queue of the four level III researches and a Scribe, /// "Queue: Statecraft III 99%, Letters III, Trade III, Arms III, Scribe" -/// (67 characters of 8.4 px at the 14 px HUD font): its five lines then -/// fit the 88 px between the bar's top padding and the window's bottom. +/// (406 px in Inter at the 14 px HUD font, art pass; 67 characters of +/// 8.4 px, 563 px, in M4b's monospace Fira Mono): its five lines then fit +/// the 88 px between the bar's top padding and the window's bottom. pub const INFO_WIDTH_PX: f32 = 580.0; /// Most lines the selection panel shows for a selection of units: the /// rows of 14 px text that fit the 88 px between the bottom bar's top @@ -193,10 +195,7 @@ impl Plugin for CommandCardPlugin { fn text(value: &str, size: f32) -> impl Bundle { ( Text::new(value), - TextFont { - font_size: FontSize::Px(size), - ..default() - }, + crate::fonts::hud_font(size), TextColor(palette::HUD_TEXT), ) } @@ -2089,16 +2088,13 @@ mod tests { ); } - /// Bevy 0.19.1's default font, the only one the game uses until M7 - /// (`bevy_text-0.19.1/src/FiraMono-subset.ttf`), is monospace: every - /// glyph advances 600 of its 1000 units per em. - const GLYPH_ADVANCE_EM: f32 = 0.6; /// Bevy 0.19.1's default line height, `LineHeight::RelativeToFont(1.2)`. const LINE_HEIGHT_EM: f32 = 1.2; - /// How many characters of `font_px` text fit on one line `width_px` wide. - fn chars_per_line(width_px: f32, font_px: f32) -> usize { - (width_px / (GLYPH_ADVANCE_EM * font_px)).floor() as usize + /// The width of one line of `font_px` text in the UI font (Inter since + /// the art pass: its real advances, `crate::fonts::measure`). + fn line_width(line: &str, font_px: f32) -> f32 { + crate::fonts::measure::text_width_px(line, font_px) } /// How many rows of HUD text fit in the selection panel: from the @@ -2111,17 +2107,17 @@ mod tests { /// row of `INFO_WIDTH_PX`, and the lines within the rows from the bar's /// top padding to the bottom of the window. fn assert_fits_the_panel(text: &str) { - let (rows, width) = (panel_rows(), chars_per_line(INFO_WIDTH_PX, HUD_FONT_PX)); + let rows = panel_rows(); assert!( text.lines().count() <= rows, "{} lines, {rows} fit:\n{text}", text.lines().count() ); for line in text.lines() { + let width = line_width(line, HUD_FONT_PX); assert!( - line.chars().count() <= width, - "{} characters, {width} fit on a row: {line}", - line.chars().count() + width <= INFO_WIDTH_PX, + "{width} px, {INFO_WIDTH_PX} fit on a row: {line}" ); } } @@ -2317,7 +2313,6 @@ mod tests { /// building and Yeoman ramps) and every blocker a Research button shows. #[test] fn every_card_line_fits_its_button() { - let width = chars_per_line(CARD_BUTTON_PX.x, CARD_FONT_PX); let mut lab = Lab::new(standard()); let rules = lab.sim.rules().clone(); let s = strings(); @@ -2399,10 +2394,11 @@ mod tests { lines.extend(capped); for line in &lines { + let width = line_width(line, CARD_FONT_PX); assert!( - line.chars().count() <= width, - "{} characters, {width} fit on a button: {line}", - line.chars().count() + width <= CARD_BUTTON_PX.x, + "{width} px, {} fit on a button: {line}", + CARD_BUTTON_PX.x ); } } diff --git a/crates/game/src/economy_hud.rs b/crates/game/src/economy_hud.rs index f5c059f..1308582 100644 --- a/crates/game/src/economy_hud.rs +++ b/crates/game/src/economy_hud.rs @@ -31,7 +31,6 @@ use std::collections::BTreeMap; use bevy::prelude::*; -use bevy::text::FontSize; use rules::Rules; use rules::strings::{Strings, fill}; use sim::{BuildingId, PlayerId, RejectReason, SimEvent}; @@ -275,10 +274,7 @@ impl Plugin for EconomyHudPlugin { fn hud_text(text: &str) -> impl Bundle { ( Text::new(text), - TextFont { - font_size: FontSize::Px(HUD_FONT_PX), - ..default() - }, + crate::fonts::hud_font(HUD_FONT_PX), TextColor(palette::HUD_TEXT), ) } @@ -1173,4 +1169,59 @@ mod tests { "Lore 12 / 999 +10 / 30 s" ); } + + /// The top bar laid out with Inter at 1280 x 800 (`ui_test`): a stock + /// that changes without changing its number of digits moves no + /// readout. Each readout's figures are tabular (Inter's `tnum`), so + /// "Grain 111" and "Grain 444" are one width and every readout right of + /// it stays put; with Inter's default proportional figures '1' is + /// 5.7 px and '4' 9.0 px at 14 px, and each later readout would slide + /// by about 10 px. The same holds for the pop readout's own digits. + #[test] + fn readouts_hold_their_place_when_a_number_changes() { + use crate::ui_test::{layout_app, rect, settle}; + let rules = rules(); + let (setup, _) = sim::scenarios::economy_walkthrough(&rules); + let names: Vec = rules + .resources + .resources + .iter() + .map(|r| r.name.clone()) + .collect(); + let mut app = layout_app(UVec2::new(1280, 800)); + app.world_mut() + .insert_non_send(SimHandle::from_setup(setup, rules)); + app.add_systems(Startup, (crate::hud::spawn_hud, spawn_economy_hud).chain()); + settle(&mut app); + let readouts: Vec<(usize, Entity)> = { + let mut q = app.world_mut().query::<(Entity, &ResourceReadout)>(); + let mut v: Vec<(usize, Entity)> = q.iter(app.world()).map(|(e, r)| (r.0, e)).collect(); + v.sort_unstable(); + v + }; + assert_eq!(readouts.len(), names.len()); + let pop = crate::ui_test::single::(&mut app); + let set = |app: &mut App, digit: char| { + for (i, e) in &readouts { + let n: String = std::iter::repeat_n(digit, 3).collect(); + app.world_mut().get_mut::(*e).unwrap().0 = + format!("{} {n} +{n} / 30 s", names[*i]); + } + let n: String = std::iter::repeat_n(digit, 2).collect(); + app.world_mut().get_mut::(pop).unwrap().0 = format!("Pop {n} / {n}"); + settle(app); + let mut rects: Vec = readouts.iter().map(|(_, e)| rect(app, *e)).collect(); + rects.push(rect(app, pop)); + rects + }; + let ones = set(&mut app, '1'); + let fours = set(&mut app, '4'); + for (i, (a, b)) in ones.iter().zip(&fours).enumerate() { + assert!(a.width() > 50.0, "readout {i} was measured: {a:?}"); + assert!( + (a.min.x - b.min.x).abs() < 0.01 && (a.max.x - b.max.x).abs() < 0.01, + "readout {i} moved from {a:?} to {b:?}" + ); + } + } } diff --git a/crates/game/src/fonts.rs b/crates/game/src/fonts.rs new file mode 100644 index 0000000..5c360c7 --- /dev/null +++ b/crates/game/src/fonts.rs @@ -0,0 +1,404 @@ +//! The UI font (art pass slice 5, owner decision 2): Inter 4.1 +//! (`assets/fonts/InterVariable.ttf`, OFL-1.1, its licence beside it as +//! `assets/fonts/OFL-Inter.txt`) replaces Bevy's built-in Fira Mono subset +//! as the font every `TextFont` without a handle draws with. +//! +//! [`install_ui_font`] runs in `Startup`: it reads the file with `std::fs` +//! (no asset load, so nothing waits on the asset server) and inserts it +//! into `Assets` at `AssetId::default()`, the id `bevy_text`'s +//! `TextPlugin` put Fira Mono at and the id `TextFont::default()`'s handle +//! points to. Bevy 0.19.1's `load_font_assets_into_font_collection` +//! (`PostUpdate`) registers each font id once, in a `Local` set, so the +//! replacement must land before the first `PostUpdate`; `Startup` does +//! (`install_ui_font_replaces_the_default_before_the_first_post_update`). +//! A missing, unreadable or unparsable file is a `warn!` and Fira Mono +//! stays, so the game still shows text: `Font::from_bytes` never parses, +//! so the bytes are first registered in a throwaway `FontCx` the way +//! `load_font_assets_into_font_collection` will register them, and a file +//! that yields no font family (empty, truncated, not a font) is refused. +//! +//! Every HUD, overlay and menu text takes its `TextFont` from +//! [`hud_font`], which turns on Inter's tabular figures (`tnum`): Inter's +//! default figures are proportional ('1' is 5.7 px and '4' 9.0 px at +//! 14 px), so a stock or a countdown that changed by a digit would move +//! every readout beside it; with `tnum` every digit is 1328/2048 em +//! (9.08 px at 14 px) and a number keeps its width while its digit count +//! does. +//! +//! The HUD's fit tests measure text with Inter's real advance widths (the +//! test-only `measure` module, `skrifa` 0.42.1, the version parley +//! already locks): the sum of the nominal advances at the default +//! instance (weight 400, the weight `TextFont::default()` asks for), each +//! digit through Inter's `tnum` substitution as [`hud_font`] draws it, +//! with no kerning, which in Inter narrows far more pairs than it widens. + +use std::path::{Path, PathBuf}; + +use bevy::prelude::*; +use bevy::text::{FontCx, FontFeatureTag}; + +/// The UI font under the assets directory. +pub const UI_FONT_FILE: &str = "fonts/InterVariable.ttf"; + +/// Where [`install_ui_font`] reads the UI font from. +#[derive(Resource, Debug, Clone, PartialEq, Eq)] +pub struct UiFontFile(pub PathBuf); + +/// Installs Inter as the default font in `Startup`. +pub struct FontsPlugin { + /// The resolved assets directory (`Cli::resolve_assets_dir`). + pub assets: PathBuf, +} + +impl Plugin for FontsPlugin { + fn build(&self, app: &mut App) { + app.insert_resource(UiFontFile(ui_font_path(&self.assets))) + .add_systems(Startup, install_ui_font); + } +} + +/// The UI font's path under `assets`. +pub fn ui_font_path(assets: &Path) -> PathBuf { + assets.join(UI_FONT_FILE) +} + +/// The `TextFont` of every HUD, overlay and menu text: the default font +/// (Inter once [`install_ui_font`] ran) at `px` logical px with tabular +/// figures, so a changing number keeps its width. +pub fn hud_font(px: f32) -> TextFont { + TextFont { + font_size: FontSize::Px(px), + font_features: [FontFeatureTag::TABULAR_FIGURES].into(), + ..default() + } +} + +/// Read [`UiFontFile`] and put it in `Assets` at `AssetId::default()` +/// in place of Bevy's Fira Mono. Logs `ui font: ` once installed, or +/// warns and keeps Fira Mono when the file cannot be read or holds no +/// font family Bevy could register. +pub fn install_ui_font(file: Res, fonts: Option>>) { + let Some(mut fonts) = fonts else { + return; + }; + let path = file.0.display(); + let bytes = match std::fs::read(&file.0) { + Ok(bytes) => bytes, + Err(err) => { + warn!("ui font {path}: {err}; keeping Bevy's built-in FiraMono"); + return; + } + }; + let font = Font::from_bytes(bytes); + // `load_font_assets_into_font_collection` registers the asset's bytes + // with fontique, which skips data it cannot parse (no family, no + // alias), and the default `TextFont` would then name a family that + // does not exist. Register them the same way first. + if FontCx::default() + .collection + .register_fonts(font.data.clone(), None) + .is_empty() + { + warn!("ui font {path}: not a font file; keeping Bevy's built-in FiraMono"); + return; + } + match fonts.insert(AssetId::default(), font) { + Ok(()) => info!("ui font: {path}"), + Err(err) => warn!("ui font {path}: {err}; keeping Bevy's built-in FiraMono"), + } +} + +/// Text widths with Inter's advances, for the HUD fit tests. +#[cfg(test)] +pub(crate) mod measure { + use std::sync::OnceLock; + + use std::collections::BTreeMap; + + use skrifa::instance::{LocationRef, Size}; + use skrifa::raw::TableProvider; + use skrifa::raw::tables::gsub::{SingleSubst, SubstitutionSubtables}; + use skrifa::raw::types::Tag; + use skrifa::{FontRef, GlyphId, MetadataProvider}; + + /// The repository's `assets/fonts/InterVariable.ttf`, read once. + pub fn inter() -> &'static [u8] { + static BYTES: OnceLock> = OnceLock::new(); + BYTES.get_or_init(|| { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../assets") + .join(super::UI_FONT_FILE); + std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())) + }) + } + + /// Inter's `tnum` feature as a glyph map: every single substitution of + /// the GSUB lookups the feature names (Inter's figures map to their + /// tabular forms there). + pub fn tnum_map() -> &'static BTreeMap { + static MAP: OnceLock> = OnceLock::new(); + MAP.get_or_init(|| { + let font = FontRef::new(inter()).expect("Inter parses"); + let gsub = font.gsub().expect("Inter has GSUB"); + let features = gsub.feature_list().expect("a feature list"); + let lookups = gsub.lookup_list().expect("a lookup list"); + let mut map = BTreeMap::new(); + for record in features.feature_records() { + if record.feature_tag() != Tag::new(b"tnum") { + continue; + } + let feature = record + .feature(features.offset_data()) + .expect("the tnum feature"); + for index in feature.lookup_list_indices() { + let lookup = lookups + .lookups() + .get(usize::from(index.get())) + .expect("a tnum lookup"); + let Ok(SubstitutionSubtables::Single(subtables)) = lookup.subtables() else { + continue; + }; + for subtable in subtables.iter() { + match subtable.expect("a single substitution") { + SingleSubst::Format1(t) => { + let delta = i32::from(t.delta_glyph_id()); + for g in t.coverage().expect("coverage").iter() { + let to = (i32::from(g.to_u16()) + delta).rem_euclid(65536); + map.insert( + GlyphId::from(g), + GlyphId::new(u32::try_from(to).unwrap()), + ); + } + } + SingleSubst::Format2(t) => { + let coverage = t.coverage().expect("coverage"); + for (g, to) in coverage.iter().zip(t.substitute_glyph_ids()) { + map.insert(GlyphId::from(g), GlyphId::from(to.get())); + } + } + } + } + } + } + map + }) + } + + /// The width of `text` on one line at `font_px`, logical px, as + /// `super::hud_font` draws it: the sum of Inter's advance widths at its + /// default instance, each glyph `tnum` substitutes (the figures) taken + /// in its tabular form. Panics on a character Inter has no glyph for + /// (it would draw as a fallback). + pub fn text_width_px(text: &str, font_px: f32) -> f32 { + let font = FontRef::new(inter()).expect("Inter parses"); + let charmap = font.charmap(); + let metrics = font.glyph_metrics(Size::new(font_px), LocationRef::default()); + let tnum = tnum_map(); + text.chars() + .map(|c| { + let glyph = charmap + .map(c) + .unwrap_or_else(|| panic!("Inter has no glyph for {c:?} in {text:?}")); + let glyph = tnum.get(&glyph).copied().unwrap_or(glyph); + metrics + .advance_width(glyph) + .unwrap_or_else(|| panic!("no advance for {c:?}")) + }) + .sum() + } +} + +#[cfg(test)] +mod tests { + use super::measure::text_width_px; + use super::*; + use bevy::asset::AssetPlugin; + use bevy::text::{FontCx, load_font_assets_into_font_collection}; + + /// An app with the asset server, `Assets` holding Fira Mono at the + /// default id and `bevy_text`'s font registration in `PostUpdate`, as + /// `TextPlugin` sets them up (without its layout and atlas systems). + fn text_app() -> App { + let mut app = App::new(); + app.add_plugins((MinimalPlugins, AssetPlugin::default())) + .init_asset::() + .init_resource::() + .add_systems(PostUpdate, load_font_assets_into_font_collection); + app.world_mut() + .resource_mut::>() + .insert( + AssetId::default(), + Font::from_bytes(bevy::text::DEFAULT_FONT_DATA.to_vec()), + ) + .unwrap(); + app + } + + fn has_family(app: &mut App, name: &str) -> bool { + app.world_mut() + .resource_mut::() + .collection + .family_id(name) + .is_some() + } + + fn repo_assets() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets") + } + + /// One update: `Startup` puts Inter at the default id before the + /// first `PostUpdate` registers it, so the default family is Inter and + /// Fira Mono is never registered. Without the plugin (the positive + /// control) Fira Mono is. + #[test] + fn install_ui_font_replaces_the_default_before_the_first_post_update() { + let mut control = text_app(); + control.update(); + assert!(has_family(&mut control, "Fira Mono"), "the control"); + assert!(!has_family(&mut control, "Inter Variable")); + + let mut app = text_app(); + app.add_plugins(FontsPlugin { + assets: repo_assets(), + }); + app.update(); + assert!(has_family(&mut app, "Inter Variable")); + assert!(!has_family(&mut app, "Fira Mono")); + let fonts = app.world().resource::>(); + let installed = fonts.get(AssetId::default()).expect("the default font"); + assert_eq!(installed.data.data(), measure::inter()); + } + + /// A missing file keeps Fira Mono (a `warn!`, no panic). + #[test] + fn a_missing_font_file_keeps_fira_mono() { + let mut app = text_app(); + app.add_plugins(FontsPlugin { + assets: PathBuf::from("/nonexistent/eonmark-assets"), + }); + app.update(); + assert!(has_family(&mut app, "Fira Mono")); + assert!(!has_family(&mut app, "Inter Variable")); + } + + /// A file that reads but is no font (empty, truncated or garbage) + /// keeps Fira Mono too: `Font::from_bytes` never parses, so installing + /// such bytes would replace Fira Mono with a font Bevy then registers + /// no family for, and every default `TextFont` would draw nothing. + #[test] + fn an_unparsable_font_file_keeps_fira_mono() { + let truncated = measure::inter()[..4096].to_vec(); + for (name, bytes) in [ + ("empty", Vec::new()), + ("garbage", b"not a font file at all".repeat(64)), + ("truncated", truncated), + ] { + let dir = std::env::temp_dir() + .join(format!("eonmark-font-test-{}-{name}", std::process::id())); + let file = ui_font_path(&dir); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + std::fs::write(&file, &bytes).unwrap(); + let mut app = text_app(); + app.add_plugins(FontsPlugin { + assets: dir.clone(), + }); + app.update(); + std::fs::remove_dir_all(&dir).unwrap(); + assert!(has_family(&mut app, "Fira Mono"), "{name}"); + assert!(!has_family(&mut app, "Inter Variable"), "{name}"); + let fonts = app.world().resource::>(); + let installed = fonts.get(AssetId::default()).expect("the default font"); + assert_eq!( + installed.data.data(), + bevy::text::DEFAULT_FONT_DATA, + "{name}" + ); + } + } + + /// Every text the HUD, the outcome overlay and the main menu spawn + /// asks for Inter's tabular figures (`tnum`), so a number that changes + /// keeps its width and nothing beside it moves. + #[test] + fn every_hud_text_uses_tabular_figures() { + use bevy::text::{FontFeatureTag, FontFeatures}; + let data = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data"); + let rules = rules::Rules::load(&data).expect("rules load"); + let strings = rules::strings::Strings::load(&data, "en").expect("en.ron loads"); + let (setup, _) = sim::scenarios::economy_walkthrough(&rules); + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .insert_resource(crate::economy_hud::UiStrings(strings)) + .add_systems( + Startup, + ( + crate::hud::spawn_hud, + crate::economy_hud::spawn_economy_hud, + crate::age_hud::spawn_age_hud, + crate::command_card::spawn_card, + crate::markers::spawn_annex_readout, + crate::outcome::spawn_outcome_overlay, + crate::menu::main_menu::spawn_main_menu, + ) + .chain(), + ); + app.world_mut() + .insert_non_send(crate::sim_driver::SimHandle::from_setup(setup, rules)); + app.update(); + let tnum = FontFeatures::from([FontFeatureTag::TABULAR_FIGURES]); + let mut texts = app + .world_mut() + .query_filtered::<(Entity, &TextFont), With>(); + let texts: Vec<(Entity, TextFont)> = texts + .iter(app.world()) + .map(|(e, f)| (e, f.clone())) + .collect(); + assert!(texts.len() > 15, "{} texts", texts.len()); + for (e, font) in texts { + assert_eq!( + font.font_features, + tnum, + "text {e} ({:?})", + app.world().get::(e).map(|t| t.0.clone()) + ); + } + } + + /// The measure is Inter's, not a monospace one: a narrow glyph is + /// narrower than a wide one, a space adds width, and the HUD's longest + /// lines (`command_card::INFO_WIDTH_PX`, `CARD_BUTTON_PX`) fit with + /// room to spare. The fit tests in `command_card` and `age_hud` use + /// this measure. + #[test] + fn hud_text_fits_with_inter_advances() { + let (i, w) = (text_width_px("i", 14.0), text_width_px("W", 14.0)); + assert!(i > 0.0 && w > 2.0 * i, "i={i} W={w}"); + // Figures are tabular, as `hud_font` draws them: one width each, + // 1328/2048 em, wider than any of Inter's proportional figures. + let one = text_width_px("1", 14.0); + for d in '0'..='9' { + assert!( + (text_width_px(&d.to_string(), 14.0) - one).abs() < 1e-4, + "{d}" + ); + } + assert!((one - 14.0 * 1328.0 / 2048.0).abs() < 1e-3, "{one}"); + assert!(super::measure::tnum_map().len() >= 10); + assert!(text_width_px("a b", 14.0) > text_width_px("ab", 14.0)); + // Twice the size, twice the width. + let one = text_width_px("Lumber", 12.0); + assert!((text_width_px("Lumber", 24.0) - 2.0 * one).abs() < 1e-3); + let queue = "Queue: Statecraft III 99%, Letters III, Trade III, Arms III, Scribe"; + let panel = text_width_px(queue, crate::hud::HUD_FONT_PX); + assert!( + panel < crate::command_card::INFO_WIDTH_PX, + "{panel} px of {}", + crate::command_card::INFO_WIDTH_PX + ); + let button = text_width_px("Needs Statecraft II", crate::command_card::CARD_FONT_PX); + assert!( + button < crate::command_card::CARD_BUTTON_PX.x, + "{button} px" + ); + } +} diff --git a/crates/game/src/hud.rs b/crates/game/src/hud.rs index 7980857..aa05f8b 100644 --- a/crates/game/src/hud.rs +++ b/crates/game/src/hud.rs @@ -39,7 +39,6 @@ use bevy::picking::events::{Click, Pointer}; use bevy::picking::hover::HoverMap; use bevy::picking::pointer::{Location, PointerAction, PointerButton, PointerId, PointerInput}; use bevy::prelude::*; -use bevy::text::FontSize; use bevy::window::{PrimaryWindow, WindowRef}; use sim::{Command, UnitId, scenarios}; @@ -266,10 +265,7 @@ impl Plugin for HudPlugin { fn hud_text(text: &str) -> impl Bundle { ( Text::new(text), - TextFont { - font_size: FontSize::Px(HUD_FONT_PX), - ..default() - }, + crate::fonts::hud_font(HUD_FONT_PX), TextColor(palette::HUD_TEXT), ) } diff --git a/crates/game/src/lib.rs b/crates/game/src/lib.rs index 78aab27..1b58a20 100644 --- a/crates/game/src/lib.rs +++ b/crates/game/src/lib.rs @@ -30,6 +30,12 @@ //! units and buildings, flat entities keyed by `TargetId` (M4b), and the //! dev-only hierarchy check. //! - [`hud`]: top and bottom bars, `PointerOverUi`, the `hud_click` check. +//! - [`minimap`]: the minimap bottom-left over the bottom bar (art pass): +//! terrain, territory, buildings, units, the selection and the view +//! trapezoid; a click or drag moves the camera, a right click orders; +//! [`minimap_check`] is its real-input check (`--minimap-check`). +//! - [`fonts`]: Inter as the UI font, installed over Bevy's Fira Mono in +//! `Startup` (art pass). //! - [`age_hud`]: the age readout and the Advance button on a strip under //! the top bar (M4b), greyed by `SimView::check_advance_age`. //! - [`camera`]: the yaw-locked RTS camera. @@ -43,6 +49,8 @@ //! - [`draw_stats`]: the draw-call counter (art pass), rebuilt from Bevy's //! public render-phase data in the render world; the dev panel row and //! the `draws` and `triangles` exit lines (`--draw-stats`, `dev`). +//! - [`combat_fx`]: hit feedback (art pass): arrows, Mangonel stones and +//! melee lunges from `SimEvent::Hit`, presentation only. //! - [`markers`]: the attrition indicator under bleeding units, the supply //! ring around a selected Supply Wain, the annexation timer around a //! contested Town and the annexation readout (M5a). @@ -64,6 +72,7 @@ pub mod buildings; pub mod camera; pub mod cli; pub mod click_check; +pub mod combat_fx; pub mod command_card; #[cfg(feature = "dev")] pub mod dev_tools; @@ -71,6 +80,7 @@ pub mod director; pub mod draw_stats; pub mod economy_hud; pub mod field; +pub mod fonts; pub mod frame_stats; pub mod ground; pub mod headless; @@ -80,6 +90,8 @@ pub mod hud; pub mod macos_menu; pub mod markers; pub mod menu; +pub mod minimap; +pub mod minimap_check; pub mod models; pub mod monitor_loss; pub mod orders; @@ -91,3 +103,5 @@ pub mod render_watchdog; pub mod selection; pub mod sim_driver; pub mod terrain_props; +#[cfg(test)] +mod ui_test; diff --git a/crates/game/src/markers.rs b/crates/game/src/markers.rs index a290524..3d9ad5c 100644 --- a/crates/game/src/markers.rs +++ b/crates/game/src/markers.rs @@ -42,7 +42,6 @@ use std::f32::consts::{FRAC_PI_2, TAU}; use bevy::gizmos::config::GizmoLineConfig; use bevy::light::NotShadowCaster; use bevy::prelude::*; -use bevy::text::FontSize; use bevy::transform::TransformSystems; use rules::Rules; use rules::strings::fill; @@ -471,10 +470,7 @@ pub fn spawn_annex_readout(mut commands: Commands, root: Query String { fn text(value: &str, size: f32) -> impl Bundle { ( Text::new(value), - TextFont { - font_size: FontSize::Px(size), - ..default() - }, + crate::fonts::hud_font(size), TextColor(palette::HUD_TEXT), Pickable::IGNORE, ) diff --git a/crates/game/src/minimap.rs b/crates/game/src/minimap.rs new file mode 100644 index 0000000..4d32dd1 --- /dev/null +++ b/crates/game/src/minimap.rs @@ -0,0 +1,1859 @@ +//! The minimap (art pass, owner decision 7): bottom-left above the bottom +//! bar, [`MINIMAP_PX`] logical px square, north up, one texel per map tile. +//! It shows the terrain, territory and its borders, every building (a site +//! blended toward stone), an outline around every Town (amber, steady, +//! while it is being annexed), every unit (no fog until M7, which suits +//! `--spectate`), the local selection and the camera's view trapezoid. No +//! pings, nothing blinks. +//! +//! **Placement.** A frame node ([`MinimapFrame`], a child of `HudRoot`) at +//! `left: 16 px` (outside the camera's 12 px edge-scroll band) and +//! `bottom: BOTTOM_BAR_HEIGHT_PX + PADDING_PX`, with a +//! [`MINIMAP_BORDER_PX`] border in `palette::HUD_PANEL`, holds the +//! [`Minimap`] `ImageNode`. Both keep the default `Pickable`, so the +//! minimap blocks world clicks and `hud::PointerOverUi` is true over it. +//! The centred message line and annexation readout stay where they are; +//! no text of theirs reaches it (`minimap_overlaps_no_hud_text`). +//! +//! **Painting.** The image is `Rgba8UnormSrgb`, one texel per tile, sampled +//! nearest. Three layers ([`MinimapCanvas`]): +//! +//! 1. terrain, painted once from `ground::base_colors`, darkened to +//! [`palette::MINIMAP_TERRAIN_SHADE`]; +//! 2. territory over it, rebuilt in `FixedUpdate` right after the sim step +//! whenever the field's border sources change (the trigger and the step +//! `field::sync_field` uses), so a new border shows in the frame of the +//! tick that made it: owned tiles blend [`palette::MINIMAP_TERRITORY`] +//! toward the owner's team colour, owned tiles with a 4-neighbour of +//! another owner [`palette::MINIMAP_BORDER`]; +//! 3. everything that moves, over a copy of the first two +//! ([`paint_dynamic`]): buildings, Town outlines, unit dots +//! ([`UNIT_DOT_TEXELS`] square, the team colour brightened), the +//! selection in `palette::HUD_TEXT`, and the view trapezoid: the four +//! corners of the world view between the HUD bars cast to the ground +//! with `orders::ground_hit` ([`view_quad`]), drawn as clipped 1-texel +//! lines. +//! +//! [`paint_minimap`] repaints at most once a frame, and only when the tick, +//! the camera's focus or zoom, the window size, the selection or the +//! territory changed ([`PaintKey`]); each repaint is timed and the exit +//! line `frame_stats: minimap repaints= paint_us mean= p95= +//! max=` reports them. +//! +//! **Input** (observers on the [`Minimap`] entity; a UI hit position runs +//! from `(-0.5, -0.5)` at the node's top left to `(0.5, 0.5)`): +//! +//! - a primary press (not in attack-move mode) moves the camera's focus to +//! the tile's centre, keeping the zoom; a primary drag does the same from +//! the pointer's location mapped through the node's transform, since +//! `Pointer` carries no hit; +//! - a primary click in attack-move mode issues `AttackMove` to the tile +//! for the own selected units and disarms; the camera stays; +//! - a secondary click in the normal mode issues [`ground_order`]: +//! `Move` (shift queues) for the own selected units, or `SetRally` with an +//! own building selected. +//! +//! Orders go through `PendingCommands::push_local` (the sim validates them) +//! and leave the usual move or attack marker at the tile. They are issued +//! only while the local player is human and the match is not over: these +//! observers run outside `hud::WorldInputSet`, so they check both +//! themselves. Moving the camera is a view action, so the press and drag +//! handlers do not check either and it works while spectating. After the +//! outcome it does not: the outcome overlay (`outcome::OUTCOME_Z`, a +//! full-window node with the default `Pickable`) covers the minimap, so a +//! press there lands on the overlay and never reaches these observers (a +//! drag started on the minimap before the outcome and held through it +//! still moves the camera, since `Pointer` follows the pressed +//! entity). + +use std::collections::{BTreeMap, BTreeSet}; +use std::time::Instant; + +use bevy::app::AppExit; +use bevy::asset::RenderAssetUsages; +use bevy::image::ImageSampler; +use bevy::picking::events::{Click, Drag, Pointer, Press}; +use bevy::picking::pointer::PointerButton; +use bevy::prelude::*; +use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat}; +use bevy::window::PrimaryWindow; +use rules::Rules; +use sim::{BuildingId, Command, Fx, FxVec2, SimView, Source, UnitId}; + +use crate::camera::RtsCamera; +use crate::frame_stats::{STDOUT_PREFIX, summarize}; +use crate::hud::{BOTTOM_BAR_HEIGHT_PX, HudRoot, PADDING_PX, TOP_BAR_HEIGHT_PX, spawn_hud}; +use crate::orders::{OrderMode, ground_hit, ground_order}; +use crate::palette; +use crate::present::world_from_sim; +use crate::selection::{RingAssets, Selection, spawn_attack_marker, spawn_move_marker}; +use crate::sim_driver::{ + LocalSeat, MatchOver, PendingCommands, SimHandle, SimSystems, local_player_is_human, + sim_stepped, +}; + +/// The minimap's side, logical px (owner decision 7): 1.5 px per tile on a +/// 128-tile map at scale 1, 3 physical px per tile on the Retina stream. +pub const MINIMAP_PX: f32 = 192.0; +/// The frame's distance from the window's left edge, logical px: past the +/// camera's 12 px edge-scroll band (`camera::EDGE_SCROLL_MARGIN_PX`), so +/// the pointer over the minimap never scrolls the view. +pub const MINIMAP_LEFT_PX: f32 = 16.0; +/// The frame's distance from the window's bottom edge, logical px: on the +/// bottom bar, with the HUD's padding. +pub const MINIMAP_BOTTOM_PX: f32 = BOTTOM_BAR_HEIGHT_PX + PADDING_PX; +/// The frame's border, logical px. +pub const MINIMAP_BORDER_PX: f32 = 3.0; +/// The frame's outer side, logical px. +pub const MINIMAP_FRAME_PX: f32 = MINIMAP_PX + 2.0 * MINIMAP_BORDER_PX; +/// A unit's dot, texels a side. +pub const UNIT_DOT_TEXELS: i32 = 2; + +/// The frame around the minimap (a child of `HudRoot`). +#[derive(Component, Debug, Clone, Copy)] +pub struct MinimapFrame; + +/// The minimap's `ImageNode`. +#[derive(Component, Debug, Clone, Copy)] +pub struct Minimap; + +/// One sRGB texel. +pub type Texel = [u8; 4]; + +/// The minimap image and its cached layers. +#[derive(Resource, Debug)] +pub struct MinimapCanvas { + /// The image the [`Minimap`] node shows. + pub image: Handle, + /// Map width and height in tiles (texels). + pub size: (usize, usize), + /// Terrain, darkened (painted once). + pub terrain: Vec, + /// Terrain plus territory and borders. + pub territory: Vec, + /// The border sources [`MinimapCanvas::territory`] shows. + shown: Option>, + /// Bumped whenever the territory layer is rebuilt. + pub territory_generation: u32, + /// What the image shows now; `None` before the first repaint. + last: Option, +} + +/// Repaint and input counters, for the exit line and `--minimap-check`. +#[derive(Resource, Debug, Default, Clone)] +pub struct MinimapStats { + /// Repaints so far. + pub repaints: u32, + /// Each repaint's paint time, microseconds. + pub paint_us: Vec, + /// Camera moves by a minimap press or drag. + pub camera_moves: u32, + /// `Pointer` events handled. + pub drags: u32, + /// The exit line was printed. + printed: bool, +} + +impl MinimapStats { + /// `minimap repaints= paint_us mean= p95= max=`. + pub fn line(&self) -> String { + let (mean, p95, max) = summarize(&self.paint_us); + format!( + "minimap repaints={} paint_us mean={mean:.1} p95={p95:.1} max={max:.1}", + self.repaints + ) + } +} + +/// Everything a repaint depends on besides the sim tick's state: when none +/// of it changed, the image is current. +#[derive(Debug, Clone, PartialEq)] +pub struct PaintKey { + /// The sim tick. + pub tick: u32, + /// The camera's focus and zoom. + pub camera: Option<(Vec3, f32)>, + /// The window's logical size. + pub window: Option, + /// The selected units and building. + pub units: BTreeSet, + /// The selected building. + pub building: Option, + /// [`MinimapCanvas::territory_generation`]. + pub territory: u32, +} + +/// Spawns the minimap, keeps it painted and handles its input. Added by +/// `app::run`. +pub struct MinimapPlugin; + +impl Plugin for MinimapPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(Startup, spawn_minimap.after(spawn_hud)) + .add_systems( + FixedUpdate, + sync_territory_layer + .after(SimSystems::Step) + .run_if(sim_stepped), + ) + .add_systems( + PostUpdate, + paint_minimap.after(bevy::transform::TransformSystems::Propagate), + ) + .add_systems(Last, print_minimap_line.after(bevy::window::ExitSystems)); + } +} + +/// An sRGB texel of `c`. +pub fn texel(c: Color) -> Texel { + c.to_srgba().to_u8_array() +} + +/// `a` blended `t` of the way toward `b`, per channel (alpha from `a`). +pub fn mix(a: Texel, b: Texel, t: f32) -> Texel { + let ch = |x: u8, y: u8| (f32::from(x) + (f32::from(y) - f32::from(x)) * t).round() as u8; + [ch(a[0], b[0]), ch(a[1], b[1]), ch(a[2], b[2]), a[3]] +} + +/// The terrain layer of a `width` x `height` map: each tile's ground +/// colour (`ground::base_colors`, a 128 x 128 grid) darkened to +/// [`palette::MINIMAP_TERRAIN_SHADE`]. +pub fn terrain_layer(map: Option<&rules::MapDef>, width: usize, height: usize) -> Vec { + let colors = crate::ground::base_colors(map); + let n = crate::ground::GROUND_SIZE_TILES as usize; + let black = [0, 0, 0, 255]; + (0..height) + .flat_map(|y| (0..width).map(move |x| (x, y))) + .map(|(x, y)| { + let c = colors.get(y * n + x).copied().unwrap_or(palette::GRASS[0]); + mix(texel(c), black, 1.0 - palette::MINIMAP_TERRAIN_SHADE) + }) + .collect() +} + +/// `terrain` with the territory of `owners` (the field's owner codes, 0 +/// neutral, else player index + 1) over it: an owned tile blends +/// [`palette::MINIMAP_TERRITORY`] toward its owner's team colour, an owned +/// tile with a 4-neighbour of another owner (or neutral land) +/// [`palette::MINIMAP_BORDER`]. +pub fn territory_layer(terrain: &[Texel], owners: &[u8], width: usize) -> Vec { + let height = owners.len().checked_div(width).unwrap_or(0); + let mut out = terrain.to_vec(); + for y in 0..height { + for x in 0..width { + let i = y * width + x; + let code = owners[i]; + if code == 0 || i >= out.len() { + continue; + } + let team = texel(palette::team_color(code - 1)); + let differs = |nx: isize, ny: isize| { + nx >= 0 + && ny >= 0 + && (nx as usize) < width + && (ny as usize) < height + && owners[ny as usize * width + nx as usize] != code + }; + let (xi, yi) = (x as isize, y as isize); + let border = differs(xi - 1, yi) + || differs(xi + 1, yi) + || differs(xi, yi - 1) + || differs(xi, yi + 1); + let t = if border { + palette::MINIMAP_BORDER + } else { + palette::MINIMAP_TERRITORY + }; + out[i] = mix(out[i], team, t); + } + } + out +} + +/// A texel buffer being painted, with clipped writes. +struct Paint<'a> { + px: &'a mut [Texel], + width: usize, + height: usize, +} + +impl Paint<'_> { + fn set(&mut self, x: i32, y: i32, c: Texel) { + self.blend(x, y, c, 1.0); + } + + /// Blend texel `(x, y)` `t` of the way toward `c` (skipped off the + /// image). + fn blend(&mut self, x: i32, y: i32, c: Texel, t: f32) { + if x >= 0 && y >= 0 && (x as usize) < self.width && (y as usize) < self.height { + let i = y as usize * self.width + x as usize; + self.px[i] = if t >= 1.0 { c } else { mix(self.px[i], c, t) }; + } + } + + /// A 1-texel line from `a` to `b` (texel coordinates) blended `t` + /// toward `c`, clipped to the image (Bresenham over the floored end + /// points; ends far outside the image are first clamped to a few + /// image sizes away, which keeps the visible part and bounds the + /// steps). + fn line(&mut self, a: Vec2, b: Vec2, c: Texel, t: f32) { + let limit = 4.0 * (self.width + self.height) as f32; + let clamp = |p: Vec2| p.clamp(Vec2::splat(-limit), Vec2::splat(limit)); + let (a, b) = (clamp(a), clamp(b)); + let (mut x0, mut y0) = (a.x.floor() as i32, a.y.floor() as i32); + let (x1, y1) = (b.x.floor() as i32, b.y.floor() as i32); + let (dx, dy) = ((x1 - x0).abs(), -(y1 - y0).abs()); + let (sx, sy) = (if x0 < x1 { 1 } else { -1 }, if y0 < y1 { 1 } else { -1 }); + let mut err = dx + dy; + loop { + self.blend(x0, y0, c, t); + if x0 == x1 && y0 == y1 { + break; + } + let e2 = 2 * err; + if e2 >= dy { + err += dy; + x0 += sx; + } + if e2 <= dx { + err += dx; + y0 += sy; + } + } + } + + /// The 1-texel ring just outside the `w` x `h` rect at `(x, y)`. + fn outline(&mut self, x: i32, y: i32, w: i32, h: i32, c: Texel) { + for i in x - 1..=x + w { + self.set(i, y - 1, c); + self.set(i, y + h, c); + } + for j in y..y + h { + self.set(x - 1, j, c); + self.set(x + w, j, c); + } + } +} + +/// The dynamic layer over a copy of `territory`: complete buildings fill +/// their footprint in their owner's team colour, sites the same blended +/// [`palette::MINIMAP_SITE`] toward stone; units as +/// [`UNIT_DOT_TEXELS`]-square dots in their team colour brightened +/// [`palette::MINIMAP_UNIT_BRIGHTEN`], the selected ones (and the selected +/// building) in `palette::HUD_TEXT`; over them a ring of `palette::HUD_TEXT` +/// just outside every Town, `palette::ATTRITION` amber while it is being +/// annexed; then `quad`, the view trapezoid in texel coordinates, as +/// 1-texel lines. +pub fn paint_dynamic( + territory: &[Texel], + width: usize, + view: &SimView<'_>, + rules: &Rules, + selection: &Selection, + quad: Option<[Vec2; 4]>, +) -> Vec { + let mut px = territory.to_vec(); + let height = px.len().checked_div(width).unwrap_or(0); + let mut paint = Paint { + px: &mut px, + width, + height, + }; + let selected = texel(palette::HUD_TEXT); + for b in view.buildings() { + let Some(kind) = rules.building_kind(b.kind.0) else { + continue; + }; + let team = texel(palette::team_color(b.owner.0)); + let fill = match b.construction { + sim::Construction::Complete => team, + sim::Construction::Site { .. } => { + mix(team, texel(palette::STONE), palette::MINIMAP_SITE) + } + }; + let fill = if selection.building == Some(b.id) { + selected + } else { + fill + }; + let (x, y) = (i32::from(b.tile.x), i32::from(b.tile.y)); + let (w, h) = (kind.width_tiles as i32, kind.height_tiles as i32); + for j in y..y + h { + for i in x..x + w { + paint.set(i, j, fill); + } + } + } + for u in view.units() { + let c = if selection.units.contains(&u.id) { + selected + } else { + mix( + texel(palette::team_color(u.owner.0)), + [255, 255, 255, 255], + palette::MINIMAP_UNIT_BRIGHTEN, + ) + }; + let (x0, y0) = dot_origin(u.pos); + for j in y0..y0 + UNIT_DOT_TEXELS { + for i in x0..x0 + UNIT_DOT_TEXELS { + paint.set(i, j, c); + } + } + } + // Town outlines over the unit dots: the attackers that start an + // annexation stand next to the Town, where the ring is. + for b in view.buildings() { + let Some(kind) = rules.building_kind(b.kind.0).filter(|k| k.town) else { + continue; + }; + let ring = if view.annexation(b.id).is_some() { + texel(palette::ATTRITION) + } else { + texel(palette::HUD_TEXT) + }; + paint.outline( + i32::from(b.tile.x), + i32::from(b.tile.y), + kind.width_tiles as i32, + kind.height_tiles as i32, + ring, + ); + } + if let Some(q) = quad { + let c = texel(palette::HUD_TEXT); + for k in 0..4 { + paint.line(q[k], q[(k + 1) % 4], c, palette::MINIMAP_VIEW); + } + } + px +} + +/// The top-left texel of a unit's dot: the [`UNIT_DOT_TEXELS`] square +/// whose centre is nearest `pos`. +pub fn dot_origin(pos: FxVec2) -> (i32, i32) { + let half = Fx::from_ratio(UNIT_DOT_TEXELS, 2); + let corner = |v: Fx| (v - half).round_to_int(); + (corner(pos.x), corner(pos.y)) +} + +/// The texel under a UI hit position `p`, normalised to the node from +/// `(-0.5, -0.5)` (top left) to `(0.5, 0.5)`: `floor((p + 0.5) * size)`, +/// clamped to the image. +pub fn texel_from_norm(p: Vec2, size: (usize, usize)) -> (u16, u16) { + let axis = |v: f32, n: usize| { + let max = n.saturating_sub(1); + let t = ((v + 0.5) * n as f32).floor(); + if t.is_nan() || t < 0.0 { + 0 + } else { + (t as usize).min(max) + } + }; + let clamp16 = |v: usize| u16::try_from(v).unwrap_or(u16::MAX); + (clamp16(axis(p.x, size.0)), clamp16(axis(p.y, size.1))) +} + +/// A logical window position normalised to a UI node the way +/// `bevy_ui`'s picking backend normalises a hit: through the node's +/// `UiGlobalTransform` inverse (physical px) over its computed size. +pub fn norm_from_logical( + position: Vec2, + node: &ComputedNode, + transform: &UiGlobalTransform, +) -> Vec2 { + let physical = position / node.inverse_scale_factor; + transform.inverse().transform_point2(physical) / node.size() +} + +/// The sim position of tile `(x, y)`'s centre. +pub fn tile_centre(x: u16, y: u16) -> FxVec2 { + FxVec2::new( + Fx::from_int(i32::from(x)) + Fx::HALF, + Fx::from_int(i32::from(y)) + Fx::HALF, + ) +} + +/// World metres to minimap texel coordinates (`(0, 0)` is the north-west +/// corner of tile `(0, 0)`). +pub fn texel_from_world(p: Vec3) -> Vec2 { + let half = crate::ground::HALF_EXTENT; + Vec2::new(p.x + half, p.z + half) +} + +/// The view trapezoid: where the four corners of the world view between +/// the HUD bars (`window` logical px, the top bar above, the bottom bar +/// below) meet the ground (`orders::ground_hit`), in texel coordinates, +/// clockwise from the top left. `None` when a corner misses the ground. +pub fn view_quad(cam: &Camera, cam_tf: &GlobalTransform, window: Vec2) -> Option<[Vec2; 4]> { + let top = TOP_BAR_HEIGHT_PX; + let bottom = (window.y - BOTTOM_BAR_HEIGHT_PX).max(top); + let corners = [ + Vec2::new(0.0, top), + Vec2::new(window.x, top), + Vec2::new(window.x, bottom), + Vec2::new(0.0, bottom), + ]; + let mut out = [Vec2::ZERO; 4]; + for (o, c) in out.iter_mut().zip(corners) { + *o = texel_from_world(ground_hit(cam, cam_tf, c)?); + } + Some(out) +} + +/// The image the minimap shows: `width` x `height` sRGB texels, sampled +/// nearest, kept in the main world so it can be repainted. +pub fn minimap_image(width: usize, height: usize, px: &[Texel]) -> Image { + let mut image = Image::new( + Extent3d { + width: u32::try_from(width).unwrap_or(1), + height: u32::try_from(height).unwrap_or(1), + depth_or_array_layers: 1, + }, + TextureDimension::D2, + px.concat(), + TextureFormat::Rgba8UnormSrgb, + RenderAssetUsages::default(), + ); + image.sampler = ImageSampler::nearest(); + image +} + +/// Startup, after the HUD: the frame and the image node, the terrain and +/// territory layers. Without a sim (some tests) the map is 128 x 128 grass. +pub fn spawn_minimap( + mut commands: Commands, + root: Query>, + sim: Option>, + mut images: ResMut>, +) { + let (map, width, height, owners, shown) = match sim.as_ref() { + Some(s) => { + let view = s.view(); + let field = view.territory(); + ( + s.rules().map(&s.setup().map).cloned(), + usize::from(view.map().width()), + usize::from(view.map().height()), + field.owner_grid().to_vec(), + Some(field.sources().clone()), + ) + } + None => { + let n = crate::ground::GROUND_SIZE_TILES as usize; + (None, n, n, vec![0; n * n], None) + } + }; + let terrain = terrain_layer(map.as_ref(), width, height); + let territory = territory_layer(&terrain, &owners, width); + let image = images.add(minimap_image(width, height, &territory)); + commands.insert_resource(MinimapCanvas { + image: image.clone(), + size: (width, height), + terrain, + territory, + shown, + territory_generation: 0, + last: None, + }); + let Ok(root) = root.single() else { + return; + }; + commands.entity(root).with_children(|root| { + root.spawn(( + Name::new("Minimap frame"), + MinimapFrame, + Node { + position_type: PositionType::Absolute, + left: Val::Px(MINIMAP_LEFT_PX), + bottom: Val::Px(MINIMAP_BOTTOM_PX), + width: Val::Px(MINIMAP_FRAME_PX), + height: Val::Px(MINIMAP_FRAME_PX), + border: UiRect::all(Val::Px(MINIMAP_BORDER_PX)), + ..default() + }, + BorderColor::all(palette::HUD_PANEL), + BackgroundColor(palette::HUD_PANEL), + )) + .with_children(|frame| { + frame + .spawn(( + Name::new("Minimap"), + Minimap, + ImageNode::new(image), + Node { + width: Val::Px(MINIMAP_PX), + height: Val::Px(MINIMAP_PX), + ..default() + }, + )) + .observe(on_minimap_press) + .observe(on_minimap_drag) + .observe(on_minimap_click); + }); + }); +} + +/// After every stepped tick: rebuild the territory layer when the field's +/// border sources differ from the ones it shows (`field::sync_field`'s +/// trigger), so the next repaint shows the new border. +pub fn sync_territory_layer(sim: NonSend, canvas: Option>) { + let Some(mut canvas) = canvas else { + return; + }; + let view = sim.view(); + let field = view.territory(); + if canvas.shown.as_ref() == Some(field.sources()) { + return; + } + let width = canvas.size.0; + canvas.territory = territory_layer(&canvas.terrain, field.owner_grid(), width); + canvas.shown = Some(field.sources().clone()); + canvas.territory_generation = canvas.territory_generation.wrapping_add(1); +} + +/// `PostUpdate`, after transforms propagate: repaint when the +/// [`PaintKey`] changed, timing the paint. +#[allow(clippy::too_many_arguments)] // Bevy system: each parameter is one resource or query +pub fn paint_minimap( + sim: NonSend, + canvas: Option>, + mut stats: ResMut, + selection: Res, + rts: Query<&RtsCamera>, + cameras: Query<(&Camera, &GlobalTransform), With>, + windows: Query<&Window, With>, + mut images: ResMut>, +) { + let Some(mut canvas) = canvas else { + return; + }; + let window = windows + .single() + .ok() + .map(|w| Vec2::new(w.width(), w.height())); + let key = PaintKey { + tick: sim.tick(), + camera: rts.single().ok().map(|c| (c.focus, c.zoom)), + window, + units: selection.units.clone(), + building: selection.building, + territory: canvas.territory_generation, + }; + if canvas.last.as_ref() == Some(&key) { + return; + } + let start = Instant::now(); + let quad = match (cameras.single(), window) { + (Ok((cam, cam_tf)), Some(size)) => view_quad(cam, cam_tf, size), + _ => None, + }; + let px = paint_dynamic( + &canvas.territory, + canvas.size.0, + &sim.view(), + sim.rules(), + &selection, + quad, + ); + if let Some(mut image) = images.get_mut(&canvas.image) { + image.data = Some(px.concat()); + } + stats.repaints += 1; + stats.paint_us.push(start.elapsed().as_secs_f64() * 1e6); + canvas.last = Some(key); +} + +/// Focus the RTS camera on tile `(x, y)`'s centre, keeping the zoom. +fn focus_on(cameras: &mut Query<&mut RtsCamera>, stats: &mut MinimapStats, x: u16, y: u16) { + let at = world_from_sim(tile_centre(x, y), 0.0); + for mut cam in cameras.iter_mut() { + cam.focus = Vec3::new(at.x, 0.0, at.z); + } + stats.camera_moves += 1; +} + +/// A primary press (not in attack-move mode) moves the camera to the +/// pressed tile. +pub fn on_minimap_press( + press: On>, + canvas: Res, + mode: Res, + mut stats: ResMut, + mut cameras: Query<&mut RtsCamera>, +) { + let event = &press.event().event; + if event.button != PointerButton::Primary || *mode == OrderMode::AttackMove { + return; + } + let Some(position) = event.hit.position else { + return; + }; + let (x, y) = texel_from_norm(position.truncate(), canvas.size); + focus_on(&mut cameras, &mut stats, x, y); +} + +/// A primary drag (not in attack-move mode) keeps the camera on the tile +/// under the pointer. +pub fn on_minimap_drag( + drag: On>, + canvas: Res, + mode: Res, + nodes: Query<(&ComputedNode, &UiGlobalTransform), With>, + mut stats: ResMut, + mut cameras: Query<&mut RtsCamera>, +) { + let pointer = drag.event(); + if pointer.event.button != PointerButton::Primary || *mode == OrderMode::AttackMove { + return; + } + let Ok((node, transform)) = nodes.get(pointer.entity) else { + return; + }; + if node.size().min_element() <= 0.0 { + return; + } + let norm = norm_from_logical(pointer.pointer_location.position, node, transform); + let (x, y) = texel_from_norm(norm, canvas.size); + stats.drags += 1; + focus_on(&mut cameras, &mut stats, x, y); +} + +/// The world state a minimap order reads, for [`on_minimap_click`]. +#[derive(bevy::ecs::system::SystemParam)] +pub struct MinimapOrders<'w> { + canvas: Res<'w, MinimapCanvas>, + mode: ResMut<'w, OrderMode>, + selection: Res<'w, Selection>, + pending: ResMut<'w, PendingCommands>, + keys: Option>>, + seat: Option>, + over: Option>, + ring_assets: Option>, + time: Res<'w, Time>, +} + +/// A primary click in attack-move mode issues `AttackMove` to the tile and +/// disarms; a secondary click in the normal mode issues +/// [`ground_order`] (`Move`, or `SetRally` for an own building). +/// Nothing while the local slot is a bot or after the outcome. +pub fn on_minimap_click( + click: On>, + sim: NonSend, + mut orders: MinimapOrders, + mut commands: Commands, +) { + let event = &click.event().event; + if !local_player_is_human(orders.seat.take()) || orders.over.is_some() { + return; + } + let mode = match (event.button, *orders.mode) { + (PointerButton::Primary, OrderMode::AttackMove) => OrderMode::AttackMove, + (PointerButton::Secondary, OrderMode::Normal) => OrderMode::Normal, + _ => return, + }; + let Some(position) = event.hit.position else { + return; + }; + let (x, y) = texel_from_norm(position.truncate(), orders.canvas.size); + let target = tile_centre(x, y); + let queue = orders + .keys + .as_ref() + .is_some_and(|k| k.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight])); + let view = sim.view(); + if mode == OrderMode::AttackMove { + *orders.mode = OrderMode::Normal; + } + let Some(cmd) = ground_order(mode, &view, &orders.selection, target, queue) else { + return; + }; + let attack = matches!(cmd, Command::AttackMove { .. }); + let seq = orders.pending.push_local(cmd); + info!("minimap: seq {seq} to tile ({x}, {y})"); + if let Some(assets) = orders.ring_assets.as_deref() { + let at = world_from_sim(target, 0.0); + let now = orders.time.elapsed_secs(); + if attack { + spawn_attack_marker(&mut commands, assets, at, now); + } else { + spawn_move_marker(&mut commands, assets, at, now); + } + } +} + +/// On the frame an `AppExit` was written, print [`MinimapStats::line`] +/// once (after `ExitSystems`, as `frame_stats` does). +pub fn print_minimap_line(mut exits: MessageReader, mut stats: ResMut) { + if exits.is_empty() || stats.printed { + exits.clear(); + return; + } + exits.clear(); + stats.printed = true; + println!("{STDOUT_PREFIX}{}", stats.line()); +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use bevy::camera::NormalizedRenderTarget; + use bevy::picking::backend::HitData; + use bevy::picking::pointer::{Location, PointerId}; + use rules::Terrain; + use sim::{BuildingId, PlayerCommand, PlayerId}; + + use super::*; + use crate::sim_driver::{LOCAL_PLAYER, LocalSeat}; + + fn rules() -> Rules { + Rules::load(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data")) + .expect("rules load") + } + + /// `economy_walkthrough` at tick 1: the standard start (P0's Seat at + /// (24, 64) with its Yeomen, P1's at (103, 64)) and its tick-0 + /// commands. + fn walkthrough() -> SimHandle { + let rules = rules(); + let (setup, stream) = sim::scenarios::economy_walkthrough(&rules); + let mut sim = SimHandle::from_setup(setup, rules); + let tick0: Vec = stream + .iter() + .filter(|(t, _)| *t == 0) + .flat_map(|(_, c)| c.iter().cloned()) + .collect(); + sim.step(&tick0); + sim + } + + fn step(sim: &mut SimHandle, cmds: Vec) { + let batch: Vec = (9000..) + .zip(cmds) + .map(|(seq, c)| PlayerCommand::new(LOCAL_PLAYER, seq, c)) + .collect(); + sim.step(&batch); + for _ in 0..sim.setup().cmd_delay { + sim.step_once(); + } + } + + fn layers(sim: &SimHandle) -> (Vec, Vec, usize) { + let view = sim.view(); + let (w, h) = ( + usize::from(view.map().width()), + usize::from(view.map().height()), + ); + let terrain = terrain_layer(sim.rules().map(&sim.setup().map), w, h); + let territory = territory_layer(&terrain, view.territory().owner_grid(), w); + (terrain, territory, w) + } + + fn own_units(sim: &SimHandle) -> Vec { + sim.view() + .units() + .filter(|u| u.owner == LOCAL_PLAYER) + .map(|u| u.id) + .collect() + } + + #[test] + fn texel_from_norm_maps_corners_and_clamps() { + let size = (128, 128); + assert_eq!(texel_from_norm(Vec2::new(-0.5, -0.5), size), (0, 0)); + assert_eq!(texel_from_norm(Vec2::new(0.0, 0.0), size), (64, 64)); + assert_eq!(texel_from_norm(Vec2::new(0.4999, 0.4999), size), (127, 127)); + assert_eq!(texel_from_norm(Vec2::new(0.5, 0.5), size), (127, 127)); + assert_eq!(texel_from_norm(Vec2::new(-3.0, 9.0), size), (0, 127)); + assert_eq!(texel_from_norm(Vec2::new(f32::NAN, 0.25), size), (0, 96)); + // A non-square map maps each axis on its own. + assert_eq!(texel_from_norm(Vec2::new(0.25, 0.25), (64, 32)), (48, 24)); + } + + /// Terrain darkened, owned land and its border blended toward the team + /// colour, the Seats filled and outlined, unit dots brightened, the + /// selected Yeoman in the HUD text colour. + #[test] + fn paint_shows_terrain_territory_buildings_units_and_selection() { + let mut sim = walkthrough(); + // Two Yeomen alone on open land, so no other dot covers theirs. + let yeoman_kind = sim::UnitKindId( + u16::try_from( + sim.rules() + .units + .iter() + .position(|k| k.id == "yeoman") + .unwrap(), + ) + .unwrap(), + ); + let lone = [FxVec2::from_ints(64, 20), FxVec2::from_ints(70, 20)]; + step( + &mut sim, + lone.iter() + .map(|&at| Command::DebugSpawn { + owner: LOCAL_PLAYER, + kind: yeoman_kind, + at, + count: 1, + }) + .collect(), + ); + let (terrain, territory, w) = layers(&sim); + let view = sim.view(); + let rules = sim.rules(); + let map = rules.map(&sim.setup().map).unwrap(); + let shade = |c: Color| { + mix( + texel(c), + [0, 0, 0, 255], + 1.0 - palette::MINIMAP_TERRAIN_SHADE, + ) + }; + let at = |x: usize, y: usize| y * w + x; + let find = |t: Terrain| { + (0..w * w) + .find(|i| map.tile((i % w) as u16, (i / w) as u16) == t) + .unwrap() + }; + assert_eq!(terrain[find(Terrain::Forest)], shade(palette::FOREST_FLOOR)); + assert_eq!(terrain[find(Terrain::Water)], shade(palette::WATER)); + assert_eq!(terrain[find(Terrain::Mountain)], shade(palette::ROCK)); + + // A unit whose dot overlaps no other dot (painted in id order, a + // later dot covers an earlier one). + let isolated = |id: UnitId| { + let me = dot_origin(view.unit(id).unwrap().pos); + view.units().filter(|o| o.id != id).all(|o| { + let d = dot_origin(o.pos); + (d.0 - me.0).abs() >= UNIT_DOT_TEXELS || (d.1 - me.1).abs() >= UNIT_DOT_TEXELS + }) + }; + let alone: Vec = own_units(&sim) + .into_iter() + .filter(|u| isolated(*u)) + .collect(); + assert_eq!(alone.len(), 2, "the two lone Yeomen: {alone:?}"); + let yeoman = alone[0]; + let mut selection = Selection::default(); + selection.set([yeoman]); + let px = paint_dynamic(&territory, w, &view, rules, &selection, None); + + // Owned land: interior and border, away from buildings and units. + let owners = view.territory().owner_grid(); + let covered = |x: usize, y: usize| { + view.buildings().any(|b| { + let k = rules.building_kind(b.kind.0).unwrap(); + let (bx, by) = (usize::from(b.tile.x), usize::from(b.tile.y)); + (bx.saturating_sub(1)..=bx + k.width_tiles as usize).contains(&x) + && (by.saturating_sub(1)..=by + k.height_tiles as usize).contains(&y) + }) || view.units().any(|u| { + let (ux, uy) = dot_origin(u.pos); + (ux..ux + UNIT_DOT_TEXELS).contains(&(x as i32)) + && (uy..uy + UNIT_DOT_TEXELS).contains(&(y as i32)) + }) + }; + let team0 = texel(palette::team_color(0)); + let (mut interior, mut border) = (0, 0); + for y in 1..w - 1 { + for x in 1..w - 1 { + if owners[at(x, y)] != 1 || covered(x, y) { + continue; + } + let edge = [at(x - 1, y), at(x + 1, y), at(x, y - 1), at(x, y + 1)] + .iter() + .any(|&n| owners[n] != 1); + let t = if edge { + border += 1; + palette::MINIMAP_BORDER + } else { + interior += 1; + palette::MINIMAP_TERRITORY + }; + assert_eq!(px[at(x, y)], mix(terrain[at(x, y)], team0, t), "({x}, {y})"); + } + } + assert!(interior > 100 && border > 20, "{interior} {border}"); + // Neutral land is the terrain alone. + let neutral = (0..w * w) + .find(|&i| owners[i] == 0 && !covered(i % w, i / w)) + .unwrap(); + assert_eq!(px[neutral], terrain[neutral]); + + // Each Seat fills its footprint with its owner's colour inside a + // ring of the HUD text colour. + for b in view.buildings().filter(|b| b.town_state.is_some()) { + let k = rules.building_kind(b.kind.0).unwrap(); + let (x, y) = (usize::from(b.tile.x), usize::from(b.tile.y)); + let team = texel(palette::team_color(b.owner.0)); + assert_eq!(px[at(x + 1, y + 1)], team); + assert_eq!(px[at(x - 1, y - 1)], texel(palette::HUD_TEXT), "ring"); + assert_eq!( + px[at(x + k.width_tiles as usize, y + 1)], + texel(palette::HUD_TEXT), + "ring" + ); + } + + // Unit dots: brightened team colour; the selected one in HUD text. + let bright = mix(team0, [255, 255, 255, 255], palette::MINIMAP_UNIT_BRIGHTEN); + for &id in &alone { + let (x, y) = dot_origin(view.unit(id).unwrap().pos); + let want = if id == yeoman { + texel(palette::HUD_TEXT) + } else { + bright + }; + for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] { + let i = at((x + dx) as usize, (y + dy) as usize); + assert_eq!(px[i], want, "unit {id:?}"); + } + } + } + + /// The minimap app: the canvas and the image node under a HUD root, + /// the RTS camera, and the territory and paint systems in `Update`. + fn minimap_app(sim: SimHandle) -> App { + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .init_resource::>() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::>() + .add_systems(Startup, spawn_minimap) + .add_systems(Update, (sync_territory_layer, paint_minimap).chain()); + app.world_mut().spawn((HudRoot, Node::default())); + app.world_mut().insert_non_send(sim); + app.update(); + app + } + + fn image_px(app: &App) -> Vec { + let canvas = app.world().resource::(); + app.world() + .resource::>() + .get(&canvas.image) + .and_then(|i| i.data.clone()) + .unwrap() + } + + /// A Watchtower placed in the same update's step shows its new land + /// on the image that update paints (the M7 "within one frame" clause, + /// without fog), and the territory layer is rebuilt once. + #[test] + fn a_new_border_source_repaints_territory_in_the_same_update() { + let mut app = minimap_app(walkthrough()); + let before = image_px(&app); + let generation = app.world().resource::().territory_generation; + let owners_before = app + .world() + .non_send::() + .view() + .territory() + .owner_grid() + .to_vec(); + { + let mut sim = app.world_mut().non_send_mut::(); + let tower = sim.rules().building_kind_by_id("watchtower").unwrap().0; + step( + &mut sim, + vec![Command::DebugPlaceBuilding { + owner: LOCAL_PLAYER, + kind: sim::BuildingKindId(tower), + tile: sim::Tile::new(46, 56), + complete: true, + }], + ); + // The walkthrough's own tick-0 commands apply in these steps + // too (its outside Croft is rejected on purpose); ours is 9000. + let events = sim.drain_events(); + assert!( + !events.iter().any( + |e| matches!(e, sim::SimEvent::CommandRejected { seq, .. } if *seq >= 9000) + ), + "{events:?}" + ); + } + app.update(); + let after = image_px(&app); + let canvas = app.world().resource::(); + assert_eq!(canvas.territory_generation, generation + 1); + let sim = app.world().non_send::(); + let owners = sim.view().territory().owner_grid(); + // The tiles the tower won, away from its own footprint and dots. + let won: Vec = (0..owners.len()) + .filter(|&i| owners_before[i] == 0 && owners[i] == 1) + .filter(|&i| !(i % 128 == 46 && i / 128 == 56)) + .collect(); + assert!(won.len() >= 8, "{} tiles won", won.len()); + for &i in &won { + assert_ne!( + after[i * 4..i * 4 + 4], + before[i * 4..i * 4 + 4], + "tile {i}" + ); + let border = [i - 1, i + 1, i - 128, i + 128] + .iter() + .any(|&n| owners[n] != 1); + let t = if border { + palette::MINIMAP_BORDER + } else { + palette::MINIMAP_TERRITORY + }; + assert_eq!( + canvas.territory[i], + mix(canvas.terrain[i], texel(palette::team_color(0)), t) + ); + } + } + + /// [`MinimapPlugin`] on the game's own schedule: `SimPlugin` steps the + /// walkthrough in `FixedUpdate` (one tick per 50 ms update) with a + /// Watchtower placed by the scenario stream, and the plugin's + /// `sync_territory_layer` (after `SimSystems::Step`, only when a tick + /// stepped) and `paint_minimap` (`PostUpdate`) run where the plugin puts + /// them. In the very update whose tick gives the tower its land, the + /// territory layer is rebuilt once and the image shows every won tile + /// in P0's territory colour: the M7 "mirrors borders within one frame" + /// clause, without fog. Any update before it leaves the image's + /// territory as it was. + #[test] + fn the_plugin_shows_a_new_border_in_the_update_of_its_tick() { + use crate::sim_driver::{ScenarioInput, SimPlugin}; + use bevy::time::TimeUpdateStrategy; + let rules = rules(); + let (setup, mut stream) = sim::scenarios::economy_walkthrough(&rules); + let tower = rules.building_kind_by_id("watchtower").unwrap().0; + let place = PlayerCommand::new( + LOCAL_PLAYER, + 9000, + Command::DebugPlaceBuilding { + owner: LOCAL_PLAYER, + kind: sim::BuildingKindId(tower), + tile: sim::Tile::new(46, 56), + complete: true, + }, + ); + let at = 10; + match stream.binary_search_by_key(&at, |(t, _)| *t) { + Ok(i) => stream[i].1.push(place), + Err(i) => stream.insert(i, (at, vec![place])), + } + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .insert_resource(TimeUpdateStrategy::ManualDuration(Duration::ZERO)) + .init_resource::>() + .init_resource::() + .init_resource::() + .init_resource::>() + .add_plugins(SimPlugin::new( + SimHandle::from_setup(setup, rules), + Box::new(ScenarioInput::new(stream)), + )) + .add_plugins(MinimapPlugin); + app.world_mut().spawn((HudRoot, Node::default())); + app.update(); + app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_millis( + 50, + ))); + let owners = |app: &App| { + app.world() + .non_send::() + .view() + .territory() + .owner_grid() + .to_vec() + }; + let mut checked = false; + for _ in 0..200 { + let (owners_before, image_before) = (owners(&app), image_px(&app)); + let generation = app.world().resource::().territory_generation; + app.update(); + let owners_after = owners(&app); + let image_after = image_px(&app); + let canvas = app.world().resource::(); + let won: Vec = (0..owners_after.len()) + .filter(|&i| owners_before[i] == 0 && owners_after[i] == 1) + .filter(|&i| !(i % 128 == 46 && i / 128 == 56)) + .collect(); + if won.is_empty() { + continue; + } + assert!(won.len() >= 8, "{} tiles won", won.len()); + assert_eq!( + canvas.territory_generation, + generation + 1, + "the layer is rebuilt once, in this update" + ); + for &i in &won { + assert_ne!( + image_after[i * 4..i * 4 + 4], + image_before[i * 4..i * 4 + 4], + "tile {i} changes on the image in the update of its tick" + ); + let interior = [i - 1, i + 1, i - 128, i + 128] + .iter() + .all(|&n| owners_after[n] == 1); + if interior { + assert_eq!( + canvas.territory[i], + mix( + canvas.terrain[i], + texel(palette::team_color(0)), + palette::MINIMAP_TERRITORY + ), + "tile {i}" + ); + } + } + checked = true; + break; + } + assert!(checked, "the tower never won land"); + } + + /// While P0 annexes P1's Seat (`capture_seat`), its ring is amber and + /// P0's own Seat keeps the HUD text ring. + #[test] + fn a_town_under_annexation_gets_the_amber_outline() { + let rules = rules(); + let (setup, stream) = sim::scenarios::capture_seat(&rules); + let mut sim = SimHandle::from_setup(setup, rules); + let seat = BuildingId(2); + for t in 0..3000 { + if sim.view().annexation(seat).is_some() { + break; + } + let cmds: Vec = stream + .iter() + .filter(|(k, _)| *k == t) + .flat_map(|(_, c)| c.iter().cloned()) + .collect(); + sim.step(&cmds); + } + let view = sim.view(); + assert!(view.annexation(seat).is_some(), "the timer runs"); + let (_, territory, w) = layers(&sim); + let px = paint_dynamic( + &territory, + w, + &view, + sim.rules(), + &Selection::default(), + None, + ); + // Every texel of the ring just outside the footprint, drawn over + // the attackers' dots that stand there. + let ring = |b: BuildingId| -> Vec { + let b = view.building(b).unwrap(); + let (x, y) = (usize::from(b.tile.x), usize::from(b.tile.y)); + let mut out = Vec::new(); + for i in x - 1..=x + 3 { + out.push(px[(y - 1) * w + i]); + out.push(px[(y + 3) * w + i]); + } + for j in y..y + 3 { + out.push(px[j * w + x - 1]); + out.push(px[j * w + x + 3]); + } + out + }; + assert!(ring(seat).iter().all(|t| *t == texel(palette::ATTRITION))); + assert!( + ring(BuildingId(1)) + .iter() + .all(|t| *t == texel(palette::HUD_TEXT)) + ); + } + + /// A camera at the default framing over a 1280 x 800 window. + fn camera(rts: &RtsCamera) -> (Camera, GlobalTransform) { + let mut camera = Camera::default(); + camera.computed.target_info = Some(bevy::camera::RenderTargetInfo { + physical_size: UVec2::new(1280, 800), + scale_factor: 1.0, + }); + camera.computed.clip_from_view = Projection::Perspective(PerspectiveProjection { + fov: 40.0_f32.to_radians(), + aspect_ratio: 1280.0 / 800.0, + ..default() + }) + .get_clip_from_view(); + let tf = GlobalTransform::from( + Transform::from_translation(rts.eye()).looking_at(rts.focus, Vec3::Y), + ); + (camera, tf) + } + + /// The trapezoid holds the focus, its far (north) edge is wider than + /// its near one, and it sits north of the near edge (north up). + #[test] + fn frustum_trapezoid_contains_the_focus_and_its_far_edge_is_wider() { + let rts = RtsCamera { + focus: Vec3::new(-10.0, 0.0, 5.0), + ..RtsCamera::default() + }; + let (cam, tf) = camera(&rts); + let q = view_quad(&cam, &tf, Vec2::new(1280.0, 800.0)).expect("every corner hits"); + let focus = texel_from_world(rts.focus); + // Convex and clockwise on screen (y down): every cross product of an + // edge with the focus has the same sign. + let side = |a: Vec2, b: Vec2| (b - a).perp_dot(focus - a); + let signs: Vec = (0..4).map(|k| side(q[k], q[(k + 1) % 4]) > 0.0).collect(); + assert!(signs.iter().all(|s| *s == signs[0]), "{q:?} {focus}"); + let far = q[1].distance(q[0]); + let near = q[2].distance(q[3]); + assert!(far > near * 1.2, "far {far} near {near}"); + assert!(q[0].y < focus.y && q[3].y > focus.y, "north up: {q:?}"); + // Zooming out widens it. + let wide = RtsCamera { + zoom: crate::camera::ZOOM_MAX, + ..rts.clone() + }; + let (cam, tf) = camera(&wide); + let q2 = view_quad(&cam, &tf, Vec2::new(1280.0, 800.0)).unwrap(); + assert!(q2[1].distance(q2[0]) > far); + // The lines are drawn: some texel along the near edge changed. + let sim = walkthrough(); + let (_, territory, w) = layers(&sim); + let plain = paint_dynamic( + &territory, + w, + &sim.view(), + sim.rules(), + &Selection::default(), + None, + ); + let lined = paint_dynamic( + &territory, + w, + &sim.view(), + sim.rules(), + &Selection::default(), + Some(q), + ); + let mid = (q[2] + q[3]) / 2.0; + let i = mid.y.floor() as usize * w + mid.x.floor() as usize; + assert_ne!(plain[i], lined[i]); + assert_eq!( + lined[i], + mix(plain[i], texel(palette::HUD_TEXT), palette::MINIMAP_VIEW) + ); + } + + /// The input app: [`minimap_app`] with the RTS camera, the image node + /// laid out by hand (192 px square centred at (115, 595), scale 1). + fn input_app(sim: SimHandle) -> (App, Entity) { + let mut app = minimap_app(sim); + app.world_mut().spawn(RtsCamera::default()); + let minimap = app + .world_mut() + .query_filtered::>() + .single(app.world()) + .unwrap(); + app.world_mut().entity_mut(minimap).insert(( + ComputedNode { + size: Vec2::splat(MINIMAP_PX), + inverse_scale_factor: 1.0, + ..default() + }, + UiGlobalTransform::from_translation(Vec2::new(115.0, 595.0)), + )); + (app, minimap) + } + + fn location(position: Vec2) -> Location { + Location { + target: NormalizedRenderTarget::None { + width: 1, + height: 1, + }, + position, + } + } + + fn hit(norm: Vec2) -> HitData { + HitData::new(Entity::PLACEHOLDER, 0.0, Some(norm.extend(0.0)), None) + } + + fn press(app: &mut App, minimap: Entity, norm: Vec2) { + app.world_mut().trigger(Pointer::new( + PointerId::Mouse, + location(Vec2::ZERO), + Press { + button: PointerButton::Primary, + hit: hit(norm), + count: 1, + }, + minimap, + )); + } + + fn drag(app: &mut App, minimap: Entity, position: Vec2) { + app.world_mut().trigger(Pointer::new( + PointerId::Mouse, + location(position), + Drag { + button: PointerButton::Primary, + distance: Vec2::ZERO, + delta: Vec2::ZERO, + }, + minimap, + )); + } + + fn click(app: &mut App, minimap: Entity, norm: Vec2, button: PointerButton) { + app.world_mut().trigger(Pointer::new( + PointerId::Mouse, + location(Vec2::ZERO), + Click { + button, + hit: hit(norm), + duration: Duration::ZERO, + count: 1, + }, + minimap, + )); + } + + fn focus(app: &mut App) -> Vec3 { + app.world_mut() + .query::<&RtsCamera>() + .single(app.world()) + .unwrap() + .focus + } + + fn queued(app: &mut App) -> Vec { + app.world_mut() + .resource_mut::() + .drain_sorted() + .into_iter() + .map(|pc| pc.cmd) + .collect() + } + + /// A press moves the focus to the pressed tile's centre, a drag to the + /// tile under the pointer (mapped through the node's transform), the + /// zoom stays; in attack-move mode a press leaves the camera. + #[test] + fn left_press_and_drag_move_the_camera() { + let (mut app, minimap) = input_app(walkthrough()); + let zoom = app + .world_mut() + .query::<&RtsCamera>() + .single(app.world()) + .unwrap() + .zoom; + press(&mut app, minimap, Vec2::new(0.25, 0.25)); + // Tile (96, 96): its centre at 96.5 - 64 = 32.5 m. + assert_eq!(focus(&mut app), Vec3::new(32.5, 0.0, 32.5)); + // 48 px left of the node's centre: norm (-0.25, 0), tile (32, 64). + drag(&mut app, minimap, Vec2::new(115.0 - 48.0, 595.0)); + assert_eq!(focus(&mut app), Vec3::new(-31.5, 0.0, 0.5)); + let stats = app.world().resource::().clone(); + assert_eq!((stats.camera_moves, stats.drags), (2, 1)); + assert_eq!( + app.world_mut() + .query::<&RtsCamera>() + .single(app.world()) + .unwrap() + .zoom, + zoom + ); + // Off the node, a drag clamps to the edge. + drag(&mut app, minimap, Vec2::new(-500.0, 595.0)); + assert_eq!(focus(&mut app).x, -63.5); + + *app.world_mut().resource_mut::() = OrderMode::AttackMove; + let before = focus(&mut app); + press(&mut app, minimap, Vec2::new(0.25, 0.25)); + drag(&mut app, minimap, Vec2::new(115.0, 595.0)); + assert_eq!(focus(&mut app), before, "attack-move mode keeps the camera"); + } + + /// In attack-move mode a primary click issues one `AttackMove` of the + /// own selected units to the tile's centre and disarms; the camera + /// stays. In the normal mode a primary click issues nothing. + #[test] + fn primary_click_in_attack_move_mode_issues_attack_move() { + let sim = walkthrough(); + let own = own_units(&sim); + let (mut app, minimap) = input_app(sim); + app.world_mut() + .resource_mut::() + .set(own.iter().copied()); + click( + &mut app, + minimap, + Vec2::new(0.0, 0.0), + PointerButton::Primary, + ); + assert!(queued(&mut app).is_empty(), "normal mode: nothing"); + + *app.world_mut().resource_mut::() = OrderMode::AttackMove; + let before = focus(&mut app); + click( + &mut app, + minimap, + Vec2::new(0.0, -0.25), + PointerButton::Primary, + ); + assert_eq!( + queued(&mut app), + vec![Command::AttackMove { + units: own, + target: tile_centre(64, 32), + }] + ); + assert_eq!(*app.world().resource::(), OrderMode::Normal); + assert_eq!(focus(&mut app), before); + } + + /// A right click orders the own selected units (an enemy unit in the + /// selection is left out) to the tile's centre; Shift queues it; in + /// attack-move mode it issues nothing (the world's right click cancels + /// that mode). + #[test] + fn right_click_orders_own_selected_units_to_the_tile() { + let sim = walkthrough(); + let own = own_units(&sim); + let enemy = sim + .view() + .units() + .find(|u| u.owner != LOCAL_PLAYER) + .unwrap() + .id; + let (mut app, minimap) = input_app(sim); + app.world_mut() + .resource_mut::() + .set(own.iter().copied().chain([enemy])); + click( + &mut app, + minimap, + Vec2::new(-0.5, -0.5), + PointerButton::Secondary, + ); + assert_eq!( + queued(&mut app), + vec![Command::Move { + units: own.clone(), + target: tile_centre(0, 0), + queue: false, + }] + ); + app.world_mut() + .resource_mut::>() + .press(KeyCode::ShiftLeft); + click( + &mut app, + minimap, + Vec2::new(0.1, 0.2), + PointerButton::Secondary, + ); + assert_eq!( + queued(&mut app), + vec![Command::Move { + units: own, + target: tile_centre(76, 89), + queue: true, + }] + ); + *app.world_mut().resource_mut::() = OrderMode::AttackMove; + click(&mut app, minimap, Vec2::ZERO, PointerButton::Secondary); + assert!(queued(&mut app).is_empty()); + } + + /// With an own building selected a right click sets its rally point at + /// the tile's centre; with another player's building it issues nothing. + #[test] + fn right_click_with_a_building_selected_sets_its_rally() { + let sim = walkthrough(); + let (own, enemy) = { + let view = sim.view(); + let find = |p: PlayerId| view.buildings().find(|b| b.owner == p).unwrap().id; + (find(LOCAL_PLAYER), find(PlayerId(1))) + }; + let (mut app, minimap) = input_app(sim); + app.world_mut() + .resource_mut::() + .set_building(own); + click(&mut app, minimap, Vec2::ZERO, PointerButton::Secondary); + assert_eq!( + queued(&mut app), + vec![Command::SetRally { + building: own, + target: tile_centre(64, 64), + }] + ); + app.world_mut() + .resource_mut::() + .set_building(enemy); + click(&mut app, minimap, Vec2::ZERO, PointerButton::Secondary); + assert!(queued(&mut app).is_empty()); + } + + /// Nothing is issued with no selection, with only another player's + /// units, while the local slot is a bot (`--spectate`) or after the + /// outcome; the press handler itself still moves the camera in each + /// case. (In the app the outcome overlay covers the minimap, so that + /// last press never reaches it; this test triggers on the minimap + /// entity directly, past picking.) + #[test] + fn right_click_with_no_own_units_or_after_match_over_issues_nothing() { + let sim = walkthrough(); + let own = own_units(&sim); + let enemy: Vec = sim + .view() + .units() + .filter(|u| u.owner != LOCAL_PLAYER) + .map(|u| u.id) + .collect(); + let (mut app, minimap) = input_app(sim); + let right = |app: &mut App| { + click(app, minimap, Vec2::ZERO, PointerButton::Secondary); + queued(app) + }; + assert!(right(&mut app).is_empty(), "no selection"); + app.world_mut().resource_mut::().set(enemy); + assert!(right(&mut app).is_empty(), "only enemy units"); + + app.world_mut() + .resource_mut::() + .set(own.iter().copied()); + app.world_mut().insert_resource(LocalSeat { human: false }); + assert!(right(&mut app).is_empty(), "spectating"); + *app.world_mut().resource_mut::() = OrderMode::AttackMove; + click(&mut app, minimap, Vec2::ZERO, PointerButton::Primary); + assert!(queued(&mut app).is_empty(), "no attack-move either"); + *app.world_mut().resource_mut::() = OrderMode::Normal; + app.world_mut().insert_resource(LocalSeat { human: true }); + assert_eq!(right(&mut app).len(), 1, "the positive control"); + + app.world_mut().insert_resource(MatchOver { + result: sim::MatchResult { + outcome: sim::Outcome::Draw, + reason: sim::OutcomeReason::Territory, + tick: 1, + }, + hash: 0, + replay: None, + }); + assert!(right(&mut app).is_empty(), "after the outcome"); + let before = focus(&mut app); + press(&mut app, minimap, Vec2::new(-0.25, 0.25)); + assert_ne!( + focus(&mut app), + before, + "the handler does not gate camera moves on MatchOver" + ); + } + + /// One repaint per change of tick, camera, selection or territory, and + /// none otherwise. + #[test] + fn minimap_repaints_only_on_change() { + let (mut app, _) = input_app(walkthrough()); + let repaints = |app: &App| app.world().resource::().repaints; + app.update(); + let first = repaints(&app); + assert!(first >= 1); + app.update(); + app.update(); + assert_eq!(repaints(&app), first, "nothing changed"); + + app.world_mut() + .query::<&mut RtsCamera>() + .single_mut(app.world_mut()) + .unwrap() + .focus + .x += 3.0; + app.update(); + assert_eq!(repaints(&app), first + 1, "the camera moved"); + app.update(); + assert_eq!(repaints(&app), first + 1); + + let unit = own_units(app.world().non_send::())[0]; + app.world_mut().resource_mut::().set([unit]); + app.update(); + assert_eq!(repaints(&app), first + 2, "the selection changed"); + + app.world_mut().non_send_mut::().step_once(); + app.update(); + assert_eq!(repaints(&app), first + 3, "a tick passed"); + app.update(); + assert_eq!(repaints(&app), first + 3); + let stats = app.world().resource::(); + assert_eq!(stats.paint_us.len(), stats.repaints as usize); + assert!( + stats.line().starts_with("minimap repaints="), + "{}", + stats.line() + ); + } + + /// The HUD laid out at 1280 x 800 with Inter (`ui_test`: Bevy's own + /// text measure and UI layout over the real nodes that + /// `economy_hud::spawn_economy_hud`, `markers::spawn_annex_readout` and + /// [`spawn_minimap`] spawn): every text the message line or the + /// annexation readout can show, at its longest and shaped with its own + /// `TextFont`, is laid out clear of the minimap's frame, which spans + /// their rows. A change to either line's node (its centring, its + /// width, its bottom) or to the frame's is laid out here too. + #[test] + fn minimap_overlaps_no_hud_text() { + use crate::economy_hud::{MessageLine, spawn_economy_hud}; + use crate::hud::HUD_FONT_PX; + use crate::markers::{AnnexReadout, spawn_annex_readout}; + use crate::ui_test::{layout_app, rect, settle, single}; + let (w, h) = (1280.0, 800.0); + let mut app = layout_app(UVec2::new(1280, 800)); + app.world_mut().insert_non_send(walkthrough()); + app.add_systems( + Startup, + ( + spawn_hud, + spawn_economy_hud, + spawn_annex_readout, + spawn_minimap, + ) + .chain(), + ); + settle(&mut app); + let frame_entity = single::(&mut app); + let frame = rect(&app, frame_entity); + let placed = Rect::new( + MINIMAP_LEFT_PX, + h - MINIMAP_BOTTOM_PX - MINIMAP_FRAME_PX, + MINIMAP_LEFT_PX + MINIMAP_FRAME_PX, + h - MINIMAP_BOTTOM_PX, + ); + assert!( + (frame.min - placed.min).length() < 0.01 && (frame.max - placed.max).length() < 0.01, + "the frame is laid out at {frame:?}, its constants say {placed:?}" + ); + assert!(frame.min.x >= crate::camera::EDGE_SCROLL_MARGIN_PX); + assert!(frame.max.y <= h - BOTTOM_BAR_HEIGHT_PX, "above the bar"); + let message = single::(&mut app); + let annex = single::(&mut app); + + let rules = rules(); + let strings = rules::strings::Strings::load( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data"), + "en", + ) + .unwrap(); + let longest = |names: Vec<&str>| { + names + .into_iter() + .max_by(|a, b| { + crate::fonts::measure::text_width_px(a, HUD_FONT_PX) + .total_cmp(&crate::fonts::measure::text_width_px(b, HUD_FONT_PX)) + }) + .unwrap_or("") + .to_string() + }; + let building = longest(rules.buildings.iter().map(|k| k.name.as_str()).collect()); + let name = longest( + rules + .buildings + .iter() + .map(|k| k.name.as_str()) + .chain(rules.units.iter().map(|k| k.name.as_str())) + .chain(rules.techs.iter().map(|t| t.name.as_str())) + .chain(rules.ages.iter().map(|a| a.name.as_str())) + .collect(), + ); + let unit = longest(rules.units.iter().map(|k| k.name.as_str()).collect()); + let resource = longest( + rules + .resources + .resources + .iter() + .map(|r| r.name.as_str()) + .collect(), + ); + let cost = rules + .resources + .resources + .iter() + .map(|r| format!("9999 {}", r.name)) + .collect::>() + .join(" "); + let fill = |template: &str| { + rules::strings::fill( + template, + &[ + ("building", &building), + ("name", &name), + ("unit", &unit), + ("resource", &resource), + ("cost", &cost), + ("count", "9999"), + ("cap", "9999"), + ("used", "9999"), + ("rate", "9999"), + ("time", "10:00"), + ("key", "W"), + ("hash", "0x0123456789abcdef"), + ("seed", "18446744073709551615"), + ], + ) + }; + // The message line: every rejection, notice and tooltip (a greyed + // button's click shows its tooltip there), and a rejection with + // what it requires, as `command_card` joins them. + let mut lines: Vec = strings.reject.values().map(|t| fill(t)).collect(); + lines.extend( + strings + .hud + .iter() + .filter(|(k, _)| !k.starts_with("menu.") && !k.starts_with("outcome.")) + .map(|(_, t)| fill(t)), + ); + let rejects: Vec = strings.reject.values().map(|t| fill(t)).collect(); + let longest_reject = longest(rejects.iter().map(String::as_str).collect()); + lines.push(format!( + "{longest_reject}. {}", + fill(strings.hud("tooltip.requires").unwrap()) + )); + // The readout: an attacking and a defending timer side by side. + let readout = format!( + "{} {}", + fill(strings.hud("annex.attacking").unwrap()), + fill(strings.hud("annex.defending").unwrap()) + ); + for (entity, text) in lines + .iter() + .map(|l| (message, l.as_str())) + .chain([(annex, readout.as_str())]) + { + app.world_mut().get_mut::(entity).unwrap().0 = text.to_string(); + app.update(); + let laid = rect(&app, entity); + // Bevy shaped it: within a few px of the sum of Inter's advances + // (kerning narrows a little), not a stale or empty measure. + let advances = crate::fonts::measure::text_width_px(text, HUD_FONT_PX); + assert!( + (laid.width() - advances).abs() < 0.05 * advances + 2.0, + "{text:?} laid out {} px wide, its advances sum to {advances}", + laid.width() + ); + assert!(laid.max.x < w, "{text:?} fits the window: {laid:?}"); + assert!( + laid.min.y < frame.max.y && laid.max.y > frame.min.y, + "the row spans the minimap: {laid:?}" + ); + assert!( + laid.min.x > frame.max.x, + "{text:?} is laid out from x {:.1}, into the minimap's frame (right edge {})", + laid.min.x, + frame.max.x + ); + } + } +} diff --git a/crates/game/src/minimap_check.rs b/crates/game/src/minimap_check.rs new file mode 100644 index 0000000..5029f40 --- /dev/null +++ b/crates/game/src/minimap_check.rs @@ -0,0 +1,425 @@ +//! `--minimap-check` (art pass, acceptance AP6): the minimap's input +//! proven with real X input, as `--click-check` proves unit picking +//! (synthetic `PointerInput` fails on lavapipe, issue #33). Run on +//! `--scenario economy_walkthrough`. Once the sim reaches +//! [`MINIMAP_CHECK_TICK`] and the minimap is laid out, the game prints the +//! image node's rect and the first step; a driver (xdotool under Xvfb) +//! performs each step at the printed logical pixels, and after each the +//! game judges it, prints `minimap_check: pass ...` and the next step: +//! +//! 1. `step 1 press px=,`: a left click on the south-east quarter; +//! the camera's focus must move to that tile (by a minimap press). +//! 2. `step 2 drag from=, to=, tile=,`: a left drag; the focus must +//! follow the pointer to the end tile (by `Pointer` events: the +//! press alone only reaches the start tile). +//! 3. `step 3 right_click px=,`: the check has selected the local +//! player's units; exactly one `Move` of exactly those units to that +//! tile's centre must be queued. +//! 4. `step 4 key=a left_click px=,`: the driver presses A (arming +//! attack-move, `orders::order_mode_keys`), then left-clicks; exactly +//! one `AttackMove` to that tile must be queued, the mode must be back +//! to normal, and the camera must not have moved. +//! +//! Then it prints `minimap_check: ok` and exits 0, or at the first failed +//! step `minimap_check: FAIL ` and exits 1 (the `hud::verdict_line` +//! form); a run that ends before the last step prints `minimap_check: +//! FAIL stopped at step `. + +use bevy::app::AppExit; +use bevy::prelude::*; +use sim::{Command, UnitId}; + +use crate::camera::RtsCamera; +use crate::hud::WorldInputSet; +use crate::minimap::{Minimap, MinimapCanvas, MinimapStats, tile_centre}; +use crate::orders::{OrderMode, own_selected_units}; +use crate::present::world_from_sim; +use crate::selection::Selection; +use crate::sim_driver::{LOCAL_PLAYER, PendingCommands, SimHandle, SimSystems}; + +/// The sim tick at which the check starts. +pub const MINIMAP_CHECK_TICK: u32 = 40; +/// How close the camera's focus must come to a tile's centre, metres. +pub const FOCUS_TOLERANCE_M: f32 = 1.0; + +/// Where the check is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Step { + /// Waiting for the tick and the layout. + #[default] + Wait, + /// Step 1, a left press. + Press, + /// Step 2, a left drag. + Drag, + /// Step 3, a right click. + RightClick, + /// Step 4, A then a left click. + AttackMove, + /// The verdict is out. + Done, +} + +impl Step { + fn number(self) -> u8 { + match self { + Step::Wait | Step::Press => 1, + Step::Drag => 2, + Step::RightClick => 3, + Step::AttackMove => 4, + Step::Done => 5, + } + } +} + +/// The step tiles (texels): the south-east quarter's centre, the drag's +/// end, the right click's and the attack-move click's targets, as shares +/// of the map's width and height. +pub const PRESS_AT: Vec2 = Vec2::new(0.75, 0.75); +/// The drag's end. +pub const DRAG_TO: Vec2 = Vec2::new(0.30, 0.45); +/// The right click. +pub const RIGHT_AT: Vec2 = Vec2::new(0.25, 0.60); +/// The attack-move click. +pub const ATTACK_AT: Vec2 = Vec2::new(0.35, 0.40); + +/// State of the check. +#[derive(Resource, Debug, Default, Clone)] +pub struct MinimapCheck { + /// The current step. + pub step: Step, + /// The minimap image's rect, logical px. + pub rect: Option, + /// The tile the current step aims at. + pub tile: (u16, u16), + /// `MinimapStats::camera_moves` when the step began. + pub moves_before: u32, + /// `MinimapStats::drags` when the step began. + pub drags_before: u32, + /// The units selected for steps 3 and 4. + pub units: Vec, + /// Local commands queued since the step began. + pub seen: Vec, + /// The highest local seq already tallied. + counted_through: Option, + /// The first failure. + pub failure: Option, +} + +impl MinimapCheck { + /// Record the local commands waiting in `pending` not seen yet. + pub fn tally(&mut self, pending: &PendingCommands) { + for pc in pending.peek() { + if pc.player != LOCAL_PLAYER || self.counted_through.is_some_and(|s| pc.seq <= s) { + continue; + } + self.counted_through = Some(pc.seq); + self.seen.push(pc.cmd.clone()); + } + } + + /// The logical pixel at the centre of `tile` on the minimap. + pub fn px(&self, tile: (u16, u16), size: (usize, usize)) -> Vec2 { + let rect = self.rect.unwrap_or_default(); + let share = Vec2::new( + (f32::from(tile.0) + 0.5) / size.0.max(1) as f32, + (f32::from(tile.1) + 0.5) / size.1.max(1) as f32, + ); + rect.min + share * rect.size() + } +} + +/// The tile at `share` of a `size` map. +pub fn tile_at(share: Vec2, size: (usize, usize)) -> (u16, u16) { + let axis = |s: f32, n: usize| { + u16::try_from(((s * n as f32) as usize).min(n.saturating_sub(1))).unwrap_or(0) + }; + (axis(share.x, size.0), axis(share.y, size.1)) +} + +/// `px=, tile=,`, the pixel rounded. +fn at(p: Vec2, tile: (u16, u16)) -> String { + format!( + "px={},{} tile={},{}", + p.x.round() as i64, + p.y.round() as i64, + tile.0, + tile.1 + ) +} + +/// `minimap_check: ok`, or `minimap_check: FAIL `. +pub fn verdict_line(failure: Option<&str>) -> String { + match failure { + None => "minimap_check: ok".to_string(), + Some(why) => format!("minimap_check: FAIL {why}"), + } +} + +/// How far the camera's focus is from `tile`'s centre, metres (ground +/// plane). +fn focus_off(cameras: &Query<&RtsCamera>, tile: (u16, u16)) -> Option<(Vec3, f32)> { + let want = world_from_sim(tile_centre(tile.0, tile.1), 0.0); + cameras.single().ok().map(|c| { + let d = Vec2::new(c.focus.x - want.x, c.focus.z - want.z).length(); + (c.focus, d) + }) +} + +/// Prints the steps and judges them. Added by `app::run` under +/// `--minimap-check`. +pub struct MinimapCheckPlugin; + +impl Plugin for MinimapCheckPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(FixedUpdate, tally_pending.before(SimSystems::Step)) + .add_systems(Update, run_minimap_check.after(WorldInputSet)) + .add_systems(Last, report_unfinished.after(bevy::window::ExitSystems)); + } +} + +fn tally_pending(mut check: ResMut, pending: Res) { + check.tally(&pending); +} + +/// The check's state machine (see the module docs). +#[allow(clippy::too_many_arguments)] // Bevy system: each parameter is one resource or query +pub fn run_minimap_check( + sim: NonSend, + mut check: ResMut, + canvas: Option>, + stats: Res, + nodes: Query<(&ComputedNode, &UiGlobalTransform), With>, + cameras: Query<&RtsCamera>, + buttons: Res>, + mode: Res, + mut selection: ResMut, + pending: Res, + mut exit: MessageWriter, +) { + let Some(canvas) = canvas else { + return; + }; + let size = canvas.size; + check.tally(&pending); + let left = buttons.just_released(MouseButton::Left); + let right = buttons.just_released(MouseButton::Right); + let mut fail: Option = None; + match check.step { + Step::Wait => { + if sim.tick() < MINIMAP_CHECK_TICK { + return; + } + let Ok((node, transform)) = nodes.single() else { + return; + }; + if node.size().min_element() <= 0.0 { + return; + } + let scale = node.inverse_scale_factor; + let centre = transform.translation * scale; + let half = node.size() * scale / 2.0; + let rect = Rect::from_center_half_size(centre, half); + check.rect = Some(rect); + println!( + "minimap_check: rect left={} top={} width={} height={} texels={}x{}", + rect.min.x.round(), + rect.min.y.round(), + rect.width().round(), + rect.height().round(), + size.0, + size.1 + ); + check.tile = tile_at(PRESS_AT, size); + check.moves_before = stats.camera_moves; + let p = check.px(check.tile, size); + println!("minimap_check: step 1 press {}", at(p, check.tile)); + check.step = Step::Press; + } + Step::Press if left => match focus_off(&cameras, check.tile) { + _ if stats.camera_moves == check.moves_before => { + fail = Some("step 1: the press did not move the camera".into()); + } + Some((focus, d)) if d <= FOCUS_TOLERANCE_M && focus.x > 0.0 && focus.z > 0.0 => { + println!("minimap_check: pass 1 focus={:.1},{:.1}", focus.x, focus.z); + let from = check.tile; + check.tile = tile_at(DRAG_TO, size); + check.drags_before = stats.drags; + let (a, b) = (check.px(from, size), check.px(check.tile, size)); + println!( + "minimap_check: step 2 drag from={},{} to={},{} tile={},{}", + a.x.round() as i64, + a.y.round() as i64, + b.x.round() as i64, + b.y.round() as i64, + check.tile.0, + check.tile.1 + ); + check.step = Step::Drag; + } + other => { + fail = Some(format!( + "step 1: focus {:?} is not on tile {:?} in the south-east quarter", + other.map(|(f, _)| f), + check.tile + )); + } + }, + Step::Drag if left => match focus_off(&cameras, check.tile) { + _ if stats.drags == check.drags_before => { + fail = Some("step 2: no drag reached the minimap".into()); + } + Some((focus, d)) if d <= FOCUS_TOLERANCE_M => { + println!( + "minimap_check: pass 2 focus={:.1},{:.1} drags={}", + focus.x, + focus.z, + stats.drags - check.drags_before + ); + let own: Vec = sim + .view() + .units() + .filter(|u| u.owner == LOCAL_PLAYER) + .map(|u| u.id) + .collect(); + selection.set(own.iter().copied()); + check.units = own_selected_units(&sim.view(), &selection); + check.tile = tile_at(RIGHT_AT, size); + check.seen.clear(); + let p = check.px(check.tile, size); + println!( + "minimap_check: step 3 right_click {} units={}", + at(p, check.tile), + check.units.len() + ); + check.step = Step::RightClick; + } + other => { + fail = Some(format!( + "step 2: focus {:?} did not follow the drag to tile {:?}", + other.map(|(f, _)| f), + check.tile + )); + } + }, + Step::RightClick if right => { + let want = Command::Move { + units: check.units.clone(), + target: tile_centre(check.tile.0, check.tile.1), + queue: false, + }; + if check.units.is_empty() { + fail = Some("step 3: the local player has no units".into()); + } else if check.seen != [want] { + fail = Some(format!( + "step 3: expected one Move of {} units to tile {:?}, saw {:?}", + check.units.len(), + check.tile, + check.seen + )); + } else { + println!("minimap_check: pass 3 move units={}", check.units.len()); + check.tile = tile_at(ATTACK_AT, size); + check.seen.clear(); + check.moves_before = stats.camera_moves; + let p = check.px(check.tile, size); + println!( + "minimap_check: step 4 key=a left_click {}", + at(p, check.tile) + ); + check.step = Step::AttackMove; + } + } + Step::AttackMove if left => { + let target = tile_centre(check.tile.0, check.tile.1); + let attack_moves = check + .seen + .iter() + .filter(|c| matches!(c, Command::AttackMove { target: t, .. } if *t == target)) + .count(); + if check.seen.len() != 1 || attack_moves != 1 { + fail = Some(format!( + "step 4: expected one AttackMove to tile {:?}, saw {:?} (mode {:?})", + check.tile, check.seen, *mode + )); + } else if *mode != OrderMode::Normal { + fail = Some(format!("step 4: the mode stayed {:?}", *mode)); + } else if stats.camera_moves != check.moves_before { + fail = Some("step 4: the attack-move click moved the camera".into()); + } else { + println!("minimap_check: pass 4 attack_move"); + println!("{}", verdict_line(None)); + check.step = Step::Done; + exit.write(AppExit::Success); + } + } + _ => {} + } + if let Some(why) = fail { + println!("{}", verdict_line(Some(&why))); + check.failure = Some(why); + check.step = Step::Done; + exit.write(AppExit::error()); + } +} + +/// On an exit before the verdict (`--exit-after-seconds`, a closed +/// window): say where the check stopped. +pub fn report_unfinished(mut exits: MessageReader, mut check: ResMut) { + if exits.is_empty() { + return; + } + exits.clear(); + if check.step != Step::Done { + let why = format!("stopped at step {}", check.step.number()); + println!("{}", verdict_line(Some(&why))); + check.failure = Some(why); + check.step = Step::Done; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sim::FxVec2; + + #[test] + fn verdicts_and_step_pixels_are_exact() { + assert_eq!(verdict_line(None), "minimap_check: ok"); + assert_eq!( + verdict_line(Some("step 2: x")), + "minimap_check: FAIL step 2: x" + ); + let size = (128, 128); + assert_eq!(tile_at(PRESS_AT, size), (96, 96)); + assert_eq!(tile_at(Vec2::new(1.0, 1.0), size), (127, 127), "clamped"); + let check = MinimapCheck { + rect: Some(Rect::new(19.0, 499.0, 211.0, 691.0)), + ..MinimapCheck::default() + }; + // 192 px over 128 tiles: tile 96's centre is 96.5 * 1.5 px in. + let p = check.px((96, 96), size); + assert!((p.x - (19.0 + 144.75)).abs() < 1e-3, "{p}"); + assert!((p.y - (499.0 + 144.75)).abs() < 1e-3, "{p}"); + assert_eq!(at(p, (96, 96)), "px=164,644 tile=96,96"); + } + + #[test] + fn tally_records_each_local_command_once() { + let mut pending = PendingCommands::default(); + let mut check = MinimapCheck::default(); + let mv = Command::Move { + units: vec![UnitId(1)], + target: FxVec2::ZERO, + queue: false, + }; + pending.push_local(mv.clone()); + check.tally(&pending); + check.tally(&pending); + assert_eq!(check.seen, vec![mv.clone()]); + pending.drain_sorted(); + check.tally(&pending); + assert_eq!(check.seen, vec![mv]); + } +} diff --git a/crates/game/src/orders.rs b/crates/game/src/orders.rs index 9f24846..e74f4ec 100644 --- a/crates/game/src/orders.rs +++ b/crates/game/src/orders.rs @@ -381,11 +381,44 @@ pub fn context_order( } } } - Some(Command::Move { - units: own, - target: point, - queue, - }) + ground_order(OrderMode::Normal, view, selection, point, queue) +} + +/// The order a click on bare ground at `target` issues in `mode`, with +/// nothing under the pointer (the right click's last context, and every +/// minimap order, `minimap.rs`): in [`OrderMode::Normal`], `SetRally` when +/// an own building is selected, else `Move` of the own selected units +/// (queued when `queue`); in [`OrderMode::AttackMove`], `AttackMove` of the +/// own selected units. `None` when there is nothing to order: no own unit +/// (or own building) selected, or placement mode. +pub fn ground_order( + mode: OrderMode, + view: &SimView<'_>, + selection: &Selection, + target: FxVec2, + queue: bool, +) -> Option { + match mode { + OrderMode::Normal => { + if let Some(building) = selection.building { + return view + .building(building) + .filter(|b| b.owner == LOCAL_PLAYER) + .map(|_| Command::SetRally { building, target }); + } + let units = own_selected_units(view, selection); + (!units.is_empty()).then_some(Command::Move { + units, + target, + queue, + }) + } + OrderMode::AttackMove => { + let units = own_selected_units(view, selection); + (!units.is_empty()).then_some(Command::AttackMove { units, target }) + } + OrderMode::Place(_) => None, + } } /// Where an attack marker for `target` goes: a unit's position on the diff --git a/crates/game/src/outcome.rs b/crates/game/src/outcome.rs index 451b2f4..4d4f11b 100644 --- a/crates/game/src/outcome.rs +++ b/crates/game/src/outcome.rs @@ -16,7 +16,6 @@ //! moves. Registered only by the windowed app, never by `--headless-run`. use bevy::prelude::*; -use bevy::text::FontSize; use rules::Rules; use rules::strings::fill; use sim::{Outcome, OutcomeReason, PlayerId, SimView}; @@ -93,10 +92,7 @@ pub fn spawn_outcome_overlay(mut commands: Commands) { overlay.spawn(( OutcomeLine(i), Text::new(""), - TextFont { - font_size: FontSize::Px(size), - ..default() - }, + crate::fonts::hud_font(size), TextColor(palette::HUD_TEXT), )); } diff --git a/crates/game/src/palette.rs b/crates/game/src/palette.rs index c28ab8e..3d31b4c 100644 --- a/crates/game/src/palette.rs +++ b/crates/game/src/palette.rs @@ -108,6 +108,34 @@ pub const ANNEX_TRACK: Color = Color::srgba(0.227, 0.227, 0.247, 0.8); /// over the frozen world. pub const OUTCOME_SCRIM: Color = Color::srgba(0.16, 0.18, 0.20, 0.80); +/// Arrows in flight (art pass hit feedback, unlit): the dark neutral +/// (#3A3A3F, `docs/ART_STYLE.md`, "Neutral and fog"), a streak, not a glow. +pub const ARROW: Color = Color::srgb(0.227, 0.227, 0.247); +/// Mangonel stones in flight (unlit): Ore (#6E6A72, `docs/ART_STYLE.md`). +pub const STONE_SHOT: Color = Color::srgb(0.431, 0.416, 0.447); + +/// The minimap's terrain layer (art pass): each tile's ground colour +/// scaled to this share of its value, so territory, buildings and units +/// stand out on it. +pub const MINIMAP_TERRAIN_SHADE: f32 = 0.70; +/// How far an owned tile on the minimap blends toward its owner's team +/// colour. +pub const MINIMAP_TERRITORY: f32 = 0.35; +/// How far an owned tile on the border (a 4-neighbour of another owner or +/// neutral land) blends toward its owner's team colour. +pub const MINIMAP_BORDER: f32 = 0.75; +/// How far a construction site on the minimap blends from its owner's +/// colour toward [`STONE`]. +pub const MINIMAP_SITE: f32 = 0.5; +/// How far a unit's dot on the minimap is brightened from its team colour +/// toward white: 45%, so a dot stands out on its own team's tinted land +/// (the plan's 15% left P0's Yeomen nearly invisible on P0's territory in +/// the first lavapipe shot). +pub const MINIMAP_UNIT_BRIGHTEN: f32 = 0.45; +/// How far the camera's view trapezoid on the minimap blends toward +/// [`HUD_TEXT`] (it reads as the HUD text at 80%). +pub const MINIMAP_VIEW: f32 = 0.80; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/game/src/ui_test.rs b/crates/game/src/ui_test.rs new file mode 100644 index 0000000..fb6385c --- /dev/null +++ b/crates/game/src/ui_test.rs @@ -0,0 +1,130 @@ +//! Test-only: a headless app that shapes UI text with Inter and lays the +//! UI out the way Bevy 0.19.1's `UiPlugin` and `TextPlugin` do, without a +//! window, a renderer or picking. +//! +//! [`layout_app`] adds, in `PostUpdate`, the systems `UiPlugin` orders for +//! layout (`bevy_ui` `lib.rs`): `propagate_ui_target_cameras` in +//! `UiSystems::Prepare`, the two `HierarchyPropagatePlugin`s in +//! `UiSystems::Propagate`, `measure_text_system` in `UiSystems::Content` +//! (after `bevy_text`'s `load_font_assets_into_font_collection`) and +//! `ui_layout_system` in `UiSystems::Layout`. The UI camera is a `Camera` +//! with `IsDefaultUiCamera` whose viewport is the window's physical size, +//! which is what `propagate_ui_target_cameras` reads +//! (`Camera::physical_viewport_size`); the scale factor is 1, so logical +//! and physical px agree. [`crate::fonts::FontsPlugin`] installs the +//! repository's Inter in `Startup`, so text nodes are measured with the +//! font the game draws and with their own `TextFont` (size and features). +//! +//! Callers add their `Startup` spawn systems, then call `update` a few +//! times ([`settle`]) and read `ComputedNode` and `UiGlobalTransform` +//! through [`rect`]. + +use std::path::Path; + +use bevy::app::{HierarchyPropagatePlugin, PropagateSet}; +use bevy::asset::AssetPlugin; +use bevy::camera::Viewport; +use bevy::prelude::*; +use bevy::text::{FontCx, LayoutCx, RemSize, TextPipeline, load_font_assets_into_font_collection}; +use bevy::ui::ui_surface::UiSurface; +use bevy::ui::update::propagate_ui_target_cameras; +use bevy::ui::widget::measure_text_system; +use bevy::ui::{ + ComputedUiRenderTargetInfo, ComputedUiTargetCamera, UiScale, UiSystems, ui_layout_system, +}; + +use crate::fonts::FontsPlugin; + +/// An app that lays out UI nodes in a `window` of logical px at scale 1 +/// with Inter installed as the default font. `Assets` exists (the +/// minimap's image node needs it). +pub(crate) fn layout_app(window: UVec2) -> App { + let mut app = App::new(); + app.add_plugins(( + MinimalPlugins, + AssetPlugin::default(), + HierarchyPropagatePlugin::::new(PostUpdate), + HierarchyPropagatePlugin::::new(PostUpdate), + FontsPlugin { + assets: Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets"), + }, + )) + .init_asset::() + .init_asset::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .configure_sets( + PostUpdate, + ( + UiSystems::Prepare, + UiSystems::Propagate, + UiSystems::Content, + UiSystems::Layout, + ) + .chain(), + ) + .configure_sets( + PostUpdate, + PropagateSet::::default().in_set(UiSystems::Propagate), + ) + .configure_sets( + PostUpdate, + PropagateSet::::default().in_set(UiSystems::Propagate), + ) + .add_systems( + PostUpdate, + ( + propagate_ui_target_cameras.in_set(UiSystems::Prepare), + load_font_assets_into_font_collection.before(UiSystems::Content), + measure_text_system.in_set(UiSystems::Content), + ui_layout_system.in_set(UiSystems::Layout), + ), + ); + app.world_mut().spawn(( + Camera { + viewport: Some(Viewport { + physical_size: window, + ..default() + }), + ..default() + }, + IsDefaultUiCamera, + )); + app +} + +/// Run enough updates for spawned nodes to be propagated, their text +/// measured (a text node retries until its font is registered) and laid +/// out. +pub(crate) fn settle(app: &mut App) { + for _ in 0..3 { + app.update(); + } +} + +/// The laid-out rect of `entity` in logical window px (top-left origin), +/// from its `ComputedNode` size and `UiGlobalTransform` centre. +pub(crate) fn rect(app: &App, entity: Entity) -> Rect { + let node = app + .world() + .get::(entity) + .expect("a laid-out node"); + let transform = app + .world() + .get::(entity) + .expect("a laid-out node"); + let centre = crate::hud::node_centre_logical(node, transform); + Rect::from_center_size(centre, node.size() * node.inverse_scale_factor()) +} + +/// The one entity with component `C`. +pub(crate) fn single(app: &mut App) -> Entity { + app.world_mut() + .query_filtered::>() + .single(app.world()) + .expect("exactly one") +} diff --git a/data/visuals.ron b/data/visuals.ron index b6d03ad..5e53c14 100644 --- a/data/visuals.ron +++ b/data/visuals.ron @@ -47,10 +47,12 @@ // roof mount sinks the pole into the building so the cloth clears it, // and a free front corner of the footprint takes it on the ground. // Every banner is one shared mesh with one material per team. The -// Mangonel carries the same banner (art slice 4), which follows it. +// Mangonel carries the same banner (art slice 4), which follows it, +// smaller, against its own 120‰ floor (owner decision 15). // seat_flag_at (Town kinds) puts `seat_flag` on a Seat: on the castle's // keep, inside the footprint and wholly above the Town's own banner, so -// a Seat reads apart from any other Town by its place. +// a Seat reads apart from any other Town by its place (owner decision +// 16 of 2026-10-28 accepted this rule; no height ratio applies). // sites: one look per equal share of the build progress, neutral, scaled by // the footprint side; rubble: the wreck of a destroyed building for // ds deciseconds; terrain: props on forest, mountain and water (never @@ -64,9 +66,10 @@ // Shieldbearer 1.45 m, Outrider 2.05 m, 1.41x the Shieldbearer), lifted // 1 cm, each in its character's team swatch; the Mangonel is the catapult // of the KayKit catapult tower (its `catapult_*` nodes, turned to face +X, - // lowered from the tower top) with the team banner (scale 700, 204‰; it - // reaches past the engine's 0.5 m radius, so packed Mangonels' poles and - // cloths cross, an owner trade-off in docs/ART_STYLE.md); the Wain a + // lowered from the tower top) with the team banner centred on it (scale + // 450, a 1.25 m banner, 123‰ against the Mangonel's own 120‰ floor, owner + // decision 15 of 2026-10-28; it reaches 0.60 m from the centre, 0.10 m + // past the engine's 0.5 m radius, docs/ART_STYLE.md); the Wain a // KayKit wheelbarrow loaded with a sack and a crate, flying the flag 1.4x // as a pennant. Each fallback is the kind's M4b look. A one-line swap // back: `"yeoman": Primitive(Capsule),` (docs/ART_STYLE.md). @@ -77,7 +80,7 @@ "shieldbearer": Scene(path: "models/kaykit_adventurers/shieldbearer.glb", scale_x100: 60, y_offset_tiles_x100: 1, yaw_deg: 0, team: Some("adv_knight"), fallback: Some(Body(shape: Cuboid, height_tiles_x100: 130))), "bowman": Scene(path: "models/kaykit_adventurers/bowman.glb", scale_x100: 63, y_offset_tiles_x100: 1, yaw_deg: 0, team: Some("adv_rogue"), fallback: Some(Body(shape: Cylinder, height_tiles_x100: 190))), "outrider": Scene(path: "models/kaykit_adventurers/outrider.glb", scale_x100: 84, y_offset_tiles_x100: 1, yaw_deg: 0, team: Some("adv_knight"), fallback: Some(Body(shape: Capsule, height_tiles_x100: 180, lying: true))), - "mangonel": Scene(path: "models/kaykit_hexagon/buildings/blue/building_tower_catapult_blue.gltf", nodes: ["catapult_*"], scale_x100: 140, y_offset_tiles_x100: -195, yaw_deg: 90, x_offset_tiles_x100: 29, z_offset_tiles_x100: 6, team: Some("kaykit_hexagon"), fallback: Some(Primitive(Sphere)), banner_at: Some((offset_x100: (-40, 0, 40), scale_x100: 700))), + "mangonel": Scene(path: "models/kaykit_hexagon/buildings/blue/building_tower_catapult_blue.gltf", nodes: ["catapult_*"], scale_x100: 140, y_offset_tiles_x100: -195, yaw_deg: 90, x_offset_tiles_x100: 29, z_offset_tiles_x100: 6, team: Some("kaykit_hexagon"), fallback: Some(Primitive(Sphere)), banner_at: Some((offset_x100: (0, 0, 0), scale_x100: 450))), "supply_wain": Scene(path: "models/kaykit_hexagon/decoration/props/wheelbarrow.gltf", parts: [(path: "models/kaykit_hexagon/decoration/props/sack.gltf", offset_x100: (0, 11, 8), scale_x100: 130), (path: "models/kaykit_hexagon/decoration/props/crate_A_big.gltf", offset_x100: (0, 8, -6), scale_x100: 75), (path: "models/kaykit_hexagon/decoration/props/flag_blue.gltf", offset_x100: (0, 22, -8), scale_x100: 140)], scale_x100: 220, y_offset_tiles_x100: 1, yaw_deg: 90, x_offset_tiles_x100: 14, team: Some("kaykit_hexagon"), fallback: Some(Body(shape: Cuboid, height_tiles_x100: 60))), }, // Scales fill 0.95 of the footprint side (the check's suggestion); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4055589..c64812f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -174,6 +174,10 @@ The only Bevy crate and the `eonmark` binary. Bevy ECS is used here for presenta | `buildings.rs` | `BuildingEntities: HashMap` mirror synced after each tick (`sync_buildings`); since art slice 3 a complete building draws its kind's merged model (`visuals.ron` via `Rules::building_visual`) with its owner's team material, a site the construction stage for its third of the build scaled by the footprint side, and either falls back to the M3b box (one shared unit cube, one material per team and site) when there is no model; top-level extras keyed by `BuildingId` (`BuildingExtras`): the team banner at the Scene's `banner_at` (one shared mesh, one material per team, no shadow), the Seat flag on a Seat at `seat_flag_at`, and `Rubble` spawned by `spawn_rubble` from `BuildingDestroyed` before the despawn (gone after `rubble.ds` of real time or when a building takes the footprint); materials follow annexation; `building_top_y` is the one height bars and attack markers use; buildings are `Pickable` (the merged mesh is ray-cast) | M3b, art pass | | `models.rs` | Art slices 3 and 4 (units since slice 4): `ModelLibrary` (one entry per distinct `ModelKey`, the mesh-defining part of a Scene), `request_models` (Startup: every Scene file the game draws, once, with cameras, lights and animations off, source meshes main-world only, a forced nearest sampler), `finish_models` (Update: `merge_nodes` composes node transforms from the roots, keeps the nodes a glob names, places by yaw, scale and offsets as `inspect_gltf check` does, fails on a missing attribute, another topology, no indices, two textures or two base colours; a failure or 10 s of real time marks the entry failed with one `error!`), textures deduplicated by content (`content_hash`), one neutral and one team material per texture (`team_texture` repaints the swatch rects in `palette::team_color` and desaturates the rest), and `models_settled`, joined with `&&` into `sim_driver::match_running` so the sim does not step until every model has settled; added only by `app::run` | art pass | | `click_check.rs` | Art slice 4, `--click-check` (with `--scenario unit_looks`): at tick 60 prints a `click_check: target` line per unit, the pixel nearest the centroid of its projected model whose ray (`MeshRayCast`, pickable entities only) hits the unit first; after every primary release judges the selection against the next target and prints `selected` or `miss`, then `click_check: ok n/n` or `FAIL` and exits 1 on a miss; a driver clicks with real X input (art pass AP10) | art pass | +| `minimap.rs` | Art slice 5 (owner decision 7): the minimap bottom-left over the bottom bar, a 192 px `ImageNode` of one `Rgba8UnormSrgb` texel per tile in a frame under `HudRoot`; cached terrain and territory layers (the territory rebuilt in `FixedUpdate` when the field's border sources change), and `paint_dynamic` (buildings, units, the selection, Town outlines, amber while annexed, the view trapezoid from `orders::ground_hit`) repainted in `PostUpdate` only when its `PaintKey` changed; `Pointer`/`Pointer` move the camera, `Pointer` issues `orders::ground_order` (gated on a human local slot and no `MatchOver`); prints `frame_stats: minimap ...` on exit | art pass | +| `minimap_check.rs` | Art slice 5, `--minimap-check` (with `--scenario economy_walkthrough`): from tick 40 prints the minimap's rect and four steps (press, drag, right click, A then left click), judges each against the camera and the queued commands, then prints `minimap_check: ok` or `FAIL` and exits 0 or 1; a driver performs the steps with real X input (art pass AP6) | art pass | +| `combat_fx.rs` | Art slice 5, hit feedback (owner decision 11, presentation only): after each stepped tick and before `sync_lifecycle` despawns the dead, every `SimEvent::Hit` becomes an arrow (a unit kind with a range over one tile, or a building), a stone (the Siege class) or a melee `Lunge` on the attacker's entity (`hit_fx`, from data); arrows and stones are two shared meshes and unlit materials from a pool of at most 256 top-level entities, flown on the fixed clock (`fx_now`) and hidden and reused when they land | art pass | +| `fonts.rs` | Art slice 5: `install_ui_font` (Startup) reads `assets/fonts/InterVariable.ttf` with `std::fs` and inserts it at `AssetId::default()`, before the first `PostUpdate` registers fonts, so every `TextFont` draws in Inter; a missing file warns and Bevy's Fira Mono stays; the test-only `measure` sums Inter's advances (skrifa) for the HUD fit tests | art pass | | `terrain_props.rs` | Art slice 3: `decor_placements`, a pure function of the map, `visuals.ron`'s `terrain` table and each look's measured reach and height (a tile's `room` of same-terrain rings bounds a look's footprint, nothing over 1.6 m south of passable land, jitter, yaw and scale from tile hashes), and `spawn_props`, which spawns them once when models settle as top-level, unpickable entities with the look's neutral material | art pass | | `command_card.rs` | The bottom bar's command card and selection panel: Build buttons for selected builders (ramped cost, greyed with the reason before the kind's age, at the town limit or when unaffordable, from `SimView::check_build_kind`; a click enters placement), Train buttons for a selected complete building (`SimView::check_train` decides, the tooltip is the rejection's message), Cancel for a selected site, and since M4b an Attack-move button for armed selections without builders and the research panel: on a selected building that hosts techs (the Scriptorium; since M5a's rules data also the Watchtower, for Harrying) one Research button per tech line with the line's next level (`research_actions`; `SimView::check_research` greys it, the second line is the quoted cost or the blocker, the tooltip what the level requires); the panel names the selection (unit counts, or the building with progress, tier, workers, the techs researched there and the queue) and its hit points for one selected unit of either player or the selected own building (another player's building is not selectable yet) | M3b, M4b | | `placement.rs` | `OrderMode::Place`: a translucent ghost of the footprint under the cursor, green or red by `SimView::check_build` (cached per tile, builder and tick), the reason on the message line, a footprint outline on the ground; a left click issues `Build` with the nearest idle selected builder | M3b | @@ -236,15 +240,15 @@ The art pass (slice 2) vendored KayKit Medieval Hexagon (buildings, construction ### `scripts/`, `justfile` -`bundle.sh` assembles `Eonmark.app` and signs it last (M8). `check_assets.sh` and `check_trademark.sh` are CI gates (M0). `render_smoke.sh` renders a scenario under Xvfb and lavapipe and fails on any ERROR line (M3b, CI's game-linux job). `m4b_outpost_drive.sh` plays ROADMAP M4b acceptance line 1 on the interactive `--scenario enemy_outpost` with real X input (xdotool under Xvfb and lavapipe) and verifies the recorded replay (M4b; run by hand, not in CI). The `justfile` wraps the CI commands so `just ci` runs locally what GitHub runs remotely; it skips a missing optional tool with an install hint instead of failing. There is no `xtask` crate. +`bundle.sh` assembles `Eonmark.app` and signs it last (M8). `check_assets.sh` and `check_trademark.sh` are CI gates (M0). `render_smoke.sh` renders a scenario under Xvfb and lavapipe and fails on any ERROR line (M3b, CI's game-linux job). `m4b_outpost_drive.sh` plays ROADMAP M4b acceptance line 1 on the interactive `--scenario enemy_outpost` with real X input (xdotool under Xvfb and lavapipe) and verifies the recorded replay (M4b; run by hand, not in CI). The art pass adds `fetch_assets.sh` (maintainer-only: the sha256-pinned third-party files of `tools/assets/sources.tsv`, licence texts first), `draw_trace_xcheck.sh` (the draw-call counter against Bevy's `detailed_trace` draw lines, in both GPU preprocessing modes; re-run on any Bevy bump), `check_draws.sh` (the draw-call gate: a `frame_stats: draws` line's run and fight maxima under 500, in CI's game-linux job and `art_checks.sh`), `art_checks.sh` (`just art-checks`: the AP1 to AP4 and AP7 proxies and the five `docs/screenshots/art-*.png` under lavapipe) and `art_drive.sh` (`just art-drive`: `--click-check` and `--minimap-check` driven with real X input). The `justfile` wraps the CI commands so `just ci` runs locally what GitHub runs remotely; it skips a missing optional tool with an install hint instead of failing. There is no `xtask` crate. ### `.github/` -`ci.yml` runs five jobs: `check` on macOS (fmt, clippy, nextest, doc, machete, typos, asset and trademark gates, single-Bevy check, the game's headless replay of the `move_500` fixture, `sim-cli verify --release` of that fixture and, from M4a, of `combat_skirmish`, from M4b of `enemy_outpost_walkthrough`, and from M5a of `capture_seat` and `stalemate_45min`), `headless` on Ubuntu (sim, rules, ai and sim-cli tests with `PROPTEST_CASES=256`, from M4b the ignored `enemy_outpost` tests, data-check, selftest, `verify --release` of the `move_500`, `group_spiral`, `snapshot_restore`, `economy_second_town`, `economy_walkthrough`, `combat_skirmish`, `enemy_outpost_walkthrough` and, from M5a, `capture_seat` and `stalemate_45min` fixtures, engine boundary check, `pathfinding`-is-dev-only check, the same headless replay run and, from M4b, the game's `--headless-run` of `enemy_outpost_walkthrough`), `game-linux` on Ubuntu (clippy of the game crate, then `cargo build` and the Xvfb + lavapipe render smoke test `scripts/render_smoke.sh` over the `economy_walkthrough` and, from M4b, the `unit_looks` scenario, which fails on any ERROR log line and uploads the `render-smoke` artifact), `deny` (cargo-deny) and `hash-parity` (diffs the `tick=1200 hash=...` replay line and the `move_500`, `combat_skirmish` and, from M5a, `capture_seat` and `stalemate_45min` `OK final_hash=...` lines produced on each OS; until M2 the first line was the 200-tick skirmish smoke). `release-check.yml` builds release and bundles on pushes to main. `release.yml` publishes on tags. Dependabot ignores Bevy and the two egui crates for minor and major bumps. See [RELEASING.md](RELEASING.md). +`ci.yml` runs five jobs: `check` on macOS (fmt, clippy, nextest, doc, machete, typos, asset and trademark gates, single-Bevy check, the game's headless replay of the `move_500` fixture, `sim-cli verify --release` of that fixture and, from M4a, of `combat_skirmish`, from M4b of `enemy_outpost_walkthrough`, and from M5a of `capture_seat` and `stalemate_45min`), `headless` on Ubuntu (sim, rules, ai and sim-cli tests with `PROPTEST_CASES=256`, from M4b the ignored `enemy_outpost` tests, data-check, selftest, `verify --release` of the `move_500`, `group_spiral`, `snapshot_restore`, `economy_second_town`, `economy_walkthrough`, `combat_skirmish`, `enemy_outpost_walkthrough` and, from M5a, `capture_seat` and `stalemate_45min` fixtures, engine boundary check, `pathfinding`-is-dev-only check, the same headless replay run and, from M4b, the game's `--headless-run` of `enemy_outpost_walkthrough`), `game-linux` on Ubuntu (clippy of the game crate, then `cargo build` and the Xvfb + lavapipe render smoke test `scripts/render_smoke.sh` over the `economy_walkthrough`, from M4b the `unit_looks` and `enemy_outpost` and, from the art pass, the `art_bench` scenario, which fails on any ERROR log line and uploads the `render-smoke` artifact; since the art pass every smoke runs with `--draw-stats` and a step gates each run's draw calls under 500 with `scripts/check_draws.sh`), `deny` (cargo-deny) and `hash-parity` (diffs the `tick=1200 hash=...` replay line and the `move_500`, `combat_skirmish` and, from M5a, `capture_seat` and `stalemate_45min` `OK final_hash=...` lines produced on each OS; until M2 the first line was the 200-tick skirmish smoke). `release-check.yml` builds release and bundles on pushes to main. `release.yml` publishes on tags. Dependabot ignores Bevy and the two egui crates for minor and major bumps. See [RELEASING.md](RELEASING.md). ### `docs/` -This file, [DETERMINISM.md](DETERMINISM.md), [DATA_FORMAT.md](DATA_FORMAT.md), [DEPENDENCIES.md](DEPENDENCIES.md), [ART_STYLE.md](ART_STYLE.md), [BUILD_TIMES.md](BUILD_TIMES.md), [NAME.md](NAME.md), [PLAYTEST.md](PLAYTEST.md), [RELEASING.md](RELEASING.md), [ROADMAP.md](ROADMAP.md), per-subsystem design docs under `design/`, and architecture decision records under [adr/](adr/README.md). Each design doc states Eonmark's own target numbers and an acceptance checklist and arrives with the milestone that implements its system. +This file, [DETERMINISM.md](DETERMINISM.md), [DATA_FORMAT.md](DATA_FORMAT.md), [DEPENDENCIES.md](DEPENDENCIES.md), [ART_STYLE.md](ART_STYLE.md), [BUILD_TIMES.md](BUILD_TIMES.md), [NAME.md](NAME.md), [PLAYTEST.md](PLAYTEST.md), [RELEASING.md](RELEASING.md), [ROADMAP.md](ROADMAP.md), [STREAMING.md](STREAMING.md) (the owner's stream set-up), per-subsystem design docs under `design/`, and architecture decision records under [adr/](adr/README.md). Each design doc states Eonmark's own target numbers and an acceptance checklist and arrives with the milestone that implements its system. ## One frame, end to end @@ -374,7 +378,8 @@ All names are original. The trademark gate `scripts/check_trademark.sh` runs in | M3a / M3b | Economy, Towns, territory, construction | Border overlay then `FieldExt` shader, build ghost, command card, resource bar | Economy, territory and towns design docs | | M4a / M4b | Tech, ages, combat | Research panel, age indicator, health bars, unit silhouettes, attack orders, the `enemy_outpost` scenarios with the walkthrough director and the fight window | Tech and combat design docs; `bench --combat`, the criterion `combat_step` and the `combat_skirmish` golden (M4a); the `enemy_outpost_walkthrough` golden and the `scripts/m4b_outpost_drive.sh` real-input drive (M4b) | | M5a / M5b | Attrition, supply, annexation, victory; first complete bot | Victory overlay, New Game (the main menu, `--spectate`) | Attrition and towns design docs; `bench --combat --harrying`, the `capture_seat` and `stalemate_45min` goldens and `just m5a-checks` (M5a); the AI design doc, `play-bots`, `bench --bots`, the `m5_first_match` golden, `scripts/check_first_wave.sh` and `just m5b-checks` (M5b) | +| Art pass (pre-stream) | `art_bench` stress scenario; `visuals.ron` out of `rules_hash` (ADR 0003) | KayKit models through `visuals.ron` with team banners, terrain props, construction stages, rubble, the Seat flag, the minimap without fog, Inter, hit feedback, the draw-call counter | `tools/inspect_gltf`, `scripts/fetch_assets.sh`, `draw_trace_xcheck.sh`, `check_draws.sh`, `art_checks.sh`, `art_drive.sh`; [STREAMING.md](STREAMING.md) | | M6 | Difficulties, personalities, influence maps | Game speed, pause, game-over stats | Fun gate, match log | -| M7 | Visibility grid | Fog, minimap, menus, glTF, audio | Finalized [ART_STYLE.md](ART_STYLE.md) | +| M7 | Visibility grid | Fog (also on the minimap), menus, Kenney panels, audio | Finalized [ART_STYLE.md](ART_STYLE.md) | | M8 | none | none | `bundle.sh`, release workflow, v0.1.0 | | M9 | Second faction as data | Bevy 0.20 migration (gated) | [adr/0002](adr/0002-bevy-0-20-migration.md) | diff --git a/docs/ART_STYLE.md b/docs/ART_STYLE.md index b037c56..9ae36d9 100644 --- a/docs/ART_STYLE.md +++ b/docs/ART_STYLE.md @@ -76,7 +76,7 @@ Primitive silhouettes (M2 to M7): |---|---|---| | Units | KayKit Adventurers characters, the KayKit catapult and a loaded wheelbarrow since art slice 4 (the table under "Unit looks (art slice 4)"); each kind's M4b look (table below) is its fallback | Team colour on clothes, plate and banners; M4b gave each kind its own primitive silhouette, M2 drew every unit as a smooth `Capsule` | | Buildings | KayKit Hexagon models since art slice 3 (the table under "Building looks"); `Primitive(Cuboid)`, the M3b team-coloured box over the footprint, is every building's fallback | Team colour on roofs and trim, plus a team banner beside each building | -| Town | KayKit castle; a Seat flies the Seat flag on its keep | The Seat flag (1.8 m) flies on top of the keep, wholly above the Town's own banner (a `check` rule), so a Seat reads apart from any other Town by its place, not its size: other banners are larger (the Town's own 2.83 m, the Muster Hall's 2.16 m, the Mangonel's 1.94 m) | +| Town | KayKit castle; a Seat flies the Seat flag on its keep | The Seat flag (1.8 m) flies on top of the keep, wholly above the Town's own banner (a `check` rule the owner accepted on 2026-10-28, owner decision 16 of the art pass, in place of the retired 1.4x height ratio), so a Seat reads apart from any other Town by its place, not its size: other banners are larger (the Town's own 2.83 m, the Muster Hall's 2.16 m) | | Construction site | KayKit construction stages A, B and C, one per third of the build, neutral | Scaled by the footprint side; the health bar shows the progress | | Rubble | KayKit `building_destroyed`, neutral, for 30 s | Where a building or site was destroyed; gone early when a new building takes the footprint | | Terrain props | KayKit trees on forest, mountains and rocks on mountain, lilies and plants on water (`terrain` in `visuals.ron`) | Never on grass; a look only where its footprint stays on its own terrain; nothing over 1.6 m on a tile whose north neighbour is passable, so props never hide units | @@ -104,7 +104,7 @@ Two kinds share a shape only when they differ by at least 1.4 times on some axis The sign-off also looks at a crowd, because the lineup stands every unit alone 4 m from the next and cannot show how the looks pack in a fight: `target/ci/eonmark --background --scenario enemy_outpost --camera 100,32,40 --exit-after-seconds 150 --screenshot-at 250:crowd.png` (`just render-smoke out enemy_outpost 100,32,40 250 150` under Xvfb; with unit models lavapipe reaches tick 250 about 93 s in) frames the 200 vs 200 prologue at 40 m mid-fight. Every look is exactly its kind's collision diameter across except the Outrider's, whose lying pill is 1.8 m long against a 0.9 m diameter. Melee range is measured edge to edge (0.25 m for the Outrider) and the sim keeps units apart only by a soft push, so two Outriders holding on each other stand at most 1.15 m apart, centre to centre, while each pill reaches 0.9 m ahead of its centre: head-on they overlap by at least 0.65 m, and in a packed melee the pills visibly pass through each other. Shortening the pill is a data-only change, `height_tiles_x100` of `"outrider"` in `data/visuals.ron` (120 keeps a lying pill, cuts the head-on overlap to 0.05 m and still differs from the Yeoman's upright pill by 1.7 times along its length, and `data-check`'s 1.4x silhouette check (`rules::Visuals::validate`; at M4b it was `present::tests::every_unit_kind_has_its_own_silhouette`, which since the art pass measures the frozen M4b looks) passes with it, but reads closer to the Mangonel's 1.0 m ball); since `rules_version` 25 `visuals.ron` is not in `rules_hash` ([ADR 0003](adr/0003-visuals-outside-rules-hash.md)), so it needs no `rules_version` bump and no `just rerecord` (at M4b it needed both). The owner decides that trade at the sign-off. -Motion is procedural, not animation clips, in v0.1: since art slice 4 walking units (civilians, infantry, cavalry) bob up to 5 cm once per 0.6 m walked (`present::Gait`, presentation only), while siege engines and the Wain roll; hit feedback follows in slice 5. +Motion is procedural, not animation clips, in v0.1: since art slice 4 walking units (civilians, infantry, cavalry) bob up to 5 cm once per 0.6 m walked (`present::Gait`, presentation only), while siege engines and the Wain roll. Since art slice 5 every hit shows (`crates/game/src/combat_fx.rs`, presentation only, owner decision 11): a ranged attacker (a unit kind whose range is over one tile, or a Town or Watchtower) looses a 0.5 m arrow in the dark neutral `#3A3A3F`, a Siege kind a 0.25 m stone in Ore `#6E6A72`, each flying 0.25 s to the target and pointing along its path, and a melee attacker lunges 0.2 m toward its target and back in 0.15 s. Arrows fly on a low 0.8 m arc rather than the plan's straight streak: launched at chest height they flew inside the figures between archer and target and could not be seen in the 24-40 m shots, while over the heads they read as dark diagonal strokes, unlike the flat horizontal bar tracks of the same grey. Both are unlit, cast no shadow and never glow; they are two shared meshes and two materials (two draw bins) from a pool of 256 entities, and a hit while all are in flight draws nothing. They run on the fixed clock the units are interpolated with, so a slow frame that runs several ticks shows each tick's shots along their paths. ## Territory and fog @@ -121,7 +121,7 @@ The ground's material is the `FieldExt` extended material (`crates/game/src/fiel ## HUD -`bevy_ui` only in shipped builds. Inter (variable weight) for all text. Kenney UI Pack 9-slice panels through `NodeImageMode::Sliced`. Matte panels, thin borders, no drop shadows, no glow. The top resource bar shows current stockpile and per-30-s rate per resource and turns the readout amber while production is being discarded at the Yield Cap. Disabled command-card buttons are greyed with a tooltip, not hidden. Layout-only root nodes are `Pickable::IGNORE` so empty HUD space passes clicks to the world. `bevy_egui` appears only behind the `dev` feature. +`bevy_ui` only in shipped builds. Inter (variable weight) for all text: shipped by the art pass as `assets/fonts/InterVariable.ttf` (Inter 4.1, OFL-1.1, its licence beside it as `OFL-Inter.txt`), installed over Bevy's built-in FiraMono at startup (`crates/game/src/fonts.rs`; a missing, unreadable or unparsable file is a warning and FiraMono stays). Every HUD, overlay and menu text uses Inter's tabular figures (`fonts::hud_font`, OpenType `tnum`), so a stock, a rate or a countdown that changes keeps its width and the readouts beside it stay still; Inter's default figures are proportional ('1' 5.7 px, '4' 9.0 px at 14 px). The HUD's fit tests measure text with Inter's real advance widths, figures in their tabular form, and the minimap's overlap test lays the HUD out with Bevy's own text measure and UI layout. Kenney UI Pack 9-slice panels through `NodeImageMode::Sliced` stay in M7. Matte panels, thin borders, no drop shadows, no glow. The top resource bar shows current stockpile and per-30-s rate per resource and turns the readout amber while production is being discarded at the Yield Cap. Disabled command-card buttons are greyed with a tooltip, not hidden. Layout-only root nodes are `Pickable::IGNORE` so empty HUD space passes clicks to the world. `bevy_egui` appears only behind the `dev` feature. ## P3 and sRGB check @@ -206,12 +206,12 @@ Every unit kind is one line of `data/visuals.ron`: a static model with its team | Shieldbearer | `Knight.glb`: body, helmet, cape, `Rectangle_Shield`, `1H_Sword` | `Blocking` (tower shield in front) | 60 | 1.19 x 1.44 x 0.90 | 5,274 | `adv_knight`: red tabard and cape, steel plate, two-handed grip | 471‰ | | Bowman | `Rogue_Hooded.glb`: body, hooded head, cape, `2H_Crossbow` | `2H_Ranged_Aiming` (crossbow levelled) | 63 | 1.08 x 1.41 x 1.15 | 4,797 | `adv_rogue`: green hood, cloak and sleeves | 589‰ | | Outrider | `Knight.glb`: body, helmet, cape, `2H_Sword` | `2H_Melee_Idle` | 84 | 1.68 x 2.04 x 1.53 | 5,124 | `adv_knight` | 523‰ | -| Mangonel | `building_tower_catapult_blue`: the `catapult_*` nodes (turret and arm; the tower dropped), turned 90° to face +X and lowered 1.95 m from the tower top | static | 140 | 1.45 x 0.79 x 0.99 | 1,326 | `kaykit_hexagon`, plus the team banner at scale 700 (1.94 m) | 25‰ / 204‰ with the banner | +| Mangonel | `building_tower_catapult_blue`: the `catapult_*` nodes (turret and arm; the tower dropped), turned 90° to face +X and lowered 1.95 m from the tower top | static | 140 | 1.45 x 0.79 x 0.99 | 1,326 | `kaykit_hexagon`, plus the team banner centred at scale 450 (1.25 m) | 25‰ / 123‰ with the banner (its own floor 120‰, owner decision 15) | | Supply Wain | `wheelbarrow` with `sack`, `crate_A_big` and `flag_blue` (scale 140) as `parts`, turned 90° (wheel ahead) | static | 220 | 1.57 x 1.34 x 0.52 | 668 | `kaykit_hexagon`: the flag as a pennant | 88‰ | The Bowman is the hooded Rogue since the review of 2026-10-28: baked from the bare `Rogue.glb`, its brown hair (hue about 17°, beside the red team's 6°) covered most of what the 55° camera sees, so at 40 m a blue Bowman showed 9-12% team pixels against 46-87% on the other kinds and read as part of the red army, although `check` measured 282‰ of its whole surface; the hood is in the `adv_rogue` swatch, so it shows 589‰ and the team colour now covers the head. The crossbow still keeps it 1.61 times deeper than the Yeoman. -The heights are the art-pass plan's: the Yeoman 1.30 m, the Scribe, Skirmisher and Bowman 1.40 m, the Shieldbearer 1.45 m and the Outrider 2.05 m, 1.41 times the Shieldbearer (no CC0 horse is reachable, so it is the Knight with a two-handed sword, drawn larger). Each character's swatch rects are cells of its baked 256 px atlas picked with `inspect_gltf palette` (the Adventurers ship in one colour, so `swatches` has no red variant to diff); the Knights' red tabard alone is 55-61‰ of their surface, so their swatch takes the steel plate too, and the Barbarian's blue cloth (70‰) takes the fur hat and cape. The rest of each atlas is desaturated 25%, so team colour stays the strongest hue. The Mangonel's catapult carries 25‰ of team texels, so it carries the team banner (owner decision 12 of the art pass): one shared mesh with every building's banner, a top-level entity that follows the unit (`present::UnitBanner`), measured with the model. A banner of a readable 1.25 m (scale 450) gives 123‰; the shipped 1.94 m one meets the 200‰ floor, and its KayKit pole is 0.39 m thick at that scale. A unit's banner may stand taller than the unit (a standard over a siege engine), and unlike a building's it is not held inside a footprint, since it moves with its unit. At scale 700 it reaches 1.32 m behind the Mangonel's centre and 0.6 m to its right, well past its 0.5 m collision radius, and the sim keeps units apart only by a soft push (about 1.0-1.1 m centre to centre in a packed group), so in a group of Mangonels, or a crowd, the poles and cloths pass through the neighbouring catapults and units (`art_bench`'s ten engines at tick 60 make a fence of crossing planks). A smaller banner clips less but misses the 200‰ floor: scale 450 at offset (-20, 0, 0) still reaches 0.79 m behind and gives 123‰, and staying inside the radius takes about scale 380, centred, which gives 100‰. The owner decides that trade at the sign-off, as for the Outrider's overlap above. The Mangonel's health bar rides 0.3 m over the banner's top (2.24 m), not over the catapult's, so the cloth never covers it. The Wain meets its 80‰ floor with the flag scaled 1.4 times as a pennant on its crate. +The heights are the art-pass plan's: the Yeoman 1.30 m, the Scribe, Skirmisher and Bowman 1.40 m, the Shieldbearer 1.45 m and the Outrider 2.05 m, 1.41 times the Shieldbearer (no CC0 horse is reachable, so it is the Knight with a two-handed sword, drawn larger). Each character's swatch rects are cells of its baked 256 px atlas picked with `inspect_gltf palette` (the Adventurers ship in one colour, so `swatches` has no red variant to diff); the Knights' red tabard alone is 55-61‰ of their surface, so their swatch takes the steel plate too, and the Barbarian's blue cloth (70‰) takes the fur hat and cape. The rest of each atlas is desaturated 25%, so team colour stays the strongest hue. The Mangonel's catapult carries 25‰ of team texels, so it carries the team banner (owner decision 12 of the art pass): one shared mesh with every building's banner, a top-level entity that follows the unit (`present::UnitBanner`), measured with the model. A unit's banner may stand taller than the unit (a standard over a siege engine), and unlike a building's it is not held inside a footprint, since it moves with its unit; `check` prints how far it reaches from the unit's centre in plan view (`banner_reach=`, any facing) and a note when that passes the unit's collision radius. Slices 3 and 4 shipped a 1.94 m banner (scale 700, 204‰ against the 200‰ unit floor) that reached 1.32 m behind the Mangonel's centre and 0.6 m to its right; the sim keeps units apart only by a soft push (about 1.0-1.1 m centre to centre in a packed group), so in a group of Mangonels, or a crowd, its poles and cloths passed through the neighbouring catapults and units (`art_bench`'s ten engines at tick 60 made a fence of crossing planks). The owner chose the smaller banner on 2026-10-28 (owner decision 15 of the art pass): the Mangonel alone flies it at scale 450, a 1.25 m cloth whose pole is about two-thirds as thick (450/700 of every dimension: 0.25 m deep with the cloth, against 0.39 m at scale 700), and its own team floor is lowered to 120‰ to match (it measures 123‰; every other unit keeps 200‰, and the Town's 2.83 m and Ore Pit's 2.33 m banners stay). Centred on the engine (`offset_x100: (0, 0, 0)`, the mount of least reach), it reaches 0.60 m from the centre, 0.10 m past the 0.5 m radius, against 1.32 m before, so two packed engines' cloths touch only when they stand closer than about 1.2 m with their banners turned toward each other. Decision 15 also reads "no crowd clipping", which scale 450 does not fully give: packed engines' cloths can still cross, and the catapult itself (1.45 m long on a 1.0 m diameter) overlaps a packed neighbour at any banner size, while a banner inside the radius (scale about 380) gives 100‰, under the 120‰ floor, so no banner scale meets both. The owner judges it at the AP8 sign-off ([PLAYTEST.md](PLAYTEST.md#owners-mac-checklist-art-pass)). The Mangonel's health bar rides 0.3 m over the banner's top (1.55 m), not over the catapult's, so the cloth never covers it. The Wain meets its 80‰ floor with the flag scaled 1.4 times as a pennant on its crate. The triangle budget is the art-pass plan's unit cap of 5,500 (slice 2 measured 4,005-5,325 with the kept items; no lighter CC0 character is reachable). `art_bench` (400 units, two full Towns, 60 m) draws `triangles main_max=2117469` in the main pass under lavapipe, under twice the plan's 1.2 M estimate for units, so no cape, helmet or hat was dropped; the owner's Mac run (AP5) decides whether any must go. Draw calls do not grow: each kind is one mesh, the characters share one material per atlas and team, the Mangonel and the Wain the Hexagon one, and the banners the building banners' bin (`art_bench` max 105, as in slice 3 with the M4b looks). @@ -272,7 +272,7 @@ The pre-stream art pass ([ROADMAP.md](ROADMAP.md), "Art pass (pre-stream)") vend | Seat flag and banner triangles | 100 | `flag_blue` 48 | | Interior prop triangles | 1,600 | `trees_B_large` 1,540 (the large trees stay) | | Edge prop triangles | 300 | `tree_single_B` 220 | -| Team area | units 200‰, the Wain 80‰, buildings 100‰ (model plus banner), the Seat flag and the banner 300‰ | Hexagon buildings 5‰ (mine) to 280‰ (church); flag 457‰ | +| Team area | units 200‰, the Wain 80‰, the Mangonel 120‰ (owner decision 15; model plus banner), buildings 100‰ (model plus banner), the Seat flag and the banner 300‰ | Hexagon buildings 5‰ (mine) to 280‰ (church); flag 457‰ | The team area is the share of a model's surface whose faces sample its `team_swatches` rects. KayKit Hexagon's team texels are one rect of the shared atlas, `(17, 787, 91, 207)`, found by `inspect_gltf swatches` over the blue and red variants of every vendored building, the catapult tower and the flag; no non-team face samples it, and no neutral model (stages, rubble, nature, cart props) does either. @@ -291,7 +291,7 @@ The team area is the share of a model's surface whose faces sample its `team_swa | mangonel (unit) | `building_tower_catapult_blue`, catapult nodes | about 25‰ | 200‰, under | | Seat flag | `flag_blue` | 457‰ | 300‰, passes | -The rect cannot be widened: it is already every texel that differs between KayKit's blue and red variants, so these buildings simply carry little team surface. The owner chose a team banner on every team building and on the Mangonel (owner decision 12 of the art pass, 2026-10-28; a base disc, lower floors and other models were declined), with the floor measured on model plus banner: the "Building looks" table above has the result, and every building reaches the floor (the Town and the Ore Pit with banners taller than the slice 3 cap, which the 2026-10-28 review dropped as not the owner's). The Mangonel carries the same banner since slice 4 ("Unit looks (art slice 4)" below). +The rect cannot be widened: it is already every texel that differs between KayKit's blue and red variants, so these buildings simply carry little team surface. The owner chose a team banner on every team building and on the Mangonel (owner decision 12 of the art pass, 2026-10-28; a base disc, lower floors and other models were declined), with the floor measured on model plus banner: the "Building looks" table above has the result, and every building reaches the floor (the Town and the Ore Pit with banners taller than the slice 3 cap, which the 2026-10-28 review dropped as not the owner's). The Mangonel carries the same banner since slice 4, at scale 450 against its own 120‰ floor since owner decision 15 ("Unit looks (art slice 4)" below). `inspect_gltf palette [--grid 16]` prints the base texture's cell grid: each used cell's texel rect, mean colour and the nodes whose faces sample it. It is how a character's `team_swatches` rects are picked (art slice 4), since the Adventurers characters ship in one colour and have no red variant for `swatches` to diff: run it on the source file (`--grid 8` gives the 128 x 256 px colour cells of these atlases) and divide the rects by the bake's downsample (4 for the 256 px bakes). @@ -312,7 +312,9 @@ Attach one 1280x720 screenshot at 40 m to every PR that adds or changes art, and | `visuals.ron` changed, Rust did not (for a model swap) | | | Draw-call counter in the dev panel is under 500 with the test scene | | -The last row had no counter to read through M4b: Bevy 0.19.1 keeps its batch counts `pub(crate)` and wgpu has no draw counters, so it was marked n/a in the PR with the expected counts instead (at 200 vs 200 about 75 to 100 batches, [design/ui.md](design/ui.md#health-bars-rings-and-markers)), and an Xcode Metal frame capture on the Mac stood in for it. The art pass's counter (`--draw-stats`, the dev panel) reads it: `art_bench` with every building, site, banner and terrain prop as a model peaks at 111 draws under lavapipe (art slice 3), and still 111 with every unit as a model too (art slice 4, framed at 40 m on P0's Town). +The last row is read from the counter since the art pass, and is no longer marked n/a. Through M4b it had no counter to read: Bevy 0.19.1 keeps its batch counts `pub(crate)` and wgpu has no draw counters, so it was marked n/a in the PR with the expected counts instead (at 200 vs 200 about 75 to 100 batches, [design/ui.md](design/ui.md#health-bars-rings-and-markers)), and an Xcode Metal frame capture on the Mac stood in for it. The art pass's counter (`--draw-stats`, the dev panel's `draw calls` row) reads it: `art_bench` with every building, site, banner and terrain prop as a model peaked at 111 draws under lavapipe (art slice 3), and with every unit a model too it peaks at 109 in its 60 m framing and 115 at 40 m over P0's Town (art slice 6, 1280 x 720); CI's render smoke gates it under 500 (`scripts/check_draws.sh`), and `docs/screenshots/art-dev-panel.png` shows the row. + +The art pass's sign-off (ROADMAP "Art pass (pre-stream)", AP8) uses four shots at 1280 x 720, made under lavapipe by `just art-checks` (`scripts/art_checks.sh`) with `--screenshot-at`: `docs/screenshots/art-units-40m.png` (`unit_looks`, every kind of both teams at 40 m), `art-town-40m.png` (`art_bench` at `--camera 24,64,40`: P0's Seat with its twelve buildings, three sites, the forests and the front of the fight), `art-battle-60m.png` (`art_bench` in its own 60 m framing at tick 300, mid-fight, every P0 unit selected) and `art-match-minimap.png` (the `m5_first_match` setup spectated at tick 13206, a match in progress with its minimap). The agent ticks each row against them in the PR; the owner signs them on the P3 display and as sRGB exports ([PLAYTEST.md](PLAYTEST.md#owners-mac-checklist-art-pass)). ## Milestones @@ -323,4 +325,5 @@ The last row had no counter to read through M4b: Bevy 0.19.1 keeps its batch cou | M3b | Territory tint and border; build ghost in green or red | | M4b | Five distinct unit silhouettes; health bars | | M5a | Attrition indicator, supply ring, annexation timer | -| M7 | Fog through the field shader, minimap, Inter, Kenney panels, CC0 glTF models, audio; palette finalized here | +| Art pass (pre-stream) | CC0 KayKit models through `visuals.ron` for every unit and building, with team banners; construction stages, rubble and the Seat flag; dressed forests, mountains and water; the minimap without fog; Inter; arrows, stones and lunges; the debug grid off; the draw-call counter. Sign-off shots `docs/screenshots/art-*.png` | +| M7 | Fog through the field shader (and on the minimap), Kenney panels, audio; palette finalized here | diff --git a/docs/BUILD_TIMES.md b/docs/BUILD_TIMES.md index d2982b7..e96e6b1 100644 --- a/docs/BUILD_TIMES.md +++ b/docs/BUILD_TIMES.md @@ -85,6 +85,12 @@ This document records measured build, test and CI wall times on the development | Art-pass slice 4, `--scenario unit_looks --click-check` driven with xdotool under Xvfb (a scratch driver; slice 6 writes `scripts/art_drive.sh`) | 2026-10-28 | same VM, llvmpipe, 1280x800; not the dev Mac | targets printed at tick 60, about 4 s in; 16 clicks judged by 38 s; `click_check: ok 16/16`, exit 0, no ERROR line | Every target `moved=0`, on the centroid of its projected model. The replay verifies (`OK final_hash=0x129bbb9072fbded4 ticks=611`). A first drive that aimed at the centre of each model's bounds read `click_check: FAIL 15/16 selected; missed skirmisher team 1` (the point fell between the Barbarian's body and his axes). | | `just art-bake` (six characters from the download cache, then `scripts/check_assets.sh`) | 2026-10-28 | same VM | 2.4 warm (45.7 with the `inspect_gltf` release build, while another build ran) | Baking twice gives byte-identical files, equal to the committed ones (`cmp`, and `git status` clean under `assets/`). | | `scripts/draw_trace_xcheck.sh ` in art-pass slice 4, with every unit a model | 2026-10-28 | same VM, llvmpipe, 1280x800; not the dev Mac | 1219 wall (20 min 19 s): the trace build 951 (15 min 51 s, into a scratch `XCHECK_TARGET_DIR` outside the repository, 4.1 GB, deleted afterwards with the 723 MB binary the script copies beside its logs; `df -h /` showed 8.9 GB free before, 4.2 GB at the peak and 8.9 GB after), the four runs about 5 min | All four PASS. `unit_looks`: `mode=culling` 97 frames, 96 equal, counter 6499 against trace 6432; `mode=preprocessing_only` (`indirect_max=0 direct_max=65`) 87 frames, 86 equal, 5829 against 5762. `art_bench`: `mode=culling` 53 frames, 52 equal, 5405 against 5302; `mode=preprocessing_only` (`indirect_max=0 direct_max=93`) 43 frames, 42 equal, 4391 against 4288. As before, every frame matches but the last, which the exit cuts short (67 and 103 draws). Fewer frames than in slice 3 in the same 40 and 90 s, since lavapipe now draws the character models; the four replays verify. | +| `cargo test -p inspect_gltf --profile ci` (art-pass slice 6: 26 unit tests and the 2 `tests/repo.rs` tests that check the committed `assets/` and `data/visuals.ron`) | 2026-10-28 | same 4-vCPU Intel Xeon 2.1 GHz Linux cloud VM | 1.8 to 2.1 warm (three runs: 2.10, 1.82, 1.91) | Shell clock around the whole command; `repo_models_pass_check` and `attribution_rows_match_the_sources` take 1.33 s of it (they parse every committed model). | +| `scripts/fetch_assets.sh` into a fresh cache, then `just art-bake` twice (art-pass slice 6: the AP3 bake-twice check from the network) | 2026-10-28 | same 4-vCPU Intel Xeon 2.1 GHz Linux cloud VM | fetch 24.8 wall (licence texts first, 100 files downloaded from `raw.githubusercontent.com`, every sha256 pin OK, 0 installed into `assets/`); each bake 1.3 to 1.4 wall | The two bakes are byte-identical (`diff -r` exit 0) and equal to the committed files (`git status --porcelain assets/ tools/` empty; `check_assets: bake pins ok (6 baked files match tools/assets/bake.ron)`). The 44 MB cache was deleted afterwards. | +| Art-pass slice 6, `just art-checks ` (`scripts/art_checks.sh`: data-check, the AP2 walkthrough on a scratch copy with all 11 fixtures verified, `inspect_gltf check`, `check_assets.sh` and its self-test, then five windowed runs at 1280x720 in background mode: `unit_looks` 60 s, `art_bench` at 40 m and in its framing 120 s each, the spectated `m5_first_match` setup from tick 13006 90 s, a dev build's `art_bench` 120 s) | 2026-10-28 | same VM, llvmpipe under Xvfb, `window logical=1280x720 physical=1280x720 scale=1.0`; not the dev Mac | 538 wall (8 min 58 s); every step PASS | Draw-call maxima: `unit_looks` 70; `art_bench` 115 at `--camera 24,64,40` (`fight_max=115`) and 109 in its 60 m framing (`fight_max=109`, `indirect_max=97 sorted_max=12 mode=culling`); the spectated match 91; the dev build 110 (its panel at tick 250: `107 (max 108) / 500 ind 94 dir 0 sorted 13`). `triangles main_max=2090217` in the framed `art_bench`. Its fight line: `fight frames=72 seconds=80.32 mean=1115.51 p95=1557.62 max=1641.17 fps=0.9 ticks=47..388 on_screen_max=348 on_screen_min=348/348`; tick 300 came 76 s after start-up (74 s at 40 m). Minimap paint p95 332.6 to 361.9 us over 112 to 196 repaints. Every run's replay verifies. An earlier run the same day, with the dev panel at tick 100, read `draw calls 0 (max 0)`: the counter skips the first 30 frames, which on lavapipe last past tick 100, so the script takes tick 250. | +| Art-pass slice 6, `just render-smoke art_bench '' 200 120` (the CI game-linux run added in slice 6, at the smoke's 1280x800) | 2026-10-28 | same VM, llvmpipe, 1280x800; not the dev Mac | tick 200 came 63.8 s after start-up; 103 frames in 120.08 s (0.9 fps), `ticks=493 dropped_ticks=0` | `draws frames=73 mean=105.8 p95=109 max=109 indirect_max=97 direct_max=0 sorted_max=12 mode=culling fight_max=109`; `fight frames=72 seconds=94.70 mean=1315.30 p95=1802.46 fps=0.8 ticks=47..388`; `scripts/check_draws.sh --fight` ok. Tick 300 came 78 to 87 s in on the same VM (slices 4 to 6), which left a 120 s deadline too little margin on a runner of unknown speed, so CI takes tick 200; the run still covers the whole fight. The replay verifies (`OK final_hash=0x60eabb2255d8224a ticks=493`). | +| Art-pass slice 6, `just art-drive ` (`scripts/art_drive.sh`: xdotool under Xvfb, `unit_looks --click-check` then `economy_walkthrough --minimap-check`) | 2026-10-28 | same VM, llvmpipe, 1280x800; not the dev Mac | 51 and 54 wall (two runs) | `click_check: ok 16/16` (16 clicks, each judged before the next) and `minimap_check: ok` (press, drag, right click, A then left click), both games exit 0 with no ERROR line, both replays verify. | +| `scripts/draw_trace_xcheck.sh ` in art-pass slice 6, at the art pass's close | 2026-10-28 | same VM, llvmpipe, 1280x800; not the dev Mac | 1275 wall (21 min 15 s): the trace build 1006 (16 min 46 s, into a scratch `XCHECK_TARGET_DIR` outside the repository, 4.1 GB, deleted afterwards with the 692 MB binary the script copies beside its logs; `df -h /` showed 8.7 GB free before, 4.0 GB at the lowest and 8.6 GB after), the four runs about 4.5 min | All four PASS. `unit_looks`: `mode=culling` 90 frames, 89 equal, counter 6210 against trace 6141; `mode=preprocessing_only` (`indirect_max=0 direct_max=65`) 98 frames, 97 equal, 6762 against 6693. `art_bench`: `mode=culling` 47 frames, 46 equal, 4957 against 4851 (`fight_max=107`); `mode=preprocessing_only` (`indirect_max=0 direct_max=95`) 45 frames, 44 equal, 4759 against 4652. As in slices 1, 3 and 4, every frame matches but the last, which the exit cuts short (69 and 106 to 107 draws). The minimap and the hit feedback of slice 5 are in these counts; the four replays verify. | ## CI job wall times @@ -98,6 +104,7 @@ This document records measured build, test and CI wall times on the development | `deny` | ubuntu-24.04 | 2026-10-06 | 54 | 46 | cargo-deny-action, advisory database fetch dominates. | | `hash-parity` | ubuntu-24.04 | 2026-10-06 | 5 | 6 | Artifact download plus `diff`. Needs `check` and `headless`. | | `headless`, the steps M5b's waves slice adds | ubuntu-24.04 | 2026-10-23 | not yet measured | about 25 s of work measured locally | Not yet run on GitHub Actions (nothing pushed). On the 4-vCPU VM: the `m5_first_match` verify 1.5 s, acceptance 1's play-bots twice 5 s, acceptance 2 from either side 3.5 s, the ignored `m5b_` tests 11.3 s (built by the job's earlier `cargo test`), the game's `--headless-run` of `m5_first_match` 1.9 s (the game is built for `move_500`). The `check` job adds the 1.5 s verify; `hash-parity` one more `diff`. Estimated 1 to 2 minutes on the runners. | +| `game-linux`, the `art_bench` render smoke and the draw-call gate art-pass slice 6 adds | ubuntu-24.04 | 2026-10-28 | not yet measured | about 125 s of work measured locally | Not yet run on GitHub Actions (nothing pushed). The run lasts its 120 s deadline (`art_bench '' 200 120`) plus start-up; the gate step reads four logs in well under a second. Measured on the 4-vCPU VM: tick 200 at 63.8 s. Read the real figure off the art pass PR's Actions run. | ## Audio backend at M0 @@ -124,6 +131,7 @@ Append a row whenever a bench acceptance passes. Budgets come from the design. | AI think time per tick (`sim-cli bench --units 400 --ticks 1200 --bots`, release: `scenarios::bots_bench`, two Standard seats at Masonry with 2000 of each resource, 200 units a side, half at the own rally with 2 Mangonels and a Supply Wain, half on the enemy's land 12 tiles north of its Seat; `ai_ms` is the time inside the bots' `think` per tick) | M5b | 2026-10-23 | 0.032 to 0.036 | 0.21 to 0.22 | < 0.5 | Measured on a 4-vCPU Intel Xeon 2.1 GHz Linux cloud VM, not the dev Mac; `SIM_VERSION` 17, `rules_version` 23. Three runs at seed 1 at the slice's head: `ai_ms` mean 0.0360 / 0.0342 / 0.0317 ms, p95 0.218 / 0.224 / 0.208 ms, max 1.722 / 1.255 / 1.243 ms; `plans=240` (every 10 ticks, two seats), `plan_ms_max` 1.24 to 1.72 ms (one plan), `commands=268`; whole step mean 0.696 / 0.649 / 0.620 ms (p95 2.6 to 2.8 ms) against 0.771 / 0.723 / 0.692 ms for the same stream with Passive seats (`control_step_ms`); both seats defend first, then each launches a wave (`waves_launched=2 defends=2`); `rejected=5` (orders for units that died during the command delay); `path_queue_max=307`; `final_hash=0x1408f5fd31a1ba6f` every run. Exit 0 (the bench exits 1 only when the bots were not exercised: fewer than 200 plans, 2 waves or 1 defence). Re-measure on the dev Mac. | | AI think time per tick after the M5b waves slice's review fixes (same command and world) | M5b | 2026-10-23 | 0.033 to 0.036 | 0.20 to 0.21 | < 0.5 | Same 4-vCPU Intel Xeon 2.1 GHz Linux cloud VM, not the dev Mac; `SIM_VERSION` 17, `rules_version` 24. Three runs at seed 1: `ai_ms` mean 0.0338 / 0.0361 / 0.0332 ms, p95 0.213 / 0.203 / 0.206 ms, max 2.757 / 2.298 / 1.376 ms; `plans=240`, `plan_ms_max` 1.38 to 2.76 ms, `commands=268`; whole step mean 0.629 / 0.666 / 0.634 ms against 0.677 / 0.714 / 0.731 ms for `control_step_ms`; `waves_launched=2 defends=2 engaged=0 rejected=5 path_queue_max=307 final_hash=0x1408f5fd31a1ba6f`, the slice head's hash, so no decision in this 1200-tick world changed (a defence's cap is only reached after 1200 ticks of it, and the build backoff is for border buildings only). Exit 0 every run. Re-measure on the dev Mac. | | AI think time per tick at the M5b close (same command and world) | M5b | 2026-10-26 | 0.033 to 0.035 | 0.21 to 0.28 | < 0.5 | Same 4-vCPU Intel Xeon 2.1 GHz Linux cloud VM, not the dev Mac; `SIM_VERSION` 17, `rules_version` 24. Three runs at seed 1: `ai_ms` mean 0.0332 / 0.0351 / 0.0346 ms, p95 0.206 / 0.280 / 0.217 ms, max 1.607 / 1.280 / 1.370 ms; `plans=240`, `plan_ms_max` 1.60 / 1.28 / 1.37 ms, `commands=268`; whole step mean 0.670 / 0.689 / 0.689 ms against 0.745 / 0.747 / 0.734 ms for `control_step_ms`; `waves_launched=2 defends=2 engaged=0 rejected=5 path_queue_max=307 final_hash=0x1408f5fd31a1ba6f`. Exit 0 every run. ROADMAP M5b line 3 is ticked on these runs (PR #47). Re-measure on the dev Mac. | +| Frame time, `art_bench` (400 units fighting + two full Towns, no fog), `--window-size 1280x720` on the Retina display (2560x1440 physical), release, window focused (art pass AP5) | Art pass | TBD (the owner's Mac run) | | | >= 60 fps in the `fight` line, p95 <= 16.7 | The owner pastes the `window`, `fight`, `draws` (with `mode=`), `triangles` and `minimap` lines and the `--max-fps 1000` headroom run (docs/PLAYTEST.md, "Owner's Mac checklist (art pass)"). Lavapipe proxy, which can neither pass nor fail it: draws max 109, `triangles main_max=2090277`, 0.9 fps. | | Frame time, 400 units + two Towns + fog, 1440p release | M7 | TBD (M7) | | | >= 60 fps on a base M-series chip | Draw calls < 500 from the dev-panel counter | ## How to measure diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md index 05b322d..0d852ed 100644 --- a/docs/DEPENDENCIES.md +++ b/docs/DEPENDENCIES.md @@ -36,6 +36,7 @@ This document lists every third-party crate Eonmark uses, the version it is pinn | `pathfinding` | 4.16.0 | Test oracle for the in-house A*: reachability and cost equality | `sim` (dev only) | Its `astar` runs to completion and has no budget or resume API, so it is never used at runtime. `cargo tree -p sim -e normal` must not list it. | | `proptest` | 1.11.0 | Random-command determinism, cache on/off equivalence, territory incremental-equals-full | `sim` (dev only) | Locally 8 cases in `m1.rs` (32 until the M4a combat behaviour slice, then 16 until the M4b combat UI slice, each lowered to keep `cargo test -p sim` under 10 s), 16 in `m3a.rs` (32 until the M4b combat UI slice), 8 in `m4a.rs`; `PROPTEST_CASES=256` in CI. | | `criterion` | 0.8.2 | `step_500_units`, `territory_recompute`, `astar_budget` benches | `sim` (dev only), from M1 | Declared in `[workspace.dependencies]`; enters `Cargo.lock` when `crates/sim/benches` lands. `sim-cli bench` is the CI-facing wrapper. | +| `skrifa` | `=0.42.1` | The HUD fit tests measure text with the UI font's real advance widths (`game::fonts::measure`: Inter 4.1's `hmtx` advances at its default instance, weight 400), replacing M4b's 0.6 em monospace assumption for FiraMono (art pass slice 5) | `game` (dev only) | Declared in `[workspace.dependencies]` with `default-features = false` and `features = ["std"]`, pinned `=` to the copy parley 0.9.0 (Bevy's text layout) already locks, so `Cargo.lock` gains no package: its only change is the `"skrifa 0.42.1"` line in the `game` package's dependency list (Cargo records dev-dependency edges there). Not `ttf-parser`: that crate is unmaintained (RUSTSEC-2026-0192), and the `deny.toml` ignore says it is reached only through `sctk-adwaita` on Linux, which a new edge would make false. | ## Toolchain and dev tools diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 560c8bd..74c05c6 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -77,44 +77,72 @@ build order whose `Research` host is never built) and #50 (`sim-cli data-check --verbose` printing each build order's step count and the step of its first `Advance`). -Next is the pre-stream art pass, before the first gameplay stream and +The pre-stream art pass came next, before the first gameplay stream and before M6 (the owner's decision of 2026-10-21 under "Order of work" in the -ROADMAP): CC0 models through `data/visuals.ron`, dressed terrain, a -minimap and a dev-panel draw-call counter. The owner took every default of -its implementation plan on 2026-10-26. It starts with ADR 0003, its own -small PR on top of the M5b merge (`e84af88`): -`docs/adr/0003-visuals-outside-rules-hash.md` (Accepted) takes -`data/visuals.ron` out of `rules_hash` (`Rules::visuals` is -`#[serde(skip)]`; the sim never reads it; UI strings are the precedent, and -`data-check` still validates every entry), with one `rules_version` bump -(24 to 25) and a header-only re-record of the eleven fixtures, no `.hash` -change. From 25 a look edit needs no bump. The timing trap: a replay -recorded on a build before the bump (the owner's M5b match, if it is played -on `e84af88`) prints `RULES CHANGED` on later builds and is verified with -the build that recorded it. No command prints a header's -`rules_version`; the `replay 0x…` hash in the `RULES CHANGED` line names -the build (`0xb231ab753cb4197e` is `rules_version` 24, `e84af88`; `sim-cli -data-check data` prints each commit's hash; `docs/PLAYTEST.md`, "Owner's -Mac checklist (M5b)"). The art slices follow -in one PR rebased on it, with no further bump: the packs (KayKit Medieval Hexagon -and KayKit Adventurers, and the Inter font) vendored with their licence -proof, buildings, sites and terrain, the eight unit kinds, the minimap, -the draw-call counter and the close. Its first slice records the pass, its -acceptance list and the owner's answers as a ROADMAP section "Art pass -(pre-stream)"; until then the plan is not in the repository. M6 follows -the art pass. +ROADMAP; its section "Art pass (pre-stream)" holds the acceptance list AP1 +to AP10 and the owner's answers). The owner took every default of its +implementation plan on 2026-10-26 and answered the questions the slices +raised on 2026-10-28 (owner decisions 12 to 16: team banners, the +Mangonel's smaller banner, the Seat flag's placement rule). It landed in +four PRs with no sim change after the first: + +- ADR 0003 (PR #51, `15f1616`): `data/visuals.ron` leaves `rules_hash` + (`Rules::visuals` is `#[serde(skip)]`; the sim never reads it; UI strings + are the precedent, and `data-check` still validates every entry), with + one `rules_version` bump (24 to 25) and a header-only re-record of the + eleven fixtures, no `.hash` change. From 25 a look edit needs no bump. + The timing trap: a replay recorded on a build before the bump (the + owner's M5b match, if it is played on `e84af88`) prints `RULES CHANGED` + on later builds and is verified with the build that recorded it. No + command prints a header's `rules_version`; the `replay 0x…` hash in the + `RULES CHANGED` line names the build (`0xb231ab753cb4197e` is + `rules_version` 24, `e84af88`; `sim-cli data-check data` prints each + commit's hash; `docs/PLAYTEST.md`, "Owner's Mac checklist (M5b)"). +- Slices 1 and 2 (PR #52): the `visuals.ron` schema for buildings, sites, + rubble, the Seat flag, terrain and team swatches; `data-check --assets`; + the `art_bench` stress scene; the asset root, `--window-size`, the grid + off by default; the draw-call counter (`--draw-stats`, the dev panel's + `draw calls` row, `scripts/draw_trace_xcheck.sh`); rings and attrition + diamonds as shared meshes; `tools/inspect_gltf`; the KayKit and Inter + files vendored with sha256 pins, licence texts and per-file rows. +- Slices 3 and 4 (PR #53, `d0dabbf`): every building, site, rubble, the + Seat flag, terrain props and all eight unit kinds drawn as KayKit models + with data-driven team banners, the load gate, the walk bob and + `--click-check`. +- Slices 5 and 6 (the art-pass close PR, branch `art-s56`): the minimap + (bottom-left, no fog), Inter, hit feedback, the Mangonel's 1.25 m + banner; `scripts/art_checks.sh` (`just art-checks`: the AP1 to AP4 and + AP7 proxies and the five `docs/screenshots/art-*.png`), + `scripts/art_drive.sh` (`just art-drive`: `click_check: ok 16/16` and + `minimap_check: ok` with real X input), CI's `art_bench` render smoke + with its draw-call gate (`scripts/check_draws.sh`), the owner's Mac + checklist and [STREAMING.md](STREAMING.md). The AP ticks and M7's + `check_assets` and `data-check` ticks are made in that PR with its + observed output. + +**The art pass is ready for the owner's Mac sign-off and then the first +stream.** What only the owner can close: AP5 (60 fps in `art_bench` at +1280x720 on the Retina display, with the `--max-fps 1000` headroom run), +AP8 (the four `docs/screenshots/art-*.png` against the `ART_STYLE.md` +checklist, on the P3 display and as sRGB exports), and AP6's "by hand" (the +minimap clicked, dragged and right-clicked in a real match); the commands +are in `docs/PLAYTEST.md`, "Owner's Mac checklist (art pass)". The stream +itself follows [STREAMING.md](STREAMING.md): the two commands, the window +and focus settings that bite, OBS, a five-minute pre-flight and what to +keep afterwards. M6 follows the art pass. Open owner checks on the dev Mac, batched into one session under the owner's decision 3 in the ROADMAP's M4b Notes, which M5b's decision 9 extends to M5b (an exception to rule 1 in `docs/IMPLEMENTER_NOTES.md`; accepted risk: Mac-only problems surface late). The exact commands are in -`docs/PLAYTEST.md`, "Owner's Mac checklist (M5b)" and "Owner's Mac -checklist (M4b)": +`docs/PLAYTEST.md`, "Owner's Mac checklist (art pass)", "Owner's Mac +checklist (M5b)" and "Owner's Mac checklist (M4b)": - M3b: `cargo run --release -p game -- --scenario economy_walkthrough` at - >= 60 fps with the draw-call count (an Xcode Metal frame capture, since - no dev-panel counter exists yet), the border screenshot review against - `docs/ART_STYLE.md` ([owner]), and the M2 and M3b controls checklists. + >= 60 fps with the draw-call count (since the art pass the dev panel's + `draw calls` row, and `--draw-stats` in a release build), the border + screenshot review against `docs/ART_STYLE.md` ([owner]), and the M2 and + M3b controls checklists. - M4a: re-measure `sim-cli bench --units 400 --ticks 1200 --combat` and `cargo test -p sim` (under 10 s) on the Mac; both were measured on a Linux VM. @@ -137,6 +165,11 @@ checklist (M4b)": `just m5b-checks` on Metal (the menu, the New Game click and the overlay have only been drawn by lavapipe); and a note on whether the bot felt winnable and losable (owner decision 4). +- Art pass: AP5's fps run with its headroom run, AP8's sign-off of the + four screenshots with the P3 and sRGB check, the minimap by hand, the + dev panel's counter with 100+ units selected, and M3b line 2 and M4b + line 2 re-measured with the models (`docs/PLAYTEST.md`, "Owner's Mac + checklist (art pass)"). A milestone is done when every acceptance checkbox in `docs/ROADMAP.md` is ticked with pasted command output in a merged PR. @@ -149,8 +182,9 @@ ticked with pasted command output in a merged PR. | Per-system design | `docs/design/*.md` | Complete, with target numbers | | Dependency pins and policy | `docs/DEPENDENCIES.md`, `docs/adr/` | Complete | | Simulation crates | `crates/sim`, `crates/rules`, `crates/ai`, `crates/sim-cli` | M3a merged (PR #26: economy, Towns, territory, construction, gathering, training); M4a merged (PR #35: tech, ages, combat, the counter triangle, `bench --combat`, the `combat_skirmish` golden); M4b merged (PR #38: the `enemy_outpost` scenarios, their golden and `SimView::building_hp`); M5a merged (PR #43: attrition, supply, annexation, the outcome, `bench --combat --harrying`, the `capture_seat` and `stalemate_45min` goldens); M5b merged (PR #47, `SIM_VERSION` 17, `rules_version` 24: the bot seats and hashed bot memory, `data/ai/`, the Standard bot in `crates/ai`, `play-bots`, `bench --bots`, the `m5_first_match` golden) | -| Presentation crate | `crates/game` | M2 merged: camera, selection and orders, replay recorder, headless run, macOS menu; M3b merged (PR #31): buildings, terrain, the FieldExt territory shader, resource bar and messages, command card, placement ghost, context orders; M4b merged (PR #38; line 2 waits for the Mac): unit looks, health bars, attack orders, research panel, age strip, notices, the walkthrough director and the fight window; M5a merged (PR #43): the freeze at the outcome, the outcome overlay with the final hash, the attrition indicator, the supply ring, the annexation timer and readout; M5b's game half merged (PR #47; line 4 waits for the owner's match): the main menu and New Game against the Standard bot, `--skip-menu`, `--spectate` and `--fast-forward-to`, the lazy recorder | -| CI and release automation | `.github/workflows/`, `scripts/`, `justfile` | In use since M0: every PR runs check, headless, game-linux, deny and hash-parity | +| Presentation crate | `crates/game` | M2 merged: camera, selection and orders, replay recorder, headless run, macOS menu; M3b merged (PR #31): buildings, terrain, the FieldExt territory shader, resource bar and messages, command card, placement ghost, context orders; M4b merged (PR #38; line 2 waits for the Mac): unit looks, health bars, attack orders, research panel, age strip, notices, the walkthrough director and the fight window; M5a merged (PR #43): the freeze at the outcome, the outcome overlay with the final hash, the attrition indicator, the supply ring, the annexation timer and readout; M5b's game half merged (PR #47; line 4 waits for the owner's match): the main menu and New Game against the Standard bot, `--skip-menu`, `--spectate` and `--fast-forward-to`, the lazy recorder; the art pass (PRs #52 and #53, and its close PR; AP5, AP8 and AP6's hand check wait for the owner's Mac): KayKit models through `data/visuals.ron` with team banners, terrain props, the minimap, Inter, hit feedback and the draw-call counter | +| Art and assets | `assets/`, `data/visuals.ron`, `tools/inspect_gltf`, `tools/assets/` | Art pass: KayKit Medieval Hexagon and KayKit Adventurers (CC0) and Inter 4.1 (OFL) vendored with sha256 pins and per-file `ATTRIBUTION.md` rows; `inspect_gltf check` runs in CI | +| CI and release automation | `.github/workflows/`, `scripts/`, `justfile` | In use since M0: every PR runs check, headless, game-linux, deny and hash-parity; since the art pass game-linux also renders `art_bench` and gates draw calls under 500 | ## Read these first, in this order diff --git a/docs/PLAYTEST.md b/docs/PLAYTEST.md index 896920c..42eb03b 100644 --- a/docs/PLAYTEST.md +++ b/docs/PLAYTEST.md @@ -125,6 +125,71 @@ ROADMAP M5b line 4 is an [owner] line ("From the window: New Game starts a match | `just m5b-checks` (local only, like `just m5a-checks`) | `just m5b-checks out` (`scripts/m5b_checks.sh target/ci/eonmark target/release/sim-cli out`; on Linux without a display it wraps every game run, and `scripts/m2_checks.sh`, in `xvfb-run` on lavapipe itself; `M5B_OVERLAY_SECONDS=90` and `M5B_CLICK_SECONDS=45` by default) | 1. the `m5_first_match` golden's `sim-cli verify`; 2. `--spectate --seed 1 --fast-forward-to --screenshot-at :m5b-overlay.png` (the Standard bot against itself, m5_first_match's setup), its `fast_forward:` and `outcome:` lines against the golden's tick and hash, and `sim-cli verify` of its own replay; 3. a plain launch for 6 s with `--screenshot m5b-menu.png`: it stays on the menu and writes no replay; 4. a plain launch with `--seed 7 --new-game-after-seconds 3`: the synthetic mouse clicks the real New Game button, and the replay verifies with the Standard bot rebuilt from its header; 5. `scripts/m2_checks.sh` checks 3 and 4 with `--skip-menu` on the same binaries; 6. no `eonmark` or `Xvfb` process left | the script prints `m5b_checks: ok`: every step `PASS`, no ERROR line, `fast_forward: tick=`, `outcome: victory player=0 decisive=true reason=seat_captured tick= hash=` and `OK final_hash= ticks=` for the game's replay (`` and the hash are read from the golden), no `match:`/`replay:` line and an empty replay directory for the menu run, `new-game: clicked at`, `match: new game vs standard personality=standard seed=7 map=plains_1v1` and `OK` with more than 600 ticks for the New Game run | 2026-10-26, Linux VM, lavapipe, slice 4 head (3 min 21 s for the whole script): every step `PASS`; `fast_forward: tick=25612 wall_s=1.49`; `outcome: victory player=0 decisive=true reason=seat_captured tick=26012 hash=0x3d2552033d51b4a2` and `OK final_hash=0x3d2552033d51b4a2 ticks=26012` for the game's replay; the menu run wrote nothing; `new-game: clicked at (566, 455) after 4.00 s`, `match: new game vs standard personality=standard seed=7 map=plains_1v1`, `OK final_hash=0x461acceffe99d3bc ticks=822` for the New Game replay; m2_checks `PASS check 3` and `PASS check 4`. The pictures are [screenshots/m5b-overlay.png](screenshots/m5b-overlay.png) and [screenshots/m5b-menu.png](screenshots/m5b-menu.png). Re-run at the M5b close (slice 5, at `0aea6b7`, the code the close ships; 164 s): every step `PASS`, ending `m5b_checks: ok, m5_first_match decided at tick 26012 with hash 0x3d2552033d51b4a2; menu and New Game proxies pass`; `fast_forward: tick=25612 wall_s=1.58`; `outcome: victory player=0 decisive=true reason=seat_captured tick=26012 hash=0x3d2552033d51b4a2` and `OK final_hash=0x3d2552033d51b4a2 ticks=26012` for the game's replay; the menu run printed no `match:` or `replay:` line and wrote nothing; `new-game: clicked at (566, 455) after 3.95 s`, `match: new game vs standard personality=standard seed=7 map=plains_1v1`, `OK final_hash=0x9e3858e14223ca5c ticks=810`; `PASS check 3: clean exit: OK final_hash=0xf91a4944a42c051c ticks=101` and `PASS check 4: kill -9: truncated replay verifies: OK final_hash=0x771cc5d91db392cd ticks=120`; `pgrep -x eonmark` and `pgrep -x Xvfb` empty. Its overlay picture shows "Victory", "The Seat was captured", "21:40, tick 26012" and "Final hash 0x3d2552033d51b4a2", as the committed one does | | Game tests (`cargo test -p game`, in CI) | `cargo test -p game --profile ci` | `menu::tests::{new_game_click_enters_skirmish_and_the_sim_starts_stepping, the_sim_does_not_step_and_no_replay_is_written_while_the_menu_is_open, quit_writes_app_exit, match_lines_name_the_bots_the_seed_and_the_map}`; `app::tests::{a_plain_launch_builds_a_match_against_the_standard_bot_on_plains_1v1, scenarios_skip_menu_and_spectate_start_in_skirmish}`; `sim_driver::tests::{a_match_vs_the_standard_bot_records_a_replay_that_verifies_to_the_outcome_hash, a_replay_recorded_after_a_fast_forward_verifies_from_tick_0, fast_forward_drains_events_and_refuses_a_target_past_the_outcome, local_commands_for_an_ai_slot_are_dropped, sim_plugin_drops_local_commands_for_an_ai_slot}`; `hud::tests::a_spectator_has_no_world_input_card_or_advance`; `outcome::tests::defeat_by_the_bot_texts`; `cli::tests::m5b_flags_parse_and_fast_forward_needs_spectate` | all pass | 2026-10-26: `174 passed` (lib), `1 passed; 1 ignored` (headless_replay). Re-run after the slice 4 and 5 review fixes (the Stop button gated for a spectator, the plain launch's fresh seed asserted, the drop tested through `SimPlugin`): `175 passed` (lib), `1 passed; 1 ignored` (headless_replay) | +## Automated proxies (art pass) + +The art pass's acceptance list is in the ROADMAP's "Art pass (pre-stream)" section (AP1 to AP10). Its local proxies are two scripts, run on 2026-10-28 on the 4-vCPU Linux VM under Xvfb with Mesa's lavapipe (the `ci` profile binary in background mode, `target/release/sim-cli` for `verify`), not on the dev Mac; frame rates there measure software rendering and can neither pass nor fail AP5. CI's game-linux job renders `art_bench` too and gates every render smoke's draw calls under 500 (`scripts/check_draws.sh`). + +| Proxy | Command | Covers | Pass when | Observed | +|---|---|---|---|---| +| `just art-checks` (local only) | `just art-checks out` (builds a dev binary, kept as `target/ci/eonmark-dev`, then the `ci` game, `sim-cli` and `inspect_gltf`; `scripts/art_checks.sh`, which wraps each game run in `xvfb-run` on lavapipe when there is no display; `ART_BENCH_SECONDS=120`, `ART_LOOKS_SECONDS=60`, `ART_MATCH_SECONDS=90`, `ART_DEV_SECONDS=120`) | 1. data-check; 2. AP2's Yeoman swap on a scratch copy of `data/`; 3. AP3's `inspect_gltf check`; 4. AP1's `check_assets.sh` and its self-test; 5 to 9. five 1280x720 runs that save the four sign-off shots and the dev panel's, each run's draws under 500 (AP4 on `art_bench`), the minimap's paint p95 at most 500 us (AP6), every replay verified; 10. no process left | every step prints PASS and the script ends `art_checks: ok` | 2026-10-28, 8 min 58 s, every step PASS. `OK rules_version=25 rules_hash=0xfb0d0b8986fc8865 ... visuals=units:8 buildings:8 terrain:3 models=36`; the swapped copy `... rules_hash=0xfb0d0b8986fc8865 ... models=35`, one changed line, all 11 fixtures `OK` against it; `check: ok 37 scene(s)`; `check_assets: ok (80 files, 4230353 bytes)` and the self-test's `error: models/unattributed.glb has no row in assets/ATTRIBUTION.md ...` then `self-test ok`. Draws: `unit_looks` max 70; `art_bench` at 40 m max 115 (`fight_max=115`); in its framing `draws frames=109 mean=104.2 p95=109 max=109 indirect_max=97 direct_max=0 sorted_max=12 mode=culling fight_max=109` with `triangles main_max=2090217` and `fight ... fps=0.9 ... on_screen_min=348/348`; the spectated match max 91 (`fast_forward: tick=13006 wall_s=0.52`); the dev build max 110 with `hierarchy_check: ok frames=114 bar_targets_max=213 rings_max=200`. Minimap paint p95 332.6 to 361.9 us. Re-run after the review of slices 5 and 6 (2026-10-28, code at `b5f7154`): the first run failed one check, step 7's `minimap paint p95=575.0 us, want <= 500` over 137 repaints (`max=1610.4`; the paint code had not changed, and the same step read 303.7 us on the next run, so it is the VM's timing noise, not a regression); the re-run, 8 min 54 s, passed every step: draws `unit_looks` max 70, `art_bench` at 40 m max 115, in its framing `check_draws: ok max=109 fight_max=109 < 500 mode=culling fight ticks=47..388 frames=72`, the spectated match max 94, the dev build max 110 with `hierarchy_check: ok frames=131 bar_targets_max=213 rings_max=200`; minimap paint p95 287.9 to 327.7 us; step 10 `PASS no game this script started is running` and `PASS no Xvfb this script started is running`. | +| `just art-drive` (local only, Linux) | `just art-drive out` (`scripts/art_drive.sh target/ci/eonmark target/release/sim-cli out`: its own Xvfb per run and xdotool) | AP10: every unit of `--scenario unit_looks --click-check` clicked with real X input; AP6: `--scenario economy_walkthrough --minimap-check`'s press, drag, right click, and A then left click on the minimap | `click_check: ok 16/16` and `minimap_check: ok`, both games exit 0 with no ERROR line, both replays verify, no process left | 2026-10-28, 54 s: 16 `click_check: selected ...` lines and `click_check: ok 16/16` (`OK final_hash=0xb22949aa7aa54c0a ticks=544`); `minimap_check: pass 1 focus=32.5,32.5`, `pass 2 focus=-25.5,-6.5 drags=6`, `pass 3 move units=5`, `pass 4 attack_move`, `minimap_check: ok` (`OK final_hash=0x696ca49d752a9ca6 ticks=168`). This script gates no paint time: over its 40 to 48 repaints a run's p95 is close to the third-worst sample, and an earlier run of the same binary printed `frame_stats: minimap repaints=47 paint_us mean=238.5 p95=629.2 max=1694.6`, over AP6's 500 us, while `just art-checks`, which gates it, judges the bound over 112 to 206 repaints a run. Re-run after the review of slices 5 and 6 (2026-10-28, code at `b5f7154`), 51 s: `click_check: ok 16/16` (`OK final_hash=0xe9e2acaf1ec8d1ce ticks=551`), the same four `minimap_check: pass` lines and `minimap_check: ok` (`OK final_hash=0xae43fc0a59197776 ticks=170`, `frame_stats: minimap repaints=40 paint_us mean=193.8 p95=295.7 max=342.9`), `PASS none of the 4 games and Xvfb servers this script started is running`. | +| CI's `art_bench` render smoke and draw-call gate | `scripts/render_smoke.sh target/ci/eonmark out art_bench '' 200 120`, then `scripts/check_draws.sh --fight out/render_smoke.log` | AP4 in CI | the smoke passes and `check_draws: ok` | Locally 2026-10-28 (1280x800): tick 200 at 63.8 s, `draws ... max=109 ... fight_max=109`, `check_draws: ok max=109 fight_max=109 < 500 mode=culling`. Since the review of slices 5 and 6, `--fight` also requires that the fight was measured (a non-zero `fight_max` and a `frame_stats: fight` line with fight frames and ticks); the slice 5 verify run of `just render-smoke out art_bench '' 300 120` at `b5f7154` printed `check_draws: ok max=109 fight_max=109 < 500 mode=culling fight ticks=47..388 frames=72`. On Actions: read it off the art pass PR's game-linux job. | +| Trace cross-check | `XCHECK_TARGET_DIR= scripts/draw_trace_xcheck.sh out` (a second Bevy build with `detailed_trace`, about 4 GB) | AP4's and AP7's "the trace cross-check agrees in both modes" | four `draw_trace_xcheck: ... PASS` lines | 2026-10-28, 21 min: `unit_looks` and `art_bench`, `mode=culling` and `mode=preprocessing_only`, every frame equal but the last, which the exit cuts short (docs/BUILD_TIMES.md). | + +## Owner's Mac checklist (art pass) + +The art pass's [owner] lines and its Mac-only clauses (ROADMAP "Art pass (pre-stream)": AP5, AP8, AP6's hand check and AP4's re-read of `mode=`), in the one batched Mac session with the M3b to M5b rows below (M4b owner decision 3, M5b decision 9). Run from `main` (or the PR branch) at the repository root and paste each output into the PR. Before the first stream, run the pre-flight of [STREAMING.md](STREAMING.md) too. + +```bash +# 1. AP5: art_bench (400 units fighting, two full Towns, every P0 unit selected at tick 40) +# at 1280x720 on the Retina display. Release build; the window focused and uncovered for +# the whole run, the camera untouched (no pan, scroll, pinch, zoom or key, the cursor away +# from the window's edges). The fight runs from about 2 s to 20 s in; press Cmd-Q any time +# after 30 s, then paste every frame_stats: line (window, fight, draws, triangles, minimap). +cargo build --release -p game +target/release/eonmark --scenario art_bench --window-size 1280x720 --draw-stats +# 2. AP5's headroom: the same with vsync off. +target/release/eonmark --scenario art_bench --window-size 1280x720 --draw-stats --max-fps 1000 +# 3. AP7 on Metal: the dev panel's draw-call row with all 200 P0 units selected (tick 40 on), +# and the hierarchy check on exit. +cargo run -p game --features dev -- --scenario art_bench --exit-after-seconds 60 +# 4. Optional: one Xcode Metal frame capture of the run in step 1 (Debug > Capture GPU +# Workload), its draw count compared with the `draws` line (within about 5: the +# full-screen passes are not counted). +# 5. AP8: open the four sign-off shots on the P3 display, then export each from Preview +# as sRGB (File > Export, or View > Display profile set to sRGB) and look again. +open docs/screenshots/art-units-40m.png docs/screenshots/art-town-40m.png \ + docs/screenshots/art-battle-60m.png docs/screenshots/art-match-minimap.png +# 6. AP6 by hand, and the stream look: New Game against the Standard bot. Click and drag the +# minimap, right-click on it with units selected, press A and click on it; click one unit of +# each kind; watch the first wave arrive (about minute 11). Then in the dev build select +# 100 or more units and read the draw-call row. +cargo run --release -p game -- --window-size 1280x720 --draw-stats +cargo run -p game --features dev -- --skip-menu +# 7. ROADMAP M3b line 2 and M4b line 2, now with the models. +cargo run --release -p game -- --scenario economy_walkthrough --draw-stats +cargo run -p game --features dev -- --scenario economy_walkthrough --exit-after-seconds 60 +cargo run --release -p game -- --scenario enemy_outpost --draw-stats +# 8. Information only (audio is M7): one cpal. +cargo tree -i cpal --depth 0 +``` + +What to look for: + +1. AP5 passes with `frame_stats: window logical=1280x720 physical=2560x1440 scale=2.0`, `frame_stats: window focused=N/N occluded=0/N`, and `frame_stats: fight ... p95= ... fps= ...` with `fps` >= 60 and `p95` <= 16.7 (M4b decision 2's form; `on_screen_min` should equal its alive count, see the M4b checklist's note 1). The `draws ... max= ... mode= fight_max=` line must read max and fight_max under 500 (AP4's re-read on Metal) and names the GPU preprocessing mode Metal uses; paste it whatever it says. If fps falls short, the levers are in the art-pass plan's order: shadows off on terrain props, then `VisibilityRange` on props, then the unit triangle cap; the `triangles main_max=` line says how much there is to cut. +2. The `fight` line's mean and p95 with vsync off: the headroom under 16.7 ms, reported with the `triangles` line (AP5's last clause). +3. The Sim panel's `draw calls (max ) / 500 ind dir sorted ` row in the panel's normal text colour (it turns amber at 500 or more), and `hierarchy_check: ok ...`, exit 0. `docs/screenshots/art-dev-panel.png` is the same panel under lavapipe. +4. A capture count within about 5 of the run's `draws` line. +5. Tick the [ART_STYLE.md](ART_STYLE.md) "Screenshot sign-off checklist" for the four shots (palette, no glow, readable at 40 m, matte HUD), on both the P3 display and the sRGB export. Each row's agent check is in the art pass's PR; the owner's tick closes AP8. Anything that reads neon in either view is a `palette.rs` or `visuals.ron` edit (`neutral_desaturate_permille`, a team swatch's `desaturate_permille`), never a shader. +6. A press or drag on the minimap moves the camera there at once; a right click sends the selected units to that spot (the usual move marker appears in the world); A then a click on it attack-moves; the minimap's view outline follows the camera; borders on it change in the same moment as in the world. Every kind is selectable with one click. Note anything that reads badly on the stream. +7. M3b line 2: `fps` >= 60 in `frame_stats: frames= seconds= fps=` (the walkthrough has no fight window) and the dev panel's draw calls under 500. M4b line 2: its `fight` line as in the M4b checklist's note 1, now with the character models. +8. `cpal v0.17.3`, one line. + +At sign-off, look hard at two things the agent's check flagged: + +1. **KayKit's terracotta wood on the blue side.** The KayKit buildings' terracotta walls and trim (hue about 21°, saturation about 0.47 in `art-town-40m.png`) sit close to the red team colour on screen (P1's units measure hue about 15°, saturation 0.52), so a blue building carries a warm red-brown that a viewer might read as red. The same wood is most of the blue Mangonel and Supply Wain, which carry only their cloth in team colour (the catapult has 25‰ of team texels, 123‰ with its scale-450 banner, owner decision 15; the Wain 88‰), and on those two it outweighs the blue: counting saturated pixels (saturation at least 0.3, value at least 0.2) in `art-units-40m.png`, P0's Mangonel (855,210)-(905,252) has 343 red-hued against 121 blue-hued, and P0's Wain (950,210)-(995,250) 227 against 54, their red pixels at hue about 18°, saturation 0.47, as on P1's Mangonel and Wain, which only the cloth tells apart. The blue Crofts in `art-town-40m.png` come out the other way (927, 767 and 772 red against 1448, 1406 and 1381 blue). So an unselected, undamaged blue engine or cart behind the blue line, with no bar over it, can read as red; judge them with the buildings. If they do, the lever for the buildings and these two units alike (all use the `kaykit_hexagon` swatch) is one number in `data/visuals.ron`, `team_swatches["kaykit_hexagon"].desaturate_permille` (200 now; the props use 500), with no Rust change and no `rules_version` bump. +2. **The Mangonel's banner in a packed group.** Owner decision 15 shrank it to scale 450 (1.25 m, 123‰) and reads "no crowd clipping"; centred on the engine it still reaches 0.60 m from the centre, 0.10 m past the 0.5 m collision radius (`inspect_gltf check` notes it), so two packed engines' cloths can touch, and the catapult itself (1.45 m long on a 1.0 m diameter) overlaps a packed neighbour at any banner size. A banner inside the radius (scale about 380) gives 100‰, under the Mangonel's 120‰ floor, so no scale meets both. Look at `art_bench`'s ten engines (steps 1 and 3, or `art-battle-60m.png`) and confirm scale 450 or name another; the lever is `"mangonel"`'s `banner_at` `scale_x100` in `data/visuals.ron`, with its floor in `inspect_gltf::check`. + ## Owner's Mac checklist (M5b) ROADMAP M5b line 4, in the one batched Mac session (M5b owner decision 9, with the M3b, M4a, M4b and M5a rows below). Run from `main` (or the PR branch) at the repository root and paste each output into the PR. @@ -303,6 +368,18 @@ The walkthrough pictures are one run of the scripted proxy, with the director fr | [screenshots/m4b-looks-40m.png](screenshots/m4b-looks-40m.png) | `target/ci/eonmark --background --scenario unit_looks --exit-after-seconds 40 --screenshot-at 100:m4b-looks-40m.png` (the scenario's own 40 m framing; `OK final_hash=0x398b505a1ca290e6 ticks=800`) | One unit of each kind per team, left to right: Yeoman (upright pill), Scribe (drum), Skirmisher (cone), Shieldbearer (block), Bowman (tall post), Outrider (lying pill), Mangonel (ball), Supply Wain (low box); each told apart by shape alone; the ART_STYLE checklist frame is 1280x720, this window 1280x800 | | [screenshots/m4b-drive-destroyed.png](screenshots/m4b-drive-destroyed.png) | the real X input drive, `scripts/m4b_outpost_drive.sh`, its tick-6000 picture (`--camera 41,64,50`; `OK final_hash=0x8d86d1243d74357e ticks=6050`) | The end of line 1 played on the interactive `enemy_outpost`: the player's Scriptorium, Muster Hall and Engine Yard, the Masonry Age, the Mangonels and the mixed army where the Watchtower stood, "Watchtower destroyed" | +## Screenshots (art pass) + +Captured on 2026-10-28 by `just art-checks` on Linux under Xvfb with Mesa's lavapipe (the `ci` profile binary, background mode, `--window-size 1280x720`, `--screenshot-at`), not on the dev Mac: the same pipelines and shaders, rendered in software. Retaken the same day after the review of slices 5 and 6, so the HUD's figures are Inter's tabular ones. Re-encoded losslessly (Pillow's `optimize`; the pixels are identical). Each comes from a run whose replay verifies. The four sign-off shots are AP8's; the owner signs them on the P3 display and as sRGB exports ([Owner's Mac checklist (art pass)](#owners-mac-checklist-art-pass)). + +| File | Run | What to look for | +|---|---|---| +| [screenshots/art-units-40m.png](screenshots/art-units-40m.png) | `--scenario unit_looks` (its 40 m framing), tick 100 (`OK final_hash=0x454bfaf22653b11d ticks=974`) | One of each kind per team, left to right: Yeoman (hood, empty hands), Scribe (wide hat), Skirmisher (axes), Shieldbearer (helmet and shield), Bowman (hood and crossbow), Outrider (the tall knight with a two-handed sword), Mangonel (catapult under its team banner), Supply Wain (wheelbarrow with a pennant); blue row above, red below; the minimap bottom-left | +| [screenshots/art-town-40m.png](screenshots/art-town-40m.png) | `--scenario art_bench --camera 24,64,40`, tick 300 (`OK final_hash=0x18fb7ae9ca43ee03 ticks=665`) | P0's Seat (the castle with its Town banner and the Seat flag on the keep), five Croft cottages, the Lumber Yard, the Ore Pit's mine and banner beside the mountain, Scriptorium, Muster Hall, Engine Yard, the Watchtower, construction sites, the forest; P0's selected army (rings) and the Wains' supply rings; the front of the fight at the right edge | +| [screenshots/art-battle-60m.png](screenshots/art-battle-60m.png) | `--scenario art_bench` (its 60 m framing), tick 300, mid-fight (`OK final_hash=0x450ef9e12bf9f3be ticks=688`) | P0's Seat with its eleven buildings and three construction sites west of the fight (P1's mirrored Town is off-screen to the east, on the minimap only, outside the view outline), 200 blue against 200 red meeting east of the Town, team-coloured bars over the damaged units along the front (at 60 m the arrows are a few pixels and hard to see), red Mangonels with their banners behind the red army; the minimap with both territories and the view outline | +| [screenshots/art-match-minimap.png](screenshots/art-match-minimap.png) | `--spectate --seed 1 --fast-forward-to 13006`, tick 13206 (`OK final_hash=0xe0e7536c0284ce8b ticks=14005`, recorded from tick 13006) | A real match half way: P0's Town at Masonry Age with its buildings, Yeomen at work, the territory border; the minimap with both territories, each player's two Towns outlined (four outlines; the Standard bot builds a second Town), the units and the view trapezoid | +| [screenshots/art-dev-panel.png](screenshots/art-dev-panel.png) | a dev build (`--features dev --profile ci`), `--scenario art_bench`, tick 250 | AP7: the Sim panel's `draw calls 107 (max 108) / 500 ind 94 dir 0 sorted 13` with 125 of P0's units selected (the panel hides the minimap in dev builds) | + ## Match log (M6) Play against the Standard bot from New Game to the game-over screen. One row per match. Keep the replay file; its name is the seed and timestamp. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 69009a6..a1f9fc7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -20,7 +20,7 @@ Acceptance lines marked **[owner]** need the repository owner to play, look or s | [M5a](#m5a-sim-attrition-supply-annexation-and-victory) | Sim: attrition, supply, annexation and victory | 3 | M4a, M4b | | [M5b](#m5b-ai-the-first-complete-skirmish-opponent-and-the-first-playable-match) | AI: the first complete skirmish opponent and the first playable match | 3 | M5a | | [M6](#m6-fun-gate-difficulty-personalities-pacing-game-speed-and-logged-human-matches) | Fun gate: difficulty, personalities, pacing, game speed and logged human matches | 4 | M5b | -| [M7](#m7-fog-of-war-minimap-menus-look-and-audio-pass) | Fog of war, minimap, menus, look and audio pass | 3-4 | M6 | +| [M7](#m7-fog-of-war-minimap-menus-look-and-audio-pass) | Fog of war, minimap, menus, look and audio pass | 2-3 | M6 | | [M8](#m8-v010-release-app-bundle-and-contributor-launch) | v0.1.0 release, .app bundle and contributor launch | 3 | M7 | | [M9](#m9-post-release-second-faction-as-pure-data-bevy-020-migration-gated) | Post-release: second faction as pure data, Bevy 0.20 migration (gated) | 2-3 | M8 | @@ -32,7 +32,7 @@ Running totals: | M8 (v0.1.0) | 38 | 40 | ~43 sessions (the design's figure also covers M9) | | M9 (post-release) | 40 | 43 | ~43 sessions | -The design quotes "~33 sessions to a fun-gated skirmish and ~43 to v0.1.0". The per-milestone estimates sum to 38-40 through M8 and 40-43 through M9. Treat ~43 as the budget for everything in this file; the three-session gap is slack, not a cut. +The design quotes "~33 sessions to a fun-gated skirmish and ~43 to v0.1.0". The per-milestone estimates sum to 38-40 through M8 and 40-43 through M9. Treat ~43 as the budget for everything in this file; the three-session gap is slack, not a cut. (Note 2026-10-28: the running totals above are the original sums. The pre-stream art pass, 3 sessions outside this table, took over M7's look items, so M7 is now 2-3 sessions; with the art pass counted the sums are 35-36 through M6, 40-42 through M8 and 42-45 through M9.) ## Order of work and dependency graph @@ -209,7 +209,7 @@ The player can see borders, place buildings inside them with a green/red ghost, ### Acceptance - [x] In-game: place a Croft outside the border and the ghost is red and the HUD says 'Outside your borders'; inside it turns green, Yeomen walk over and build it; founding a second Town visibly expands the tinted territory and border line; the Grain readout turns amber once the cap is reached; at 25/25 pop the Train button is disabled with a tooltip and the sim rejects with `PopCapReached` (`--scenario economy_walkthrough` replay verifies and a `ci_testing` screenshot shows the amber readout). (Observed on PR #31, 2026-10-21, CI run 39533356188 on a7f1499, all jobs green: the headless job verifies `economy_walkthrough.eonreplay` in release (`OK final_hash=0x162c3dcd67d04737 ticks=2400`), and `crates/sim/tests/m3b.rs` checks its beats in order (the `OutsideBorders` rejection, the second Town complete before the expand beat, Grain at the Yield Cap, 25/25 and the scripted `PopCapReached`). On the game side, `command_card::tests::at_the_walkthroughs_pop_cap_train_is_disabled_and_says_why` (Train disabled, tooltip "Population cap reached") and `economy_hud::tests::the_walkthroughs_grain_readout_turns_amber_at_its_beat_and_not_before` run on the real walkthrough state. The screenshot proxy is `--screenshot-at` rather than the `ci_testing` feature: `docs/screenshots/m3b-outside.png`, `m3b-second-town.png`, `m3b-amber.png` and `m3b-popcap.png`, taken on Linux under lavapipe, and both windowed runs' replays verify; CI's render smoke step renders the same walkthrough on every PR. The red and green ghost, the placed Croft built by a Yeoman, Train with a rally point and the context right click were driven with real X input under Xvfb on 2026-10-21 (274309e). The owner's pass at the keyboard is the M3b controls checklist in `docs/PLAYTEST.md`.) -- [ ] `cargo run --release -p game -- --scenario economy_walkthrough` holds >= 60 fps on the dev Mac with the overlay (and with FieldExt if landed); dev-panel draw calls < 500. (Open, needs the dev Mac. FieldExt landed (901a846), so the run measures it. No dev-panel draw-call counter exists yet: Bevy 0.19.1 keeps the batch counts `pub(crate)` and wgpu has no draw counters, so for M3b the count comes from an Xcode Metal frame capture, and a dev-panel counter moves to the pre-stream art pass, where glTF models make it matter. Linux lavapipe frame rates (about 8 fps in software) say nothing about this line.) +- [ ] `cargo run --release -p game -- --scenario economy_walkthrough` holds >= 60 fps on the dev Mac with the overlay (and with FieldExt if landed); dev-panel draw calls < 500. (Open, needs the dev Mac. FieldExt landed (901a846), so the run measures it. No dev-panel draw-call counter exists yet: Bevy 0.19.1 keeps the batch counts `pub(crate)` and wgpu has no draw counters, so for M3b the count comes from an Xcode Metal frame capture, and a dev-panel counter moves to the pre-stream art pass, where glTF models make it matter. Linux lavapipe frame rates (about 8 fps in software) say nothing about this line.) (Note 2026-10-28, art pass: the dev-panel counter now exists, the `draw calls` row of a dev build's Sim panel, and a release build prints the same count with `--draw-stats`, so the line closes at the owner's Mac session without a frame capture; [PLAYTEST.md](PLAYTEST.md), "Owner's Mac checklist (art pass)".) - [ ] **[owner]** Screenshot of the border visual reviewed against `docs/ART_STYLE.md` (12% tint, 1-tile soft line, matte). Automated proxy: the `economy_walkthrough` replay verifies and its `ci_testing` screenshot is attached to the PR. (Proxy done on PR #31, 2026-10-21: the walkthrough replay verifies in CI and `docs/screenshots/m3b-border-40m.png` (40 m) and `m3b-second-town.png` (60 m) show the FieldExt border, taken under lavapipe; the owner's review on the Mac is open.) ### Good first issues @@ -271,7 +271,7 @@ The player can research, advance ages, and fight with visible health and distinc ### Acceptance - [x] In-game: build a Scriptorium, research 2 techs, advance to Masonry Age, build an Engine Yard, train Mangonels and a mixed army, destroy an enemy Watchtower placed via `--scenario enemy_outpost`; the auto-recorded replay verifies (the same sequence exists as a scripted replay for the owner proxy). (Observed on PR #38, 2026-10-22, on a Linux cloud VM under Xvfb with Mesa's lavapipe, not the dev Mac, on the M3b precedent (M4b plan decision 21). The scripted replay: `cargo run -p sim-cli --release -- verify crates/sim/tests/fixtures/enemy_outpost_walkthrough.eonreplay` prints `OK final_hash=0x6bbf4dc88f1a9b2e ticks=5039`; `cargo test -p sim --profile ci -- --ignored enemy_outpost` passes 3 tests, printing `Scriptorium complete 635, Muster Hall 845, Engine Yard 2865, Letters I 1225, Trade I 1807 (queued for Some(540) ticks), Masonry Age 1850`, units 411 to 417 out (the Mangonels during 3771 and 4571) and `Mangonel hits on the tower [(UnitId(416), 4757), (UnitId(417), 4759), (UnitId(416), 4837), (UnitId(417), 4839)]; tower shots 4 (none on a Mangonel); Watchtower destroyed during tick 4839`, with no rejection; the game's `--headless-run` of it ends `tick=5039 hash=0x6bbf4dc88f1a9b2e`; drawn in the window with the walkthrough director, its replay verifies (`OK final_hash=0x28a9240ee47cc750 ticks=6601`) and `docs/screenshots/m4b-*.png` show its beats. The interactive scenario with real X input: `scripts/m4b_outpost_drive.sh target/ci/eonmark target/release/sim-cli ` played `--scenario enemy_outpost` with xdotool mouse events only (drag boxes, card buttons, the placement ghost, building clicks, rally right clicks, the Advance button, the Attack-move button and a right click on the tower) and closed the window with `WM_DELETE_WINDOW`: `drive: game exit 0`, no ERROR line, `OK final_hash=0x8d86d1243d74357e ticks=6050`. A scratch re-simulation of that replay finds 17 commands from the player, every one from a click: the Scriptorium complete during tick 819 and the Muster Hall during 1094, Letters I done during 1451 and Trade I during 2051, `AgeAdvanced(P0, 1)` during 2220, the Engine Yard complete during 3517, two Skirmishers, two Bowmen and a Shieldbearer, two Mangonels out during 4582 and 5382, the army's attack-move and the Mangonels' Attack on the tower, four 120-point Mangonel hits and the Watchtower destroyed during tick 5991; the drive's last picture is `docs/screenshots/m4b-drive-destroyed.png` ("Watchtower destroyed"). The owner's hand-played run on the dev Mac is still to come (owner decision 3); its commands are item 4 of docs/PLAYTEST.md, "Owner's Mac checklist (M4b)".) -- [ ] `cargo run --release -p game -- --scenario enemy_outpost` with 200 vs 200 fighting holds >= 60 fps; health bars are not children of unit entities (asserted by a dev-only hierarchy check). +- [ ] `cargo run --release -p game -- --scenario enemy_outpost` with 200 vs 200 fighting holds >= 60 fps; health bars are not children of unit entities (asserted by a dev-only hierarchy check). (Note 2026-10-28, art pass: since art slice 4 the 400 units are KayKit character models, so the owner's Mac run measures the line with them; [PLAYTEST.md](PLAYTEST.md), "Owner's Mac checklist (art pass)".) ### Good first issues @@ -357,8 +357,9 @@ Readable at 40 m, calm, matte and low-poly, with team colours obvious and a mini - rules: the `visuals.ron` schema for buildings, construction sites, rubble, the Seat flag, terrain props and team-colour swatches, with every validator; `sim-cli data-check` checks every model file exists under `assets/`. - sim: the `art_bench` stress scenario (two full Towns, 400 units fighting). - game: the asset root, `--window-size`, the debug grid off by default (`--grid`, G), the draw-call counter (`--draw-stats`, the dev panel), selection rings and attrition diamonds as shared meshes, KayKit Medieval Hexagon buildings, sites, rubble, Seat flag and terrain props, KayKit Adventurers units, a minimap without fog, the Inter font, ranged and melee hit feedback. -- tools: `tools/inspect_gltf`, `scripts/fetch_assets.sh` with sha256 pins, `scripts/draw_trace_xcheck.sh`, `scripts/art_checks.sh`, `scripts/art_drive.sh`. +- tools: `tools/inspect_gltf`, `scripts/fetch_assets.sh` with sha256 pins, `scripts/draw_trace_xcheck.sh`, `scripts/art_checks.sh`, `scripts/art_drive.sh`, `scripts/check_draws.sh` (the draw-call gate of CI's render smoke and `art_checks.sh`). - assets: KayKit (CC0) and Inter (OFL) with their licence texts and per-file `ATTRIBUTION.md` rows. +- docs: the owner's Mac checklist in [PLAYTEST.md](PLAYTEST.md) and [STREAMING.md](STREAMING.md), the stream set-up (art slice 6). ### Notes @@ -369,20 +370,32 @@ Readable at 40 m, calm, matte and low-poly, with team colours obvious and a mini - Owner decision 12, 2026-10-28: a team banner. Every team building (and, in slice 4, the Mangonel) carries the vendored KayKit flag repainted in its owner's colour, placed and sized per building in `visuals.ron` alone (`banner_at`), drawn as one shared mesh with one material per team, and the floors are measured on model plus banner (`inspect_gltf check`); a base disc, lower floors and other models were declined. Slice 3 (2026-10-28) sizes each banner so the building reaches 100‰ wherever a banner no taller than the building can: the Lumber Yard 102‰ (1.53 m banner), the Muster Hall 102‰ (2.16 m, the largest), the Watchtower 109‰ (on its roof), the Engine Yard 102‰; the Croft (263‰) and the Scriptorium (283‰) passed already. **Held for the owner:** the Ore Pit reaches 25‰ with a readable 0.97 m banner and only 31‰ with one as tall as the mine (1.13 m; 100‰ would take a 2.3 m banner), and the Town 74‰ with a 1.25 m banner (about 2.8 m, a cloth as wide as the castle, would reach 100‰; a Seat also flies the 1.8 m Seat flag on its keep, which the measure leaves out). Both keep their readable banners and are listed in `inspect_gltf::check::PENDING_TEAM_FLOOR`, so `check` prints them as `PENDING` and stays green until the owner chooses (accept them as they are, a second banner, or another model); AP3 cannot be ticked while they are held. Screenshots: P0 and P1 Towns at 40 m, team colour on roofs and banners. (Superseded by the review note of 2026-10-28 below: no kind is held.) - Facing (slice 3, 2026-10-28): slice 2's note that KayKit buildings face +X at yaw 0 came from `check`'s `facing=` hint, which names the side with the most surface; screenshots of every building at yaw 0, 90, 180 and 270 show the doors (the Croft's, the Scriptorium's, the Muster Hall's), the mine's entrance and the forge facing +Z at yaw 0, so every building uses `yaw_deg: 0`. The hint reads 26-29% on these hex-based models and does not find a door; [ART_STYLE.md](ART_STYLE.md) says so beside it. - Units (slice 4, 2026-10-28): all eight kinds are models (owner decision 3 holds: every one of the five Adventurers characters posed, so none keeps its M4b look, which stays in each line as the `fallback`). The Yeoman, Scribe, Skirmisher, Shieldbearer, Bowman and Outrider are baked from KayKit Adventurers 1.0 (`tools/assets/bake.ron`, 4,005-5,325 triangles each against the 5,500 cap, each GLB sha256-pinned in its row), the Mangonel is the catapult of the Hexagon catapult tower and the Supply Wain a wheelbarrow with a sack, a crate and the flag; the table is in [ART_STYLE.md](ART_STYLE.md), "Unit looks". Owner decision 12 on units: the Mangonel's catapult carries 25‰ of team texels, so it flies the data-driven team banner (scale 700, a 1.94 m cloth on a 0.39 m pole, 204‰; scale 450 gives 123‰), and the Wain reaches 88‰ with its flag at scale 140, kept as its banner. `art_bench` reads `triangles main_max=2117469` under lavapipe, under twice the plan's 1.2 M, so no cape, helmet or hat was dropped; AP5 on the Mac decides the rest. The 1.4x rule compares models per rig group, a shared rig on a shared texture, since every Adventurers character shares one skeleton. `--click-check` judged real clicks under Xvfb `ok 16/16` without a picking proxy. Nothing in slice 4 ticks an acceptance line: AP3 still waits on the held Ore Pit and Town floors. -- Review of slices 3 and 4 (2026-10-28): owner decision 12 keeps the floors and sets no banner-height cap, so the hold is lifted. Every building reaches 100‰ with its banner: the Town 102‰ with a 2.83 m banner whose pole stands in its front-left tower and whose cloth flies over the gate, the Ore Pit 102‰ with a 2.33 m banner at the mine's front-left foot, turned 45° to fit its 2 m footprint, the rest as before; `inspect_gltf check` fails any kind under its floor (`PENDING_TEAM_FLOOR` and its `PENDING` lines are gone, so CI no longer passes a building under a floor the owner kept). Slice 3's two size rules were the implementer's, not decision 12's: a banner taller than its building is now a printed note, and the Seat flag's "1.4 times the Town's banner" became a placement rule, its foot above the top of the Town's own banner (on the keep, 4.90 m against 3.74 m). Every banner and the Seat flag now stand inside their footprint, a new `check` rule: slice 3's banners stood beside their buildings and hung up to 1.81 m over the next tiles, into the mountain beside the Ore Pit and over units walking past. A building drawn as the M3b box after its model fails carries no banner, and a Seat's flag then stands at the box's north-east corner. **For the owner at the 40 m sign-off (AP8):** the Town's and the Ore Pit's banners are the sizes the 100‰ floor takes on 67‰ and 5‰ of model team colour; a smaller banner misses the floor, and each mount is one line of `visuals.ron`. Likewise the Mangonel's 1.94 m banner (scale 700, 204‰) reaches past its 0.5 m collision radius, so in a packed group its poles and cloths pass through the neighbouring engines and units; one inside the radius (scale about 380) gives 100‰ against the 200‰ unit floor, and its bar now rides over the banner. The same review baked the Bowman from the hooded Rogue (`Rogue_Hooded.glb` with its crossbow): the bare Rogue's brown hair covered most of what the camera sees, so a blue Bowman showed 9% team blue at 40 m against 54% brown and read as red; hooded it shows 62% blue (589‰ of its surface), still 1.61 times deeper than the Yeoman. And `visuals.ron` gained `neutral_desaturate_permille` (500): props, sites and rubble are desaturated like the texels outside a team swatch, so KayKit's trees (median saturation 0.77 at 40 m, twice the team roofs') no longer outshout the team colours (0.27 now). +- Review of slices 3 and 4 (2026-10-28): owner decision 12 keeps the floors and sets no banner-height cap, so the hold is lifted. Every building reaches 100‰ with its banner: the Town 102‰ with a 2.83 m banner whose pole stands in its front-left tower and whose cloth flies over the gate, the Ore Pit 102‰ with a 2.33 m banner at the mine's front-left foot, turned 45° to fit its 2 m footprint, the rest as before; `inspect_gltf check` fails any kind under its floor (`PENDING_TEAM_FLOOR` and its `PENDING` lines are gone, so CI no longer passes a building under a floor the owner kept). Slice 3's two size rules were the implementer's, not decision 12's: a banner taller than its building is now a printed note, and the Seat flag's "1.4 times the Town's banner" became a placement rule, its foot above the top of the Town's own banner (on the keep, 4.90 m against 3.74 m). Every banner and the Seat flag now stand inside their footprint, a new `check` rule: slice 3's banners stood beside their buildings and hung up to 1.81 m over the next tiles, into the mountain beside the Ore Pit and over units walking past. A building drawn as the M3b box after its model fails carries no banner, and a Seat's flag then stands at the box's north-east corner. **For the owner at the 40 m sign-off (AP8):** the Town's and the Ore Pit's banners are the sizes the 100‰ floor takes on 67‰ and 5‰ of model team colour; a smaller banner misses the floor, and each mount is one line of `visuals.ron`. Likewise the Mangonel's 1.94 m banner (scale 700, 204‰) reaches past its 0.5 m collision radius, so in a packed group its poles and cloths pass through the neighbouring engines and units; one inside the radius (scale about 380) gives 100‰ against the 200‰ unit floor, and its bar now rides over the banner. **The owner answered on 2026-10-28** (owner decisions 15 and 16): the Town's 2.83 m and the Ore Pit's 2.33 m banners stay as they are; the Mangonel alone shrinks its banner to scale 450 (1.25 m, 123‰), and only the Mangonel's own team floor is lowered to 120‰ in `inspect_gltf check` (every other unit keeps 200‰); art slice 5 centres it on the engine, the mount of least reach, so it reaches 0.60 m from the centre, 0.10 m past the 0.5 m radius (`check` now prints `banner_reach=` and notes a unit banner past its radius). Decision 15 also reads "no crowd clipping", and scale 450 does not fully give that: packed engines' cloths can still touch when they stand closer than about 1.2 m, and the catapult itself (1.45 m long on a 1.0 m diameter) overlaps a packed neighbour at any banner size. A banner inside the radius (scale about 380) gives 100‰, under the 120‰ floor, so no banner scale meets both; the owner confirms scale 450 at the AP8 sign-off (PLAYTEST's "Owner's Mac checklist (art pass)"). The Seat flag's placement rule (its foot above the top of the Town's banner, enforced by `check`) is accepted, and the 1.4x height ratio stays retired. The same review baked the Bowman from the hooded Rogue (`Rogue_Hooded.glb` with its crossbow): the bare Rogue's brown hair covered most of what the camera sees, so a blue Bowman showed 9% team blue at 40 m against 54% brown and read as red; hooded it shows 62% blue (589‰ of its surface), still 1.61 times deeper than the Yeoman. And `visuals.ron` gained `neutral_desaturate_permille` (500): props, sites and rubble are desaturated like the texels outside a team swatch, so KayKit's trees (median saturation 0.77 at 40 m, twice the team roofs') no longer outshout the team colours (0.27 now). +- Slice 5 (2026-10-28): the minimap, Inter and hit feedback. The minimap sits bottom-left over the bottom bar at 192 px (owner decision 7; [ui.md](design/ui.md)): terrain, territory and borders rebuilt in the tick that changed them, buildings, every unit (no fog yet), the selection, Town outlines that turn amber while annexed, and the view trapezoid; a press or drag moves the camera, a right click issues `orders::ground_order` and a click with attack-move armed an `AttackMove`. Unit dots are brightened 45% instead of the plan's 15%, which left them unreadable on their own territory. `--minimap-check` judged a scratch xdotool drive under Xvfb `minimap_check: ok` (press, drag, right click, A then left click); `scripts/art_drive.sh` is slice 6's. Inter 4.1 replaces Bevy's Fira Mono at startup and the HUD fit tests measure Inter's real advances through skrifa 0.42.1 (a game dev-dependency on the copy parley locks; no new package in `Cargo.lock`). Every `SimEvent::Hit` draws an arrow, a Mangonel stone or a melee lunge (presentation only); arrows fly on a low arc so they show over the heads between archer and target. Nothing in slice 5 ticks an acceptance line. +- Slice 6 (2026-10-28): the close. `scripts/art_checks.sh` (`just art-checks`) runs data-check, the Yeoman swap walkthrough on a scratch copy of `data/` (AP2), `inspect_gltf check` (AP3), `check_assets.sh` and its self-test (AP1), and five windowed runs at 1280 x 720 under lavapipe that save `docs/screenshots/art-units-40m.png`, `art-town-40m.png`, `art-battle-60m.png`, `art-match-minimap.png` and, from a dev build, `art-dev-panel.png`, each run's draws gated under 500 and its replay verified. `scripts/art_drive.sh` (`just art-drive`) takes over the slice 4 and 5 scratch drivers: `click_check: ok 16/16` and `minimap_check: ok` with real X input. CI's game-linux render smoke adds `art_bench '' 200 120` (tick 200, a 120 s deadline: lavapipe draws `art_bench` at about one frame a second, so on the 4-vCPU VM tick 300 came 74 to 87 s in, too close to 120 s for a runner of unknown speed, while tick 200 came 64 s in) and a step that runs `scripts/check_draws.sh` over every smoke's log. [STREAMING.md](STREAMING.md) is the owner's stream set-up. The acceptance ticks (AP1 to AP4, AP6, AP7, AP9, AP10, and M7's `check_assets` and `data-check` lines) are made in the art pass's close PR with its own observed output, since each names the PR; AP4's CI clause is read on that PR's Actions run. AP5, AP8 and AP6's hand check wait for the owner's Mac ([PLAYTEST.md](PLAYTEST.md), "Owner's Mac checklist (art pass)"). +- Review of slices 5 and 6 (2026-10-28): an empty, truncated or garbage `InterVariable.ttf` used to replace Fira Mono with a font Bevy registers no family for, so every text drew nothing with no warning; `fonts::install_ui_font` now registers the bytes first and keeps Fira Mono with a `warn!` (`an_unparsable_font_file_keeps_fira_mono`). Inter's default figures are proportional, so each stock, rate or countdown that changed by a digit moved the readouts beside it; every HUD, overlay and menu text now uses Inter's tabular figures (`fonts::hud_font`), checked with Bevy's own text measure and UI layout (`readouts_hold_their_place_when_a_number_changes`), and the fit tests measure figures in that form. `minimap_overlaps_no_hud_text` now lays out the real nodes instead of rebuilding them from constants, and `the_plugin_shows_a_new_border_in_the_update_of_its_tick` checks AP6's "within one frame" on `MinimapPlugin`'s own schedule. The minimap does not move the camera after the outcome (the outcome overlay covers it); the docs said it did. `scripts/check_draws.sh --fight` now requires that the fight was measured (a non-zero `fight_max` and a `frame_stats: fight` line with fight frames and ticks), since `fight_max=0` passed and `fight_max < 500` follows from `max < 500`. `art_checks.sh` and `art_drive.sh` check only the games and Xvfb servers they started (the dev binary `eonmark-dev` was missed; another session's Xvfb failed them). PLAYTEST's captions now say what the 60 m and match shots show, its sign-off note adds the blue Mangonel and Supply Wain, whose bodies are the same terracotta wood, and decision 15's "no crowd clipping" clause is recorded with the 0.10 m that scale 450 still reaches past the radius. The five screenshots were retaken with the tabular figures. +- Close (2026-10-28, PR #54): AP1, AP2, AP3, AP7, AP9 and AP10 are ticked with their evidence, and so are M7's `check_assets` and `data-check` lines (owner decision 8). AP4 and AP6 stay open until their Mac clauses are observed: AP4's re-read of `mode=` on the Mac (its lavapipe gate passes in `just art-checks` and in CI's game-linux job) and AP6's check by hand. AP5 and AP8 are the owner's. All four are in PLAYTEST's "Owner's Mac checklist (art pass)". ### Acceptance -- [ ] **AP1. Assets policy (M7 acceptance line 3, verbatim).** "`scripts/check_assets.sh` passes: every non-allowlisted file under `assets/` has an `ATTRIBUTION.md` row with CC0-1.0, CC-BY-4.0 or OFL-1.1, every OFL font has a sibling license text, no file > 2 MB, total < 300 MB; injecting an unattributed file fails the script and it prints the unmatched path." Proof: `scripts/check_assets.sh` prints `check_assets: ok ( files, bytes)`. `scripts/check_assets.sh --self-test` copies only `assets/` into a temp root, injects `models/unattributed.glb`, requires exit 1 and the path on stderr, then exit 0 once the file is removed. The allowlist is unchanged. -- [ ] **AP2. Data (M7 acceptance line 4, verbatim).** "`sim-cli data-check` fails if any unit or building kind lacks a `visuals.ron` entry; swapping one entry back to `Primitive(Capsule)` requires no Rust change (walkthrough in `docs/ART_STYLE.md`)." Proof: negative tests in `crates/sim-cli/tests/data_check.rs` and `crates/rules/src/visuals.rs`; the scripted walkthrough swaps `"yeoman"` to `Primitive(Capsule)`; data-check is OK, `cargo nextest run --workspace` is green, `git diff --stat` shows only `data/visuals.ron`, `just verify` is OK on every fixture, and `rules_hash` is unchanged (ADR 0003). -- [ ] **AP3. Models.** Every unit and building kind is drawn from a CC0 glTF through visuals.ron, and `cargo run -p inspect_gltf --release -- check data assets` prints `ok` for every Scene. It stays unticked while any kind is on its fallback. Baking twice is byte-identical. +- [x] **AP1. Assets policy (M7 acceptance line 3, verbatim).** "`scripts/check_assets.sh` passes: every non-allowlisted file under `assets/` has an `ATTRIBUTION.md` row with CC0-1.0, CC-BY-4.0 or OFL-1.1, every OFL font has a sibling license text, no file > 2 MB, total < 300 MB; injecting an unattributed file fails the script and it prints the unmatched path." Proof: `scripts/check_assets.sh` prints `check_assets: ok ( files, bytes)`. `scripts/check_assets.sh --self-test` copies only `assets/` into a temp root, injects `models/unattributed.glb`, requires exit 1 and the path on stderr, then exit 0 once the file is removed. The allowlist is unchanged. (Observed on PR #54, 2026-10-28: `scripts/check_assets.sh` printed `check_assets: pins ok (72 vendored files match tools/assets/sources.tsv)`, `check_assets: bake pins ok (6 baked files match tools/assets/bake.ron)` and `check_assets: ok (80 files, 4230353 bytes)`; `scripts/check_assets.sh --self-test` printed `error: models/unattributed.glb has no row in assets/ATTRIBUTION.md (first column must be 'models/unattributed.glb')` and then `check_assets: self-test ok (an unattributed file fails with its path; removing it passes; a changed pinned file fails with its path; restoring it passes; the same for the baked models/kaykit_adventurers/yeoman.glb)`, in `just ci` and in `just art-checks` step 4 at the review fix round's head; the allowlist (`ATTRIBUTION.md|LICENSE-ASSETS.md|shaders/*|.gitkeep|*/.gitkeep`) is the same `case` block as at `e84af88`, before the art pass.) +- [x] **AP2. Data (M7 acceptance line 4, verbatim).** "`sim-cli data-check` fails if any unit or building kind lacks a `visuals.ron` entry; swapping one entry back to `Primitive(Capsule)` requires no Rust change (walkthrough in `docs/ART_STYLE.md`)." Proof: negative tests in `crates/sim-cli/tests/data_check.rs` and `crates/rules/src/visuals.rs`; the scripted walkthrough swaps `"yeoman"` to `Primitive(Capsule)`; data-check is OK, `cargo nextest run --workspace` is green, `git diff --stat` shows only `data/visuals.ron`, `just verify` is OK on every fixture, and `rules_hash` is unchanged (ADR 0003). (Observed on PR #54, 2026-10-28: negative tests pass: `data_check.rs` `a_unit_kind_without_a_visual_exits_1_naming_visuals_ron`, `a_building_without_a_visual_exits_1_naming_visuals_ron`, `a_missing_visuals_ron_exits_1_naming_it`, `a_scene_whose_file_is_missing_exits_1_naming_the_path` (16 passed), `visuals.rs` `missing_kind_names_file_and_field`, `visuals_need_every_building_kind`, `unknown_building_key_is_rejected` (26 visuals tests passed). The walkthrough in the tree at the review fix round's head, `"yeoman"` swapped to `Primitive(Capsule)`: `data-check data` printed `OK rules_version=25 rules_hash=0xfb0d0b8986fc8865 ... visuals=units:8 buildings:8 terrain:3 models=35` (36 and the same `rules_hash` before the swap); `cargo test --workspace --locked --profile ci` (nextest is not installed on the VM) 25 suites, 849 passed, 0 failed, 21 ignored; `git diff --stat` ` data/visuals.ron | 2 +-` and nothing else; `just verify` all 11 fixtures OK with their committed hashes; restored. `just art-checks` step 2 repeats it on a scratch copy: one changed line, same `rules_hash`, `models=35`, all 11 fixtures OK.) +- [x] **AP3. Models.** Every unit and building kind is drawn from a CC0 glTF through visuals.ron, and `cargo run -p inspect_gltf --release -- check data assets` prints `ok` for every Scene. It stays unticked while any kind is on its fallback. Baking twice is byte-identical. (Observed on PR #54, 2026-10-28: `cargo run -p inspect_gltf --release -- check data assets` printed an `ok` line for every Scene and `check: ok 37 scene(s)` (in `just ci` and `just art-checks` step 3 at the review fix round's head): `units["yeoman"|"scribe"|"skirmisher"|"shieldbearer"|"bowman"|"outrider"]` from `models/kaykit_adventurers/*.glb` (446 to 668‰), `units["mangonel"]` the Hexagon catapult (`team=123‰ model_team=25‰ banner=1.25m banner_reach=0.60m`, its 120‰ floor, owner decision 15), `units["supply_wain"]` the wheelbarrow (88‰), and all eight `buildings[...]` Hexagon models (102 to 283‰). No kind is on its fallback: every windowed run logged `models: settled ready=36 failed=0`. Baking twice is byte-identical (slice 6, from a fresh fetch): `scripts/fetch_assets.sh` into a fresh cache (licences first, every pin OK), `just art-bake` twice, `diff -r` exit 0, `git status --porcelain assets/ tools/` empty, `check_assets: bake pins ok (6 baked files match tools/assets/bake.ron)`; `bake::tests::bake_twice_is_byte_identical` passes.) - [ ] **AP4. Draw calls.** `--scenario art_bench --draw-stats` (400 units, two full Towns, all P0 units selected) prints `draws … max < 500` and `fight_max < 500` under lavapipe. This is gated in `art_checks.sh` and in CI's render smoke, and re-read on the Mac with its `mode=`. The trace cross-check agrees in both modes. - [ ] **AP5 [owner, Mac]. fps.** `cargo run --release -p game -- --scenario art_bench --window-size 1280x720 --draw-stats` on the Retina display prints `window … physical=2560x1440`. With the window focused and the camera untouched, it holds ≥ 60 fps in the `fight` line with p95 ≤ 16.7 ms (M4b decision 2's form), and a `--max-fps 1000` headroom run is reported with the `triangles` line. - [ ] **AP6. Minimap without fog.** The minimap mirrors borders within one frame, a left click or drag moves the camera, a right click orders the selected units, and the view trapezoid is drawn. Proof: the minimap tests; `minimap_check: ok` from `scripts/art_drive.sh` (real X input under Xvfb, local); `paint_us p95 ≤ 500`. The owner checks it by hand on the Mac. -- [ ] **AP7. Counter.** The dev panel shows the last frame's draws and the run's maximum against 500. Proof: the `draw_stats` tests, `draw_trace_xcheck.sh` agreeing in both modes, and `docs/screenshots/art-dev-panel.png`. +- [x] **AP7. Counter.** The dev panel shows the last frame's draws and the run's maximum against 500. Proof: the `draw_stats` tests, `draw_trace_xcheck.sh` agreeing in both modes, and `docs/screenshots/art-dev-panel.png`. (Observed on PR #54, 2026-10-28: the `draw_stats` tests pass (7: `indirect_buffers_count_one_draw_per_entry`, `sorted_items_count_one_draw_per_batch`, `indirect_sorted_items_are_not_counted_twice`, `no_indirect_views_count_bins_and_unbatchables`, `draw_line_reports_mean_p95_max_and_mode`, `triangles_come_from_indices_or_vertices`, `wgpu_features_are_disabled_by_name`); `scripts/draw_trace_xcheck.sh` at the review fix round's head PASS in both modes for `unit_looks` (6762 against 6693 culling, 6555 against 6486 preprocessing_only) and `art_bench` (4552 against 4445, 4447 against 4340), every frame equal but the last; `docs/screenshots/art-dev-panel.png`, retaken in that round, shows `draw calls 107 (max 108) / 500 ind 94 dir 0 sorted 13` from a dev build's `art_bench` at tick 250.) - [ ] **AP8 [owner]. Sign-off.** `docs/screenshots/art-units-40m.png`, `art-town-40m.png`, `art-battle-60m.png` and `art-match-minimap.png` (1280 x 720) are signed off against the `ART_STYLE.md` checklist ("palette, no glow, readable at 40 m, matte HUD") plus the P3 and sRGB check. Proxy: `scripts/art_checks.sh` produces them under lavapipe with `--screenshot-at` (the M3b, M4b and M5a precedent), and the agent ticks every checklist row in the PR before tagging the owner. -- [ ] **AP9. No sim change.** After the slice 1 bump, `git diff --stat ..HEAD -- crates/sim/tests/fixtures` is empty and `just verify` is OK at every slice. (Note 2026-10-28: `` is `15f1616`; see Notes.) -- [ ] **AP10. Click.** Every unit kind of both teams is selectable by a real click: `click_check: ok 16/16` from `scripts/art_drive.sh` (local, xdotool). +- [x] **AP9. No sim change.** After the slice 1 bump, `git diff --stat ..HEAD -- crates/sim/tests/fixtures` is empty and `just verify` is OK at every slice. (Note 2026-10-28: `` is `15f1616`; see Notes.) (Observed on PR #54, 2026-10-28: `git diff --stat 15f1616..HEAD -- crates/sim/tests/fixtures` prints nothing at the review fix round's head; `just verify` printed OK for all 11 fixtures with their committed hashes there (capture_seat 0xbd9f9df3f98fcc23, combat_skirmish 0x9c42bb7bfd4dcef3, economy_second_town 0x4b3429bcfd9ec100, economy_walkthrough 0x88bb89e34ec941bd, enemy_outpost_walkthrough 0xd0684c93f3398915, group_spiral 0xf468c100c2b042e1, m5_first_match 0x3d2552033d51b4a2, move_500 0x02e11c8e91350d52, move_500_short 0x46eaeacec41e1b86, snapshot_restore 0x9625510823fec01f, stalemate_45min 0x261fad6cc4323139), as it did at slices 1 to 6; `rules_version` 25 and `SIM_VERSION` 17 throughout.) +- [x] **AP10. Click.** Every unit kind of both teams is selectable by a real click: `click_check: ok 16/16` from `scripts/art_drive.sh` (local, xdotool). (Observed on PR #54, 2026-10-28: `scripts/art_drive.sh` at the review fix round's head clicked every target of `--scenario unit_looks --click-check` with xdotool under Xvfb: 16 `click_check: selected unit= kind= team=` lines, every kind of both teams, then `click_check: ok 16/16`, exit 0, no ERROR line, replay `OK final_hash=0xe9e2acaf1ec8d1ce ticks=551`.) + +### Good first issues + +They replace M7's Croft issue, which the art pass made moot by mapping the Croft. Not filed yet: each is filed with the art pass's close. + +- **A Croft composite through `parts`** (help wanted, platform:macos: judging the look needs the window). Make the Croft a cottage with sacks or crates of grain beside it, in `data/visuals.ron` alone: give `"croft"` a `parts` list (the Supply Wain's line is the example) with `decoration/props/sack.gltf` or `crate_A_big.gltf`, both vendored, or fetch KayKit's `building_grain` through a new `tools/assets/sources.tsv` row and its `ATTRIBUTION.md` row (`just fetch-assets`). How to verify: `cargo run -p inspect_gltf --release -- check data assets` prints an `ok buildings["croft"] ...` line with `team=` at 100‰ or more and every part inside the 2 x 2 footprint; `cargo run -p sim-cli -- data-check data` prints OK; `git diff --stat` shows only `data/visuals.ron` (plus the two manifest rows for a fetched file); `just verify` is OK on every fixture (no `rules_version` bump, ADR 0003); and `cargo run --release -p game -- --scenario art_bench --camera 24,64,40 --window-size 1280x720 --exit-after-seconds 30 --screenshot croft.png` shows five Crofts that still read as cottages at 40 m, attached to the PR. +- **`data-check` suggests the closest file for a missing Scene path** (any OS). When a `visuals.ron` Scene names a file that is not under the assets directory, the error should end with `did you mean ''?` for the closest existing `.gltf` or `.glb` path (fewest edits; none when nothing is close). How to verify: a new test in `crates/sim-cli/tests/data_check.rs` copies `data/` to a temp directory, misspells `building_castle_blue.gltf` as `building_castel_blue.gltf`, runs `data-check --assets assets` on the copy, and asserts exit 1 and both the missing path and `did you mean 'models/kaykit_hexagon/buildings/blue/building_castle_blue.gltf'?` on stderr; `cargo test -p sim-cli` is green. +- **`inspect_gltf inspect --json`** (any OS). Print the `inspect` report (nodes, meshes and primitives with their counts and attributes, images, skins, animations and bounds) as one JSON object for scripts. How to verify: a test in `tools/inspect_gltf` runs `inspect --json` on the synthetic glTF the existing inspect test writes and checks the parsed node and triangle counts against the text report's; `cargo test -p inspect_gltf` is green, and `cargo run -p inspect_gltf --release -- inspect --json assets/models/kaykit_hexagon/buildings/blue/building_castle_blue.gltf | python3 -m json.tool` parses. `serde_json` is already in `Cargo.lock`, so the lockfile gains no package. ### Estimate @@ -428,27 +441,27 @@ The game looks and sounds like a finished small game: 3-state fog of war through ### Deliverables - sim: `visibility.rs` (per-player u8 grid from vision radii; hashed; `SimView` exposes it so the AI respects fog). -- game: FieldExt shader mandatory now (fog channel 0.0/0.45/1.0), enemy Visibility toggling, minimap Image painted from territory + fog + units each frame with click-to-move and right-click-to-order, main menu, pause menu, `audio.rs` facade with ~10 CC0 OGG clips. -- `assets/`: KayKit Medieval Builder/Hexagon buildings (team colors), KayKit Adventurers units, Kenney Nature Kit trees, Kenney UI Pack + Game Icons, Inter + OFL text; `data/visuals.ron` maps every kind; `tools/inspect_gltf`. +- game: FieldExt shader mandatory now (fog channel 0.0/0.45/1.0), enemy Visibility toggling, minimap Image painted from territory + fog + units each frame with click-to-move and right-click-to-order, main menu, pause menu, `audio.rs` facade with ~10 CC0 OGG clips. (Note 2026-10-28: the minimap without fog, with click-to-move and right-click-to-order, was pulled into the art pass, `crates/game/src/minimap.rs`; M7 adds the fog layer and hides the enemy units its fog hides.) +- `assets/`: KayKit Medieval Builder/Hexagon buildings (team colors), KayKit Adventurers units, Kenney Nature Kit trees, Kenney UI Pack + Game Icons, Inter + OFL text; `data/visuals.ron` maps every kind; `tools/inspect_gltf`. (Note 2026-10-28: pulled into the art pass, see "Art pass (pre-stream)": KayKit Medieval Hexagon buildings with team colours, KayKit Adventurers units, Inter with its OFL text, `data/visuals.ron` mapping every kind and `tools/inspect_gltf` are done; the Kenney Nature Kit trees were replaced by KayKit Hexagon nature; the Kenney UI Pack and Game Icons stay in M7.) - `docs/ART_STYLE.md` finalized with checklist and import rules; `docs/design/fog.md`; `docs/screenshots/m7_town.png` via `ci_testing`. ### Acceptance - [ ] `cargo test -p sim fog`: visibility grid matches vision radii for a fixture; an enemy unit outside all vision cells is hidden in `SimView`; all earlier golden fixtures verify after the single `rules_version` bump in this milestone. -- [ ] In-game: unexplored terrain is black, explored darkened, visible lit; enemy units vanish without friendly vision; the minimap mirrors fog and borders within one frame; clicking the minimap moves the camera; right-clicking it orders selected units (`--scenario fog_walkthrough` replay + `ci_testing` screenshots as the owner proxy). -- [ ] `scripts/check_assets.sh` passes: every non-allowlisted file under `assets/` has an `ATTRIBUTION.md` row with CC0-1.0, CC-BY-4.0 or OFL-1.1, every OFL font has a sibling license text, no file > 2 MB, total < 300 MB; injecting an unattributed file fails the script and it prints the unmatched path. -- [ ] `sim-cli data-check` fails if any unit or building kind lacks a `visuals.ron` entry; swapping one entry back to `Primitive(Capsule)` requires no Rust change (walkthrough in `docs/ART_STYLE.md`). -- [ ] `cargo run --release -p game` shows >= 60 fps at 1440p with 400 units + two full Towns + fog; draw calls < 500; `cargo tree -i cpal` shows exactly one cpal version. -- [ ] **[owner]** `docs/screenshots/m7_town.png` signed off against the `ART_STYLE.md` checklist (palette, no glow, readable at 40 m, matte HUD) in the PR. Automated proxy: the screenshot is produced by `ci_testing` from the `fog_walkthrough` scenario and committed; the agent checks it against the written checklist before asking. +- [ ] In-game: unexplored terrain is black, explored darkened, visible lit; enemy units vanish without friendly vision; the minimap mirrors fog and borders within one frame; clicking the minimap moves the camera; right-clicking it orders selected units (`--scenario fog_walkthrough` replay + `ci_testing` screenshots as the owner proxy). (Note 2026-10-28, art pass: without fog, the minimap's borders within one frame, a click or drag moving the camera and a right click ordering the selected units are observed by the art pass's AP6, with `minimap_check: ok` from `scripts/art_drive.sh`; the fog, its darkening on the minimap and enemy units vanishing stay with M7, so the line stays open.) +- [x] `scripts/check_assets.sh` passes: every non-allowlisted file under `assets/` has an `ATTRIBUTION.md` row with CC0-1.0, CC-BY-4.0 or OFL-1.1, every OFL font has a sibling license text, no file > 2 MB, total < 300 MB; injecting an unattributed file fails the script and it prints the unmatched path. (Observed on PR #54, 2026-10-28: art pass AP1: `scripts/check_assets.sh` printed `check_assets: ok (80 files, 4230353 bytes)` with every pin OK, and `scripts/check_assets.sh --self-test` failed the injected `models/unattributed.glb` with `error: models/unattributed.glb has no row in assets/ATTRIBUTION.md (first column must be 'models/unattributed.glb')` and passed once it was removed. Re-run `check_assets.sh` at M7's close once the Kenney and audio files land (owner decision 8 of the art pass).) +- [x] `sim-cli data-check` fails if any unit or building kind lacks a `visuals.ron` entry; swapping one entry back to `Primitive(Capsule)` requires no Rust change (walkthrough in `docs/ART_STYLE.md`). (Observed on PR #54, 2026-10-28: art pass AP2: `data-check` fails a unit or building kind without a `visuals.ron` entry (`a_unit_kind_without_a_visual_exits_1_naming_visuals_ron`, `a_building_without_a_visual_exits_1_naming_visuals_ron`); the literal swap of `"yeoman"` to `Primitive(Capsule)` in the walkthrough of `docs/ART_STYLE.md` changed only `data/visuals.ron` (`data/visuals.ron | 2 +-`), `data-check` printed OK with the same `rules_hash=0xfb0d0b8986fc8865` and `models=35`, every workspace test passed (849) and every fixture verified, with no Rust change.) +- [ ] `cargo run --release -p game` shows >= 60 fps at 1440p with 400 units + two full Towns + fog; draw calls < 500; `cargo tree -i cpal` shows exactly one cpal version. (Note 2026-10-28, art pass, without fog: `--scenario art_bench` (400 units, two full Towns, every P0 unit selected) peaks at 109 draw calls a frame under lavapipe, gated under 500 in CI's render smoke (AP4); the fps at 1440p is AP5, the owner's Mac run, still open; `cargo tree -i cpal --depth 0` prints `cpal v0.17.3`, one version, information only since audio is M7. M7 re-runs the line with fog, so it stays open.) +- [ ] **[owner]** `docs/screenshots/m7_town.png` signed off against the `ART_STYLE.md` checklist (palette, no glow, readable at 40 m, matte HUD) in the PR. Automated proxy: the screenshot is produced by `ci_testing` from the `fog_walkthrough` scenario and committed; the agent checks it against the written checklist before asking. (Note 2026-10-28: the art pass's AP8, the four `docs/screenshots/art-*.png` signed off against the same checklist without fog, is this line's fog-free precursor; M7 still produces and signs `m7_town.png`.) ### Good first issues -- Replace the Croft primitive with a KayKit field model via `visuals.ron` (platform:macos, help wanted). +- Replace the Croft primitive with a KayKit field model via `visuals.ron` (platform:macos, help wanted). (Superseded 2026-10-28: the art pass maps the Croft to a KayKit cottage; its own good first issues, under "Art pass (pre-stream)", replace this one, among them a Croft composite.) - Record or source a CC0 'age advance' chime and add its attribution row (any OS). ### Estimate -3-4 sessions. +2-3 sessions (3-4 until 2026-10-28, when the art pass took over its look items: models, terrain, the fog-free minimap and Inter; owner decision 8 of the art pass). ## M8: v0.1.0 release, .app bundle and contributor launch diff --git a/docs/STREAMING.md b/docs/STREAMING.md new file mode 100644 index 0000000..7bc68c8 --- /dev/null +++ b/docs/STREAMING.md @@ -0,0 +1,46 @@ +# Streaming Eonmark + +How the owner streams a match from the dev Mac: the commands, the settings that bite, OBS, a five-minute pre-flight and what to keep afterwards. Every flag, path and number below was checked against the code at the art pass's close (2026-10-28); `eonmark --help` prints the flags. + +## Two stream formats + +| Format | Command | Notes | +|---|---|---| +| You against the Standard bot | `cargo run --release -p game -- --skip-menu --window-size 1280x720 --draw-stats` | `--skip-menu` starts the match at once. Without `--seed ` the seed comes from the wall clock; it is in the replay's file name (`-.eonreplay`), which the outcome overlay shows. A seed does not reproduce a match you played: your orders are in the replay, not the seed. | +| Bot against bot (spectate) | `cargo run --release -p game -- --spectate --seed 1 --window-size 1280x720 --draw-stats` | Seed 1 is the `m5_first_match` setup: P0 takes P1's Seat at tick 26012, 21:40 on the overlay's clock. You can select units and move the camera but give no orders. `--fast-forward-to 9000` skips the opening (7:30) before the window opens; it works only with `--spectate` and is refused if the match is decided by then. The replay is recorded from that tick and still verifies. | + +- **Why 1280x720:** it is the logical size the art pass measures fps at (AP5 in the ROADMAP's "Art pass (pre-stream)"). On a Retina display it is 2560x1440 physical pixels, which OBS scales down to 1080p. A 1920x1080 window renders 3840x2160 physical pixels, 2.25 times as many, for nothing once OBS scales it to 1080p. +- **`--draw-stats`** costs next to nothing and makes the game print its `frame_stats:` lines on exit (frame times, the window, draw calls); without it a plain match prints only the minimap's line. +- **Framing:** the camera starts over your first units at 35 m, its default zoom; the wheel or a pinch zooms from 15 to 60 m. The art pass is judged at 40 m, and `--camera 24,64,40` opens there over P0's Seat (`--camera ,,`, a map tile and a zoom). +- **Build:** a release build only. A `dev` build links Bevy dynamically and draws the egui dev panels over the game. + +## Settings that bite + +1. **Keep the game window uncovered.** macOS stops presenting frames to a window that is fully covered (wgpu checks the window's occlusion before each frame), so a Window Capture in OBS freezes while anything covers the whole game. Put OBS on a second display, or capture the display rather than the window. The `frame_stats: window focused=/ occluded=/ events=...` exit line counts the covered frames. +2. **Unfocused means 60 Hz.** Bevy 0.19.1's default `WinitSettings::game()` updates a focused window continuously and an unfocused one at most every 1/60 s. That suits a 60 fps stream, but click the game before you play so OBS does not hold the focus. Background mode (`--background`) keeps updating continuously; it is for automated runs only. +3. **Power:** plug in the adapter and set System Settings, Battery, Energy Mode to High Power. Apple Silicon lowers its GPU clocks on battery. + +## OBS (Apple Silicon) + +- **Source:** macOS Screen Capture, method "Window", the Eonmark window, cursor shown. +- **Video:** base canvas 2560x1440; output 1920x1080 with the Lanczos downscale; 60 fps. +- **Encoder:** Apple VT H.264 Hardware Encoder; CBR, keyframe interval 2 s, profile High; 6,000 Kbps for Twitch at 1080p60, 9,000 to 12,000 Kbps for YouTube at 1080p60. +- **Audio:** the game has none yet (M7), so the microphone only. + +## Pre-flight (5 minutes) + +1. `cargo build --release -p game -p sim-cli`. +2. Run the exact stream command for two minutes off air, with the window focused, then quit with Cmd-Q. +3. Check the exit lines: + - `frame_stats: window logical=1280x720 physical=2560x1440 scale=2.0`; + - `frame_stats: frames= seconds= fps=` at 60 or more, and `frame_stats: frame_ms mean= p95= ...` with p95 at or under 16.7; + - `frame_stats: window focused=/ occluded=0/`; + - `frame_stats: draws frames= mean= p95= max= ...` with max under 500. +4. Verify the replay the run printed as `replay: `: `cargo run --release -p sim-cli -- verify ` must print `OK final_hash=0x... ticks=`. + +## After the stream + +- Every match records a replay. The game prints `replay: ` on stdout when the match starts; the file is under `~/Library/Application Support/com.tonianev.Eonmark/replays/` unless `--replay-dir` names another directory. +- Verify it with `sim-cli verify` from the same checkout and keep it with the VOD: a later build whose rules differ prints `RULES CHANGED` instead of `OK` ([DETERMINISM.md](DETERMINISM.md)). +- For a spectated match, put the seed in the VOD description: `--spectate --seed ` on the same build plays the same match again. A match you played needs its replay file; the game has no replay viewer yet, and `eonmark --headless-run ` re-simulates it without a window and checks its final hash. +- A bug seen on stream becomes an issue with the replay attached; `cargo run --release -p sim-cli -- hash-dump --every 1` prints a hash per tick to bisect a divergence ([DETERMINISM.md](DETERMINISM.md)). diff --git a/docs/design/combat.md b/docs/design/combat.md index 18ad32d..5faf9fc 100644 --- a/docs/design/combat.md +++ b/docs/design/combat.md @@ -49,7 +49,7 @@ damage = floor(floor(raw * counter / 1000) * variance / 1000) defender.hp -= damage ``` -`damage_variance_permille` defaults to 0 in `rules.ron`, so combat is exactly reproducible from stats alone and stays readable. Setting it above 0 draws from the single `Pcg32` in the sim, which is hashed, so replays still verify. Hits land immediately in the sim; projectiles are render-side only. Cooldowns are authored in deciseconds (`_ds` fields) and converted once by `Rules::ticks_from_ds`. +`damage_variance_permille` defaults to 0 in `rules.ron`, so combat is exactly reproducible from stats alone and stays readable. Setting it above 0 draws from the single `Pcg32` in the sim, which is hashed, so replays still verify. Hits land immediately in the sim; projectiles are render-side only (art pass: `crates/game/src/combat_fx.rs` draws an arrow, a stone or a melee lunge for every `SimEvent::Hit`, chosen from the attacker's class and range, and never writes to the sim). Cooldowns are authored in deciseconds (`_ds` fields) and converted once by `Rules::ticks_from_ds`. ## Shooters diff --git a/docs/design/fog.md b/docs/design/fog.md index b1dfb55..0e8b0de 100644 --- a/docs/design/fog.md +++ b/docs/design/fog.md @@ -16,7 +16,7 @@ Area: `area:sim` (grid, `SimView`) and `area:game` (shader channel, visibility t | Texture channel | B of the 128 x 128 RGBA8 field texture (R owner, G strength, B fog, A supply) | `crates/game/src/field.rs` | | Enemy units | hidden unless their tile is state 2 for the viewing player | `present.rs` | | Enemy buildings | shown with current state when their tile is state 1 or 2; no ghosting | `present.rs` | -| Minimap | same grid, same multipliers, repainted every frame | `hud/minimap.rs` | +| Minimap | same grid, same multipliers, repainted when the tick, camera, selection or territory changed | `crates/game/src/minimap.rs` (built without fog by the art pass; M7 adds the fog layer) | | Frame budget | 400 units + two full Towns + fog at >= 60 fps, 1440p, release, base M-series | M7 acceptance | | Hash impact | one `rules_version` bump in M7; every earlier golden fixture must verify after it | M7 acceptance | @@ -74,6 +74,8 @@ The minimap is a CPU-painted 128 x 128 `Image` shown through an `ImageNode`, one Left-clicking the minimap moves the camera to that tile. Right-clicking it orders the current selection there, through the same command path as a right-click on the ground. The minimap must mirror fog and borders within one frame of the sim change that caused them. +The art pass built this minimap without fog (`crates/game/src/minimap.rs`, [ui.md](ui.md)): steps 1, 2 and 4 with every unit and building shown, plus the view trapezoid and Town outlines. It is painted from cached layers rather than every frame: the terrain once, the territory again in the `FixedUpdate` step that changed the field's border sources (so a border shows in the frame of its tick: `the_plugin_shows_a_new_border_in_the_update_of_its_tick` runs `MinimapPlugin` on its own schedule with `SimPlugin`, and `a_new_border_source_repaints_territory_in_the_same_update` checks the repaint itself), and the rest whenever the tick, the camera or the selection changed. M7 adds step 3 as one more cached layer rebuilt when the visibility grid changes, and filters the enemy dots of step 4. + ## Interactions with other systems | System | Interaction | diff --git a/docs/design/ui.md b/docs/design/ui.md index 3cadfa8..9f95654 100644 --- a/docs/design/ui.md +++ b/docs/design/ui.md @@ -35,8 +35,8 @@ M2 skeleton (`hud.rs`, done): a full-window root node with `Pickable::IGNORE` (` | Top right, under the bar | Age strip | The current age name (Hearth, Masonry, Charter) and the Advance button (M4b, done), on a strip hanging under the right end of the top bar so the bar's room stays with the resource readouts. The button is always shown until the last age: greyed with the tech count ("Advance: 1 / 2 techs") while that is short, else showing the next age and its cost ("Advance to Masonry Age: 250 Grain 100 Lore"), greyed while a resource is short (owner decision 7, ROADMAP M4b Notes; ART_STYLE "greyed, not hidden"). | | Bottom left | Selection panel | One selected unit or building: name, HP, owner, current order; for a building, its training or research queue with progress and a Cancel per slot (per-slot Cancel is not built yet: help-wanted issue #41). Several units: a grid of kind icons with counts; clicking an icon narrows the selection to that kind. | | Bottom right | Command card | Buttons for what the selection can do: build (Yeomen), train (Muster Hall, Engine Yard, Town), research (Scriptorium, Watchtower), rally, stop, attack-move. Each button shows its cost. A button is disabled with a tooltip when the player cannot afford it, is pop-capped, lacks the age or tech, or has no building site; the tooltip text is the same reason string the sim uses in `CommandRejected`. | -| Bottom centre | Minimap | A CPU-painted 128 x 128 `Image` in an `ImageNode` painted from the territory and fog grids plus unit and building dots each frame (M7, see [fog.md](fog.md)). Left click moves the camera. Right click orders the selection. | -| Above the command card | Message line | The last rejection or notice for a few seconds: "Outside your borders", "Not enough Ore", "Population cap reached", "Town annexed". Since M4b also "Letters I researched", "Masonry Age reached", "Watchtower destroyed" and "Muster Hall lost", and since M5a the annexation notices, "Town is being annexed", "Annexation stopped", "Town annexed" and "Town was lost" (see Notices below). | +| Bottom left, above the bar | Minimap | Built by the art pass without fog (owner decision 7; `crates/game/src/minimap.rs`): a 192 px square (`minimap::MINIMAP_PX`, 1.5 px a tile at scale 1, 3 physical px on the Retina stream) at `left: 16 px`, outside the 12 px edge-scroll band, and `bottom: 104 px`, on the bottom bar's padding, in a 3 px `HUD_PANEL` frame. It is not at the bottom centre the design first named, because at 1280 px the bar is full: the selection panel (up to 580 px), the card (572 px) and the Stop button (72 px). North is up; one `Rgba8UnormSrgb` texel per tile, sampled nearest: the terrain darkened to 70%, owned land blended 35% toward its team colour and its border tiles 75%, buildings in team colour (sites half stone), every unit as a 2 x 2 dot in its team colour brightened 45% (no fog until M7; the local selection in the HUD text colour), a 1-texel ring of the HUD text colour around every Town that turns amber (`palette::ATTRITION`, steady) while the Town is being annexed, and the camera's view trapezoid. A primary press or drag moves the camera to the tile, keeping the zoom; with attack-move armed a primary click issues `AttackMove` there; a right click issues `orders::ground_order` (`Move`, Shift queues; `SetRally` with an own building selected). Orders need a human local slot and no outcome yet, since its observers run outside `WorldInputSet`; moving the camera works while spectating too, but not after the outcome, when the outcome overlay covers the minimap and takes its clicks. It repaints only when the tick, the camera, the window, the selection or the territory changed (`frame_stats: minimap repaints= paint_us mean= p95= max=` on exit; 194 / 347 / 435 us mean / p95 / max on the VM's `economy_walkthrough` under lavapipe, against the 200 / 500 us target). M7 adds the fog layer ([fog.md](fog.md)). | +| Above the bottom bar, centred | Message line | The last rejection or notice for a few seconds: "Outside your borders", "Not enough Ore", "Population cap reached", "Town annexed". Since M4b also "Letters I researched", "Masonry Age reached", "Watchtower destroyed" and "Muster Hall lost", and since M5a the annexation notices, "Town is being annexed", "Annexation stopped", "Town annexed" and "Town was lost" (see Notices below). | | Above the message line | Annexation readout (M5a) | Each running annexation timer the local player is part of, with the time left until the flip: "Annexing Town: 0:42" or "Town is being annexed: 0:42" (`markers::annex_readout_text`, the `annex.attacking` and `annex.defending` strings; the seconds are rounded up, so it reads 0:01 in the last step; `markers_follow_exposure_selection_and_the_timer` reads it for the attacker, the defender and a bystander, and before any timer runs). It sits 24 px above the message line because M4b's top bar has no room left; empty when no timer runs. | Costs and names on buttons come from `data/rules/*.ron` and `data/rules/strings/en.ron`; the HUD never hard-codes a number. @@ -57,11 +57,11 @@ M4b (`command_card.rs`, done): a selected own, complete Scriptorium (the `host` | Tooltip | Enabled: "Research Letters I, 25 s. Costs 60 Lore" with the quoted time. Greyed: the rejection's message plus what the level requires, the level before it and the age that unlocks it ("Not available in this age. Requires Arms I, Masonry Age"); a first level of the first age requires nothing more | | Click | `Research { building, tech }` (`command_card::card_command`, as for Train and Cancel); a greyed button puts its tooltip on the message line instead | -A finished line reads "Researched" because a line's top level is unlocked only in the last age and `check_research` checks the age before "already researched". The selection panel adds "Researched: Trade I, Letters I" for a Scriptorium, the top level done in each line it hosts, before the queue line. So that this fifth line never pushes the queue line below the window, the panel is up to `command_card::INFO_WIDTH_PX` = 580 px wide (300 px before M4b) and each of a Scriptorium's lines fits one row: the longest are "Researched: Arms III, Statecraft III, Trade III, Letters III" (60 characters) and "Queue: Statecraft III 99%, Letters III, Trade III, Arms III, Scribe" (67), at 8.4 px a character in the 14 px HUD font, and five rows of 16.8 px fit the 88 px from the bar's top padding to the bottom of the window (`the_scriptoriums_longest_panel_fits_the_bottom_bar`). The panel is a flex item that fills the room left of the card, its text capped at that width, so a window too narrow for the full width narrows the text and wraps it instead of laying it over the card. Per-slot queue Cancel buttons and the infinite-queue toggle are not in M4b (owner decision 9): issues #41 and #42. +A finished line reads "Researched" because a line's top level is unlocked only in the last age and `check_research` checks the age before "already researched". The selection panel adds "Researched: Trade I, Letters I" for a Scriptorium, the top level done in each line it hosts, before the queue line. So that this fifth line never pushes the queue line below the window, the panel is up to `command_card::INFO_WIDTH_PX` = 580 px wide (300 px before M4b) and each of a Scriptorium's lines fits one row: the longest are "Researched: Arms III, Statecraft III, Trade III, Letters III" (60 characters) and "Queue: Statecraft III 99%, Letters III, Trade III, Arms III, Scribe" (67), at 8.4 px a character in M4b's monospace 14 px FiraMono (563 px; since the art pass the HUD font is Inter, where it measures 406 px with Inter's advances, `fonts::measure`), and five rows of 16.8 px fit the 88 px from the bar's top padding to the bottom of the window (`the_scriptoriums_longest_panel_fits_the_bottom_bar`). The panel is a flex item that fills the room left of the card, its text capped at that width, so a window too narrow for the full width narrows the text and wraps it instead of laying it over the card. Per-slot queue Cancel buttons and the infinite-queue toggle are not in M4b (owner decision 9): issues #41 and #42. ## Age indicator and Advance button (M4b) -M4b (`age_hud.rs`, done): the age readout and the Advance button sit on the age strip (`age_hud::AgeStrip`), a panel as tall as the top bar (`AGE_STRIP_HEIGHT_PX` = 28 px) that hangs under the bar's right end, its content right-aligned with the pop readout above it; it grows leftward with the button's label and blocks world clicks as the bars do. The top bar holds only the resource readouts and the pop readout, as in M3b, and its texts never wrap (`TextLayout::no_wrap`): a bar too full for the window runs off its right end instead of splitting every readout onto a second line over the world. With the age on the top bar, the walkthrough's own stocks at tick 1838 left 88 px between the readouts and the age row while one capped rate adds 118 px (", capped at 70"), so a single capped rate wrapped the whole bar. Now the bar fits the 1280 px window with every stockpile at four digits, two rates held at a Trade Yield Cap of 100 and a pop of 100 / 100, with 33 px to spare (`the_top_bar_fits_four_digit_stocks_and_two_capped_rates`, at 8.4 px a character); a third capped rate at that size runs off the end. The `hud_click` points (open ground at the window centre, the empty bottom bar, the Stop button) are unchanged. +M4b (`age_hud.rs`, done): the age readout and the Advance button sit on the age strip (`age_hud::AgeStrip`), a panel as tall as the top bar (`AGE_STRIP_HEIGHT_PX` = 28 px) that hangs under the bar's right end, its content right-aligned with the pop readout above it; it grows leftward with the button's label and blocks world clicks as the bars do. The top bar holds only the resource readouts and the pop readout, as in M3b, and its texts never wrap (`TextLayout::no_wrap`): a bar too full for the window runs off its right end instead of splitting every readout onto a second line over the world. With the age on the top bar, the walkthrough's own stocks at tick 1838 left 88 px between the readouts and the age row while one capped rate adds 118 px (", capped at 70"), so a single capped rate wrapped the whole bar. Now the bar fits the 1280 px window with every stockpile at four digits, two rates held at a Trade Yield Cap of 100 and a pop of 100 / 100, with 33 px to spare (`the_top_bar_fits_four_digit_stocks_and_two_capped_rates`, at 8.4 px a character in M4b's monospace FiraMono); a third capped rate at that size ran off the end. Since the art pass the test measures every readout with Inter's real advances (`fonts::measure`), where the same bar is narrower. The `hud_click` points (open ground at the window centre, the empty bottom bar, the Stop button) are unchanged. | Part | Contents | | --- | --- | @@ -184,8 +184,8 @@ Keys the design fixes are given as such. Keys marked "proposed" are not fixed by | Camera pan | W A S D, arrow keys, edge scroll, two-finger trackpad scroll (`MouseScrollUnit::Pixel`) (`camera.rs`; S and A are shared with Stop and attack-move, see Open questions) | M2 | | Camera zoom | mouse wheel (`MouseScrollUnit::Line`), trackpad pinch (`PinchGesture`, Bevy `gestures` feature) | M2 | | Show or hide the debug grid (the 8-tile gizmo grid) | G (`ground::toggle_grid`, a view setting that works while spectating and after the outcome); the grid is off by default, `--grid` starts with it on (art-pass owner decision 11: no debug furniture on the stream) | art pass | -| Camera jump | left click on the minimap | M7 | -| Order via minimap | right click on the minimap | M7 | +| Camera jump | left click or drag on the minimap (`minimap::on_minimap_press`, `on_minimap_drag`) | art pass | +| Order via minimap | right click on the minimap; a left click with attack-move armed (`minimap::on_minimap_click`) | art pass | The macOS Cmd+Q menu item (`Quit Eonmark`, id `quit`) quits cleanly through Bevy's `AppExit`; see [macos-packaging.md](macos-packaging.md). Control groups: ctrl+1 to 9 assign, 1 to 9 recall (`selection::control_groups`, `Selection::groups`). Double-click selects every unit of the clicked kind whose projected position lies inside `Camera::logical_viewport_rect`. Edge-scroll margin and camera speed moving into `rules.ron` is a `good first issue`. @@ -199,7 +199,7 @@ Rendering of units between ticks: `present.rs` keeps the previous and current si The bundle declares `NSHighResolutionCapable` (see [macos-packaging.md](macos-packaging.md)). Bevy's `WindowResolution` keeps logical and physical size apart, so a 1280 x 800 window renders at 2560 x 1600 on a Retina display. `UiScale` multiplies `Val::Px` only, so every HUD dimension is authored in `Val::Px` and the UI scale setting is a single persisted multiplier. Percent and viewport units are avoided in the HUD for this reason. -Text uses the Inter variable font through a variable `TextFont`; the OFL license text ships beside the font file. Panels use Kenney UI Pack 9-slice images through `NodeImageMode::Sliced`. The palette is matte and judged on both a P3 display and an sRGB screenshot, because wgpu 29 can oversaturate on wide-gamut surfaces (see [../ART_STYLE.md](../ART_STYLE.md)). +Text uses the Inter variable font through a variable `TextFont`; the OFL license text ships beside the font file. Every HUD, overlay and menu text takes its `TextFont` from `fonts::hud_font`, which turns on Inter's tabular figures (`tnum`): with Inter's default proportional figures a stock going from 111 to 144 would move every readout right of it by about 10 px, so the top bar would twitch with each drop-off (`readouts_hold_their_place_when_a_number_changes`). Panels use Kenney UI Pack 9-slice images through `NodeImageMode::Sliced`. The palette is matte and judged on both a P3 display and an sRGB screenshot, because wgpu 29 can oversaturate on wide-gamut surfaces (see [../ART_STYLE.md](../ART_STYLE.md)). ## Health bars, rings and markers @@ -254,6 +254,7 @@ Not counted: the full-screen passes (tonemapping, upscaling, about 2 a frame) an - **Cross-check.** `scripts/draw_trace_xcheck.sh` builds the game with `bevy/detailed_trace`, whose `TrackedRenderPass` logs every draw, and requires the trace's per-frame sums to match the counter's (`draw_stats: frame total=...` at DEBUG level) within one frame's draws, in both modes. It stays for the Bevy 0.20 migration. - **Baseline, art-pass slice 1** (lavapipe, `render_smoke.sh`, 2026-10-26 and 2026-10-27; boxes and M4b looks; `docs/BUILD_TIMES.md` has the rows): `unit_looks` 29 draws a frame (27 indirect, 2 sorted); `enemy_outpost` 22 (`fight_max=22`); `art_bench` max 42, `fight_max=42` (32 indirect, 10 sorted) with all 200 of P0's units selected. Before the rings became meshes the same `art_bench` run read max 241 with `sorted_max=210`: the 200 gizmo rings were 200 of them, and the mesh rings cost one indirect draw. `mode=culling` on lavapipe. - **The stress scene.** `--scenario art_bench`: two full Towns (twelve complete buildings a side), three sites and 400 units fighting east of P0's Town, framed at 60 m (`app::ART_BENCH_FRAMING`); its director selects all 200 of P0's units at tick 40 (`director::art_bench_beats`), so the selection term is part of the count. +- **The gate (art slice 6).** `scripts/check_draws.sh ` fails a run whose `frame_stats: draws` line has `max` (and, with `--fight`, `fight_max`) at 500 or more, no counted frame, or `mode=none`. With `--fight` it also fails unless the fight was measured: `fight_max` present and non-zero (it is never above `max`, so its bound alone would add nothing, and the game prints it as 0 when no counted frame fell inside the fight window) and a `frame_stats: fight` line with fight frames and `ticks=..`, not `none`. CI's game-linux job runs it over every render smoke, `art_bench '' 200 120` included, and `scripts/art_checks.sh` over each of its windowed runs. With every building, prop and unit a model, `art_bench` peaks at 109 draws in its 60 m framing (`fight_max=109`, 97 indirect and 12 sorted at most) and at 115 at 40 m over P0's Town, `unit_looks` at 70 (lavapipe, 1280 x 720, 2026-10-28): a quarter of the budget. The owner re-reads the line, with its `mode=`, on the Mac ([../PLAYTEST.md](../PLAYTEST.md#owners-mac-checklist-art-pass)). ## Developer-only panels @@ -305,6 +306,8 @@ M5b: The proxy, with M5b owner decision 8's substitutions (`--screenshot-at` instead of `ci_testing`, the replay recorded from the fast-forward tick), is `just m5b-checks`; the owner's run is the [Owner's Mac checklist (M5b)](../PLAYTEST.md#owners-mac-checklist-m5b). +Art pass (pre-stream): the minimap (AP6), the draw-call counter (AP7) and clicking every unit kind (AP10) are acceptance lines of the ROADMAP's "Art pass (pre-stream)" section, not copied here. Their proxies are `just art-drive` (`click_check: ok 16/16` and `minimap_check: ok` with real X input) and `just art-checks` (the five `docs/screenshots/art-*.png`, the dev panel's among them, with every run's draws gated under 500); the owner's part is the [Owner's Mac checklist (art pass)](../PLAYTEST.md#owners-mac-checklist-art-pass). + M6: - [ ] Game speed x4 and pause work and the replay verifies with the same final hash as the x1 replay of the same scripted input (`--scenario scripted_match --speed 4`). @@ -331,12 +334,14 @@ cargo run --release -p game # M5b: the main me cargo run --release -p game -- --spectate # the Standard bot against itself cargo run --release -p game -- --spectate --seed 1 --fast-forward-to 25612 # m5_first_match's last 400 ticks just m5b-checks # the M5b proxy: overlay, menu, New Game click, m2_checks 3 and 4 +just art-checks # the art pass's proxies and screenshots (Linux: under xvfb-run on lavapipe) +just art-drive # the art pass's click and minimap checks with real X input (Linux, Xvfb + xdotool) ``` ## Open questions - Key overlap between camera pan and unit orders: M2 fixed S = Stop and A = attack-move while `camera.rs` also pans on W/A/S/D, so with units selected S both pans the camera down and stops them, and A pans left and arms attack-move. Options: pan on arrow keys and edge scroll only, move Stop and attack-move to other keys, or make the order keys win when a selection exists. The owner kept the overlap for M4b on 2026-10-22 (ROADMAP M4b Notes, decision 4) and revisits it at the controls playtest. Keys for infinite queue, idle Yeoman and the speed steps remain proposed. -- Whether the minimap draws the camera's view rectangle. Not in the design; cheap to add in M7 if the owner wants it. +- Whether the minimap draws the camera's view rectangle. Answered by the art pass (owner decision 7): it draws the view trapezoid, the four corners of the world view between the HUD bars cast to the ground (`minimap::view_quad`), wider at its far, northern edge as the perspective camera sees it. - Whether game speed x1/x2/x4 ships in release builds or stays `dev`-only. Listed as an owner decision in [../ROADMAP.md](../ROADMAP.md); this document assumes release. - Tooltip delay and whether tooltips also appear on enabled buttons (cost breakdown). M3b decides; `good first issue` for hover states exists. - Whether the selection panel shows a unit's attrition state as text in addition to the indicator on the unit. M5a ships the indicator only (`SimView::attrition` has the pulse, its interval and the owner for such a line); the text waits for the owner's playtest. diff --git a/docs/screenshots/art-battle-60m.png b/docs/screenshots/art-battle-60m.png new file mode 100644 index 0000000..dee4d25 Binary files /dev/null and b/docs/screenshots/art-battle-60m.png differ diff --git a/docs/screenshots/art-dev-panel.png b/docs/screenshots/art-dev-panel.png new file mode 100644 index 0000000..63d5971 Binary files /dev/null and b/docs/screenshots/art-dev-panel.png differ diff --git a/docs/screenshots/art-match-minimap.png b/docs/screenshots/art-match-minimap.png new file mode 100644 index 0000000..ed0240c Binary files /dev/null and b/docs/screenshots/art-match-minimap.png differ diff --git a/docs/screenshots/art-town-40m.png b/docs/screenshots/art-town-40m.png new file mode 100644 index 0000000..9eaa75d Binary files /dev/null and b/docs/screenshots/art-town-40m.png differ diff --git a/docs/screenshots/art-units-40m.png b/docs/screenshots/art-units-40m.png new file mode 100644 index 0000000..0c55550 Binary files /dev/null and b/docs/screenshots/art-units-40m.png differ diff --git a/justfile b/justfile index 66e8fee..dc3b6ce 100644 --- a/justfile +++ b/justfile @@ -113,6 +113,21 @@ m5b-checks OUT="": cargo build -p sim-cli --locked --release scripts/m5b_checks.sh target/ci/eonmark target/release/sim-cli "{{OUT}}" +# Art pass acceptance proxies, local only (ROADMAP "Art pass (pre-stream)"): data-check, the Yeoman swap walkthrough on a scratch copy of data/ (AP2), `inspect_gltf check` (AP3), check_assets and its self-test (AP1), then five windowed runs in background mode at 1280x720 that save art-units-40m.png, art-town-40m.png, art-battle-60m.png (with AP4's draw-call gate), art-match-minimap.png and, from a dev build, art-dev-panel.png (AP7), each verifying its replay; see scripts/art_checks.sh. On Linux without a display it runs under xvfb-run on lavapipe, about 9 minutes. The dev binary is kept as target/ci/eonmark-dev, since the plain build writes target/ci/eonmark. +art-checks OUT="": + cargo build -p game --locked --profile ci --features dev + cp target/ci/eonmark target/ci/eonmark-dev + cargo build -p game --locked --profile ci + cargo build -p sim-cli --locked --release + cargo build -p inspect_gltf --locked --release + scripts/art_checks.sh target/ci/eonmark target/release/sim-cli target/release/inspect_gltf "{{OUT}}" target/ci/eonmark-dev + +# Art pass real-input checks, local only (AP10 and AP6): xdotool clicks every unit of `--scenario unit_looks --click-check` (`click_check: ok 16/16`) and performs the four steps of `--scenario economy_walkthrough --minimap-check` (`minimap_check: ok`) under Xvfb on lavapipe, and verifies both replays (needs Xvfb and xdotool; Linux); see scripts/art_drive.sh. +art-drive OUT="": + cargo build -p game --locked --profile ci + cargo build -p sim-cli --locked --release + scripts/art_drive.sh target/ci/eonmark target/release/sim-cli "{{OUT}}" + # Determinism self-test: scripted ticks twice on two threads, hashes must match. selftest: cargo run -p sim-cli --locked -- selftest diff --git a/scripts/art_checks.sh b/scripts/art_checks.sh new file mode 100755 index 0000000..715c43c --- /dev/null +++ b/scripts/art_checks.sh @@ -0,0 +1,355 @@ +#!/usr/bin/env bash +# art_checks.sh: the automated proxies of the art pass's acceptance list +# (ROADMAP "Art pass (pre-stream)"), and the producer of its four sign-off +# screenshots and the dev-panel one. `just art-checks` builds the binaries +# and runs it; it is a local, PR-pasted check like scripts/m5b_checks.sh. +# The real-input checks (AP6's minimap, AP10's clicks) are +# scripts/art_drive.sh. +# +# 1. `sim-cli data-check data` prints OK (rules_version, rules_hash, the +# visuals counts and models=). +# 2. AP2, the swap walkthrough of docs/ART_STYLE.md on a scratch copy of +# data/: the "yeoman" line of visuals.ron becomes +# `Primitive(Capsule)`. The copy differs from data/ in that one line +# only, its data-check prints OK with the same rules_hash and one model +# fewer, and every golden fixture verifies OK against it (ADR 0003: +# visuals.ron is not in rules_hash). The checkout is not touched. +# 3. AP3: `inspect_gltf check data assets` prints `check: ok +# scene(s)`. +# 4. AP1: `scripts/check_assets.sh` and `scripts/check_assets.sh +# --self-test` pass. +# 5. art-units-40m.png: `--scenario unit_looks` (its own 40 m framing), +# tick 100. +# 6. art-town-40m.png: `--scenario art_bench --camera 24,64,40` (P0's +# Seat, its buildings, the sites and the forests at 40 m), tick 300. +# 7. art-battle-60m.png: `--scenario art_bench` in its own framing +# (60 m over P0's Town and the 200 vs 200 fight, every P0 unit +# selected at tick 40), tick 300, mid-fight. AP4's gate: +# scripts/check_draws.sh --fight (draws max < 500, and the fight +# measured: a non-zero fight_max under 500 and a `frame_stats: fight` +# line with fight frames and ticks); the triangles, fight and minimap +# lines are printed, and the minimap's paint p95 must be at most +# 500 us (AP6). +# 8. art-match-minimap.png: `--spectate --seed 1` (the m5_first_match +# setup) fast-forwarded to T/2, with a screenshot 200 ticks later, +# where T is the tick `sim-cli verify` of the m5_first_match golden +# prints: a match in progress with its minimap. +# 9. art-dev-panel.png (AP7): the dev build's Sim panel with its `draw +# calls` row over `--scenario art_bench`, tick 250 (the counter skips +# the first 30 frames, which on lavapipe last past tick 100). Skipped +# (not failed) when no dev binary is given. +# Every game run of steps 5 to 9 uses --background (docs/IMPLEMENTER_NOTES.md +# rule 25), --window-size 1280x720 (docs/ART_STYLE.md's screenshot +# size), --draw-stats and --screenshot-at, and must exit 0 with no ERROR +# line, save its PNG and print its `frame_stats: draws` line under 500; +# steps 5 to 8 also `sim-cli verify` the replay the run recorded. +# 10. No game and no Xvfb this script started is left: every game run +# of steps 5 to 9 passes `--replay-dir `, the dev binary +# (eonmark-dev) too, and every Xvfb is xvfb-run's with +# `-auth /xvfb.auth`, so `pgrep -f` on those finds exactly +# this run's processes. Neither a name match (`pgrep -x eonmark`, +# which misses eonmark-dev and flags other sessions' games and +# displays) nor `pgrep -f 'target/.*/eonmark'` (which matches this +# script's own command line) would. +# +# On Linux without a display every game runs under `xvfb-run` on Mesa's +# lavapipe (1280 x 800 screen). There `art_bench` draws about one frame a +# second with its 400 character models, and the sim advances at most +# 250 ms a frame, so tick 300 comes about 85 s in: its runs last +# ART_BENCH_SECONDS (120). The screenshots are not copied anywhere; the PR copies +# the four sign-off shots and the dev-panel shot to docs/screenshots/. +# +# Usage: scripts/art_checks.sh [out-dir] [eonmark dev binary] +# Env: ART_BENCH_SECONDS=120 how long each art_bench run lasts +# ART_LOOKS_SECONDS=60 how long the unit_looks run lasts +# ART_MATCH_SECONDS=90 how long the spectated match run lasts +# ART_DEV_SECONDS=120 how long the dev-panel run lasts +set -euo pipefail + +usage="usage: scripts/art_checks.sh [out-dir] [eonmark dev binary]" +bin="${1:?$usage}" +simcli="${2:?$usage}" +inspect="${3:?$usage}" +out="${4:-}" +devbin="${5:-}" +if [ -z "$out" ]; then + out="$(mktemp -d "${TMPDIR:-/tmp}/eonmark-art-checks.XXXXXX")" +fi +mkdir -p "$out" +out="$(cd "$out" && pwd)" +abs() { echo "$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"; } +bin="$(abs "$bin")" +simcli="$(abs "$simcli")" +inspect="$(abs "$inspect")" +if [ -n "$devbin" ]; then devbin="$(abs "$devbin")"; fi +bench_seconds="${ART_BENCH_SECONDS:-120}" +looks_seconds="${ART_LOOKS_SECONDS:-60}" +match_seconds="${ART_MATCH_SECONDS:-90}" +dev_seconds="${ART_DEV_SECONDS:-120}" + +root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$root" + +# Background mode for every windowed run (rule 25). +export EONMARK_BACKGROUND=1 + +fail=0 +bad() { echo "FAIL $*"; fail=1; } +ok() { echo "PASS $*"; } + +# Linux without a display: a virtual one on lavapipe. +wrap=() +if [ "$(uname)" = Linux ] && [ -z "${DISPLAY:-}" ]; then + icd=$(ls /usr/share/vulkan/icd.d/lvp_icd*.json 2>/dev/null | head -1 || true) + if [ -z "$icd" ] || ! command -v xvfb-run >/dev/null; then + echo "error: no display, and xvfb-run or lavapipe is missing (apt-get install xvfb mesa-vulkan-drivers)" >&2 + exit 1 + fi + # Its own auth file, so step 10 can tell this run's Xvfb from others. + : >"$out/xvfb.auth" + wrap=(xvfb-run -a -f "$out/xvfb.auth" -s "-screen 0 1280x800x24" env VK_ICD_FILENAMES="$icd") +fi + +# $1 as an extended regular expression that matches only itself. +ere_quote() { printf '%s' "$1" | sed 's/[][\.*^$+?(){}|]/\\&/g'; } + +# No ERROR line in the log $1. +no_errors() { + local errors + errors=$(sed 's/\x1b\[[0-9;]*m//g' "$1" | grep -E '^[^ ]+ +ERROR ' || true) + if [ -z "$errors" ]; then ok "no ERROR line"; else bad "ERROR lines in $1:"; echo "$errors"; fi +} + +# Run the game binary $1 with "${@:4}" into the log $2, saving the PNG $3: +# exit 0, no ERROR line, the PNG, and the draws line under 500 +# (scripts/check_draws.sh; a non-empty $fight adds --fight, which requires +# that the fight was measured too). Sets `status` and `log`. +status=0 +log="" +fight="" +run_game() { + local game=$1 png=$3 + log=$2 + shift 3 + rm -f "$png" + echo "+ ${wrap[*]:-} $game --background --window-size 1280x720 $* > $log" + status=0 + # `${wrap[@]+...}`: an empty array under `set -u` in macOS's bash 3.2. + ${wrap[@]+"${wrap[@]}"} "$game" --background --window-size 1280x720 "$@" >"$log" 2>&1 || status=$? + if [ "$status" -eq 0 ]; then ok "the game exited 0"; else bad "the game exited $status"; fi + no_errors "$log" + if [ -s "$png" ]; then + ok "screenshot $png ($(wc -c <"$png" | tr -d ' ') bytes)" + else + bad "no screenshot ($png)" + fi + grep -E '^frame_stats: (draws |triangles |fight |minimap |window logical=)' "$log" | sed 's/^/ /' || true + local gate=() verdict + if [ -n "$fight" ]; then gate=(--fight); fi + if verdict=$(scripts/check_draws.sh ${gate[@]+"${gate[@]}"} "$log" 2>&1); then + ok "$(tail -n 1 <<<"$verdict")" + else + bad "the draw-call gate:" + sed 's/^/ /' <<<"$verdict" + fi +} + +# `sim-cli verify` of the replay the run whose log is $log recorded. +verify_run() { + local replay verify_out + replay=$(sed -n 's/^replay: //p' "$log" | head -n 1) + if [ -n "$replay" ] && [ -f "$replay" ]; then + verify_out=$("$simcli" verify "$replay" 2>&1 || true) + echo " $verify_out" + case "$verify_out" in + "OK final_hash="*) ok "the run's replay verifies ($(basename "$replay"))" ;; + *) bad "sim-cli verify $replay did not print OK" ;; + esac + else + bad "no replay: line or file in $log" + fi +} + +# The minimap's paint p95 in the log $log is at most 500 us (AP6). +minimap_p95() { + local line p95 + line=$(grep '^frame_stats: minimap ' "$log" || true) + p95=$(sed -n 's/.* p95=\([0-9.]*\) .*/\1/p' <<<"$line") + if [ -n "$p95" ] && awk -v p="$p95" 'BEGIN { exit !(p <= 500) }'; then + ok "minimap paint p95=$p95 us <= 500" + else + bad "minimap paint p95=${p95:-missing} us, want <= 500 ($line)" + fi +} + +echo "==> 1. sim-cli data-check data" +dc=$("$simcli" data-check data 2>&1) || true +echo " $dc" +case "$dc" in + "OK rules_version="*) ok "data-check" ;; + *) bad "data-check did not print OK" ;; +esac +rules_hash=$(sed -n 's/.* rules_hash=\(0x[0-9a-f]*\) .*/\1/p' <<<"$dc") +models=$(sed -n 's/.* models=\([0-9]*\).*/\1/p' <<<"$dc") + +echo +echo "==> 2. AP2: the Yeoman swap walkthrough on a scratch copy of data/" +w="$out/ap2" +rm -rf "$w" +mkdir -p "$w" +cp -R data "$w/data" +sed 's|^ "yeoman": Scene(.*| "yeoman": Primitive(Capsule),|' data/visuals.ron >"$w/data/visuals.ron" +changed=$(diff -rq data "$w/data" || true) +lines=$(diff data/visuals.ron "$w/data/visuals.ron" | grep -c '^[<>]' || true) +echo " $changed" +diff data/visuals.ron "$w/data/visuals.ron" | grep '^>' | sed 's/^/ /' || true +if [ "$(wc -l <<<"$changed")" -eq 1 ] && [[ "$changed" == *"visuals.ron"* ]] && [ "$lines" -eq 2 ]; then + ok "the copy differs from data/ in one line of visuals.ron" +else + bad "the swap changed more than the yeoman line" +fi +dc2=$("$simcli" data-check "$w/data" --assets assets 2>&1) || true +echo " $dc2" +hash2=$(sed -n 's/.* rules_hash=\(0x[0-9a-f]*\) .*/\1/p' <<<"$dc2") +models2=$(sed -n 's/.* models=\([0-9]*\).*/\1/p' <<<"$dc2") +case "$dc2" in + "OK rules_version="*) ok "data-check of the swapped copy" ;; + *) bad "data-check of the swapped copy did not print OK" ;; +esac +if [ -n "$hash2" ] && [ "$hash2" = "$rules_hash" ]; then ok "rules_hash unchanged ($hash2)"; else bad "rules_hash moved: $rules_hash -> ${hash2:-missing}"; fi +if [ -n "$models" ] && [ -n "$models2" ] && [ "$models2" -eq $((models - 1)) ]; then + ok "models=$models2, one fewer: the Yeoman's model is no longer loaded" +else + bad "models=${models2:-missing}, want $((${models:-1} - 1))" +fi +verified=0 +total=0 +for f in crates/sim/tests/fixtures/*.eonreplay; do + total=$((total + 1)) + v=$("$simcli" verify --data "$w/data" "$f" 2>&1 || true) + echo " $(basename "$f"): $v" + case "$v" in "OK final_hash="*) verified=$((verified + 1)) ;; esac +done +if [ "$verified" -eq "$total" ] && [ "$total" -gt 0 ]; then + ok "all $total golden fixtures verify against the swapped copy" +else + bad "$verified of $total fixtures verify against the swapped copy" +fi + +echo +echo "==> 3. AP3: inspect_gltf check data assets" +ic=$("$inspect" check data assets 2>&1) && ic_status=0 || ic_status=$? +grep -E '^ok units\[' <<<"$ic" | sed 's/^/ /' || true +summary=$(tail -n 1 <<<"$ic") +echo " $summary" +if [ "$ic_status" -eq 0 ] && [[ "$summary" == "check: ok "* ]]; then ok "$summary"; else bad "inspect_gltf check (exit $ic_status)"; fi + +echo +echo "==> 4. AP1: scripts/check_assets.sh and its --self-test" +if ca=$(scripts/check_assets.sh 2>&1); then ok "$(tail -n 1 <<<"$ca")"; else bad "check_assets.sh:"; echo "$ca"; fi +if st=$(scripts/check_assets.sh --self-test 2>&1); then + grep -E 'unattributed|self-test' <<<"$st" | sed 's/^/ /' || true + ok "check_assets.sh --self-test" +else + bad "check_assets.sh --self-test:" + echo "$st" +fi + +echo +echo "==> 5. art-units-40m.png: unit_looks at 40 m, tick 100" +png="$out/art-units-40m.png" +fight="" +run_game "$bin" "$out/units.log" "$png" --scenario unit_looks --draw-stats \ + --replay-dir "$out" --exit-after-seconds "$looks_seconds" --screenshot-at "100:$png" +verify_run + +echo +echo "==> 6. art-town-40m.png: art_bench at --camera 24,64,40, tick 300" +png="$out/art-town-40m.png" +fight=1 +run_game "$bin" "$out/town.log" "$png" --scenario art_bench --camera 24,64,40 --draw-stats \ + --replay-dir "$out" --exit-after-seconds "$bench_seconds" --screenshot-at "300:$png" +verify_run + +echo +echo "==> 7. art-battle-60m.png: art_bench in its framing, tick 300 (AP4's gate)" +png="$out/art-battle-60m.png" +fight=1 +run_game "$bin" "$out/battle.log" "$png" --scenario art_bench --draw-stats \ + --replay-dir "$out" --exit-after-seconds "$bench_seconds" --screenshot-at "300:$png" +minimap_p95 +verify_run + +echo +echo "==> 8. art-match-minimap.png: --spectate --seed 1, fast-forwarded to T/2" +fixture_line=$("$simcli" verify crates/sim/tests/fixtures/m5_first_match.eonreplay 2>&1 || true) +echo " m5_first_match: $fixture_line" +case "$fixture_line" in + "OK final_hash="*" ticks="*) + ticks="${fixture_line##* ticks=}" + from=$((ticks / 2)) + at=$((from + 200)) + png="$out/art-match-minimap.png" + fight="" + run_game "$bin" "$out/match.log" "$png" --spectate --seed 1 --fast-forward-to "$from" --draw-stats \ + --replay-dir "$out" --exit-after-seconds "$match_seconds" --screenshot-at "$at:$png" + ff=$(grep '^fast_forward: ' "$log" || true) + echo " $ff" + case "$ff" in + "fast_forward: tick=$from "*) ok "fast-forwarded to tick $from, screenshot at tick $at" ;; + *) bad "expected 'fast_forward: tick=$from ...'" ;; + esac + minimap_p95 + verify_run + ;; + *) bad "the m5_first_match golden does not verify" ;; +esac + +echo +echo "==> 9. art-dev-panel.png: the dev build's Sim panel over art_bench, tick 250" +if [ -z "$devbin" ]; then + echo "SKIP no dev binary given (just art-checks builds one)" +else + # A `--features dev` binary links Bevy (`dynamic_linking`) and libstd + # dynamically; `cargo run` would point the loader at both. + libdir="$(rustc --print target-libdir)" + deps="$(dirname "$devbin")/deps" + LD_LIBRARY_PATH="$deps:$libdir${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + DYLD_FALLBACK_LIBRARY_PATH="$deps:$libdir${DYLD_FALLBACK_LIBRARY_PATH:+:$DYLD_FALLBACK_LIBRARY_PATH}" + export LD_LIBRARY_PATH DYLD_FALLBACK_LIBRARY_PATH + png="$out/art-dev-panel.png" + fight=1 + run_game "$devbin" "$out/dev.log" "$png" --scenario art_bench \ + --replay-dir "$out" --exit-after-seconds "$dev_seconds" --screenshot-at "250:$png" + grep '^hierarchy_check: ' "$log" | sed 's/^/ /' || true +fi + +echo +echo "==> 10. no game process left" +games_re="--replay-dir $(ere_quote "$out")( |\$)" +xvfb_re="-auth $(ere_quote "$out/xvfb.auth")( |\$)" +for _ in 1 2 3 4 5 6 7 8 9 10; do + if pgrep -f -- "$games_re" >/dev/null || pgrep -f -- "$xvfb_re" >/dev/null; then sleep 1; else break; fi +done +if left=$(pgrep -f -- "$games_re"); then + bad "a game this script started is still running:" + ps -o pid=,args= -p "${left//$'\n'/,}" || true +else + ok "no game this script started is running (pgrep -f -- '--replay-dir $out' is empty; eonmark and eonmark-dev alike)" +fi +if [ ${#wrap[@]} -gt 0 ]; then + if left=$(pgrep -f -- "$xvfb_re"); then + bad "an Xvfb this script started is still running:" + ps -o pid=,args= -p "${left//$'\n'/,}" || true + else + ok "no Xvfb this script started is running (pgrep -f -- '-auth $out/xvfb.auth' is empty)" + fi +fi + +echo +ls -l "$out"/art-*.png 2>/dev/null | awk '{print " " $5 " " $NF}' || true +if [ "$fail" -ne 0 ]; then + echo "art_checks: FAIL ($out)" + exit 1 +fi +echo "art_checks: ok, every step passed; the screenshots are in $out" diff --git a/scripts/art_drive.sh b/scripts/art_drive.sh new file mode 100755 index 0000000..4128aba --- /dev/null +++ b/scripts/art_drive.sh @@ -0,0 +1,320 @@ +#!/usr/bin/env bash +# art_drive.sh: the art pass's real-input checks (ROADMAP "Art pass +# (pre-stream)", AP10 and AP6), with real X input from xdotool on the real +# window, the scripts/m4b_outpost_drive.sh precedent. Synthetic pointer +# input fails under lavapipe (issue #33), so the game only prints what to +# click and judges what the clicks did; this script does the clicking. +# `just art-drive` builds both binaries and runs it. Local only, like +# scripts/m5b_checks.sh: pasted into the PR, not a CI job. +# +# 1. Click check (AP10): `--scenario unit_looks --click-check`. At tick +# 60 the game prints one `click_check: target unit= kind= +# team= px=,` line per unit, a pixel whose ray hits that +# unit's model first (crates/game/src/click_check.rs). The script +# clicks each target in turn and waits for the game's `click_check: +# selected ...` (or `miss ...`) line before the next. Pass +# when the game prints `click_check: ok 16/16` (all eight kinds of both +# teams) and exits 0. +# 2. Minimap check (AP6): `--scenario economy_walkthrough +# --minimap-check`. From tick 40 the game prints the minimap's rect, +# then one step at a time (crates/game/src/minimap_check.rs): +# `step 1 press px=,` (a left press must move the camera's +# focus into that quarter), `step 2 drag from=, to=,` (a +# left drag, the focus must follow), `step 3 right_click px=,` +# (with the director's Yeomen selected, exactly one Move to that tile +# must be queued) and `step 4 key=a left_click px=,` (A arms +# attack-move, then a left click on the minimap must queue one +# AttackMove). The script performs each step when it is printed. Pass +# when the game prints `minimap_check: ok` and exits 0. +# Each run must also exit with no ERROR line in its log, and `sim-cli +# verify` of the replay it recorded must print OK: the clicks were real +# commands through the recorder. +# 3. No game and no Xvfb this script started is left: the script keeps +# the process id and name of each game and Xvfb it starts and checks +# that none of them is still running, so another session's game or +# display on the same machine does not fail it. +# +# Each run gets its own Xvfb (1280 x 800, a free display picked with +# -displayfd), runs in background mode (docs/IMPLEMENTER_NOTES.md rule 25: +# the window opens unfocused in the top-left corner, so window pixels are +# screen pixels with no window manager) and has a 240 s deadline as a +# safety net; on the 4-vCPU VM the click check ends about 40 s in and the +# minimap check about 30 s in. Background windows start unfocused, so the +# script focuses the window (`xdotool windowfocus`) before it sends the A +# key. Run it from the repository root's checkout (`sim-cli verify` reads +# data/ from there). +# +# Needs Xvfb, xdotool, Mesa's lavapipe (mesa-vulkan-drivers) and the X +# libraries scripts/render_smoke.sh lists. Usage: +# scripts/art_drive.sh [out-dir] +# Build the binaries with `cargo build -p game --profile ci` and +# `cargo build -p sim-cli --release`. +set -euo pipefail + +usage="usage: scripts/art_drive.sh [out-dir]" +bin="${1:?$usage}" +simcli="${2:?$usage}" +out="${3:-}" +if [ -z "$out" ]; then + out="$(mktemp -d "${TMPDIR:-/tmp}/eonmark-art-drive.XXXXXX")" +fi +mkdir -p "$out" +out="$(cd "$out" && pwd)" +bin="$(cd "$(dirname "$bin")" && pwd)/$(basename "$bin")" +simcli="$(cd "$(dirname "$simcli")" && pwd)/$(basename "$simcli")" +for tool in Xvfb xdotool; do + command -v "$tool" >/dev/null || { echo "error: $tool not found (apt-get install xvfb xdotool)" >&2; exit 1; } +done +icd=$(ls /usr/share/vulkan/icd.d/lvp_icd*.json 2>/dev/null | head -1 || true) +[ -n "$icd" ] || { echo "error: lavapipe not found (apt-get install mesa-vulkan-drivers)" >&2; exit 1; } + +root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$root" + +fail=0 +bad() { echo "FAIL $*"; fail=1; } +ok() { echo "PASS $*"; } +say() { echo " drive: $*"; } + +xvfb="" +game="" +# " " of every process this script started, for step 3. +started=() +cleanup() { + if [ -n "$game" ]; then kill "$game" 2>/dev/null || true; fi + if [ -n "$xvfb" ]; then kill "$xvfb" 2>/dev/null || true; fi + wait 2>/dev/null || true + game="" + xvfb="" +} +trap cleanup EXIT + +# Start an Xvfb for the run in directory $1 and export its DISPLAY. +start_xvfb() { + local dir=$1 + rm -f "$dir/display" + Xvfb -displayfd 3 -screen 0 1280x800x24 3>"$dir/display" >"$dir/xvfb.log" 2>&1 & + xvfb=$! + started+=("$xvfb Xvfb") + for _ in $(seq 100); do + [ -s "$dir/display" ] && break + kill -0 "$xvfb" 2>/dev/null || break + sleep 0.1 + done + if ! [ -s "$dir/display" ] || ! kill -0 "$xvfb" 2>/dev/null; then + echo "error: Xvfb did not start (see $dir/xvfb.log)" >&2 + exit 1 + fi + DISPLAY=":$(tr -d '[:space:]' <"$dir/display")" + export DISPLAY +} + +# Wait until the log $1 has a line matching the regex $2 and print the +# last such line; returns 1 if the game exits first. +wait_for() { + local log=$1 pattern=$2 + until grep -qE "$pattern" "$log"; do + if ! kill -0 "$game" 2>/dev/null; then + say "the game exited while waiting for /$pattern/" + return 1 + fi + sleep 0.2 + done + grep -E "$pattern" "$log" | tail -n 1 +} + +# A press and release a few frames apart (lavapipe draws a few a second). +click() { + xdotool mousemove "$1" "$2" + sleep 0.6 + xdotool mousedown "${3:-1}" + sleep 0.3 + xdotool mouseup "${3:-1}" +} + +# The value of field $2 (`name=value`) in line $1. +field() { + local word + for word in $1; do + if [ "${word%%=*}" = "$2" ]; then + echo "${word#*=}" + return + fi + done +} + +# Wait for the game to exit; sets `status`. +status=0 +finish_game() { + status=0 + wait "$game" || status=$? + game="" +} + +# The common verdict lines for the run whose log is $1: exit code, no +# ERROR line, and `sim-cli verify` of the recorded replay. +judge_run() { + local log=$1 errors replay verify_out + if [ "$status" -eq 0 ]; then ok "the game exited 0"; else bad "the game exited $status"; fi + errors=$(sed 's/\x1b\[[0-9;]*m//g' "$log" | grep -E '^[^ ]+ +ERROR ' || true) + if [ -z "$errors" ]; then ok "no ERROR line in $log"; else bad "ERROR lines in $log:"; echo "$errors"; fi + replay=$(sed -n 's/^replay: //p' "$log" | head -n 1) + if [ -n "$replay" ] && [ -f "$replay" ]; then + verify_out=$("$simcli" verify "$replay" 2>&1 || true) + echo " $verify_out" + case "$verify_out" in + "OK final_hash="*) ok "the run's replay verifies ($(basename "$replay"))" ;; + *) bad "sim-cli verify $replay did not print OK" ;; + esac + else + bad "no replay file in $log" + fi +} + +echo "==> 1. click check: --scenario unit_looks --click-check (AP10)" +d1="$out/click" +rm -rf "$d1" +mkdir -p "$d1" +log="$d1/game.log" +start_xvfb "$d1" +say "Xvfb on $DISPLAY" +VK_ICD_FILENAMES="$icd" "$bin" --background --scenario unit_looks --click-check \ + --exit-after-seconds 240 --replay-dir "$d1" --screenshot-at "60:$d1/targets.png" \ + >"$log" 2>&1 & +game=$! +started+=("$game $(basename "$bin")") +if wait_for "$log" '^click_check: (target|FAIL)' >/dev/null; then + # The targets are printed together in one frame; let the frame finish. + sleep 1 + mapfile -t targets < <(grep '^click_check: target ' "$log") + say "${#targets[@]} targets" + for t in "${targets[@]}"; do + i=$(awk '{print $3}' <<<"$t") + px=$(field "$t" px) + click "${px%,*}" "${px#*,}" + if ! wait_for "$log" "^click_check: (selected|miss) $i " >/dev/null; then + bad "no verdict for target $i" + break + fi + grep -E "^click_check: (selected|miss) $i " "$log" | sed 's/^/ /' + sleep 0.8 + done +fi +finish_game +verdict=$(grep -E '^click_check: (ok|FAIL)' "$log" || true) +echo " ${verdict:-no click_check verdict}" +if [ "$verdict" = "click_check: ok 16/16" ]; then + ok "every unit kind of both teams was selected by a real click" +else + bad "expected 'click_check: ok 16/16'" +fi +judge_run "$log" +cleanup + +echo +echo "==> 2. minimap check: --scenario economy_walkthrough --minimap-check (AP6)" +d2="$out/minimap" +rm -rf "$d2" +mkdir -p "$d2" +log="$d2/game.log" +start_xvfb "$d2" +say "Xvfb on $DISPLAY" +VK_ICD_FILENAMES="$icd" "$bin" --background --scenario economy_walkthrough --minimap-check \ + --exit-after-seconds 240 --replay-dir "$d2" >"$log" 2>&1 & +game=$! +started+=("$game $(basename "$bin")") +steps_ok=0 +while :; do + l=$(wait_for "$log" '^minimap_check: (step 1 |FAIL)') || break + grep '^minimap_check: rect ' "$log" | sed 's/^/ /' + echo " $l" + case "$l" in *FAIL*) break ;; esac + px=$(field "$l" px) + click "${px%,*}" "${px#*,}" + + l=$(wait_for "$log" '^minimap_check: (step 2 |FAIL)') || break + echo " $l" + case "$l" in *FAIL*) break ;; esac + from=$(field "$l" from) + to=$(field "$l" to) + x=${from%,*} y=${from#*,} x2=${to%,*} y2=${to#*,} + xdotool mousemove "$x" "$y" + sleep 0.6 + xdotool mousedown 1 + sleep 0.4 + for s in 1 2 3 4 5 6; do + xdotool mousemove $((x + (x2 - x) * s / 6)) $((y + (y2 - y) * s / 6)) + sleep 0.4 + done + sleep 0.4 + xdotool mouseup 1 + + l=$(wait_for "$log" '^minimap_check: (step 3 |FAIL)') || break + echo " $l" + case "$l" in *FAIL*) break ;; esac + px=$(field "$l" px) + click "${px%,*}" "${px#*,}" 3 + + l=$(wait_for "$log" '^minimap_check: (step 4 |FAIL)') || break + echo " $l" + case "$l" in *FAIL*) break ;; esac + px=$(field "$l" px) + xdotool mousemove "${px%,*}" "${px#*,}" + sleep 0.6 + # Background mode opens the window unfocused, and X sends key presses + # to the focused window only. + win=$(xdotool search --name '^Eonmark$' | head -n 1 || true) + if [ -n "$win" ]; then xdotool windowfocus --sync "$win" 2>/dev/null || true; fi + xdotool key a + sleep 1.0 + xdotool mousedown 1 + sleep 0.3 + xdotool mouseup 1 + steps_ok=1 + break +done +if [ "$steps_ok" -eq 1 ]; then + wait_for "$log" '^minimap_check: (ok|FAIL)' >/dev/null || true +fi +finish_game +grep -E '^minimap_check: (pass|ok|FAIL)' "$log" | sed 's/^/ /' || true +grep '^frame_stats: minimap ' "$log" | sed 's/^/ /' || true +if grep -qx 'minimap_check: ok' "$log"; then + ok "press, drag, right click and attack-move click on the minimap did what they should" +else + bad "expected 'minimap_check: ok'" +fi +judge_run "$log" +cleanup + +echo +echo "==> 3. no game process left" +# The processes of `started` still running under their own name (a +# process id the system reused for another program does not count; the +# kernel keeps the first 15 bytes of a name). +alive() { + local entry pid name comm + for entry in "${started[@]}"; do + pid=${entry%% *} + name=${entry#* } + comm=$(ps -o comm= -p "$pid" 2>/dev/null || true) + if [ -n "$comm" ] && [ "$comm" = "${name:0:15}" ]; then echo "$pid $name"; fi + done +} +for _ in 1 2 3 4 5 6 7 8 9 10; do + if [ -n "$(alive)" ]; then sleep 1; else break; fi +done +left=$(alive) +if [ -n "$left" ]; then + bad "still running:" + sed 's/^/ /' <<<"$left" +else + ok "none of the ${#started[@]} games and Xvfb servers this script started is running" +fi + +echo +if [ "$fail" -ne 0 ]; then + echo "art_drive: FAIL ($out)" + exit 1 +fi +echo "art_drive: ok, click_check: ok 16/16 and minimap_check: ok, both replays verify ($out)" diff --git a/scripts/check_draws.sh b/scripts/check_draws.sh new file mode 100755 index 0000000..0fd6f60 --- /dev/null +++ b/scripts/check_draws.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# check_draws.sh: the draw-call gate of the art pass (ROADMAP "Art pass +# (pre-stream)", AP4): read the `frame_stats: draws` exit line that a run +# with --draw-stats printed (crates/game/src/draw_stats.rs) and fail unless +# its run maximum, and its fight-window maximum when there is one, is under +# the budget (500 draw calls a frame, docs/design/ui.md "Draw calls"). +# +# frame_stats: draws frames=75 mean=105.8 p95=109 max=109 indirect_max=97 +# direct_max=0 sorted_max=12 mode=culling fight_max=109 +# +# The line must exist, count at least one frame and name a GPU +# preprocessing mode other than `none` (GL only, where the counter would +# miss the indirect draws; never one of our targets). +# +# --fight makes the gate prove that the fight's frames were measured +# (AP4's heaviest frames: 200 vs 200 with projectiles, bars and lunges). +# `fight_max` is the largest draw count of a counted frame inside the +# fight window, so it is never above `max` and `fight_max < 500` alone +# adds nothing; and the game prints `fight_max=` whenever the scenario has +# a fight window (`art_bench` and the `enemy_outpost` pair), as 0 when no +# counted frame fell inside it. So with --fight the field must be there and +# non-zero (a counted frame always draws at least the HUD), and the run's +# `frame_stats: fight` line must show fight frames (`frames=` > 0) and the +# fight's ticks (`ticks=..`, not `none`): the armies fought +# and the counter saw it. +# CI's game-linux job runs this over every render smoke's log, and +# scripts/art_checks.sh over its `art_bench` run. +# +# Usage: scripts/check_draws.sh [--fight] [budget] +# budget defaults to 500; a frame passes with fewer draws than that. +set -euo pipefail + +usage="usage: scripts/check_draws.sh [--fight] [budget]" +need_fight=0 +if [ "${1:-}" = "--fight" ]; then + need_fight=1 + shift +fi +log="${1:?$usage}" +budget="${2:-500}" +case "$budget" in + '' | *[!0-9]*) echo "error: budget must be a whole number, got '$budget'" >&2; exit 2 ;; +esac +if [ ! -f "$log" ]; then + echo "error: no log at $log" >&2 + exit 2 +fi + +line=$(grep '^frame_stats: draws ' "$log" | tail -n 1 || true) +if [ -z "$line" ]; then + echo "check_draws: FAIL no 'frame_stats: draws' line in $log (was --draw-stats passed?)" + exit 1 +fi +echo "$line" + +# The value of field $1 (`name=value`), matched as a whole word so that +# `max` never reads `indirect_max`. +field() { + local want=$1 word + for word in $line; do + if [ "${word%%=*}" = "$want" ]; then + echo "${word#*=}" + return + fi + done +} + +# A whole number, or nothing when the field is missing or malformed. +number() { + case "$1" in + '' | *[!0-9]*) echo "" ;; + *) echo "$1" ;; + esac +} + +fail=0 +fight_ticks="" +fight_frames="" +frames=$(number "$(field frames)") +max=$(number "$(field max)") +mode=$(field mode) +fight_raw=$(field fight_max) +fight=$(number "$fight_raw") +if [ -n "$fight_raw" ] && [ -z "$fight" ]; then + echo "check_draws: FAIL fight_max=$fight_raw is not a number" + fail=1 +fi +if [ -z "$frames" ] || [ "$frames" -eq 0 ]; then + echo "check_draws: FAIL no counted frame (frames=${frames:-missing})" + fail=1 +fi +if [ -z "$mode" ] || [ "$mode" = none ]; then + echo "check_draws: FAIL mode=${mode:-missing}: the counter does not count that mode (GL only), so the gate fails rather than miscount" + fail=1 +fi +if [ -z "$max" ] || [ "$max" -ge "$budget" ]; then + echo "check_draws: FAIL max=${max:-missing}, budget < $budget" + fail=1 +fi +if [ -n "$fight" ] && [ "$fight" -ge "$budget" ]; then + echo "check_draws: FAIL fight_max=$fight, budget < $budget" + fail=1 +fi +if [ "$need_fight" -eq 1 ]; then + if [ -z "$fight_raw" ]; then + echo "check_draws: FAIL no fight_max field (not a scenario with a fight window)" + fail=1 + elif [ "$fight" = 0 ]; then + echo "check_draws: FAIL fight_max=0: no counted frame fell inside the fight window (the armies never fought, or the counter missed it)" + fail=1 + fi + fight_line=$(grep '^frame_stats: fight ' "$log" | tail -n 1 || true) + if [ -z "$fight_line" ]; then + echo "check_draws: FAIL no 'frame_stats: fight' line in $log" + fail=1 + else + echo "$fight_line" + line=$fight_line + fight_frames=$(number "$(field frames)") + fight_ticks=$(field ticks) + if [ -z "$fight_frames" ] || [ "$fight_frames" -eq 0 ] || [ -z "$fight_ticks" ] || [ "$fight_ticks" = none ]; then + echo "check_draws: FAIL the fight line shows no fight (frames=${fight_frames:-missing} ticks=${fight_ticks:-missing})" + fail=1 + fi + fi +fi +if [ "$fail" -ne 0 ]; then + exit 1 +fi +echo "check_draws: ok max=$max${fight:+ fight_max=$fight} < $budget mode=$mode${fight_ticks:+ fight ticks=$fight_ticks frames=$fight_frames} ($log)" diff --git a/tools/inspect_gltf/src/check.rs b/tools/inspect_gltf/src/check.rs index 21421e1..07ee075 100644 --- a/tools/inspect_gltf/src/check.rs +++ b/tools/inspect_gltf/src/check.rs @@ -76,6 +76,8 @@ pub struct Budgets { pub unit_team_permille: u32, /// Least team share of the Supply Wain (only its pennant), permille. pub wain_team_permille: u32, + /// Least team share of the Mangonel (catapult plus banner), permille. + pub mangonel_team_permille: u32, /// Least team share of a building, permille. pub building_team_permille: u32, /// Least team share of the Seat flag, permille. @@ -101,7 +103,12 @@ impl Default for Budgets { /// - edge props: `tree_single_B` 220. /// /// The team floors are the plan's (units 200‰, the Wain 80‰, buildings - /// 100‰); the Seat flag's 300‰ sits under its measured 457‰. + /// 100‰); the Seat flag's 300‰ sits under its measured 457‰. The + /// Mangonel's own floor is 120‰ (owner decision 15 of the art pass, + /// 2026-10-28: its banner shrinks to scale 450, about 123‰, "no crowd + /// clipping"; centred it still reaches 0.10 m past the 0.5 m radius, + /// which `check` notes, and a banner inside the radius would miss the + /// floor; every other unit keeps 200‰). fn default() -> Self { Budgets { file_bytes: 2 * 1024 * 1024, @@ -115,6 +122,7 @@ impl Default for Budgets { edge_prop_triangles: 300, unit_team_permille: 200, wain_team_permille: 80, + mangonel_team_permille: 120, building_team_permille: 100, flag_team_permille: 300, } @@ -653,6 +661,26 @@ pub fn check_visuals( let h = height(&bp.tris); banner_top = bounds(bp.tris.iter().flat_map(|(p, _)| p.iter())).map(|b| b.1[1]); mounts_desc.push_str(&format!(" banner={h:.2}m")); + if let Use::Unit(kind) = usage { + // A unit's banner moves with it, so no footprint + // holds it; how far it reaches from the unit's + // centre in plan view (any facing) says whether + // packed units' banners cross (owner decision 15). + let reach = bp + .tris + .iter() + .flat_map(|(p, _)| p.iter()) + .map(|v| v[0].hypot(v[2])) + .fold(0.0f32, f32::max); + let radius = kind.radius_tiles_x100 as f32 / 100.0; + mounts_desc.push_str(&format!(" banner_reach={reach:.2}m")); + if reach > radius + FOOTPRINT_TOLERANCE { + notes.push(format!( + "the banner reaches {reach:.2} m from the unit's centre, {:.2} m past its {radius:.2} m radius, so packed units' banners cross (a size for the owner to judge)", + reach - radius + )); + } + } if let Use::Building(kind) = usage { if let Some(past) = past_footprint(&bp.tris, kind) { problems.push(format!( @@ -754,6 +782,7 @@ pub fn check_visuals( if let Some(permille) = team_permille { let floor = match usage { Use::Unit(k) if k.id == "supply_wain" => budgets.wain_team_permille, + Use::Unit(k) if k.id == "mangonel" => budgets.mangonel_team_permille, Use::Unit(_) => budgets.unit_team_permille, Use::Building(_) => budgets.building_team_permille, Use::Flag | Use::Banner => budgets.flag_team_permille, @@ -1542,9 +1571,11 @@ mod tests { /// Art slice 4 (owner decision 12): a unit's `banner_at` plants the /// team banner on it too, and its team share is measured on model plus - /// banner against the unit floor (200‰). A unit's banner may stand - /// taller than the unit (a standard on a pole over a siege engine), - /// unlike a building's. + /// banner against the unit floor: 200‰ for every unit but the Mangonel, + /// whose own floor is 120‰ (owner decision 15, 2026-10-28). A unit's + /// banner may stand taller than the unit (a standard on a pole over a + /// siege engine), unlike a building's, and the line prints how far it + /// reaches from the unit's centre, with a note past the unit's radius. #[test] fn check_measures_a_unit_banner() { let dir = TempDir::new("check-unit-banner"); @@ -1553,9 +1584,9 @@ mod tests { box_gltf(&m, "cart", [2.0, 1.5, 2.0], 0.0, false); box_gltf(&m, "flag", [0.1, 0.5, 1.0], 0.0, true); let rules = rules::Rules::load(repo_root().join("data")).unwrap(); - let run = |banner: u32| { + let run = |kind: &str, banner: u32| { let text = format!( - r#"(units: {{ "mangonel": Scene(path: "m/cart.gltf", scale_x100: 100, y_offset_tiles_x100: 0, yaw_deg: 0, team: Some("t"), banner_at: Some((offset_x100: (-80, 0, 80), scale_x100: {banner}))) }}, + r#"(units: {{ "{kind}": Scene(path: "m/cart.gltf", scale_x100: 100, y_offset_tiles_x100: 0, yaw_deg: 0, team: Some("t"), banner_at: Some((offset_x100: (-80, 0, 80), scale_x100: {banner}))) }}, buildings: {{}}, team_swatches: {{ "t": (rects: [(4, 0, 4, 4)], desaturate_permille: 0) }}, banner: Some(Scene(path: "m/flag.gltf", scale_x100: 100, y_offset_tiles_x100: 0, yaw_deg: 270, team: Some("t"))))"# @@ -1570,25 +1601,47 @@ mod tests { ) .text() }; - // At 1.5x the banner lifts the neutral cart to 108‰: under 200‰. - let text = run(150); + // At 1.5x the banner lifts the neutral cart to 108‰: under even the + // Mangonel's 120‰. + let text = run("mangonel", 150); assert!( text.contains( - "FAIL units[\"mangonel\"] m/cart.gltf: team area 108‰ (model plus banner) is under the 200‰ floor" + "FAIL units[\"mangonel\"] m/cart.gltf: team area 108‰ (model plus banner) is under the 120‰ floor" + ), + "{text}" + ); + // At 1.9x it gives 161‰ (3.97 of 24.69 m² on team texels): over the + // Mangonel's floor, under every other unit's 200‰. + let text = run("mangonel", 190); + assert!( + text.lines() + .any(|l| l.starts_with("ok units[\"mangonel\"]") && l.contains(" team=161‰ ")), + "{text}" + ); + let text = run("skirmisher", 190); + assert!( + text.contains( + "FAIL units[\"skirmisher\"] m/cart.gltf: team area 161‰ (model plus banner) is under the 200‰ floor" ), "{text}" ); // At 4x it stands 2.00 m over the 1.5 m cart and passes: 17.6 of - // 40.8 m² on team texels. - let text = run(400); + // 40.8 m² on team texels. Turned by yaw 270 the 1.0 m deep flag lies + // along x, so its far corner at (-0.8 - 2.0, 0.8 + 0.2) is 2.97 m + // from the centre, 2.47 m past the Mangonel's 0.5 m radius. + let text = run("mangonel", 400); let line = text .lines() .find(|l| l.starts_with("ok units[\"mangonel\"]")) .unwrap_or_else(|| panic!("{text}")); assert!( - line.contains(" team=431‰ model_team=0‰ banner=2.00m "), + line.contains(" team=431‰ model_team=0‰ banner=2.00m banner_reach=2.97m "), "{line}" ); + assert!( + text.contains("note units[\"mangonel\"]: the banner reaches 2.97 m from the unit's centre, 2.47 m past its 0.50 m radius"), + "{text}" + ); assert!(!text.contains("taller than"), "{text}"); }