Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 65 additions & 7 deletions faces/app/src/gallery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
71 changes: 66 additions & 5 deletions faces/mcp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down Expand Up @@ -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<Box<dyn numinous_core::Room>> {
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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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'.");
};
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down