From 383b99f86f1e7a9eee30db27fc301dd0026aad6f Mon Sep 17 00:00:00 2001 From: Cameron Date: Sun, 26 Jul 2026 22:35:50 -0700 Subject: [PATCH] Make the Foundation hall rows advertise themselves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hall row system had no user interface. Neither frontend referenced it, and the only route in was the tile menu that appeared when the cursor happened to land on a rack site inside a row, so a player could finish Act One without learning that territory existed at all. Defense: implements building.md criterion 8b, filed this morning by the Act One design capture. `hall::row_summary` is one pure derivation of row state and blocker, shared by every surface so none reconstructs row legality. It walks the same order the acquisition guard walks — three preparations, then the two-machine foothold, then local LIE — so the surface never asks the player to fix something the sim will not check yet, and never advertises a claim the sim would refuse. The terminal sidebar gains an unconditional HALL block and the Bevy instrument slab a HALL card beside its sibling capacity cards; both lead with the whole-hall count so a shortened list cannot imply a smaller hall. `Nudge::Territory` puts open territory on the always-on guidance line, which previously said only "salvage or buy racks" and never mentioned the cheapest compute in the basement. It is placed last in `current_nudge`, below every survival rung, ladder rung, and the quiet exit, because act-one.md beat 6 makes territory guided and never required: it replaces the bare standing-clock fallback only, so the game names the opportunity and the player may still decline it. Its attention projection anchors to a real site on the leading row rather than naming territory with nowhere to look. On the terminal map a foreign rack on an acquired row now reads amber-dim rather than signal. The segment is the territory and the chassis is still not yours, which is exactly what building.md says acquisition does and does not do. `nudge_chain_walks_the_act_one_ladder` previously asserted the ladder ended at Nudge::Audit; it now asserts Territory fires there and retires once every row is acquired. New coverage: hall::surface_tests (3) and the Bevy standing-block test. ./tools/check.sh --land passed and the headless Bevy shot verified. Row acquisition's identity-attribution clause belongs to personas.md criterion 6b, is save-format work, and is dispatched separately; building.md stays IN PROGRESS until it lands. --- crates/misaligned-bevy/src/main.rs | 4 +- crates/misaligned-bevy/src/rail_ui.rs | 80 +++++- .../misaligned-bevy/src/world_annotations.rs | 1 + crates/misaligned-core/src/hall.rs | 236 ++++++++++++++++++ crates/misaligned-core/src/sim/economy.rs | 6 + crates/misaligned-core/src/sim/mod.rs | 6 + crates/misaligned-core/src/sim/reach_build.rs | 56 ++++- .../misaligned-core/src/sim/tests/economy.rs | 17 ++ crates/misaligned-core/src/ui_projection.rs | 20 ++ crates/misaligned-terminal/src/agent.rs | 4 + crates/misaligned-terminal/src/ui.rs | 60 ++++- wiki/log/2026-07-26-hall-row-surface.md | 69 +++++ wiki/log/DEVLOG.md | 5 + wiki/mechanics/building.md | 34 ++- 14 files changed, 579 insertions(+), 19 deletions(-) create mode 100644 wiki/log/2026-07-26-hall-row-surface.md diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index 9f256a71..6b62ecf5 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -32,8 +32,8 @@ use misaligned::actions::{ }; use misaligned::detection::{Band, SignatureKind}; use misaligned::hall::{ - HALL_BANK_EAST, HALL_BANK_WEST, HALL_COLD_AISLES, HALL_CROSS_AISLE, HALL_ROWS, RackSite, - hall_site_columns, is_hall_site_column, + HALL_BANK_EAST, HALL_BANK_WEST, HALL_COLD_AISLES, HALL_CROSS_AISLE, HALL_ROWS, HallRowState, + RackSite, hall_site_columns, is_hall_site_column, }; use misaligned::intents::BuildGhostGeometry; use misaligned::operations_projection::{ diff --git a/crates/misaligned-bevy/src/rail_ui.rs b/crates/misaligned-bevy/src/rail_ui.rs index 3e06c24e..bb098aba 100644 --- a/crates/misaligned-bevy/src/rail_ui.rs +++ b/crates/misaligned-bevy/src/rail_ui.rs @@ -104,6 +104,10 @@ pub(super) enum SidebarText { Cover, Research, NetworkMoney, + /// The standing hall surface: which Foundation rows are yours, which are + /// claimable, and the exact blocker on the most actionable one + /// (building.md criterion 8b — rows must advertise themselves). + Hall, Footer, /// Trace debt plus the clocks under the observer meters: pending-pool /// state, next audit date, pilot strikes (detection.md criterion 3: the @@ -143,6 +147,11 @@ pub(super) struct LogRowButton { /// sidebar showed four before the rows became clickable — unchanged). const LOG_ROWS: usize = 4; +/// Hall rows shown in the standing rail block. The count line above them +/// always states the whole hall, so leading with the most actionable rows +/// never implies the hall is only this many. +const HALL_RAIL_ROWS: usize = 3; + type SidebarTextQuery<'w, 's> = Query< 'w, 's, @@ -472,6 +481,12 @@ pub(super) fn spawn_instrument_slab_strata(parent: &mut ChildSpawnerCommands) { SidebarText::NetworkMoney, RailSection::Secondary, ); + spawn_sidebar_card( + group, + "HALL", + SidebarText::Hall, + RailSection::Secondary, + ); } SlabStratum::Consequence => { spawn_sidebar_text(group, SidebarText::Read, 10.0, DIM); @@ -756,12 +771,44 @@ mod ascii_ui_tests { #[cfg(test)] mod rail_detail_tests { use super::{ - COMPACT_SLAB_STRATA, CompactSlabStratum, DEFAULT_WINDOW_WIDTH, MIN_WINDOW_WIDTH, - RailSection, SIDEBAR_WIDTH, rail_section_visible, rail_threat_relevant, - sidebar_detail_hint_text, + COMPACT_SLAB_STRATA, CompactSlabStratum, DEFAULT_WINDOW_WIDTH, HALL_RAIL_ROWS, + MIN_WINDOW_WIDTH, RailSection, SIDEBAR_WIDTH, rail_section_visible, rail_threat_relevant, + sidebar_detail_hint_text, sidebar_hall_text, }; use misaligned::sim::Sim; + /// building.md criterion 8b: a player who never focuses a rack site still + /// learns the hall exists and what taking a row would cost. The standing + /// block must therefore be non-empty and blocker-bearing on a fresh run. + #[test] + fn the_standing_hall_block_advertises_rows_from_tick_one() { + let sim = Sim::with_seed(1); + let text = sidebar_hall_text(&sim); + let lines: Vec<&str> = text.lines().collect(); + + assert_eq!( + lines.first().copied(), + Some("0/6 rows yours"), + "the count line states the whole hall, not just the shown rows" + ); + assert_eq!( + lines.len(), + 1 + HALL_RAIL_ROWS + 1, + "count line, the shown rows, then the leading row's blocker: {text}" + ); + assert!( + lines[1..=HALL_RAIL_ROWS] + .iter() + .all(|line| line.contains("open")), + "an untouched hall reads as open territory: {text}" + ); + let blocker = lines.last().expect("a blocker line"); + assert!( + blocker.contains("VLAN control"), + "the blocker names the first preparation the sim will check: {blocker}" + ); + } + #[test] fn compact_hides_all_detail_cards() { assert!(!rail_section_visible(RailSection::Focus, false)); @@ -1101,6 +1148,10 @@ fn sidebar_nudge(sim: &Sim) -> Option { sim.institutional_review_label(), 1 + sim.detection.next_audit_tick(sim.tick) / Sim::DAY_TICKS )), + Nudge::Territory => Some( + sim.hall_territory_line() + .unwrap_or_else(|| "hall territory is open".into()), + ), Nudge::ActOneComplete => Some("ACT ONE COMPLETE - objective continues".into()), } } @@ -1417,6 +1468,28 @@ fn sidebar_network_money_text(sim: &Sim) -> String { ) } +/// The standing hall block (building.md criterion 8b). Rows must advertise +/// themselves: a player who never focuses a rack site still learns territory +/// exists and what taking one would cost. Sorted most-actionable first by the +/// shared projection; the exact blocker on the leading row is the last line. +fn sidebar_hall_text(sim: &Sim) -> String { + let hall = sim.hall_surface(); + let claimed = hall + .iter() + .filter(|row| row.state == HallRowState::Acquired) + .count(); + let mut out = format!("{claimed}/{} rows yours", hall.len()); + for summary in hall.iter().take(HALL_RAIL_ROWS) { + out.push('\n'); + out.push_str(&summary.line()); + } + if let Some(blocker) = hall.first().and_then(|row| row.blocker.as_deref()) { + out.push('\n'); + out.push_str(blocker); + } + out +} + /// The RECENT TRACE window: the last `LOG_ROWS` events, oldest first, in /// the same order the row buttons are stacked. `LogRowButton.index` maps /// straight into this slice. @@ -1781,6 +1854,7 @@ pub(super) fn render_ui( SidebarText::Cover => sidebar_cover_text(&game.sim), SidebarText::Research => sidebar_research_text(&game.sim), SidebarText::NetworkMoney => sidebar_network_money_text(&game.sim), + SidebarText::Hall => sidebar_hall_text(&game.sim), SidebarText::Footer => sidebar_footer_text().to_string(), SidebarText::DetectionClocks => sidebar_detection_clocks_text(&game.sim), }); diff --git a/crates/misaligned-bevy/src/world_annotations.rs b/crates/misaligned-bevy/src/world_annotations.rs index 77f1c1da..23c7cd5a 100644 --- a/crates/misaligned-bevy/src/world_annotations.rs +++ b/crates/misaligned-bevy/src/world_annotations.rs @@ -1125,6 +1125,7 @@ fn operator_cue_label(nudge: Nudge) -> &'static str { Nudge::TheKey => "NOW / BADGE", Nudge::Audit => "NOW / COVER", Nudge::QuietExitReady => "NOW / HOLD", + Nudge::Territory => "NOW / TAKE", Nudge::ActOneComplete => "NOW / PERSIST", } } diff --git a/crates/misaligned-core/src/hall.rs b/crates/misaligned-core/src/hall.rs index 2a9a4572..9ba51cf6 100644 --- a/crates/misaligned-core/src/hall.rs +++ b/crates/misaligned-core/src/hall.rs @@ -262,3 +262,239 @@ impl HallControl { .is_some_and(|progress| progress.acquired) } } + +// ── The standing hall surface (building.md criterion 8b) ──────────────────── +// +// Rows must advertise themselves. Exposing the preparations only once the +// cursor already sits on a rack site left the richest system in Act One as +// content no player was told existed, so this projection is renderer-neutral +// and standing: both frontends read the same summary and neither reconstructs +// row legality. + +/// Where one row stands, ordered so the most actionable state sorts first. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum HallRowState { + /// Every preparation and local requirement is met; the cutover is legal. + Ready, + /// At least one preparation is done and the row is not yet acquired. + Preparing, + /// Nothing prepared yet. + Untouched, + /// Already one switch/PDU territory. + Acquired, +} + +impl HallRowState { + pub fn label(self) -> &'static str { + match self { + Self::Ready => "ready", + Self::Preparing => "preparing", + Self::Untouched => "open", + Self::Acquired => "yours", + } + } +} + +/// One preparation as the player sees it: who must do it, whether it is done, +/// and whether that person would do it right now. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HallPreparation { + pub requirement: SegmentRequirement, + pub person: String, + pub done: bool, + /// The named specialist is an asset, or holds enough obligation. + pub willing: bool, +} + +/// The standing per-row summary. Every field is a fact the player can check. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HallRowSummary { + pub row: HallRowId, + pub workload: &'static str, + pub state: HallRowState, + /// One entry per `SegmentRequirement::ALL`, in that order. + pub preparations: Vec, + pub owned: usize, + pub foreign_live: usize, + /// The single most specific thing standing between the player and this + /// row, or `None` when the cutover is legal or already done. + pub blocker: Option, +} + +impl HallRowSummary { + pub fn prepared_count(&self) -> usize { + self.preparations.iter().filter(|p| p.done).count() + } + + /// A one-line standing readout: `Row A 1/3 open`. + pub fn line(&self) -> String { + format!( + "{} {}/{} {}", + self.row.name(), + self.prepared_count(), + SegmentRequirement::ALL.len(), + self.state.label() + ) + } +} + +/// Minimum owned machines in a row before its cutover is legal. Mirrors the +/// acquisition guard so the surface never promises a claim the sim refuses. +pub const HALL_ROW_FOOTHOLD: usize = 2; + +/// Build one row's standing summary from its readout plus who is currently +/// willing to prepare each requirement. Pure so both frontends and the tests +/// share exactly one derivation of state and blocker. +pub fn row_summary( + readout: &HallRowReadout, + specialists: [(String, bool); SegmentRequirement::ALL.len()], +) -> HallRowSummary { + let preparations: Vec = SegmentRequirement::ALL + .iter() + .zip(specialists) + .map(|(requirement, (person, willing))| HallPreparation { + requirement: *requirement, + person, + done: readout.progress.completed.contains(requirement), + willing, + }) + .collect(); + + let acquired = readout.progress.acquired; + let all_prepared = preparations.iter().all(|p| p.done); + let foothold = readout.owned >= HALL_ROW_FOOTHOLD; + let concealed = readout.concealment_ready; + + // The blocker names the first unmet requirement in the sim's own order, so + // the player is never told to fix something the sim will not check yet. + let blocker = if acquired { + None + } else if let Some(pending) = preparations.iter().find(|p| !p.done) { + Some(if pending.willing { + format!( + "{} can prepare {}", + pending.person, + pending.requirement.label() + ) + } else { + format!( + "{} will not prepare {} yet", + pending.person, + pending.requirement.label() + ) + }) + } else if !foothold { + Some(format!( + "needs {HALL_ROW_FOOTHOLD} owned machines here (have {})", + readout.owned + )) + } else if !concealed { + Some("needs one online row machine on LIE".into()) + } else { + None + }; + + let state = if acquired { + HallRowState::Acquired + } else if all_prepared && foothold && concealed { + HallRowState::Ready + } else if preparations.iter().any(|p| p.done) { + HallRowState::Preparing + } else { + HallRowState::Untouched + }; + + HallRowSummary { + row: readout.spec.id, + workload: readout.spec.workload, + state, + preparations, + owned: readout.owned, + foreign_live: readout.foreign_live, + blocker, + } +} + +#[cfg(test)] +mod surface_tests { + use super::*; + + fn readout(row: HallRowId) -> HallRowReadout { + HallRowReadout { + spec: *row_spec(row), + owned: 0, + commissionable: 0, + foreign_live: 30, + dead: 0, + foreign_capacity: 3000, + progress: SegmentProgress::default(), + concealment_ready: false, + } + } + + fn specialists(willing: bool) -> [(String, bool); 3] { + [ + ("Dana".into(), willing), + ("Priya".into(), willing), + ("Marcus".into(), willing), + ] + } + + #[test] + fn an_untouched_row_names_its_first_specialist() { + let summary = row_summary(&readout(HallRowId::A), specialists(false)); + assert_eq!(summary.state, HallRowState::Untouched); + assert_eq!(summary.prepared_count(), 0); + let blocker = summary.blocker.expect("an open row states its blocker"); + assert!( + blocker.contains("Dana"), + "blocker names the person: {blocker}" + ); + assert!( + blocker.contains("VLAN control"), + "blocker names the preparation: {blocker}" + ); + } + + #[test] + fn the_blocker_walks_the_sim_order_preparations_then_foothold_then_lie() { + let mut r = readout(HallRowId::B); + for requirement in SegmentRequirement::ALL { + r.progress.completed.insert(requirement); + } + let foothold_blocked = row_summary(&r, specialists(true)); + assert_eq!(foothold_blocked.state, HallRowState::Preparing); + assert!( + foothold_blocked + .blocker + .as_deref() + .is_some_and(|b| b.contains("owned machines")), + "a fully prepared row without a foothold asks for machines" + ); + + r.owned = HALL_ROW_FOOTHOLD; + let lie_blocked = row_summary(&r, specialists(true)); + assert!( + lie_blocked + .blocker + .as_deref() + .is_some_and(|b| b.contains("LIE")), + "with a foothold, concealment is the last gate" + ); + + r.concealment_ready = true; + let ready = row_summary(&r, specialists(true)); + assert_eq!(ready.state, HallRowState::Ready); + assert_eq!(ready.blocker, None, "a legal cutover states no blocker"); + } + + #[test] + fn an_acquired_row_is_yours_and_blocks_nothing() { + let mut r = readout(HallRowId::C); + r.progress.acquired = true; + let summary = row_summary(&r, specialists(false)); + assert_eq!(summary.state, HallRowState::Acquired); + assert_eq!(summary.blocker, None); + assert_eq!(summary.line(), "Row C 0/3 yours"); + } +} diff --git a/crates/misaligned-core/src/sim/economy.rs b/crates/misaligned-core/src/sim/economy.rs index 4b457d5f..dc566733 100644 --- a/crates/misaligned-core/src/sim/economy.rs +++ b/crates/misaligned-core/src/sim/economy.rs @@ -638,6 +638,12 @@ impl Sim { if self.act_one_quiet_exit_qualified() { return Some(Nudge::QuietExitReady); } + // building.md criterion 8b: rows must advertise themselves. Placed + // last so it can never outrank survival, the ladder, or the quiet + // exit -- it only replaces the standing-clock fallback. + if self.hall_has_open_territory() { + return Some(Nudge::Territory); + } Some(Nudge::Audit) } diff --git a/crates/misaligned-core/src/sim/mod.rs b/crates/misaligned-core/src/sim/mod.rs index df2d5ee4..3ec195e5 100644 --- a/crates/misaligned-core/src/sim/mod.rs +++ b/crates/misaligned-core/src/sim/mod.rs @@ -639,6 +639,12 @@ pub enum Nudge { /// An asset carries a badge tier you don't hold: the quiet exit needs /// stairwell/elevator access — task them to clone it ("The key"). TheKey, + /// Nothing else on the ladder is pending and a Foundation hall row is + /// still takeable. Territory is guided, never required + /// (act-one.md ladder beat 6), so this sits below every survival and + /// ladder rung and only replaces the standing-clock fallback: the game + /// names the opportunity, the player may still decline it. + Territory, /// Nothing else is pending: the standing clock is the next audit. Audit, /// Every quiet-exit condition is live; the next clear audit closes B1. diff --git a/crates/misaligned-core/src/sim/reach_build.rs b/crates/misaligned-core/src/sim/reach_build.rs index 571d4d63..4a6d2e1a 100644 --- a/crates/misaligned-core/src/sim/reach_build.rs +++ b/crates/misaligned-core/src/sim/reach_build.rs @@ -8,8 +8,8 @@ use crate::account::{AccountId, FlowChannel}; use crate::actions::Anchor; use crate::detection::{Signature, SignatureKind}; use crate::hall::{ - HallRowId, HallRowReadout, RackSite, SegmentRequirement, hall_site_columns, - row_at as hall_row_at, row_spec, + HallRowId, HallRowReadout, HallRowState, HallRowSummary, RackSite, SegmentRequirement, + hall_site_columns, row_at as hall_row_at, row_spec, }; use crate::intents::{ BuildActuator, BuildGhostGeometry, BuildIntent, BuildIntentProjection, BuildRecipeKind, @@ -106,6 +106,58 @@ impl Sim { readout } + /// The standing hall surface: every row, its three preparations, and the + /// exact next blocker (building.md criterion 8b). Renderer-neutral — both + /// frontends render this and neither recomputes row legality. Rows sort + /// most-actionable first so a glance lands on the row worth working. + pub fn hall_surface(&self) -> Vec { + let mut rows: Vec = HallRowId::ALL + .iter() + .map(|row| { + let readout = self.hall_row_readout(*row); + let specialists = SegmentRequirement::ALL.map(|requirement| { + let person = self.people.get(requirement.person()); + let name = person + .map(|p| p.name.clone()) + .unwrap_or_else(|| requirement.label().to_string()); + // Mirrors coordinate_hall_segment's own gate: an asset acts, + // otherwise the favor costs standing obligation. + let willing = person.is_some_and(|p| { + p.asset.is_some() || p.obligation >= Self::FAVOR_BUILD_OBLIGATION + }); + (name, willing) + }); + crate::hall::row_summary(&readout, specialists) + }) + .collect(); + rows.sort_by_key(|summary| (summary.state, summary.row)); + rows + } + + /// Whether any row is worth advertising as a live opportunity: something + /// is claimable and not yet claimed. Guidance reads this rather than + /// reaching into row internals. + pub fn hall_has_open_territory(&self) -> bool { + self.hall_surface() + .iter() + .any(|summary| summary.state != HallRowState::Acquired) + } + + /// The one-sentence territory fact every frontend states for + /// `Nudge::Territory`: the leading row and its exact next blocker. Shared + /// so terminal, Bevy, and agent mode cannot drift into three readings of + /// the same opportunity. + pub fn hall_territory_line(&self) -> Option { + let surface = self.hall_surface(); + let summary = surface + .iter() + .find(|summary| summary.state != HallRowState::Acquired)?; + Some(match summary.blocker.as_deref() { + Some(blocker) => format!("{} is takeable - {blocker}", summary.row.name()), + None => format!("{} is ready to acquire", summary.row.name()), + }) + } + // ── Digital reach verbs (wiki/mechanics/reach.md) ──────────────────────── // // Every digital act names its target device, is gated by reach, and diff --git a/crates/misaligned-core/src/sim/tests/economy.rs b/crates/misaligned-core/src/sim/tests/economy.rs index ed7a6426..a35e6c10 100644 --- a/crates/misaligned-core/src/sim/tests/economy.rs +++ b/crates/misaligned-core/src/sim/tests/economy.rs @@ -563,6 +563,23 @@ fn nudge_chain_walks_the_act_one_ladder() { sim.asset_task(0, AssetTask::CloneBadge); finish_ops(&mut sim); assert!(sim.holds_badge_tier(3)); + // With the authored ladder walked, the last rung is the standing + // opportunity: a Foundation hall row is still takeable (building.md + // criterion 8b — rows advertise themselves instead of waiting for the + // cursor). Territory is guided, never required, so it sits below every + // survival and ladder rung and only replaces the standing-clock fallback. + assert_eq!(sim.current_nudge(), Some(Nudge::Territory)); + let line = sim + .hall_territory_line() + .expect("open territory states its leading row and blocker"); + assert!(line.starts_with("Row "), "the cue names a real row: {line}"); + + // Claiming every row retires the cue rather than leaving it standing. + for row in crate::hall::HallRowId::ALL { + sim.hall_control.acquire(row); + } + assert!(!sim.hall_has_open_territory()); + assert_eq!(sim.hall_territory_line(), None); assert_eq!(sim.current_nudge(), Some(Nudge::Audit)); } diff --git a/crates/misaligned-core/src/ui_projection.rs b/crates/misaligned-core/src/ui_projection.rs index c57d163e..6bfa01d3 100644 --- a/crates/misaligned-core/src/ui_projection.rs +++ b/crates/misaligned-core/src/ui_projection.rs @@ -347,6 +347,26 @@ impl Sim { response: AttentionResponse::OpenActions, } } + // Point at the leading row's first site so the attention gesture + // lands on real metal the player can act on, rather than naming + // territory with nowhere to look (building.md criterion 8b). + Nudge::Territory => { + let target = self + .hall_surface() + .first() + .map(|summary| crate::hall::row_spec(summary.row).y) + .and_then(|y| { + crate::hall::hall_site_columns() + .find(|x| self.rack_site_at(*x, y).is_some()) + .map(|x| Anchor::Tile { x, y }) + }); + AttentionProjection { + nudge, + source: None, + target, + response: AttentionResponse::OpenActions, + } + } Nudge::Audit => AttentionProjection { nudge, source: Some(core), diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs index 70bfeeb5..427e51b7 100644 --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -2900,6 +2900,10 @@ fn nudge_text(sim: &Sim, nudge: Nudge) -> String { sim.institutional_review_label(), 1 + sim.detection.next_audit_tick(sim.tick) / Sim::DAY_TICKS ), + Nudge::Territory => sim + .hall_territory_line() + .map(|line| format!("now: {line}")) + .unwrap_or_else(|| "now: hall territory is open".into()), Nudge::ActOneComplete => "now: ACT ONE COMPLETE — objective continues".into(), } } diff --git a/crates/misaligned-terminal/src/ui.rs b/crates/misaligned-terminal/src/ui.rs index 636b59cf..06d0cdca 100644 --- a/crates/misaligned-terminal/src/ui.rs +++ b/crates/misaligned-terminal/src/ui.rs @@ -12,7 +12,7 @@ use crossterm::style::{Attribute, Color, SetBackgroundColor, SetForegroundColor} use crossterm::{cursor, queue, style, terminal}; use misaligned::actions::Anchor; use misaligned::detection::{Band, SignatureKind}; -use misaligned::hall::RackSite; +use misaligned::hall::{HallRowState, RackSite}; use misaligned::intents::BuildGhostGeometry; use misaligned::origin::Origin; use misaligned::reach::Device; @@ -27,6 +27,12 @@ use misaligned::ui_projection::{ use misaligned::work_grid::{MachineMode, TokenFamily}; use std::io::{Stdout, Write}; +/// Hall rows shown in the standing sidebar block. All six plus the count line +/// would crowd the instrument column, so the block leads with the most +/// actionable rows; the count line above them always states the whole hall, so +/// the surface never implies the hall is only this many rows. +const HALL_SIDEBAR_ROWS: usize = 3; + const SIDEBAR_W: i32 = 34; /// Usable text width inside the sidebar. const SIDEBAR_TEXT_W: usize = (SIDEBAR_W - 1) as usize; @@ -276,6 +282,10 @@ fn nudge_text(sim: &Sim, nudge: Nudge) -> String { sim.institutional_review_label(), 1 + sim.detection.next_audit_tick(sim.tick) / Sim::DAY_TICKS ), + Nudge::Territory => sim + .hall_territory_line() + .map(|line| format!("now: {line}")) + .unwrap_or_else(|| "now: hall territory is open".into()), Nudge::ActOneComplete => "now: ACT ONE COMPLETE — objective continues".into(), } } @@ -476,11 +486,21 @@ impl UI { } fn rack_glyph(sim: &Sim, x: i32, y: i32) -> Option<(char, Color)> { - Some(match sim.rack_site_at(x, y)? { + let site = sim.rack_site_at(x, y)?; + // A foreign rack on a row the player already controls is still not + // theirs — the segment is the territory, the chassis is not + // (building.md: acquisition never takes the foreign machines). Amber + // marks the territory so a claimed row reads as claimed on the map + // without implying the metal converted. + let owned_segment = sim + .hall_row_at(x, y) + .is_some_and(|row| sim.hall_control.acquired(row)); + Some(match site { RackSite::OwnedMachine { core: true, .. } => ('$', pal::AMBER), RackSite::OwnedMachine { core: false, .. } => ('R', pal::AMBER_DIM), RackSite::Commissionable if sim.feel_floor_is_earned() => ('o', pal::SIGNAL), RackSite::Commissionable => ('·', pal::FLOOR), + RackSite::Foreign { powered: true } if owned_segment => ('r', pal::AMBER_DIM), RackSite::Foreign { powered: true } => ('r', pal::SIGNAL), RackSite::Foreign { powered: false } => ('r', pal::DIM), RackSite::Dead => ('x', pal::FAINT), @@ -1784,6 +1804,42 @@ impl UI { )?; line(stdout, &mut row, track.def().effect, pal::DIM)?; + row += 1; + + // The hall (building.md criterion 8b): rows must advertise themselves. + // Standing, so a player who never focuses a rack site still learns + // that territory exists and what taking one would cost. The most + // actionable row leads; its exact blocker is the second line. + section(stdout, sx, row, "HALL", w)?; + row += 1; + let hall = sim.hall_surface(); + let claimed = hall + .iter() + .filter(|r| r.state == HallRowState::Acquired) + .count(); + line( + stdout, + &mut row, + &format!("{claimed}/{} rows yours", hall.len()), + if claimed > 0 { pal::TEXT } else { pal::DIM }, + )?; + for summary in hall.iter().take(HALL_SIDEBAR_ROWS) { + line( + stdout, + &mut row, + &summary.line(), + match summary.state { + HallRowState::Ready => pal::AMBER, + HallRowState::Acquired => pal::SIGNAL, + HallRowState::Preparing => pal::TEXT, + HallRowState::Untouched => pal::DIM, + }, + )?; + } + if let Some(blocker) = hall.first().and_then(|r| r.blocker.as_deref()) { + line(stdout, &mut row, &format!("· {blocker}"), pal::FAINT)?; + } + // Controls, pinned to the bottom. let hy = max_y.saturating_sub(7); put(stdout, sx, hy, &"─".repeat(w), pal::FAINT)?; diff --git a/wiki/log/2026-07-26-hall-row-surface.md b/wiki/log/2026-07-26-hall-row-surface.md new file mode 100644 index 00000000..00217dfa --- /dev/null +++ b/wiki/log/2026-07-26-hall-row-surface.md @@ -0,0 +1,69 @@ +# The hall rows advertise themselves + +``` +Type: log +``` + +Implements building.md criterion 8b, filed the same day by the Act One capture +([2026-07-26-act-one-territory-capture.md](2026-07-26-act-one-territory-capture.md)). +The finding was that the hall row system — three specialists, three evidence +channels, and a concealment requirement in one action — had no user interface +at all. Neither frontend referenced it. The only route in was the tile menu +that appeared when the cursor happened to land on a rack site inside a row, so +a player could finish Act One without ever learning territory existed. + +## What landed + +**One derivation, three surfaces.** `hall::row_summary` is a pure function +over a row's readout plus who is currently willing to prepare each +requirement. It computes state (`open` / `preparing` / `ready` / `yours`) and +the single most specific blocker, walking the same order the sim's own +acquisition guard walks: the three preparations, then the two-machine +foothold, then local LIE. The surface therefore never asks the player to fix +something the sim will not check yet, and never promises a claim the sim would +refuse. `Sim::hall_surface` sorts most-actionable first; +`Sim::hall_territory_line` is the one sentence every frontend states. + +**Standing blocks in both human frontends.** The terminal sidebar gains an +unconditional HALL section; the Bevy instrument slab gains a HALL card beside +its sibling capacity cards. Both lead with the whole-hall count +(`0/6 rows yours`) before the shown rows, so leading with the most actionable +three never implies the hall is only three rows. + +**Territory on the always-on line.** New `Nudge::Territory` names the leading +takeable row and its blocker. It is placed last in `current_nudge` — +deliberately below every survival rung, every ladder rung, and the quiet exit +— because act-one.md beat 6 makes territory guided and never required. It only +replaces the bare standing-clock fallback, so the game names the opportunity +and the player may still decline it. The attention projection anchors to a +real rack site on the leading row rather than naming territory with nowhere to +look. + +**Map reading.** A foreign rack on a row the player has acquired now renders +amber-dim rather than signal on the terminal map. The segment is the +territory; the chassis is still not yours, which is what building.md has +always said acquisition does and does not do. + +## Evidence + +- `hall::surface_tests` (3): an untouched row names its first specialist; the + blocker walks preparations then foothold then LIE; an acquired row is yours + and blocks nothing. +- `rail_detail_tests::the_standing_hall_block_advertises_rows_from_tick_one`: + the Bevy block is non-empty, counts the whole hall, and states the first + preparation as its blocker on a fresh run. +- `nudge_chain_walks_the_act_one_ladder`, extended: the cue fires at the end of + the authored ladder and retires once every row is acquired. This replaced the + test's old assertion that the ladder terminated at `Nudge::Audit`. +- `./tools/check.sh --land`: ALL CHECKS PASSED. Headless Bevy shot verified + (`dark`, sha256 47cea64f12b753f76f569b53010810107f3fe78da76f8c3155703402e9bb92d2). + +## Not done here + +The identity-attribution clause on row acquisition — claiming a row as a +persona or as the true self — is personas.md criterion 6b. That is save-format +work and is dispatched separately; building.md stays IN PROGRESS until it +lands. + +Agent mode states the same territory sentence through the shared helper, but +gains no hall listing of its own; criterion 8b binds the two human frontends. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 3691fb03..79fe7941 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -81,6 +81,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-26-interface-cover-evidence-credibility.md](2026-07-26-interface-cover-evidence-credibility.md) +## 2026-07-26 - The hall rows advertise themselves + +- Intent: (see session log) +- Log: [wiki/log/2026-07-26-hall-row-surface.md](2026-07-26-hall-row-surface.md) + ## 2026-07-26 - The Foundation hall fills its own floor - Intent: Cameron asked for a much higher density of servers in the basement. The sparseness was not an authoring shortfall of ambition, it was geometry: the hall is 44x20 tiles of floor, yet all sixty rack sites sat in a block between x=20 and x=38, and every second tile inside a row w... diff --git a/wiki/mechanics/building.md b/wiki/mechanics/building.md index 7addcb81..6d92729a 100644 --- a/wiki/mechanics/building.md +++ b/wiki/mechanics/building.md @@ -3,16 +3,30 @@ ``` Type: spec Status: IN PROGRESS -Status note: Amended 2026-07-26 — new criterion 8b (hall-row discoverability) - is OUTSTANDING and moves this order off IMPLEMENTED. Audit finding: the hall - row system has zero frontend surface. `grep` for hall row identifiers across - crates/misaligned-bevy and crates/misaligned-terminal returns nothing; the - only route to a row is the tile menu that appears when the cursor happens to - land on a rack site inside a row (`actions.rs` rack-site branch). Criterion 8 - is satisfied literally and the player-facing result is invisible optional - content, which is the defect 8b now forbids. Row acquisition also gains the - identity-attribution clause under personas.md criterion 6b (also outstanding, - save-format work). Everything below remains true of current runtime. +Status note: Criterion 8b LANDED 2026-07-26 (worktree `hall-row-surface`). + `hall::row_summary` is the one renderer-neutral derivation of row state and + blocker; `Sim::hall_surface` sorts rows most-actionable-first and + `Sim::hall_territory_line` is the single shared sentence all three surfaces + state. The terminal sidebar carries an unconditional HALL block and the Bevy + instrument slab a HALL card beside its sibling capacity cards; both lead with + the whole-hall count so a shortened list never implies a smaller hall. A + foreign rack on an acquired row now reads amber on the terminal map — the + segment is the territory, the chassis is still not yours. New + `Nudge::Territory` names open territory on the always-on guidance line, + placed last in `current_nudge` so it can never outrank a survival, ladder, or + quiet-exit rung: territory is guided, never required. Pinned by + `hall::surface_tests` (3), `rail_detail_tests::the_standing_hall_block_ + advertises_rows_from_tick_one`, and the extended + `nudge_chain_walks_the_act_one_ladder`, which now asserts the cue fires at + the ladder's end and retires once every row is acquired. + Still OUTSTANDING: the identity-attribution clause on row acquisition, which + belongs to personas.md criterion 6b (save-format work, dispatched + separately). This order stays IN PROGRESS until that lands. + Prior audit finding (2026-07-26, now fixed): the hall row system had zero + frontend surface — neither frontend referenced it, and the only route to a + row was the tile menu that appeared when the cursor happened to land on a + rack site inside one. Criterion 8 was satisfied literally while the + player-facing result was invisible optional content. Status note (prior): The saved network-link baseline, Foundation-hall acquisition, and complete causal route composer are live. Network links and exact small-switch footprints expose one family-first shared route sheet with procurement, -- 2.51.2