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,