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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ project uses version-gated milestones (see ROADMAP.md), not dates.

## [Unreleased]

- 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
indistinguishable from a first run: the rank fell, the veil closed on
a player who had crossed it, earned trophies vanished, and because
nothing could be written either, the same level-up and the same
trophy were announced again on every single run. Nothing was ever at
risk of being overwritten (the delta writer fails against the same
condition, and a test now proves the bytes survive), but the silence
was its own defect. The run says what happened, names the file, says
plainly that nothing will be written over it, and announces no
crossing it cannot see. When a save is refused mid-run, the refusal
is the whole story now: a trophy that arrives every time is not a
trophy. Core grew the fallible read the faces needed to tell a first
run from an unreadable one, and the process-boundary gate holds it,
mutation-verified.
- The Galton call and the picture over it now agree about which coin
they are discussing. Four defects, one root: the wager knew its coin
and nothing else did. Four clicks that each picked a different coin
Expand Down
4 changes: 2 additions & 2 deletions crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,8 @@ pub use persistence::{
LocalScoresInventory, LocalStateEraseError, LocalStateEraseSelection, LocalStateInventory,
LocalStateLock, LocalStatePaths, correct_journal_file, erase_journal_file, erase_local_state,
inspect_journal_file, inspect_local_state, load_journal_file, load_journey_file,
load_scoreboard_file, lock_local_state, persist_journey_delta, record_journal_file,
record_score_file, remove_persisted_file, try_load_journal_file,
load_scoreboard_file, lock_local_state, persist_journey_delta, read_journey_file,
record_journal_file, record_score_file, remove_persisted_file, try_load_journal_file,
};
pub use photosensitivity::{
DARK_CEILING, GENERAL_FLASH_DELTA, MAX_FLASHES_PER_SECOND, count_flashes, flashes_per_second,
Expand Down
20 changes: 20 additions & 0 deletions crates/core/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -700,11 +700,31 @@ pub fn erase_local_state(
}

/// Load a Journey file, repairing malformed text through [`Journey::from_text`].
///
/// A file that cannot be read at all comes back as a default journey, which
/// is indistinguishable from a first run. Faces that speak to the player
/// about their progress should call [`read_journey_file`] instead and say
/// what happened: a silent default closes the veil, hides earned trophies,
/// and re-announces the same level every run.
#[must_use]
pub fn load_journey_file(path: &Path) -> Journey {
try_load_journey_file(path).unwrap_or_default()
}

/// Load a Journey file, distinguishing a first run from an unreadable one.
///
/// A missing file is a fresh player and returns a default journey. Anything
/// else (invalid UTF-8, oversized, permission denied) is an error the caller
/// must decide how to speak about, because the player behind that file has a
/// history and this process cannot see it. Writes fail closed against the
/// same condition, so nothing here risks overwriting what could not be read.
///
/// # Errors
/// Propagates the read failure for every case except a missing file.
pub fn read_journey_file(path: &Path) -> io::Result<Journey> {
try_load_journey_file(path)
}

/// Load a score file, repairing malformed text through [`Scoreboard::from_text`].
#[must_use]
pub fn load_scoreboard_file(path: &Path) -> Scoreboard {
Expand Down
47 changes: 40 additions & 7 deletions faces/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -610,14 +610,14 @@ fn cli_main() -> ExitCode {
Ok(cli) => cli,
Err(error) => return report_cli_parse_error(error),
};
let mut journey = load_journey();
let (mut journey, readable) = load_journey();
let before = journey.clone();
let earned_before = earned_names(&before, &load_scores());
let code = match cli.command {
Some(command) => run(command, &mut journey),
None => home(&journey),
};
finish_journey(&before, &journey, &earned_before);
finish_journey(&before, &journey, &earned_before, readable);
code
}

Expand Down Expand Up @@ -1205,8 +1205,28 @@ fn journey_path() -> PathBuf {
}

/// Load the journey, or start a fresh one.
fn load_journey() -> Journey {
numinous_core::load_journey_file(&journey_path())
///
/// A file that exists and cannot be read is not a fresh player. Treating it
/// as one silently demotes a rank, closes the veil, hides earned trophies,
/// and re-announces the same level every run, so this says what happened
/// once, on stderr, and returns a default marked unreadable. Nothing is at
/// risk of being written over it: the delta writer fails against the same
/// condition rather than replacing a file it could not read.
fn load_journey() -> (Journey, bool) {
let path = journey_path();
match numinous_core::read_journey_file(&path) {
Ok(journey) => (journey, true),
Err(error) => {
let where_it_lives = terminal_safe_path(&path);
report_diagnostic(&terminal_safe(&format!(
"your progress file could not be read, so this run cannot see your journey: {error}"
)));
report_diagnostic(&format!(
"nothing will be written over it. Fix or move {where_it_lives}, then play on."
));
(Journey::default(), false)
}
}
}

/// Where the high-score table lives: `NUMINOUS_SCORES` if set, else home.
Expand Down Expand Up @@ -1552,19 +1572,32 @@ fn finish_journey(
before: &Journey,
after: &Journey,
earned_before: &std::collections::BTreeSet<&'static str>,
readable: bool,
) {
if before == after {
return;
}
// The play still happened; if the ledger refuses, say so rather than
// letting the banners below celebrate progress the disk never received.
// A run that could not read the ledger has already said so, once, at the
// door. Writing now could only fail against the same condition and would
// repeat that news in different words, so this stops here: one cause,
// one telling.
if !readable {
return;
}
// The play still happened; if the ledger refuses, say so and stop. The
// banners below would otherwise celebrate a level the disk never
// received, and because nothing was written they would celebrate the
// same one again on the next run, and the next: a trophy that arrives
// every time is not a trophy, it is noise wearing one.
let saved = match numinous_core::persist_journey_delta(&journey_path(), before, after) {
Ok(saved) => saved,
Err(error) => {
warn_progress_unsaved("progress", &error);
after.clone()
report_diagnostic("what you earned this run was not recorded, so it is not announced.");
Comment thread
blisspixel marked this conversation as resolved.
return;
}
};

for ping in trophy_pings(earned_before, &saved, &load_scores()) {
println!(
"
Expand Down
48 changes: 48 additions & 0 deletions scripts/game-truth.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,50 @@ def check_fifteen_levels_the_same_on_both_faces(cli: Path, mcp: Path) -> None:
)


def check_an_unreadable_journey_is_named_not_faked(cli: Path) -> None:
"""A journey that exists and cannot be read is not a fresh player.

Treating it as one silently demoted the rank, closed the veil, and
re-announced the same level on every run, forever, because nothing was
ever written. The run must say what happened and must not celebrate a
crossing it cannot see.
"""
with tempfile.TemporaryDirectory(prefix="numinous-game-truth-") as raw:
home = Path(raw)
journey = home / "journey"
# Not text: readable bytes that are not valid UTF-8, which is the
# shape a truncated or clobbered file actually takes.
journey.write_bytes(b"plays 40\nwins 30\n\xff\xfe not text\n")
outcome = run_game(cli, home, ["plot", "sin(x)", "--width", "20", "--height", "8"], "")
said = outcome.stderr
if "could not be read" not in said:
raise GameTruthError(
"an unreadable journey was treated as a fresh player with no "
f"word to the player; stderr held: {said!r}"
)
if "LEVEL UP" in outcome.stdout or "TROPHY" in outcome.stdout:
raise GameTruthError(
"a run that cannot see the journey announced a crossing "
f"anyway: {outcome.stdout!r}"
)
if "could not be saved" in said:
raise GameTruthError(
"one cause was told twice: the run explained the unreadable "
"journey and then tried the write anyway, which can only fail "
f"against the same condition; stderr held: {said!r}"
)
if " " in said:
raise GameTruthError(
f"the player's copy carries a run of spaces: {said!r}"
)
after = journey.read_bytes()
if b"plays 40" not in after:
raise GameTruthError(
"the unreadable journey was overwritten; the player's history "
"must survive a run that could not read it"
)


def check_the_save_note_rides_the_reply_that_lost(mcp: Path) -> None:
with tempfile.TemporaryDirectory(prefix="numinous-game-truth-") as raw:
home = Path(raw)
Expand Down Expand Up @@ -266,6 +310,10 @@ def main() -> int:
"a lost save is named on the reply that lost it",
lambda: check_the_save_note_rides_the_reply_that_lost(mcp),
),
(
"an unreadable journey is named, never faked",
lambda: check_an_unreadable_journey_is_named_not_faked(cli),
),
)
for label, check in checks:
try:
Expand Down