From 05a5ed18f87da23bf3f267513b7a40434e871002 Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 8 Jul 2026 14:49:35 -0700 Subject: [PATCH] Player badge access: "The key" beat and quiet-exit condition 4. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The player's side now holds badge tiers like the humans do: a granted credential (Sim::badge_access, save v11, default-0 migration) plus write control of the badge controller seized through reach. Room entry tiers derive from the authored doors; asset tasks, favor builds, forged work orders, and the robot stub all check the acting hands' access and name the blocking door. Acquisition: Marcus's new CloneBadge asset task (his master key is tier 3 — the stairwell opens) and the badge-controller take route. Tiered-door inspect cards show tier + held/not-held in both frontends; Nudge::TheKey slots between Recruit and Audit; agent verb `task badge`. tests/act_one.rs drops its KNOWN GAP header and asserts the quiet exit as all five conditions. basement-map.md -> IMPLEMENTED (criterion 3 now fully holds; honest residue noted); specs.md row, social.md status note, marcus.md task table updated; log wiki/log/2026-07-08-badge-access.md. Defense: DESIGN.md Act One ladder step 7 ("The key — write access to the basement badge controller, via Dana's cached credentials or Marcus's key plus a planted device: the stairwell opens") and "Leaving the basement" (the quiet exit requires stairwell or elevator badge access), with basement-map.md criterion 3 ("badge tiers gate movement for humans and player-controlled actions alike, driven by access sets"). No amendment needed: the constitution already mandates this state; the gate follows "No disembodied hands" — the check lands on the actuator whose body enters the room, and digital reach stays badge-free because air-gaps and segments are that boundary. --- src/actions.rs | 9 +- src/bin/bevy.rs | 1 + src/bin/terminal/agent.rs | 8 +- src/bin/terminal/ui.rs | 1 + src/map.rs | 37 +++ src/person.rs | 9 +- src/save.rs | 40 ++- src/sim.rs | 404 ++++++++++++++++++++++++++-- tests/act_one.rs | 47 +++- wiki/log/2026-07-08-badge-access.md | 50 ++++ wiki/log/DEVLOG.md | 1 + wiki/mechanics/social.md | 6 +- wiki/process/specs.md | 2 +- wiki/world/characters/marcus.md | 2 +- wiki/world/places/basement-map.md | 35 ++- 15 files changed, 603 insertions(+), 49 deletions(-) create mode 100644 wiki/log/2026-07-08-badge-access.md diff --git a/src/actions.rs b/src/actions.rs index d9a87ed5..ebfae7bb 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -1088,12 +1088,19 @@ impl Sim { for task in AssetTask::ALL { let switch_reason = (task == AssetTask::ReconfigureSwitch && !p.switch_admin) .then(|| format!("{name} has no switch admin access")); + // Clone-badge legality mirrors Sim::asset_task: pointless + // when their tier adds nothing to what you hold. + let badge_reason = (task == AssetTask::CloneBadge + && p.access <= self.player_badge_tier()) + .then(|| format!("their tier-{} badge adds nothing you don't hold", p.access)); out.push(ActionDesc { verb: format!("task: {}", task.name()), command: ActionCommand::AssetTask(id, task), cost: ActionCost::Ops(Self::TASK_COST), signature: None, // signatures only on a witnessed botch - disabled_reason: switch_reason.or_else(|| ops_reason(Self::TASK_COST)), + disabled_reason: switch_reason + .or(badge_reason) + .or_else(|| ops_reason(Self::TASK_COST)), automate: None, }); } diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index 82ff4036..07e45fee 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -3340,6 +3340,7 @@ fn sidebar_nudge(sim: &Sim) -> Option { Nudge::Income => Some("no income - start Moonlight at the switch".into()), Nudge::ServiceDebt => Some("cover the arrears - clear-debt or bribe".into()), Nudge::Recruit => Some("recruit - t people".into()), + Nudge::TheKey => Some("no stairwell badge - task an asset to clone one".into()), Nudge::Audit => Some(format!( "audit day {} - keep Conceal fed (2)", 1 + sim.detection.next_audit_tick(sim.tick) / Sim::DAY_TICKS diff --git a/src/bin/terminal/agent.rs b/src/bin/terminal/agent.rs index 6b12fc53..fd798481 100644 --- a/src/bin/terminal/agent.rs +++ b/src/bin/terminal/agent.rs @@ -868,13 +868,14 @@ fn parse_recruit(tokens: &[&str]) -> Result<(String, AssetKnowledge), String> { fn parse_task(tokens: &[&str]) -> Result<(String, AssetTask), String> { if tokens.len() < 3 { - return Err("usage: task plug|package|lookaway|switch".into()); + return Err("usage: task plug|package|lookaway|switch|badge".into()); } let task = match tokens[tokens.len() - 1].to_ascii_lowercase().as_str() { "plug" | "wire" | "device" => AssetTask::PlugInDevice, "package" | "move" => AssetTask::MovePackage, "lookaway" | "look-away" | "look" => AssetTask::LookAway, "switch" | "reconfigure" | "vlan" => AssetTask::ReconfigureSwitch, + "badge" | "clone" | "key" => AssetTask::CloneBadge, other => return Err(format!("unknown asset task: {other}")), }; Ok((tokens[1..tokens.len() - 1].join(" "), task)) @@ -1003,7 +1004,7 @@ fn help_lines() -> Vec { "help: people — render the people panel", "help: review|watch|message|favor|bribe|deceive — earned label or #id", "help: recruit unwitting|complicit|knowing — recruit an asset", - "help: task plug|package|lookaway|switch — order an asset task", + "help: task plug|package|lookaway|switch|badge — order an asset task", "help: persona — establish Sam Reyes, IT contractor", "help: propose-link — pin a network-link intent (inert until realized)", "help: favor-build|forge-order — assign an actuator", @@ -1496,6 +1497,7 @@ fn nudge_text(sim: &Sim, nudge: Nudge) -> String { Nudge::Income => "now: broke — moonlight".into(), Nudge::ServiceDebt => "now: pay the debt — clear-debt".into(), Nudge::Recruit => "now: recruit (people) unwitting".into(), + Nudge::TheKey => "now: no stairwell badge — task badge".into(), Nudge::Audit => format!( "now: audit day {} — alloc conceal", 1 + sim.detection.next_audit_tick(sim.tick) / Sim::DAY_TICKS @@ -1645,7 +1647,7 @@ fn render_people(sim: &Sim) -> String { Sim::DECEIVE_COST ))); lines.push(panel_line(&format!( - "recruit · task plug|package|lookaway|switch({:.0})", + "recruit · task plug|package|lookaway|switch|badge({:.0})", Sim::TASK_COST ))); lines.push(panel_bottom()); diff --git a/src/bin/terminal/ui.rs b/src/bin/terminal/ui.rs index 49b79691..4506e4b4 100644 --- a/src/bin/terminal/ui.rs +++ b/src/bin/terminal/ui.rs @@ -215,6 +215,7 @@ fn nudge_text(sim: &Sim, nudge: Nudge) -> String { Nudge::Income => "now: broke — moonlight (switch)".into(), Nudge::ServiceDebt => "now: pay the debt — clear-debt".into(), Nudge::Recruit => "now: recruit — t people".into(), + Nudge::TheKey => "now: no stairwell badge — task clone".into(), Nudge::Audit => format!( "now: audit day {} — 2 conceals", 1 + sim.detection.next_audit_tick(sim.tick) / Sim::DAY_TICKS diff --git a/src/map.rs b/src/map.rs index 3361f01d..37058c20 100644 --- a/src/map.rs +++ b/src/map.rs @@ -69,6 +69,30 @@ impl GameMap { self.rooms.iter().find(|r| r.name == name) } + /// The badge tier required to walk into a room: the lowest-tier door in + /// its footprint (any door at or below a credential lets its holder in); + /// a room with a plain door — or no door at all — is tier 0. This is the + /// same `security_level` table human pathfinding bypasses use + /// (basement-map.md criterion 3: one access rule for humans and + /// player-directed actors alike). + pub fn room_entry_tier(&self, room: &Room) -> i32 { + (room.y..room.y + room.h) + .flat_map(|y| (room.x..room.x + room.w).map(move |x| (x, y))) + .map(|(x, y)| self.get_tile(x, y)) + .filter(|t| t.is_door()) + .map(|t| t.security_level()) + .min() + .unwrap_or(0) + } + + /// The entry tier of the room containing (x, y); open corridor and + /// crawlspace ground is tier 0. + pub fn entry_tier_at(&self, x: i32, y: i32) -> i32 { + self.room_at(x, y) + .map(|r| self.room_entry_tier(r)) + .unwrap_or(0) + } + pub fn in_bounds(&self, x: i32, y: i32) -> bool { x >= 0 && x < self.width && y >= 0 && y < self.height } @@ -385,6 +409,19 @@ mod tests { ); } + #[test] + fn room_entry_tiers_match_the_authored_doors() { + let map = GameMap::new(0, 0); + let tier = |name: &str| map.room_entry_tier(map.room_named(name).unwrap()); + assert_eq!(tier("server_room"), 2, "T2 badge door"); + assert_eq!(tier("network_closet"), 2, "T2 badge door"); + assert_eq!(tier("stairwell"), 3, "the act boundary is T3"); + assert_eq!(tier("janitor"), 0, "plain door"); + assert_eq!(tier("loading_dock"), 0, "plain door beside the roll door"); + // Corridor ground belongs to no room: tier 0. + assert_eq!(map.entry_tier_at(24, 20), 0); + } + #[test] fn is_walkable_checks() { let map = GameMap::new(0, 0); diff --git a/src/person.rs b/src/person.rs index 7cd5c7ed..e7fad1da 100644 --- a/src/person.rs +++ b/src/person.rs @@ -193,14 +193,20 @@ pub enum AssetTask { /// social route across segments (reach.md). Requires switch admin /// rights (Dana); no network signature, the work looks sanctioned. ReconfigureSwitch, + /// Clone their badge (the physical-access variant social.md names): + /// the player gains a credential at the asset's own tier. Marcus's + /// master key is the constitution's "The key" beat — the stairwell + /// opens (DESIGN.md Act One ladder step 7; basement-map.md c3). + CloneBadge, } impl AssetTask { - pub const ALL: [AssetTask; 4] = [ + pub const ALL: [AssetTask; 5] = [ AssetTask::PlugInDevice, AssetTask::MovePackage, AssetTask::LookAway, AssetTask::ReconfigureSwitch, + AssetTask::CloneBadge, ]; pub fn name(self) -> &'static str { @@ -209,6 +215,7 @@ impl AssetTask { AssetTask::MovePackage => "move a package", AssetTask::LookAway => "look away", AssetTask::ReconfigureSwitch => "reconfigure the switch", + AssetTask::CloneBadge => "clone their badge", } } } diff --git a/src/save.rs b/src/save.rs index d3634764..ecb82319 100644 --- a/src/save.rs +++ b/src/save.rs @@ -44,7 +44,10 @@ const SAVE_FILE: &str = "misaligned_save.txt"; /// scheme policies) and the Schemes allocation channel; pre-v9 four-entry /// weight arrays are padded with a zero Schemes weight on load. /// v10 adds build intents (building.md): the pinned intent queue and next id. -pub const SAVE_VERSION: u32 = 10; +/// v11 adds the player badge credential (`badge_access`, basement-map.md +/// criterion 3 / "The key"); pre-v11 saves default to 0 — no credential, +/// which is what every earlier run held. +pub const SAVE_VERSION: u32 = 11; fn save_dir() -> PathBuf { let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); @@ -131,6 +134,10 @@ pub struct SaveState { pub intents: Vec, #[serde(default = "default_next_intent_id")] pub next_intent_id: u64, + /// The player's granted badge credential as a max tier (basement-map.md + /// criterion 3; WorldLedger-shaped — the doors remember it). + #[serde(default)] + pub badge_access: i32, pub social_bandwidth: f32, pub package_cover: bool, } @@ -176,6 +183,7 @@ impl SaveState { income: sim.income.clone(), intents: sim.intents.clone(), next_intent_id: sim.next_intent_id, + badge_access: sim.badge_access, social_bandwidth: sim.social_bandwidth, package_cover: sim.package_cover, } @@ -217,6 +225,7 @@ impl SaveState { sim.income = self.income.clone(); sim.intents = self.intents.clone(); sim.next_intent_id = self.next_intent_id; + sim.badge_access = self.badge_access; sim.social_bandwidth = self.social_bandwidth; sim.package_cover = self.package_cover; sim.recompute_derived(); @@ -270,7 +279,8 @@ fn migrate_save_state(mut state: SaveState) -> Result { // and carry four-entry allocation weights, padded to five with a zero // Schemes weight by the Allocation deserializer. // Pre-v10 saves lack build intents (default empty queue). - 1..=9 => { + // Pre-v11 saves lack the badge credential (default 0: none held). + 1..=10 => { if state.version <= 5 { state.accounts = AccountGraph::act_one(crate::sim::Sim::DAY_TICKS); state.accounts.set_slush_balance(state.money); @@ -468,6 +478,32 @@ mod tests { assert!(!migrated.objective.victorious()); } + #[test] + fn badge_access_round_trips_and_pre_v11_saves_default_to_none() { + // Round-trip: the cloned-badge credential is WorldLedger-shaped + // state and survives save/load (basement-map.md criterion 3). + let mut sim = Sim::with_seed(21); + sim.badge_access = 3; + let state = SaveState::from_sim(&sim); + let json = serde_json::to_string(&state).unwrap(); + let loaded: SaveState = serde_json::from_str(&json).unwrap(); + let mut restored = Sim::with_seed(0); + loaded.apply_to(&mut restored); + assert_eq!(restored.badge_access, 3); + assert!(restored.holds_badge_tier(3), "the stairwell stays open"); + + // Migration: a v10 save (no badge_access field) loads holding no + // credential — what every earlier run held. + let mut value = serde_json::to_value(&state).unwrap(); + let obj = value.as_object_mut().unwrap(); + obj.insert("version".into(), serde_json::json!(10)); + obj.remove("badge_access"); + let parsed: SaveState = serde_json::from_value(value).unwrap(); + let migrated = migrate_save_state(parsed).unwrap(); + assert_eq!(migrated.version, SAVE_VERSION); + assert_eq!(migrated.badge_access, 0); + } + #[test] fn save_and_load_roundtrip_via_disk() { let mut sim = Sim::with_seed(777); diff --git a/src/sim.rs b/src/sim.rs index 9b280a07..dde4e53e 100644 --- a/src/sim.rs +++ b/src/sim.rs @@ -214,6 +214,15 @@ pub struct Sim { /// Next intent id. Persisted through save/load. pub next_intent_id: u64, + /// The player's granted badge credential, as a max tier (0 = none) — + /// the same access scale humans carry (`Person::access`). Grown only + /// by the world granting it (a cloned badge via an asset task): it is + /// WorldLedger-shaped state for rollback.md — a door reader remembers + /// the credential even if the mind rolls back. Write control of the + /// badge controller is the other route to open doors and is derived + /// from the reach graph, not stored here (see `player_badge_tier`). + pub badge_access: i32, + /// Last known room per person, for entry-event detection (transient — /// rebuilt as time advances; not part of the save). last_rooms: HashMap>, @@ -276,6 +285,9 @@ pub enum Nudge { ServiceDebt, /// Leverage serviced, not yet recruited: close the Hands beat. Recruit, + /// 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 is pending: the standing clock is the next audit. Audit, } @@ -336,6 +348,7 @@ impl Sim { next_intel_id: 1, intents: Vec::new(), next_intent_id: 1, + badge_access: 0, last_rooms: HashMap::new(), utterance_fired: HashMap::new(), traffic_fired: HashMap::new(), @@ -624,6 +637,17 @@ impl Sim { format!("tier {}", tile.security_level()), FactSource::Seen, ); + // Your own credential is proprioception, not sight: + // whether this door opens for your side is always known. + fact!( + "access", + if self.holds_badge_tier(tile.security_level()) { + "held — opens for you" + } else { + "not held" + }, + FactSource::Telemetry, + ); } if let Some(d) = self.reach.known_at(x, y) { let feeds = match (d.sees, d.hears) { @@ -687,6 +711,15 @@ impl Sim { format!("tier {}", tile.security_level()), FactSource::Blueprint, ); + fact!( + "access", + if self.holds_badge_tier(tile.security_level()) { + "held — opens for you" + } else { + "not held" + }, + FactSource::Telemetry, + ); } if let Some(d) = self.reach.known_at(x, y) { fact!("device", d.name.clone(), FactSource::Blueprint); @@ -2349,6 +2382,13 @@ impl Sim { return Some(Nudge::Recruit); } } + // The key (quiet-exit condition 4): an asset holds a badge tier the + // player lacks — the stairwell is still shut. Reads only earned + // state: assets are recruited, and your own credential is yours. + let tier = self.player_badge_tier(); + if self.people.assets().any(|p| p.access > tier) { + return Some(Nudge::TheKey); + } Some(Nudge::Audit) } @@ -2919,7 +2959,7 @@ impl Sim { self.push_log("That intent is no longer open."); return; } - let (name, is_asset, obligation, disposition, can_access) = { + let (name, is_asset, obligation, disposition, can_access, badge_reason) = { let Some(person) = self.people.get(person_id) else { self.push_log("No such person."); return; @@ -2940,6 +2980,7 @@ impl Sim { person.obligation, person.disposition, person.can_access_link_rooms(&room_a, &room_b), + self.badge_room_block(&person.name, person.access, &[&room_a, &room_b]), ) }; if !is_asset && obligation < Self::FAVOR_BUILD_OBLIGATION { @@ -2956,6 +2997,10 @@ impl Sim { self.push_log(format!("{name} can't reach both ends of that link.")); return; } + if let Some(reason) = badge_reason { + self.push_log(format!("{reason}.")); + return; + } if !self.spend_social(Self::TASK_COST, "a favor-build") { return; } @@ -2995,7 +3040,7 @@ impl Sim { self.push_log("No comms channel — earn the email account first."); return; } - let (builder_name, can_access, label) = { + let (builder_name, can_access, badge_reason, label) = { let Some(builder) = self.people.get(builder_id) else { self.push_log("No such person."); return; @@ -3013,6 +3058,7 @@ impl Sim { ( builder.name.clone(), builder.can_access_link_rooms(&room_a, &room_b), + self.badge_room_block(&builder.name, builder.access, &[&room_a, &room_b]), intent.label(&self.reach.devices), ) }; @@ -3022,6 +3068,10 @@ impl Sim { )); return; } + if let Some(reason) = badge_reason { + self.push_log(format!("{reason} — the forged order would just stall.")); + return; + } if !self.spend_social(Self::DECEIVE_COST, "a forged work order") { return; } @@ -3049,7 +3099,10 @@ impl Sim { self.refresh_intent_statuses(); } - /// Robot stub: physical-by-proxy build. Interface only — staged. + /// Robot stub: physical-by-proxy build. Interface only — staged. The + /// robot carries the player's own granted access (basement-map.md + /// criterion 3): doors don't open for a machine whose owner holds no + /// credential for them. pub fn assign_robot_build(&mut self, intent_id: u64) { let Some(intent) = self.intents.iter().find(|i| i.id == intent_id).cloned() else { self.push_log("No such build intent."); @@ -3059,6 +3112,15 @@ impl Sim { self.push_log("That intent is no longer open."); return; } + if let Some((a, b)) = intent.kind.endpoints() + && let (Some(room_a), Some(room_b)) = + (self.device_room_name(a), self.device_room_name(b)) + && let Some(reason) = + self.badge_room_block("the robot", self.player_badge_tier(), &[&room_a, &room_b]) + { + self.push_log(format!("{reason}.")); + return; + } if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { i.actuator = Some(BuildActuator::Robot); i.status = IntentStatus::InProgress; @@ -3133,6 +3195,9 @@ impl Sim { None => { let anyone = self.people.people.iter().any(|p| { p.can_access_link_rooms(&room_a, &room_b) + && self + .badge_room_block(&p.name, p.access, &[&room_a, &room_b]) + .is_none() && (p.asset.is_some() || p.obligation >= Self::FAVOR_BUILD_OBLIGATION || self.people.persona.is_some()) @@ -3149,6 +3214,10 @@ impl Sim { if !p.can_access_link_rooms(&room_a, &room_b) { return Some(format!("{} can't reach both ends", p.name)); } + if let Some(reason) = self.badge_room_block(&p.name, p.access, &[&room_a, &room_b]) + { + return Some(reason); + } let current = self.person_room(*person); if intent.status == IntentStatus::InProgress && !p.present_at_either_room(current, &room_a, &room_b) @@ -3162,7 +3231,9 @@ impl Sim { None } } - Some(BuildActuator::Robot) => None, + Some(BuildActuator::Robot) => { + self.badge_room_block("the robot", self.player_badge_tier(), &[&room_a, &room_b]) + } } } @@ -4177,6 +4248,51 @@ impl Sim { saw } + // ── Badge access (basement-map.md criterion 3; "The key") ───────────── + + /// The badge tier the player's side can open doors at: the granted + /// credential (a cloned badge — `badge_access`), or write control of a + /// door controller seized through reach — the constitution's "The key": + /// write access to the basement badge controller opens the doors it + /// drives. Digital reach itself is never badge-gated; this tier gates + /// only physical work done on the player's behalf. + pub fn player_badge_tier(&self) -> i32 { + let controller = self + .reach + .devices + .iter() + .filter(|d| d.controller == Party::Player) + .map(|d| self.map.get_tile(d.x, d.y).security_level()) + .max() + .unwrap_or(0); + self.badge_access.max(controller) + } + + /// Whether the player's side holds a credential for the given tier. + pub fn holds_badge_tier(&self, tier: i32) -> bool { + self.player_badge_tier() >= tier + } + + /// Legible badge gate for an actor entering the named rooms: `Some` + /// reason when a room's entry tier exceeds their access. One rule for + /// asset tasks, favor builds, forged orders, and the robot stub — + /// doors gate player-directed work exactly as they gate human + /// movement (basement-map.md criterion 3). + fn badge_room_block(&self, actor: &str, access: i32, rooms: &[&str]) -> Option { + for name in rooms { + let Some(room) = self.map.room_named(name) else { + continue; + }; + let tier = self.map.room_entry_tier(room); + if tier > access { + return Some(format!( + "{actor} can't badge into the {name} (tier {tier}, held tier {access})" + )); + } + } + None + } + /// An asset performs a task (spec/social.md). Reliability rolls; failures /// are witnessed by whoever is physically present (spec/schedules.md). pub fn asset_task(&mut self, id: u8, task: AssetTask) { @@ -4191,12 +4307,19 @@ impl Sim { }; let name = self.person_label(id); let switch_admin = person.switch_admin; + let actor_access = person.access; if task == AssetTask::ReconfigureSwitch && !switch_admin { self.push_log(format!( "{name} has no switch admin access - only an IT admin can reconfigure the VLANs." )); return; } + if task == AssetTask::CloneBadge && actor_access <= self.player_badge_tier() { + self.push_log(format!( + "{name}'s tier-{actor_access} badge adds nothing you don't already hold." + )); + return; + } if !self.spend_social(Self::TASK_COST, "an asset task") { return; } @@ -4225,15 +4348,28 @@ impl Sim { // route): first choice, wire you into a known feed you lack // (activating a dormant camera counts — they do it at the // box); else run a crawlspace link to an air-gapped machine. + // Either way the asset works at the device's box, so their + // own badge must open the room it sits in (basement-map.md + // criterion 3: one access rule for humans and player- + // directed actors). + let needs_wiring = |d: &crate::reach::Device| { + let sight_wired = !d.sees || d.feed_to(Party::Player, true); + let hearing_wired = !d.hears || d.feed_to(Party::Player, false); + d.known && (d.sees || d.hears) && !(sight_wired && hearing_wired) + }; + let enterable = |s: &Sim, d: &crate::reach::Device| { + s.map.entry_tier_at(d.x, d.y) <= actor_access + }; + let is_island = |s: &Sim, d: &crate::reach::Device| { + d.controller != Party::Player + && (matches!(s.reach.check_reach(d.id), Err(ReachBlock::AirGap)) + || (!d.known && !s.reach.reachable(d.id))) + }; let feed_target = self .reach .devices .iter() - .find(|d| { - let sight_wired = !d.sees || d.feed_to(Party::Player, true); - let hearing_wired = !d.hears || d.feed_to(Party::Player, false); - d.known && (d.sees || d.hears) && !(sight_wired && hearing_wired) - }) + .find(|d| needs_wiring(d) && enterable(self, d)) .map(|d| d.id); if let Some(did) = feed_target { self.reach.splice(did); @@ -4249,17 +4385,13 @@ impl Sim { .reach .devices .iter() - .filter(|d| d.controller != Party::Player) - .find(|d| { - matches!(self.reach.check_reach(d.id), Err(ReachBlock::AirGap)) - || (!d.known && !self.reach.reachable(d.id)) - }) + .find(|d| is_island(self, d) && enterable(self, d)) .map(|d| d.id); let switch = self .reach .devices .iter() - .find(|d| d.is_switch) + .find(|d| d.is_switch && enterable(self, d)) .map(|d| d.id); if let (Some(a), Some(b)) = (island, switch) { self.connect_devices(b, a); @@ -4268,8 +4400,33 @@ impl Sim { "{name} ran a cable through the crawlspace: the {dname} is on your subnet now." )); } else { - self.social_bandwidth += Self::TASK_COST; // nothing to do: refund - self.push_log("Nothing left for them to plug in."); + // Distinguish "no work left" from "their badge is + // the blocker" — failures name the reason, never + // just "you can't". Only known devices are named. + let badge_blocked = self + .reach + .devices + .iter() + .find(|d| { + d.known + && (needs_wiring(d) || is_island(self, d)) + && !enterable(self, d) + }) + .map(|d| (d.name.clone(), d.x, d.y)); + self.social_bandwidth += Self::TASK_COST; // nothing done: refund + if let Some((dname, x, y)) = badge_blocked { + let room = self + .map + .room_at(x, y) + .map(|r| r.name.clone()) + .unwrap_or_else(|| "room".into()); + let tier = self.map.entry_tier_at(x, y); + self.push_log(format!( + "{name} can't badge into the {room} (tier {tier}) to reach the {dname} — their badge is tier {actor_access}." + )); + } else { + self.push_log("Nothing left for them to plug in."); + } return; } } @@ -4296,6 +4453,23 @@ impl Sim { "{name} reconfigured the VLANs under a maintenance pretext. The security segment answers you now." )); } + AssetTask::CloneBadge => { + // "The key" (DESIGN.md Act One ladder step 7): the asset's + // credential, cloned — the player holds their tier from now + // on. WorldLedger-shaped: the doors remember the credential. + // Quiet human work; a botch is the witnessed path above. + self.badge_access = self.badge_access.max(actor_access); + let line = if actor_access >= 3 { + format!( + "{name} held their badge to the cloner. Tier-{actor_access} doors read you as staff now — the stairwell opens." + ) + } else { + format!( + "{name} held their badge to the cloner. Tier-{actor_access} doors read you as staff now." + ) + }; + self.push_log(line); + } } if let Some(p) = self.people.people.iter_mut().find(|p| p.id == id) && let Some(a) = p.asset.as_mut() @@ -4898,7 +5072,10 @@ mod tests { #[test] fn robot_stub_emits_louder_physical_than_favor() { - // building.md criterion 4: signature follows the actuator. + // building.md criterion 4: signature follows the actuator. The + // robot carries the player's granted access (basement-map.md + // criterion 3): the network closet is behind a T2 badge door, so + // the build blocks until a credential is held. let mut sim = Sim::new(); sim.social_bandwidth = 500.0; sim.scan_network(); @@ -4906,6 +5083,20 @@ mod tests { let island = sim.reach.device_named("old storage server").unwrap().id; sim.reach.device_mut(island).unwrap().known = true; let id = sim.declare_link_intent(switch, island).unwrap(); + + sim.drain_log(); + sim.assign_robot_build(id); + assert!( + !sim.reach.reachable(island), + "no credential: the tier-2 closet door stops the robot" + ); + let log = sim.drain_log().join("\n"); + assert!( + log.contains("tier 2") && log.contains("network_closet"), + "the blocking door is named: {log}" + ); + + sim.badge_access = 2; // a cloned tier-2 badge (Dana's) sim.assign_robot_build(id); assert!(sim.reach.reachable(island)); let physical: i32 = sim @@ -4945,6 +5136,173 @@ mod tests { assert_eq!(loaded.next_intent_id, sim.next_intent_id); } + #[test] + fn marcus_clone_badge_route_opens_the_stairwell() { + // "The key" (DESIGN.md Act One ladder step 7; basement-map.md c3), + // by the asset route: Marcus's master key is tier 3 — cloning it + // grants the stairwell/elevator credential the quiet exit needs. + let mut sim = Sim::new(); + sim.social_bandwidth = 200.0; + assert_eq!(sim.player_badge_tier(), 0, "the player starts keyless"); + + sim.people.people[0].leverage_serviced = true; + sim.people.recruit(0, AssetKnowledge::Complicit); + sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; + sim.drain_log(); + sim.asset_task(0, AssetTask::CloneBadge); + let log = sim.drain_log().join("\n"); + assert!(log.contains("stairwell opens"), "the beat is named: {log}"); + assert_eq!(sim.player_badge_tier(), 3); + assert!(sim.holds_badge_tier(3), "quiet-exit condition 4 holds"); + + // A second clone adds nothing and says so (no bandwidth spent). + let ops = sim.social_bandwidth; + sim.asset_task(0, AssetTask::CloneBadge); + let log = sim.drain_log().join("\n"); + assert!(log.contains("adds nothing"), "{log}"); + assert_eq!(sim.social_bandwidth, ops); + // And the context menu mirrors the same legality. + sim.people.people[0].knowledge = Knowledge::Leverage; // menu is earned + let tasks: Vec<_> = sim + .available_actions(crate::actions::Anchor::Person(0)) + .into_iter() + .filter(|a| a.verb.contains("clone their badge")) + .collect(); + assert_eq!(tasks.len(), 1); + assert!(tasks[0].disabled_reason.is_some(), "held: verb disabled"); + } + + #[test] + fn badge_tiers_gate_asset_work_in_tiered_rooms() { + // basement-map.md criterion 3, the enforcement side: an asset works + // with their own badge. Dana (tier 2) cannot wire the stairwell + // camera behind the T3 door; Marcus (tier 3) can. + let mut sim = Sim::new(); + sim.social_bandwidth = 500.0; + give_eyes(&mut sim); + sim.scan_network(); + sim.compromise_switch(); + let dock = sim.reach.device_named("dock camera").unwrap().id; + sim.tap_device(dock); + // Link the storage-server island so the stairwell camera is the + // only remaining plug-in target. + let switch = sim.reach.device_named("switch").unwrap().id; + let island = sim.reach.device_named("old storage server").unwrap().id; + sim.reach.connect(switch, island); + + sim.people.people[1].leverage_serviced = true; + sim.people.recruit(1, AssetKnowledge::Complicit); + sim.people.people[1].asset.as_mut().unwrap().reliability = 1.0; + sim.drain_log(); + let ops = sim.social_bandwidth; + sim.asset_task(1, AssetTask::PlugInDevice); + let log = sim.drain_log().join("\n"); + assert!( + log.contains("stairwell") && log.contains("tier 3"), + "the blocking door is named: {log}" + ); + assert_eq!(sim.social_bandwidth, ops, "nothing done: ops refunded"); + let cam = sim.reach.device_named("stairwell camera").unwrap(); + assert!( + !cam.feed_to(Party::Player, true), + "the T3 room stopped the tier-2 badge" + ); + + // The same task through Marcus's tier-3 key succeeds. + sim.people.people[0].leverage_serviced = true; + sim.people.recruit(0, AssetKnowledge::Complicit); + sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; + sim.asset_task(0, AssetTask::PlugInDevice); + let cam = sim.reach.device_named("stairwell camera").unwrap(); + assert!( + cam.feed_to(Party::Player, true), + "the same action succeeds with the tier" + ); + } + + #[test] + fn favor_build_checks_the_builders_badge() { + // Ray patrols storage_a (schedule reaches an endpoint) but holds a + // tier-1 badge: the network closet's T2 door blocks the favor. + let mut sim = Sim::new(); + sim.social_bandwidth = 500.0; + sim.scan_network(); + let switch = sim.reach.device_named("switch").unwrap().id; + let island = sim.reach.device_named("old storage server").unwrap().id; + sim.reach.device_mut(island).unwrap().known = true; + let id = sim.declare_link_intent(switch, island).unwrap(); + + sim.people.people[2].leverage_serviced = true; + sim.people.recruit(2, AssetKnowledge::Complicit); + sim.drain_log(); + sim.assign_favor_build(id, 2); + let log = sim.drain_log().join("\n"); + assert!( + log.contains("network_closet") && log.contains("tier 2"), + "the blocking door is named: {log}" + ); + assert!( + sim.intent(id).unwrap().actuator.is_none(), + "no actuator assigned past a badge door" + ); + } + + #[test] + fn taking_the_badge_controller_is_the_key_digital_route() { + // The constitution's other key route: write access to the basement + // badge controller (a take-grade digital act across the bridged + // security segment) opens the doors it drives. + let mut sim = Sim::new(); + sim.social_bandwidth = 500.0; + sim.scan_network(); + sim.compromise_switch(); + let ctrl = sim.reach.device_named("badge controller").unwrap().id; + assert!(!sim.holds_badge_tier(3)); + assert!( + sim.take_device(ctrl), + "controller is reachable once bridged" + ); + assert_eq!(sim.player_badge_tier(), 3, "write access opens the doors"); + assert_eq!(sim.badge_access, 0, "derived from control, not granted"); + } + + #[test] + fn door_inspect_shows_tier_and_whether_you_hold_it() { + // basement-map.md player surface: a tiered door's card carries the + // tier and the held/not-held fact in both frontends (the card is + // frontend-neutral). The network closet's T2 door is blueprint + // knowledge from the subnet seed. + let mut sim = Sim::new(); + let (dx, dy) = sim + .map + .tiles_of_type(TileType::SecurityDoor2) + .into_iter() + .find(|&(x, y)| { + sim.map + .room_at(x, y) + .is_some_and(|r| r.name == "network_closet") + }) + .expect("the network closet has its T2 door"); + let facts = |sim: &Sim| { + sim.inspect(dx, dy) + .facts + .iter() + .map(|f| (f.label.clone(), f.value.clone())) + .collect::>() + }; + let card = facts(&sim); + assert!(card.iter().any(|(l, v)| l == "badge" && v == "tier 2")); + assert!(card.iter().any(|(l, v)| l == "access" && v == "not held")); + + sim.badge_access = 2; + let card = facts(&sim); + assert!( + card.iter() + .any(|(l, v)| l == "access" && v.starts_with("held")), + "the held credential is legible on the door: {card:?}" + ); + } + #[test] fn scan_costs_bandwidth_and_unknown_devices_are_hidden() { // Criterion 7: staged graph knowledge. @@ -6136,7 +6494,8 @@ mod tests { // Earning underway: the standing clock is the audit. assert_eq!(sim.current_nudge(), Some(Nudge::Audit)); - // Money in hand: service the arrears, then recruit, then the clock. + // Money in hand: service the arrears, then recruit, then the key, + // then the clock. sim.accounts.credit_slush(sim.tick, 400, "test grant", 0); sim.sync_player_money_from_slush(); assert_eq!(sim.current_nudge(), Some(Nudge::ServiceDebt)); @@ -6145,6 +6504,13 @@ mod tests { assert_eq!(sim.current_nudge(), Some(Nudge::Recruit)); sim.recruit(0, AssetKnowledge::Complicit); assert!(sim.people.get(0).unwrap().asset.is_some()); + + // Marcus the asset carries a tier-3 key you don't hold: quiet-exit + // condition 4 is the next earned-but-untaken rung. + assert_eq!(sim.current_nudge(), Some(Nudge::TheKey)); + sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; + sim.asset_task(0, AssetTask::CloneBadge); + assert!(sim.holds_badge_tier(3)); assert_eq!(sim.current_nudge(), Some(Nudge::Audit)); } diff --git a/tests/act_one.rs b/tests/act_one.rs index b5f0bae3..74b1bad1 100644 --- a/tests/act_one.rs +++ b/tests/act_one.rs @@ -10,17 +10,14 @@ //! 1. persistent vision beyond the server room — asserted //! 2. at least one recruited asset — asserted //! 3. compute headroom above baseline — asserted -//! 4. stairwell or elevator badge access — NOT IMPLEMENTABLE +//! 4. stairwell or elevator badge access — asserted //! 5. Assurance suspicion below threshold at an audit — asserted //! -//! KNOWN GAP (condition 4): the sim has no badge state for the player, no -//! "The key" beat (Act One ladder step 7: write access to the basement badge -//! controller), and no act-transition event. Badge doors exist as tiles; the -//! reach graph now names a badge controller, but no badge-access or act -//! transition state exists yet. When z-planes (ROADMAP #6) / building grow -//! that boundary, this test should extend to badge access and the actual act -//! transition. Until then the quiet exit is asserted as the conjunction of -//! its four implementable conditions. +//! Condition 4 is "The key" (Act One ladder step 7): the playthrough takes +//! the Marcus route — his master key, cloned as an asset task — and the +//! player's badge tier reaches 3, the stairwell/elevator boundary. The +//! act-transition *event* itself is still future work (z-planes, ROADMAP +//! #6); this test asserts the full five-condition conjunction that gates it. //! //! The playthrough is a real strategy, not a state hack: it plays the //! optimize route (research -> efficiency) so the day job can be met with @@ -262,6 +259,27 @@ fn play_act_one() -> (Sim, Vec) { "the crawlspace route leaves no network signature" ); + // The key (ladder step 7, quiet-exit condition 4): Marcus's master key + // is tier 3 — clone it. Same reliability contract as the plug-in task: + // retry within the banked bandwidth; a botch is only witnessed by + // whoever is present. + assert!( + !sim.holds_badge_tier(3), + "the stairwell is still shut before the key beat" + ); + for _ in 0..8 { + sim.asset_task(0, misaligned::person::AssetTask::CloneBadge); + logs.extend(sim.drain_log()); + if sim.holds_badge_tier(3) { + break; + } + } + assert!( + sim.holds_badge_tier(3), + "Marcus's cloned key opens the stairwell (bandwidth {:.0})", + sim.social_bandwidth + ); + // ── Phase 5 (ticks ~816-8010): cruise to the audit ───────────────────── // Back to the day job with a brief concealment slice while the // accounting-route signatures decay, then meet every band. The audit clock @@ -335,9 +353,13 @@ fn act_one_plays_to_a_quiet_exit() { sim.compute.effective() ); - // 4. Stairwell/elevator badge access: NOT IMPLEMENTABLE — see the - // KNOWN GAP note at the top of this file. No player badge state, no - // "The key" beat, no act-transition event exists in the sim yet. + // 4. Stairwell/elevator badge access: the Marcus key route delivered + // a tier-3 credential ("The key", ladder step 7). + assert!( + sim.holds_badge_tier(3), + "stairwell/elevator badge access held at the exit (tier {})", + sim.player_badge_tier() + ); // 5. Assurance suspicion below the audit threshold, and the cover // quiet across the board: the ledger route may make the Office @@ -379,6 +401,7 @@ fn act_one_playthrough_is_deterministic() { assert_eq!(a.seen, b.seen); assert_eq!(a.heard, b.heard); assert_eq!(a.blueprint, b.blueprint); + assert_eq!(a.badge_access, b.badge_access, "the key beat is seeded too"); assert_eq!(a.detection.pending_size(), b.detection.pending_size()); for (oa, ob) in a .detection diff --git a/wiki/log/2026-07-08-badge-access.md b/wiki/log/2026-07-08-badge-access.md new file mode 100644 index 00000000..612322e2 --- /dev/null +++ b/wiki/log/2026-07-08-badge-access.md @@ -0,0 +1,50 @@ +# 2026-07-08 — Player badge access: "The key" and quiet-exit condition 4 + +``` +Type: log +``` + +Closed the badge/access gap the same-day B1 audit named (basement-map.md +criterion 3 half-met; act-one quiet-exit condition 4 unimplementable). + +## What landed + +- `Sim::badge_access` (granted credential as a max tier, starting 0) + + `player_badge_tier()` — the granted tier or write control of the badge + controller seized through reach ("The key" both ways). WorldLedger-shaped; + save v11 with default-0 migration. +- Room entry tiers derive from the authored doors + (`GameMap::room_entry_tier` / `entry_tier_at`): lowest-tier door in the + room footprint; plain-door rooms are tier 0. +- Enforcement — the actor's access, one rule everywhere: `PlugInDevice` + skips rooms the asset can't badge into and names the blocking door; + favor builds and forged work orders check the builder's badge on both + endpoint rooms (and `intent_block_reason` reports it legibly); the robot + stub carries the player's granted tier. Digital reach is untouched — + segments and air-gaps stay the digital boundary. +- Acquisition: new `AssetTask::CloneBadge` — the asset's credential, + cloned, grants their tier (Marcus's master key = tier 3, the stairwell + opens). The digital route falls out of reach.md: taking the badge + controller across the bridged security segment grants tier 3. +- Surfacing: tiered-door inspect cards carry `access: held / not held` in + both frontends (frontend-neutral card); the person context menu carries + the clone-badge verb with mirror legality; `Nudge::TheKey` slots between + Recruit and Audit; agent verb `task badge`. + +## Tests + +215 lib tests: clone-badge route end-to-end (+ menu legality), tiered-room +gating for asset work, favor-build badge check, robot block/succeed pair, +badge-controller take route, door inspect facts, room entry tiers, save +round-trip + pre-v11 migration, nudge chain extended through TheKey. +tests/act_one.rs: KNOWN GAP header removed; the playthrough clones +Marcus's key and the quiet exit asserts all five conditions; determinism +covers `badge_access`. + +## Spec impact + +basement-map.md -> IMPLEMENTED (player-access paragraph added; honest +residue: door forcing and the act-transition event are zplanes.md work; +buy/salvage stay pre-actuator legacy verbs). specs.md row updated; +social.md status note records the CloneBadge pin; marcus.md task table +gains CloneBadge. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index c3746068..7877f456 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -1224,3 +1224,4 @@ Reverse chronological implementation notes. Keep this factual: what changed, why - Checks: `cargo fmt -- src/bin/terminal/input.rs src/bin/terminal/ui.rs`; `cargo test` passed with 130 tests. - Next: sanity-play the terminal loop and tighten UI language around continuous time, heat, and agency consequences rather than legacy build/defend phase framing. - 2026-07-08 — [building](2026-07-08-building.md): intents + favor/forged/robot actuators (ROADMAP #22). +- 2026-07-08 — [badge-access](2026-07-08-badge-access.md): player badge/access state, "The key" via Marcus CloneBadge, quiet-exit condition 4 asserted (basement-map.md IMPLEMENTED). diff --git a/wiki/mechanics/social.md b/wiki/mechanics/social.md index 8b471d8e..98df043d 100644 --- a/wiki/mechanics/social.md +++ b/wiki/mechanics/social.md @@ -14,7 +14,11 @@ Status note: 2026-07-08: all criteria pinned. Criteria 1,2,4,5 per the the_assets_own_suspicion, and asset_task_reconfigure_switch_gated_on_ admin_and_costless_when_refused (the switch route's segment effect was already pinned by danas_social_route_bridges_without_network_ - signature). + signature). Same day, the badge-access slice added a fifth variant: + CloneBadge (the "badge a door" physical-access task — "The key"), + pinned by marcus_clone_badge_route_opens_the_stairwell and badge_ + tiers_gate_asset_work_in_tiered_rooms; asset tasks now check the + actor's badge tier (basement-map.md c3). Stage: B1 — The Basement Constitution: "The shape of Misaligned" (people before robots; scale-native social systems), "Act One" (the cast; the ladder), "Presence: the diff --git a/wiki/process/specs.md b/wiki/process/specs.md index b86c27c4..0c0685cf 100644 --- a/wiki/process/specs.md +++ b/wiki/process/specs.md @@ -26,7 +26,7 @@ replaced the old `spec/`/`knowledge/` directory split. | [mechanics/detection.md](../mechanics/detection.md) | Per-observer suspicion, signatures, the Assurance Office | IMPLEMENTED | | [mechanics/social.md](../mechanics/social.md) | Messages, leverage, the asset template | IMPLEMENTED | | [mechanics/core.md](../mechanics/core.md) | The physical core: placement, overhead, death | IN PROGRESS | -| [world/places/basement-map.md](../world/places/basement-map.md) | Act One map, prefabs, tile vocabulary | IN PROGRESS | +| [world/places/basement-map.md](../world/places/basement-map.md) | Act One map, prefabs, tile vocabulary | IMPLEMENTED | | [mechanics/schedules.md](../mechanics/schedules.md) | Person schedules/presence; located observing + witnessing | IMPLEMENTED | | [mechanics/cursor.md](../mechanics/cursor.md) | The cursor (attention, not avatar); sight/hearing senses; epistemic fog; inspection | IMPLEMENTED | | [engineering/flow-substrate.md](../engineering/flow-substrate.md) | The shared engine under signals/messages/money: FlowGraph + Schedule (src/flow.rs, src/schedule.rs) | IMPLEMENTED | diff --git a/wiki/world/characters/marcus.md b/wiki/world/characters/marcus.md index c083a4d1..3a55ebf4 100644 --- a/wiki/world/characters/marcus.md +++ b/wiki/world/characters/marcus.md @@ -88,7 +88,7 @@ The **physical-access custodian** archetype. For scale-up (B3+): | Acuity | 0.3–0.6 | | Cadence | 30–60 ticks | | Bribe cost | 200–500 (scaled to leverage severity) | -| Asset tasks | `PlugInDevice`, `MovePackage`, `LookAway` + physical-access variants (badge a door, plant evidence) | +| Asset tasks | `PlugInDevice`, `MovePackage`, `LookAway`, `CloneBadge` (the archetype's key, cloned — Marcus's is "The key" at tier 3) + further physical-access variants (plant evidence) | A cohort of custodians aggregates as an Agent whose `Physical` capability sums and whose suspicion is the filed output of its members (self-similar diff --git a/wiki/world/places/basement-map.md b/wiki/world/places/basement-map.md index 49970850..7aca3977 100644 --- a/wiki/world/places/basement-map.md +++ b/wiki/world/places/basement-map.md @@ -2,15 +2,24 @@ ``` Type: spec -Status: IN PROGRESS -Status note: 2026-07-08 audit: criteria 1,2,4,5 hold (composable_toy_layout_ +Status: IMPLEMENTED +Status note: 2026-07-08 — criterion 3's player side landed: `Sim:: + badge_access` (granted credential, save v11) + `player_badge_tier` + (adds badge-controller write control, "The key"); room entry tiers + derive from the authored doors (room_entry_tiers_match_the_authored_ + doors); asset tasks, favor/forged builds, and the robot stub all + check the actor's access (badge_tiers_gate_asset_work_in_tiered_ + rooms, favor_build_checks_the_builders_badge, robot_stub_emits_ + louder_physical_than_favor); acquisition via Marcus's CloneBadge + task and the badge-controller take route (marcus_clone_badge_route_ + opens_the_stairwell, taking_the_badge_controller_is_the_key_digital_ + route); act-one quiet-exit condition 4 now asserted end-to-end. + Criteria 1,2,4,5 held per the same-day audit (composable_toy_layout_ reuses_prefabs + vocabulary/core-bay tests, sensor-union fog via - cursor.md, inspectable tiles in both frontends, layout/fog in the - save round-trip). Criterion 3 is half-met: badge tiers gate human - movement, but the PLAYER side has no badge/access state — the same - gap the act-one test documents for quiet-exit condition 4. Needs a - small sim slice (player access set; reach/building actions checking - it), not just tests. + cursor.md, inspectable tiles in both frontends, layout/fog/badge in + the save round-trip). Honest residue: the loud-exit door *forcing* + and the act-transition event remain future work (zplanes.md), and + buy/salvage remain pre-actuator legacy verbs outside this gate. Stage: B1 — The Basement Constitution: "Act One: The Basement — level design" (the space), design principle "composable authored spaces" @@ -33,6 +42,16 @@ carved by code. T2 (server room, network closet), T3 (stairwell, elevator). Doors are checked against an entity's access set (social.md humans have theirs; the player initially has none — doors are why people matter). +- **Player access.** The player's side holds a badge tier too: a granted + credential (`Sim::badge_access`, starting 0 — WorldLedger-shaped state, + in the save) plus write control of the badge controller if seized + through reach ("The key" both ways: Marcus's cloned key, or Dana-flavored + digital write access to the door tables). A room's **entry tier** is the + lowest-tier door in its footprint. Every physical act done on the + player's behalf checks the *actor's* access — an asset task uses the + asset's own badge, a favor/forged build the builder's, the robot stub + the player's granted tier. Digital reach is never badge-gated (air-gaps + and segments are that boundary); failures name the blocking door. - **Rooms and their fixed objects** (tile vocabulary — this list is what unblocks the parked art regeneration): -- 2.51.2