From 3c9c5335b996d92e7ed552cb8e10d00b1f76d98e Mon Sep 17 00:00:00 2001 From: NoHudd <86909140+NoHudd@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:39:00 -0500 Subject: [PATCH] feat(tutorial): teach ls -a and cat via the memory checkpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tutorial never had the player read a file, so the checkpoint loop (cat a lore file -> memory restored -> autosave) went untaught. Two new steps after the fight: ls -a reveals bash_profile (item hidden: true now actually hides files from plain ls — it was declared in content but unread), cat fires the identity memory restore, and Echo names it as the checkpoint you resume from on death. ps/pwd/nav follow as before; 11 steps total. Co-Authored-By: Claude Fable 5 --- data/tutorial_hints.yaml | 15 +++-- engine/content/linker.py | 2 +- engine/schema/models.py | 3 + src/commands/items.py | 4 +- src/commands/navigation.py | 12 +++- src/tutorial_coach.py | 33 +++++++++-- tests/test_hidden_items.py | 44 +++++++++++++++ tests/test_tutorial_checkpoint.py | 91 +++++++++++++++++++++++++++++++ tests/test_tutorial_navigation.py | 11 ++-- 9 files changed, 198 insertions(+), 17 deletions(-) create mode 100644 tests/test_hidden_items.py create mode 100644 tests/test_tutorial_checkpoint.py diff --git a/data/tutorial_hints.yaml b/data/tutorial_hints.yaml index 74f941d..37f303c 100644 --- a/data/tutorial_hints.yaml +++ b/data/tutorial_hints.yaml @@ -10,7 +10,9 @@ # step4 combat opens # step4_fled fled the tutorial fight (it waits in the room for `attack`) # step5 after the first landed hit -# step5_postcombat after the fight: run ps +# step5_postcombat after the fight: run ls -a +# step_hidden ls -a revealed the hidden lore file: cat it +# step_cat the cat fired a memory restore (checkpoint): run ps # step_ps after ps: run pwd # step_up after pwd, in a room with nothing below it: cd .. # step6 after moving somewhere new: ls @@ -18,7 +20,7 @@ # completed cheat sheet, sets tutorial_state["completed"] # skip_summary shown instead of everything above if the player skips "skip_summary": |- - [bold green]ECHO>[/bold green] Got it. Quick reference: [bold]ls[/bold] scans a room, [bold]take/equip[/bold] grab and ready items. Combat opens in Selection Mode automatically — press [bold]1-9[/bold] to attack, [bold]0[/bold] to flee. Press [bold]TAB[/bold] to type [bold]use [item][/bold] instead. [bold]pwd[/bold] shows where you are, [bold]cd[/bold] moves ([bold]cd ..[/bold] goes up), [bold]ps[/bold] lists running processes. [bold]help[/bold] if stuck, [bold]man[/bold] to look a command up. Good luck. + [bold green]ECHO>[/bold green] Got it. Quick reference: [bold]ls[/bold] scans a room, [bold]take/equip[/bold] grab and ready items. Combat opens in Selection Mode automatically — press [bold]1-9[/bold] to attack, [bold]0[/bold] to flee. Press [bold]TAB[/bold] to type [bold]use [item][/bold] instead. [bold]pwd[/bold] shows where you are, [bold]cd[/bold] moves ([bold]cd ..[/bold] goes up), [bold]ps[/bold] lists running processes, [bold]ls -a[/bold] reveals hidden files. [bold]cat[/bold] reads a file — restoring a memory saves a checkpoint you resume from if you fall. [bold]help[/bold] if stuck, [bold]man[/bold] to look a command up. Good luck. "step1": |- [bold green]ECHO>[/bold green] You're in /home — a stable part of the filesystem. The rest is corrupted and needs clearing. Let's start simple. Type: [bold]ls[/bold] and press Enter to see what's here. "step2": |- @@ -32,7 +34,11 @@ "step5": |- [bold green]ECHO>[/bold green] Nice hit. One more should finish it. Need to use an item or flee instead? Press [bold]TAB[/bold] to type [bold]use [item][/bold] or [bold]flee[/bold] — TAB again to get back to Selection Mode. "step5_postcombat": |- - [bold green]ECHO>[/bold green] You won. [bold]use [item][/bold] uses something mid-fight, [bold]flee[/bold] or [bold]0[/bold] escapes a losing one. Corrupted processes like that one hide all over the system — type: [bold]ps[/bold] to list what's running here. + [bold green]ECHO>[/bold green] You won. [bold]use [item][/bold] uses something mid-fight, [bold]flee[/bold] or [bold]0[/bold] escapes a losing one. Now — this directory holds more than plain [bold]ls[/bold] admits. Files whose names start with a dot stay hidden. Type: [bold]ls -a[/bold] to list everything. +"step_hidden": |- + [bold green]ECHO>[/bold green] There — [bold]bash_profile[/bold], your own configuration file, hidden until now. [bold]ls -a[/bold] reveals hidden files anywhere, so try it in every new place. Read this one: type [bold]cat bash_profile[/bold] +"step_cat": |- + [bold green]ECHO>[/bold green] Feel that? A memory restored — and the system saved your progress. Every memory you recover with [bold]cat[/bold] is a checkpoint: if you fall, you resume from the last one. Keep reading what you find. Corrupted processes hide all over the system too — type: [bold]ps[/bold] to list what's running here. "step_ps": |- [bold green]ECHO>[/bold green] That's [bold]ps[/bold]: the processes running in this directory. Most belong to the system, but an odd one out can give away something hidden, so run it in new places. Now — do you know where you are? Type: [bold]pwd[/bold] "step_up": |- @@ -48,7 +54,8 @@ • [bold]pwd[/bold] — where am I? • [bold]cd name[/bold] — move into a directory; [bold]cd ..[/bold] goes up • [bold]ps[/bold] — list running processes (odd ones hide secrets) - • [bold]cat[/bold] — read a file + • [bold]ls -a[/bold] — reveal hidden files and directories + • [bold]cat[/bold] — read a file; restoring a memory saves a checkpoint • [bold]take / equip[/bold] — grab and ready items • [bold]1-9[/bold] in combat — attack (Selection Mode, opens automatically) • [bold]0[/bold] in combat — flee diff --git a/engine/content/linker.py b/engine/content/linker.py index 6ca4fed..53e1586 100644 --- a/engine/content/linker.py +++ b/engine/content/linker.py @@ -227,7 +227,7 @@ def find_lock_problems(content: GameContent) -> list[str]: "special_event", "story_beats", "story_location", "visibility_requirement", }), "item": frozenset({ - "auto_trigger", "consumed_on_take", "hidden", "max_uses_per_run", + "auto_trigger", "consumed_on_take", "max_uses_per_run", "only_in_unlocked", "readable", "trigger_condition", "triggers_npc_spawn", }), "enemy": frozenset({ diff --git a/engine/schema/models.py b/engine/schema/models.py index d8778b3..24fd29e 100644 --- a/engine/schema/models.py +++ b/engine/schema/models.py @@ -151,6 +151,9 @@ class Item(_Base): consumed_on_use: bool = False takeable: bool = True droppable: bool = True + # A hidden item is omitted from plain `ls`; `ls -a` lists it. It stays + # readable/takeable by name either way, like a real dotfile. + hidden: bool = False consumable: bool = False defense: int = 0 healing: int | None = None diff --git a/src/commands/items.py b/src/commands/items.py index b9aad24..b4758f2 100644 --- a/src/commands/items.py +++ b/src/commands/items.py @@ -196,7 +196,9 @@ def _render(ctx: "CommandHandler", item: "Item", item_id: str) -> bool: ctx.output.write(f"[bold]{item_name}[/bold]\n\n{content}") if item.on_read is not None: ctx.effects.execute_effect(item.on_read) - return ctx.effects.trigger_story_flag(item) + story_beat = ctx.effects.trigger_story_flag(item) + ctx.tutorial.after_lore_read(story_beat) + return story_beat class DropCommand(Command): diff --git a/src/commands/navigation.py b/src/commands/navigation.py index 93de5fd..f6b7d5a 100644 --- a/src/commands/navigation.py +++ b/src/commands/navigation.py @@ -208,6 +208,13 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None: has_content = has_content or bool(output) items = ctx.world.get_items_in_room(room_id) or [] + if not show_all: + # Hidden files only list under -a, like real dotfiles. They stay + # addressable by name (cat/take) either way. + items = [ + item_id for item_id in items + if not getattr(ctx.world.get_item(item_id), "hidden", False) + ] weapon_found, weapon_id = self._render_items( ctx, output, items, hints, long_format, has_content ) @@ -237,7 +244,10 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None: "CommandHandler", ) - ctx.tutorial.after_ls(weapon_found, weapon_id, has_directories=bool(directories)) + ctx.tutorial.after_ls( + weapon_found, weapon_id, + has_directories=bool(directories), show_all=show_all, + ) # -- sections ------------------------------------------------------------- diff --git a/src/tutorial_coach.py b/src/tutorial_coach.py index 73e1231..4f2db4d 100644 --- a/src/tutorial_coach.py +++ b/src/tutorial_coach.py @@ -7,9 +7,10 @@ STEP_NUMBERS = { "step1": 1, "step2": 2, "step3": 3, "step4": 4, "step4_fled": 4, "step5": 4, - "step5_postcombat": 5, "step_ps": 6, "step_up": 7, "step6": 8, "step6b": 9, + "step5_postcombat": 5, "step_hidden": 6, "step_cat": 7, + "step_ps": 8, "step_up": 9, "step6": 10, "step6b": 11, } -TOTAL_STEPS = 9 +TOTAL_STEPS = 11 # Hints that point at a section of the output the frontend should draw the eye to. HIGHLIGHTS = {"step6b": "Directories"} # Hints that close the tutorial; the frontend drops its pinned guidance. @@ -94,8 +95,12 @@ def current_step(self): return "step3" if not ts.get("combat_action_taken", False): return "step4" - if not ts.get("ps_used", False): + if not ts.get("ls_a_used", False): return "step5_postcombat" + if not ts.get("lore_read", False): + return "step_hidden" + if not ts.get("ps_used", False): + return "step_cat" if not ts.get("pwd_used", False): return "step_ps" if not ts.get("went_up", False): @@ -116,8 +121,9 @@ def _active(self): # --- progression: commands report what happened, the coach decides ------ - def after_ls(self, weapon_found, weapon_id, has_directories): - """ls: the first one points at the weapon; after pwd, it finds the way on.""" + def after_ls(self, weapon_found, weapon_id, has_directories, show_all=False): + """ls: the first one points at the weapon; the first -a after combat + reveals the hidden lore file; after pwd, it finds the way on.""" ts = self.player.tutorial_state if not self._active(): return @@ -127,6 +133,11 @@ def after_ls(self, weapon_found, weapon_id, has_directories): ts["found_weapon"] = True self.show_hint("step2", weapon_id) return + if show_all and ts.get("combat_action_taken", False) and not ts.get("ls_a_used", False): + ts["ls_a_used"] = True + if not ts.get("lore_read", False): + self.show_hint("step_hidden") + return if ts.get("pwd_used", False) and not ts.get("navigation_ls", False): if has_directories: ts["went_up"] = True @@ -135,9 +146,19 @@ def after_ls(self, weapon_found, weapon_id, has_directories): else: self.show_hint("step_up") + def after_lore_read(self, story_beat): + """A cat that fired a story flag: the memory-restore autosave IS the + checkpoint the tutorial is teaching, so this is the step's gate.""" + ts = self.player.tutorial_state + if not (self._active() and ts.get("combat_action_taken", False)): + return + if story_beat and not ts.get("lore_read", False): + ts["lore_read"] = True + self.show_hint("step_cat") + def after_ps(self): ts = self.player.tutorial_state - if self._active() and ts.get("combat_action_taken", False) and not ts.get("ps_used", False): + if self._active() and ts.get("lore_read", False) and not ts.get("ps_used", False): ts["ps_used"] = True self.show_hint("step_ps") diff --git a/tests/test_hidden_items.py b/tests/test_hidden_items.py new file mode 100644 index 0000000..35ae70c --- /dev/null +++ b/tests/test_hidden_items.py @@ -0,0 +1,44 @@ +"""Items marked `hidden: true` stay out of plain `ls` and appear with `ls -a`. + +The data has always declared this intent ("Requires ls -a to see") but only +rooms honored it. bash_profile is the canary: the tutorial's checkpoint step +depends on `ls -a` actually revealing it. +""" +from collections.abc import Iterator + +import pytest + +from engine.api import GameSession + + +@pytest.fixture +def session() -> Iterator[GameSession]: + s = GameSession() + s.new_game("t", "guardian") + try: + yield s + finally: + s.close() + + +def _out(session: GameSession, cmd: str) -> str: + return "\n".join(str(line) for line in session.submit(cmd)) + + +def test_plain_ls_omits_hidden_items(session: GameSession) -> None: + out = _out(session, "ls") + assert "bash_profile" not in out + # Visible files in the same room still list. + assert "readme_txt_corrupt" in out + + +def test_ls_a_reveals_hidden_items(session: GameSession) -> None: + out = _out(session, "ls -a") + assert "bash_profile" in out + + +def test_cat_reads_a_hidden_item_by_name(session: GameSession) -> None: + """Like real Unix: a hidden file is listable only with -a but always + readable by name.""" + out = _out(session, "cat bash_profile") + assert "Sysadmin Spirit" in out diff --git a/tests/test_tutorial_checkpoint.py b/tests/test_tutorial_checkpoint.py new file mode 100644 index 0000000..323b35a --- /dev/null +++ b/tests/test_tutorial_checkpoint.py @@ -0,0 +1,91 @@ +"""The post-combat tutorial leg: ls -a reveals .bash_profile, cat fires the +memory-restore checkpoint, and only then does the tutorial move on to ps. + +Assertions target s.ui.hints (which step fired), not Echo's wording. +""" +from collections.abc import Iterator + +import pytest + +from engine.api import GameSession + +_POST_COMBAT_STATE = { + "first_ls": True, + "took_weapon": True, + "equipped_weapon": True, + "combat_action_taken": True, +} + + +@pytest.fixture +def session() -> Iterator[GameSession]: + s = GameSession() + s.new_game("t", "guardian") + s.player.tutorial_state.update(_POST_COMBAT_STATE) + try: + yield s + finally: + s.close() + + +def _fired(session: GameSession) -> list[str]: + return [hint["hint_id"] for hint in session.ui.hints] + + +def test_plain_ls_does_not_advance_past_the_hidden_file_step(session: GameSession) -> None: + session.ui.hints.clear() + session.submit("ls") + assert session.player.tutorial_state.get("ls_a_used", False) is False + assert "step_hidden" not in _fired(session) + + +def test_ls_a_fires_the_hidden_file_hint(session: GameSession) -> None: + session.ui.hints.clear() + session.submit("ls -a") + assert session.player.tutorial_state["ls_a_used"] is True + assert "step_hidden" in _fired(session) + + +def test_cat_story_beat_fires_the_checkpoint_hint(session: GameSession) -> None: + session.submit("ls -a") + session.ui.hints.clear() + session.submit("cat bash_profile") + assert session.player.tutorial_state["lore_read"] is True + assert "step_cat" in _fired(session) + + +def test_plain_lore_read_is_not_a_checkpoint(session: GameSession) -> None: + """readme_txt_corrupt has no story_flag: reading it must not count as the + checkpoint step.""" + session.submit("ls -a") + session.ui.hints.clear() + session.submit("cat readme_txt_corrupt") + assert session.player.tutorial_state.get("lore_read", False) is False + + +def test_ps_does_not_advance_before_the_checkpoint(session: GameSession) -> None: + session.submit("ls -a") + session.ui.hints.clear() + session.submit("ps") + assert session.player.tutorial_state.get("ps_used", False) is False + + +def test_ps_advances_after_the_checkpoint(session: GameSession) -> None: + session.submit("ls -a") + session.submit("cat bash_profile") + session.ui.hints.clear() + session.submit("ps") + assert session.player.tutorial_state["ps_used"] is True + assert "step_ps" in _fired(session) + + +def test_current_step_walks_the_new_leg(session: GameSession) -> None: + coach = session.engine.cmd_handler.tutorial + ts = session.player.tutorial_state + assert coach.current_step() == "step5_postcombat" + ts["ls_a_used"] = True + assert coach.current_step() == "step_hidden" + ts["lore_read"] = True + assert coach.current_step() == "step_cat" + ts["ps_used"] = True + assert coach.current_step() == "step_ps" diff --git a/tests/test_tutorial_navigation.py b/tests/test_tutorial_navigation.py index b5a9c38..d6361a4 100644 --- a/tests/test_tutorial_navigation.py +++ b/tests/test_tutorial_navigation.py @@ -17,7 +17,10 @@ def session() -> Iterator[GameSession]: s.new_game("Tess", "guardian") ts = s.player.tutorial_state ts.update({"first_ls": True, "took_weapon": True, "equipped_weapon": True, - "combat_action_taken": True}) + "combat_action_taken": True, + # The ls -a / cat checkpoint leg is covered by + # test_tutorial_checkpoint.py; here it is already done. + "ls_a_used": True, "lore_read": True}) try: yield s finally: @@ -30,7 +33,7 @@ def _last_hint(s: GameSession) -> dict: def test_the_walk_from_ps_to_completion(session: GameSession) -> None: ts = session.player.tutorial_state - assert session.engine.cmd_handler.tutorial.current_step() == "step5_postcombat" + assert session.engine.cmd_handler.tutorial.current_step() == "step_cat" session.submit("ps") assert ts["ps_used"] and _last_hint(session)["hint_id"] == "step_ps" @@ -46,7 +49,7 @@ def test_the_walk_from_ps_to_completion(session: GameSession) -> None: hint = _last_hint(session) assert ts["navigation_ls"] and hint["hint_id"] == "step6b" assert hint["highlight"] == "Directories" - assert (hint["step"], hint["total"]) == (9, 9) + assert (hint["step"], hint["total"]) == (11, 11) assert "bin" in hint["text"] # the example is a directory actually listed at / session.submit("cd bin") @@ -58,7 +61,7 @@ def test_ls_before_ps_keeps_asking_for_ps(session: GameSession) -> None: session.submit("ls") assert session.ui.hints == [] # ls just lists; no new step session.submit("xyzzy") - assert _last_hint(session)["hint_id"] == "step5_postcombat" + assert _last_hint(session)["hint_id"] == "step_cat" def test_ls_in_a_dead_end_repeats_the_way_up(session: GameSession) -> None: