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
15 changes: 11 additions & 4 deletions data/tutorial_hints.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,17 @@
# 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
# step6b ls listed directories (the UI flashes them): cd into one
# 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": |-
Expand All @@ -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": |-
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion engine/content/linker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
3 changes: 3 additions & 0 deletions engine/schema/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/commands/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
12 changes: 11 additions & 1 deletion src/commands/navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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 -------------------------------------------------------------

Expand Down
33 changes: 27 additions & 6 deletions src/tutorial_coach.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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")

Expand Down
44 changes: 44 additions & 0 deletions tests/test_hidden_items.py
Original file line number Diff line number Diff line change
@@ -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
91 changes: 91 additions & 0 deletions tests/test_tutorial_checkpoint.py
Original file line number Diff line number Diff line change
@@ -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"
11 changes: 7 additions & 4 deletions tests/test_tutorial_navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"
Expand All @@ -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")
Expand All @@ -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:
Expand Down
Loading