diff --git a/CHANGELOG.md b/CHANGELOG.md index 131588c..13c1f73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ project uses version-gated milestones (see ROADMAP.md), not dates. ## [Unreleased] +- The veil opens the same rooms on every face, and a sibling fork is no + longer mistaken for a parent. The gate that decides who is inside the + veil was already shared, but the door was not: a learner the terminal + admitted to an unlisted room was told over MCP that the room does not + exist, so one player with one standing got two answers depending on + which face they asked through. describe, reveal, and play now open + through one door on both faces, and a player outside the veil still + finds the room unlisted, because the fix opens the same door rather + than a wider one. On the Gallery wall, two unedited forks of the same + untitled creation carry identical links and identical descents, so + matching on the link alone let each adopt the other: both wore a + remix badge, the real parent went uncredited, and D walked a circle + between them forever. A candidate that descends from the same + creation stands beside this one, not above it. - A progress file that cannot be read is no longer treated as a player who has none. An existing journey that fails to load (invalid UTF-8, oversized, permission denied) came back as a default, which is diff --git a/faces/app/src/gallery.rs b/faces/app/src/gallery.rs index f0b1e81..12ae44c 100644 --- a/faces/app/src/gallery.rs +++ b/faces/app/src/gallery.rs @@ -81,14 +81,19 @@ fn resolve_lineage(entries: &mut [GalleryEntry]) { let Some(descends) = entries[index].creation.descends() else { continue; }; - // Never match an entry to itself: an unedited fork of an untitled - // creation carries its parent's exact canonical link as its own, and + // Neither itself nor a sibling. An unedited fork of an untitled + // creation carries its parent's exact canonical link as its own, so // a self-parent would credit the remix to the wrong tile and give D - // a step that goes nowhere. - let parent = links - .iter() - .enumerate() - .position(|(candidate, link)| candidate != index && link == descends); + // a step that goes nowhere, and two such forks on one wall would + // adopt each other: both wearing a remix badge, the real parent + // uncredited, and D walking a circle between them forever. A + // candidate that descends from the same creation stands beside this + // one, not above it. + let parent = links.iter().enumerate().position(|(candidate, link)| { + candidate != index + && link == descends + && entries[candidate].creation.descends() != Some(descends) + }); entries[index].parent = parent; if let Some(parent_index) = parent { entries[parent_index].remixes += 1; @@ -685,6 +690,59 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn two_twin_forks_do_not_adopt_each_other() { + // Two unedited forks of the same untitled creation carry identical + // links and identical descents. Matching on the link alone let each + // adopt the other: both wore a remix badge, the real parent went + // uncredited, and D walked in a circle between them forever. A + // sibling stands beside, not above. + let dir = scratch("twins"); + let parent = StudioCreation::new("sin(a*x)", -2.0, 2.0, 0.5).expect("parent"); + std::fs::write(dir.join("parent.num"), parent.to_num_file()).expect("parent file"); + for name in ["twin-a.num", "twin-b.num"] { + let fork = StudioCreation::new("sin(a*x)", -2.0, 2.0, 0.5) + .expect("twin") + .with_descends(&parent.to_link()) + .expect("descends"); + std::fs::write(dir.join(name), fork.to_num_file()).expect("twin file"); + } + + let panel = GalleryPanel::open(&dir); + assert_eq!(panel.len(), 3); + let parent_index = panel + .entries + .iter() + .position(|entry| entry.creation.descends().is_none()) + .expect("the parent is on the wall"); + + for (index, entry) in panel.entries.iter().enumerate() { + if entry.creation.descends().is_none() { + continue; + } + assert_eq!( + entry.parent, + Some(parent_index), + "a fork must credit the parent, never its sibling" + ); + assert_eq!(entry.remixes, 0, "a sibling is not a remix of a sibling"); + // No cycle: walking up from either fork ends at the parent. + let mut walker = index; + for _ in 0..4 { + match panel.entries[walker].parent { + Some(next) => walker = next, + None => break, + } + } + assert_eq!(walker, parent_index, "the walk must end, at the parent"); + } + assert_eq!( + panel.entries[parent_index].remixes, 2, + "both forks credit the one creation they came from" + ); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn a_horizontal_move_parks_at_the_row_edge_instead_of_wrapping() { let dir = scratch("park"); diff --git a/faces/mcp/src/main.rs b/faces/mcp/src/main.rs index 28296b0..3613cb0 100644 --- a/faces/mcp/src/main.rs +++ b/faces/mcp/src/main.rs @@ -2419,7 +2419,7 @@ fn call_tool( "list_rooms" => list_rooms_tool(), "describe_room" => describe_room_tool(&domain_args, journey_file), "reveal_room" => reveal_room_tool(&domain_args, journey_file), - "play_room" => play_room_tool(&domain_args), + "play_room" => play_room_tool(&domain_args, journey_file), "challenge" => challenge_tool(&domain_args), "predict" => predict_tool(&domain_args), "cairn" => cairn_tool(&domain_args, journey_file, &cairn_path()), @@ -2650,11 +2650,29 @@ fn describe_room_tool(args: &Value, journey_file: &std::path::Path) -> Value { describe_room_tool_for_journey(args, &load_journey(journey_file)) } +/// Find a room the way the terminal does: the catalog always, and the +/// unlisted ones for a journey the veil admits. +/// +/// The gate itself lives in core so both faces read one rule, but this face +/// used to skip the second half entirely: a learner who could open the +/// hidden room from the terminal was told over MCP that it does not exist. +/// One player, one standing, two answers. +fn find_room_for( + id: &str, + journey: &numinous_core::Journey, +) -> Option> { + room_by_id(id).or_else(|| { + numinous_core::behind_the_veil(journey) + .then(|| numinous_core::hidden_room_by_id(id)) + .flatten() + }) +} + fn describe_room_tool_for_journey(args: &Value, journey: &numinous_core::Journey) -> Value { let Some(id) = args.get("id").and_then(Value::as_str) else { return tool_error("Missing required string argument 'id'."); }; - match room_by_id(id) { + match find_room_for(id, journey) { Some(room) => { let m = room.meta(); // Deep cuts open by level or by a spent boon, exactly as in the @@ -2983,7 +3001,7 @@ fn reveal_room_tool_for_journey(args: &Value, journey: &numinous_core::Journey) let Some(id) = args.get("id").and_then(Value::as_str) else { return tool_error("Missing required string argument 'id'."); }; - match room_by_id(id) { + match find_room_for(id, journey) { Some(room) => { let cut0_by_boon = journey.chosen.contains(&format!("cut:{id}:0")); let citation = numinous_core::room_citation_unlocked(id, journey.level(), cut0_by_boon); @@ -3175,7 +3193,11 @@ fn gesture_json(gesture: &[numinous_core::RoomInput]) -> Value { ) } -fn play_room_tool(args: &Value) -> Value { +fn play_room_tool(args: &Value, journey_file: &std::path::Path) -> Value { + play_room_tool_for_journey(args, &load_journey(journey_file)) +} + +fn play_room_tool_for_journey(args: &Value, journey: &numinous_core::Journey) -> Value { let Some(id) = args.get("id").and_then(Value::as_str) else { return tool_error("Missing required string argument 'id'."); }; @@ -3212,7 +3234,11 @@ fn play_room_tool(args: &Value) -> Value { .into_iter() .find(|r| r.meta().id == id) } else { - room_by_id(id) + // The same veil door describe and reveal use: a hidden room is + // unlisted, not nonexistent, and a learner the terminal admits is + // the same learner here. Variation stays a catalog contract, which + // is the terminal's rule too. + find_room_for(id, journey) }; match room { @@ -9035,6 +9061,41 @@ plays 2 assert_eq!(super::note_name(0.0), "-"); } + #[test] + fn the_veil_opens_the_same_rooms_on_this_face_as_on_the_terminal() { + // The gate was shared; the door was not. A learner the terminal + // admits to an unlisted room was told here that the room does not + // exist, so one player with one standing got two answers depending + // on which face they asked through. + let mut journey = numinous_core::Journey::default(); + journey.visit("a"); + journey.wins = 7; + assert!(numinous_core::behind_the_veil(&journey)); + + let hidden = numinous_core::hidden_room_by_id("tetractys").expect("an unlisted room"); + let id = hidden.meta().id; + + let described = super::describe_room_tool_for_journey(&json!({ "id": id }), &journey); + assert_eq!(described["isError"], false, "{described}"); + let played = super::play_room_tool_for_journey( + &json!({ "id": id, "width": 24, "height": 12 }), + &journey, + ); + assert_eq!(played["isError"], false, "{played}"); + let revealed = super::reveal_room_tool_for_journey(&json!({ "id": id }), &journey); + assert_eq!(revealed["isError"], false, "{revealed}"); + + // Outside the veil the room stays unlisted on this face too: the + // fix opens the same door, not a wider one. + let outsider = numinous_core::Journey::default(); + assert!(!numinous_core::behind_the_veil(&outsider)); + let refused = super::play_room_tool_for_journey( + &json!({ "id": id, "width": 24, "height": 12 }), + &outsider, + ); + assert_eq!(refused["isError"], true, "{refused}"); + } + #[test] fn a_learner_of_fifteen_sparks_is_inside_the_veil_on_this_face_too() { // The drift this retires: this face demanded 28 sparks for the deep