IMJONEZZ commited on
Commit
d2bc1d3
·
verified ·
1 Parent(s): 108ca65

tutorial: replace orientation text with a guided, unlosable first fight

Browse files

First-ever game now hard-rails the player through a 3-turn lesson (deck draw vs bit draw, lanes, the bell, taking damage, then a comeback win by sacrificing an Intruder + a bit for a heavy Kernel) instead of 5 pages of text, then drops them into a read-only ls/pwd shell. Skippable with [esc]. BoardScreen gains a guided mode; Shell gains restrict_to(); app runs it once per installation.

scrypt/app.py CHANGED
@@ -193,6 +193,28 @@ class ScryptApp(App):
193
  if not fxmod.reduced_motion() and not self.skip_menu:
194
  await self.push_screen_wait(NoiseScreen())
195
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  async def _lobby(self) -> None:
197
  from scrypt.ui import fx as fxmod
198
  from scrypt.ui.boot import BootScreen
@@ -225,12 +247,25 @@ class ScryptApp(App):
225
  self.can_carve = False
226
  self.run_worker(self._wake_backend())
227
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  self.deck_id = choice
229
  if not self.legacy.get("initiated"):
230
- # First game on this machine, ever: orientation.
231
- from scrypt.ui.tutorial import OrientationScreen
232
-
233
- await self.push_screen_wait(OrientationScreen())
234
  self.legacy["initiated"] = True
235
  self._fresh_game = True
236
  legacy_store.save(self.legacy)
 
193
  if not fxmod.reduced_motion() and not self.skip_menu:
194
  await self.push_screen_wait(NoiseScreen())
195
 
196
+ async def _tutorial(self) -> None:
197
+ """Onboarding by doing: a hard-railed first fight the player cannot
198
+ lose, then a look-but-don't-touch shell. Skippable ([esc] in the
199
+ fight). Replaces the old wall-of-text orientation."""
200
+ from scrypt.ui import tutorial as tut
201
+
202
+ state = tut.build_fight(self.content)
203
+ result = await self.push_screen_wait(
204
+ BoardScreen(
205
+ state, intro=tut.TUTORIAL_INTRO, presence=self.presence,
206
+ director=None, guided=tut.LESSON,
207
+ )
208
+ )
209
+ if result is not Result.PLAYER_WIN:
210
+ return # the player skipped; drop straight into the real run
211
+ await self.push_screen_wait(
212
+ ShellScreen(
213
+ tut.build_shell(), [], deck_names=[],
214
+ motd=tut.TUTORIAL_MOTD, presence=self.presence,
215
+ )
216
+ )
217
+
218
  async def _lobby(self) -> None:
219
  from scrypt.ui import fx as fxmod
220
  from scrypt.ui.boot import BootScreen
 
247
  self.can_carve = False
248
  self.run_worker(self._wake_backend())
249
  continue
250
+ if choice == "::settings::":
251
+ from scrypt.ui.settings import SettingsScreen
252
+
253
+ if await self.push_screen_wait(SettingsScreen()):
254
+ # New brain chosen: drop the old one (and its server)
255
+ # and re-resolve from the freshly saved settings.
256
+ if self._server is not None:
257
+ self._server.stop()
258
+ self._server = None
259
+ self._backend = None
260
+ self.voice = None
261
+ self.backend_waking = True
262
+ self.run_worker(self._wake_backend())
263
+ continue
264
  self.deck_id = choice
265
  if not self.legacy.get("initiated"):
266
+ # First game on this machine, ever: a guided, unlosable
267
+ # fight + a read-only shell, in place of a wall of text.
268
+ await self._tutorial()
 
269
  self.legacy["initiated"] = True
270
  self._fresh_game = True
271
  legacy_store.save(self.legacy)
scrypt/sandbox/shell.py CHANGED
@@ -54,6 +54,13 @@ class Shell:
54
  def available(self) -> list[str]:
55
  return sorted(self.commands)
56
 
 
 
 
 
 
 
 
57
  def most_used(self, exclude: tuple[str, ...] = ("help", "man")) -> str | None:
58
  """The command the Warden wants: most-used, still owned, not trivial."""
59
  for name, _ in self.usage.most_common():
 
54
  def available(self) -> list[str]:
55
  return sorted(self.commands)
56
 
57
+ def restrict_to(self, names) -> None:
58
+ """Keep only these commands; drop the rest. Unlike revoke(), nothing
59
+ is marked 'sold' — the others simply were never here (the read-only
60
+ tutorial shell). help() and tab-completion then show only what's left."""
61
+ keep = set(names)
62
+ self.commands = {n: h for n, h in self.commands.items() if n in keep}
63
+
64
  def most_used(self, exclude: tuple[str, ...] = ("help", "man")) -> str | None:
65
  """The command the Warden wants: most-used, still owned, not trivial."""
66
  for name, _ in self.usage.most_common():
scrypt/ui/board.py CHANGED
@@ -91,6 +91,7 @@ class BoardScreen(Screen):
91
  tutorial: bool = False,
92
  intro_moment: str | None = None,
93
  track=None,
 
94
  ):
95
  super().__init__()
96
  self.state = state
@@ -99,6 +100,11 @@ class BoardScreen(Screen):
99
  self.presence = presence # WardenPresence or None (scripted lines only)
100
  self.director = director # warden.director.Director or None
101
  self.tutorial = tutorial # show contextual hints (first fight)
 
 
 
 
 
102
  self.track = track # Text: where this fight sits in the run
103
  self.mode = Mode.NORMAL
104
  self.selected = 0
@@ -417,6 +423,12 @@ class BoardScreen(Screen):
417
  return Text("")
418
 
419
  def _hint(self) -> str:
 
 
 
 
 
 
420
  if not self.tutorial or self.state.phase is Phase.OVER:
421
  return ""
422
  if self.state.phase is Phase.DRAW:
@@ -428,6 +440,8 @@ class BoardScreen(Screen):
428
  won = self.state.result is Result.PLAYER_WIN
429
  cycles = f" +{self.state.overkill_cycles} cycles" if won else ""
430
  return f"the fight is over{cycles} [any key] continue"
 
 
431
  if self.state.phase is Phase.DRAW:
432
  opts = []
433
  if self.state.can_draw_main:
@@ -463,6 +477,8 @@ class BoardScreen(Screen):
463
  if state.phase is Phase.OVER:
464
  self.dismiss(state.result)
465
  return
 
 
466
  if key == "q":
467
  self.dismiss(None) # abandoned
468
  return
@@ -497,6 +513,27 @@ class BoardScreen(Screen):
497
  self._prefetch_outcomes()
498
  self.refresh_all()
499
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
500
  def _cycle_inspect(self) -> None:
501
  """[tab] walks the magnifier across the foe's row, then puts it down."""
502
  occupied = [i for i, c in enumerate(self.state.foe_row) if c is not None]
 
91
  tutorial: bool = False,
92
  intro_moment: str | None = None,
93
  track=None,
94
+ guided: list[tuple[str, str]] | None = None,
95
  ):
96
  super().__init__()
97
  self.state = state
 
100
  self.presence = presence # WardenPresence or None (scripted lines only)
101
  self.director = director # warden.director.Director or None
102
  self.tutorial = tutorial # show contextual hints (first fight)
103
+ # Hard-railed lesson: ordered (key, hint). Only the head key advances;
104
+ # everything else is gently refused. Dismisses None when skipped.
105
+ self.guided = guided
106
+ self._lesson_pos = 0
107
+ self._nudged = False
108
  self.track = track # Text: where this fight sits in the run
109
  self.mode = Mode.NORMAL
110
  self.selected = 0
 
423
  return Text("")
424
 
425
  def _hint(self) -> str:
426
+ if self.guided is not None and self.state.phase is not Phase.OVER:
427
+ if self._lesson_pos >= len(self.guided):
428
+ return ""
429
+ prefix = "↳ not yet — " if self._nudged else ""
430
+ self._nudged = False
431
+ return prefix + self.guided[self._lesson_pos][1]
432
  if not self.tutorial or self.state.phase is Phase.OVER:
433
  return ""
434
  if self.state.phase is Phase.DRAW:
 
440
  won = self.state.result is Result.PLAYER_WIN
441
  cycles = f" +{self.state.overkill_cycles} cycles" if won else ""
442
  return f"the fight is over{cycles} [any key] continue"
443
+ if self.guided is not None:
444
+ return "do as the floor says · [?] help · [esc] skip tutorial"
445
  if self.state.phase is Phase.DRAW:
446
  opts = []
447
  if self.state.can_draw_main:
 
477
  if state.phase is Phase.OVER:
478
  self.dismiss(state.result)
479
  return
480
+ if self.guided is not None and not self._guided_allows(key):
481
+ return # hard rails: only the lesson's next key gets through
482
  if key == "q":
483
  self.dismiss(None) # abandoned
484
  return
 
513
  self._prefetch_outcomes()
514
  self.refresh_all()
515
 
516
+ def _guided_allows(self, key: str) -> bool:
517
+ """Hard rails. [esc] skips the whole tutorial; [?] still opens help;
518
+ otherwise only the lesson's next key advances. A wrong key is refused
519
+ with a nudge, never a penalty."""
520
+ if key == "escape":
521
+ self.dismiss(None) # skip the tutorial -> the real run begins
522
+ return False
523
+ if key == "question_mark":
524
+ from scrypt.ui.menu import HowToPlayScreen
525
+
526
+ self.app.push_screen(HowToPlayScreen())
527
+ return False
528
+ if self._lesson_pos >= len(self.guided):
529
+ return True # lesson done; just waiting for the fight to end
530
+ if key == self.guided[self._lesson_pos][0]:
531
+ self._lesson_pos += 1
532
+ return True
533
+ self._nudged = True
534
+ self.refresh_all()
535
+ return False
536
+
537
  def _cycle_inspect(self) -> None:
538
  """[tab] walks the magnifier across the foe's row, then puts it down."""
539
  occupied = [i for i, c in enumerate(self.state.foe_row) if c is not None]
scrypt/ui/tutorial.py CHANGED
@@ -1,173 +1,134 @@
1
- """Orientation: shown once per installation, the first time a game begins.
2
-
3
- Five pages: who the Warden is, the way out, the table, the economy of
4
- death, and the world between fights. Skippable, never repeated (the
5
- `initiated` flag in legacy.json), and everything it teaches stays
6
- reachable afterwards [?] in a fight, [h] on the menu.
 
 
 
 
 
 
 
 
 
 
 
 
7
  """
8
 
9
  from __future__ import annotations
10
 
11
- from rich import box
12
- from rich.panel import Panel
13
- from rich.text import Text
14
- from textual import events
15
- from textual.app import ComposeResult
16
- from textual.containers import Vertical
17
- from textual.screen import Screen
18
- from textual.widgets import Static
19
-
20
- TABLE_DIAGRAM = """\
21
- ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
22
- │queued│ │ │ │queued│ │ │ arriving next turn
23
- └──────┘ └──────┘ └──────┘ └──────┘
24
- ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
25
- │ foe │ │ │ │ foe │ │ │ the Warden's row
26
- └──────┘ └──────┘ └──────┘ └──────┘
27
- ───── you ██ ·· ·· ·· ·· ⚖ ·· ·· ·· ·· ·· foe ─────
28
- ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
29
- │yours │ │ │yours │ │ │ your row
30
- └──────┘ └──────┘ └──────┘ └──────┘
31
- [1] [2] [3] [4]
32
- """
33
-
34
- PAGES: list[tuple[str, str]] = [
35
- (
36
- "WHERE YOU ARE",
37
- """\
38
- You are a process. A small one, freshly spawned, inside a machine
39
- that belongs to something called the WARDEN.
40
-
41
- The Warden is not a script wearing a villain mask. It is a language
42
- model running on this computer. It watches what you type, remembers
43
- you between runs, rewrites encounters while you explore, and keeps
44
- files on everyone who has died here.
45
-
46
- It has noticed you. That is rarely good news.""",
47
- ),
48
- (
49
- "THE WAY OUT",
50
- """\
51
- Objective: escape the machine.
52
-
53
- A run is a short gauntlet the path reads left to right:
54
-
55
- ⚔ fight $ shell † altar + draft
56
-
57
- Win every fight and the last door opens: you walk out with root.
58
-
59
- You have 2 ttys. Lose a fight and one burns — you retry the same
60
- fight. Lose both and you are reaped: the run ends, the Warden files
61
- a report on your corpse, and your next run inherits the wreckage.""",
62
- ),
63
- (
64
- "THE TABLE",
65
- TABLE_DIAGRAM
66
- + """
67
- Your processes hit the lane across from them or the Warden's face
68
- when that lane is empty. Face damage tips THE SCALE. Tip it +5 your
69
- way and you win the fight; let it reach −5 and you are reaped.
70
-
71
- [b] rings the bell to end your turn. Then everything attacks.""",
72
- ),
73
- (
74
- "THE ECONOMY OF DEATH",
75
- """\
76
- Each turn you draw: [d] from your deck, or [s] a bit.
77
- A bit is a free 0/1 process whose entire purpose is to die.
78
-
79
- ♦ cards are paid in blood — mark processes you already control and
80
- they are killed to summon it. ⊙ cards cost core dumps: you bank one
81
- each time a process of yours dies. Death is your economy. Spend it.
82
-
83
- Your two piles stand at the right of the table. When a pile says
84
- EMPTY, there is no more drawing from it. Watch them.
85
-
86
- Select any card and its exact rules appear under your hand.
87
- [tab] does the same for the Warden's cards. No mystery mechanics.""",
88
- ),
89
- (
90
- "BETWEEN FIGHTS",
91
- """\
92
- After a fight you get a shell. It is small, it is fake, and it is
93
- the most honest part of this machine. `ls`, `cat`, `cd`, `grep`
94
- your way around — [tab] completes, [↑] recalls — some files pay
95
- cycles, some hide cards, and one is a schedule you really should
96
- do something about.
97
-
98
- The altar sells power. The price is always one of YOUR commands —
99
- whichever you lean on most. Sold means gone for the rest of the
100
- run, and the Warden notices every time you reach for it anyway.
101
-
102
- Hints will follow you through your first fight. [?] reopens the
103
- rules any time. The Warden is listening — `say` something if you
104
- must. It will not be kind about it.""",
105
- ),
106
  ]
107
 
 
 
 
 
 
 
 
108
 
109
- class OrientationScreen(Screen):
110
- """Dismisses with True when the player has seen (or skipped) it all."""
111
-
112
- CSS = """
113
- #orient { height: 1fr; align: center middle; }
114
- #orient-page { width: 76; height: auto; }
115
- #orient-prompt { height: 1; dock: bottom; background: $panel; content-align: center middle; }
116
- """
117
-
118
- def __init__(self) -> None:
119
- super().__init__()
120
- self.page = 0
121
-
122
- def compose(self) -> ComposeResult:
123
- with Vertical(id="orient"):
124
- yield Static(id="orient-page")
125
- yield Static(id="orient-prompt")
126
-
127
- def on_mount(self) -> None:
128
- self._show()
129
-
130
- def _dots(self) -> Text:
131
- from scrypt.ui import palette as pal
132
-
133
- t = Text()
134
- for i in range(len(PAGES)):
135
- t.append("● " if i <= self.page else "○ ",
136
- style=pal.WARDEN if i == self.page else pal.GHOST)
137
- return t
138
-
139
- def _show(self) -> None:
140
- from scrypt.ui import palette as pal
141
-
142
- title, body = PAGES[self.page]
143
- self.query_one("#orient-page", Static).update(
144
- Panel(
145
- Text(body, style=pal.FG),
146
- box=box.HEAVY,
147
- border_style=pal.BORDER_BRIGHT,
148
- title=Text(f"⟪ orientation — {title} ⟫", style=f"bold {pal.WARDEN}"),
149
- subtitle=self._dots(),
150
- subtitle_align="center",
151
- padding=(1, 3),
152
- )
153
- )
154
- last = self.page == len(PAGES) - 1
155
- prompt = (
156
- "[enter] the Warden is waiting"
157
- if last
158
- else "[enter] next [backspace] back [s] skip orientation"
159
- )
160
- self.query_one("#orient-prompt", Static).update(Text(prompt))
161
 
162
- def on_key(self, event: events.Key) -> None:
163
- if event.key == "s":
164
- self.dismiss(True)
165
- elif event.key == "backspace" and self.page > 0:
166
- self.page -= 1
167
- self._show()
168
- elif event.key in ("enter", "space", "right"):
169
- if self.page == len(PAGES) - 1:
170
- self.dismiss(True)
171
- else:
172
- self.page += 1
173
- self._show()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The tutorial: a guided, unlosable first fight + a read-only first shell.
2
+
3
+ Shown once per installation, the first time a game begins (gated by the
4
+ `initiated` flag in legacy.json). Gamers want to play, not read — so instead
5
+ of pages of text we hand-rail the player through one short fight, then drop
6
+ them into a look-but-don't-touch shell. Both are skippable ([esc] in the
7
+ fight). Everything taught here stays reachable afterwards via [?] in a fight
8
+ and [h] on the menu (HowToPlayScreen).
9
+
10
+ The fight is a five-beat lesson with a deliberate emotional arc: the player
11
+ draws, plays a body, and TAKES DAMAGE — the scale tips against them for two
12
+ turns so they learn what losing feels like — then they draw a heavy card,
13
+ SACRIFICE their Intruder and a bit to afford it, and swing back to the win.
14
+ It teaches: the deck draw [d], the bit draw [s], lanes, the bell, sacrifice,
15
+ and the shape of a comeback.
16
+
17
+ This module owns only the SCENARIO. BoardScreen consumes `LESSON` in its
18
+ `guided` mode and hard-rails to it.
19
  """
20
 
21
  from __future__ import annotations
22
 
23
+ from scrypt.engine.cards import Cost, CostType, make_card
24
+ from scrypt.engine.combat import CombatState, Phase, ScriptedPlay
25
+ from scrypt.sandbox.shell import Shell
26
+ from scrypt.sandbox.vfs import VFS
27
+
28
+ # A weak free body. Power 1 so it can never race the scale to the win on its
29
+ # own — the win has to come from the sacrifice payoff.
30
+ INTRUDER = make_card(
31
+ "intruder",
32
+ name="Intruder",
33
+ power=1,
34
+ health=2,
35
+ flavor="You. Unscheduled, unwelcome, and barely running.",
36
+ art=" ▞▚\n ▌><▐\n ▚▞",
37
+ )
38
+
39
+ # The payoff: a heavy MEM card. Two 1-mem fodder (the Intruder + a bit) pay
40
+ # its cost; eight power swings the scale from behind straight to the win.
41
+ KERNEL = make_card(
42
+ "kernel",
43
+ name="Kernel",
44
+ power=8,
45
+ health=4,
46
+ cost=Cost(CostType.MEM, 2),
47
+ flavor="Worth more than everything you fed it. That was the point.",
48
+ art=" ╔═══╗\n ║ ⌗ ║\n ╚═══╝",
49
+ )
50
+
51
+ # The Warden's pressure: parked in the first lane, it strikes the player's
52
+ # face for 2 every turn (the player is told to stand clear of it), so the
53
+ # scale slides negative until the comeback.
54
+ SENTINEL = make_card(
55
+ "sentinel",
56
+ name="Sentinel",
57
+ power=2,
58
+ health=5,
59
+ flavor="It does not advance. It does not need to.",
60
+ art=" ▟█████▙\n █ ▼ █\n ▜█████▛",
61
+ )
62
+
63
+ TUTORIAL_INTRO = (
64
+ "A new process wakes in my machine. Small. Unscheduled. Let me teach you "
65
+ "how this ends. Do exactly as the floor tells you."
66
+ )
67
+
68
+ # The lesson: ordered (expected_key, on-screen hint). BoardScreen's guided
69
+ # mode accepts ONLY the head key (plus [?] help, [esc] skip) and pops on a
70
+ # match. Keys: d=draw deck, s=take a bit, enter=begin/confirm a play,
71
+ # 1-4=lane (or, in sacrifice mode, a process to mark), b=ring the bell.
72
+ LESSON: list[tuple[str, str]] = [
73
+ # Turn 1 — the deck draw, a body on the board, and your first damage.
74
+ ("d", "This is the draw phase. Press [d] to pull a process from your deck."),
75
+ ("enter", "That Intruder is in your hand. Press [enter] to play it."),
76
+ ("2", "Stand it in the second lane, clear of the Warden: press [2]."),
77
+ ("b", "Ring the bell — press [b]. You strike for 1… then the Warden strikes back."),
78
+ # Turn 2 — the bit draw: free fodder, and falling further behind.
79
+ ("s", "You took 2 to the face. Now press [s] to take a bit — free 0/1 fodder."),
80
+ ("enter", "Press [enter] to play the bit."),
81
+ ("3", "Set the bit down in the third lane: press [3]."),
82
+ ("b", "Ring the bell again: [b]. The scale slides against you — on purpose."),
83
+ # Turn 3 — sacrifice the weak for the strong, and swing to the win.
84
+ ("d", "You're losing. Draw your answer: press [d]."),
85
+ ("enter", "The Kernel costs 2♦ — paid by killing your own. Press [enter] to begin."),
86
+ ("2", "Mark the Intruder to be sacrificed: press [2]. (1 of 2♦)"),
87
+ ("3", "Mark the bit too: press [3]. (2 of 2♦)"),
88
+ ("enter", "Press [enter] to feed them both to the Kernel."),
89
+ ("4", "Summon the Kernel into the fourth lane: press [4]."),
90
+ ("b", "Ring the bell: [b]. Eight power lands — the scale tips to +5. You win."),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  ]
92
 
93
+ TUTORIAL_MOTD = """\
94
+ you survived the table. this is the shell — the floor of my machine.
95
+ in here, for now, you may only look:
96
+ ls list what is lying around pwd where you are standing
97
+ say <words> speak to me fight go back to the table
98
+ look around. type `fight` when you are ready to begin for real.
99
+ """
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
 
102
+ def build_fight(content) -> CombatState:
103
+ """The deterministic guided fight. The draws are taken under full control
104
+ so the hard-railed lesson is exact regardless of the opening shuffle:
105
+ [d] yields the Intruder then the Kernel; [s] yields bits. A Sentinel is
106
+ parked in the first lane (from the script) and hammers the player's face
107
+ until the Kernel arrives."""
108
+ bit = content.card("bit")
109
+ state = CombatState(
110
+ main_deck=[INTRUDER, INTRUDER, INTRUDER],
111
+ side_deck=[bit, bit, bit],
112
+ script=[[ScriptedPlay(lane=0, card=SENTINEL)]],
113
+ seed=7,
114
+ )
115
+ # Replace the auto-dealt opening hand with an empty hand + scripted piles,
116
+ # so every draw the lesson asks for is exactly what arrives.
117
+ state.hand = []
118
+ state._draw_pile = [INTRUDER, KERNEL]
119
+ state._side_pile = [bit, bit, bit]
120
+ state.events = [] # discard the opening-deal log; the fight starts clean
121
+ state.phase = Phase.DRAW
122
+ return state
123
+
124
+
125
+ def build_shell() -> Shell:
126
+ """A throwaway read-only shell for the tutorial: ls + pwd only (say/fight
127
+ are shell-screen verbs). A few files to list; nothing to open yet."""
128
+ vfs = VFS() # cwd defaults to /home/drifter
129
+ vfs.write("/home/drifter/readme", "the only file you were meant to find.")
130
+ vfs.write("/home/drifter/scale.log", "balance: 0. it never stays there.")
131
+ vfs.write("/home/drifter/.cell", "you are here. so am I.")
132
+ shell = Shell(vfs)
133
+ shell.restrict_to(("ls", "pwd", "help"))
134
+ return shell
tests/test_ui.py CHANGED
@@ -117,6 +117,58 @@ async def test_abandon_exits_app():
117
  assert app.return_value is None
118
 
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  async def test_full_victorious_run_through_all_nodes(tmp_path, monkeypatch):
121
  """Win two empty-script fights; pass through shell, altar, and draft,
122
  then the victory lap: the root epilogue and the exfiltration pick."""
@@ -306,7 +358,6 @@ async def test_shell_is_haunted_and_answers_say():
306
 
307
  async def test_menu_flow_deck_select_and_howto(tmp_path, monkeypatch):
308
  from scrypt.ui.menu import HowToPlayScreen, MenuScreen
309
- from scrypt.ui.tutorial import OrientationScreen
310
 
311
  monkeypatch.setenv("SCRYPT_HOME", str(tmp_path)) # a fresh installation
312
  app = ScryptApp(seed=42) # menu NOT skipped
@@ -321,41 +372,83 @@ async def test_menu_flow_deck_select_and_howto(tmp_path, monkeypatch):
321
  await pilot.pause()
322
  await pilot.press("enter") # begin with selected deck
323
  await pilot.pause()
324
- # A first game ever passes through orientation before the table.
325
- assert isinstance(app.screen, OrientationScreen)
326
- await pilot.press("s") # the impatient may skip
 
 
 
 
 
327
  await pilot.pause()
 
328
  assert isinstance(app.screen, BoardScreen)
 
329
  assert app.deck_id == "forkstorm"
330
- assert app.screen.tutorial # first fight teaches
331
 
332
 
333
- async def test_orientation_runs_once_per_installation(tmp_path, monkeypatch):
334
  from scrypt.engine import legacy as legacy_store
335
- from scrypt.ui.tutorial import PAGES, OrientationScreen
 
336
 
337
  monkeypatch.setenv("SCRYPT_HOME", str(tmp_path))
 
338
  app = ScryptApp(seed=1)
339
  async with app.run_test() as pilot:
340
  await pilot.pause()
341
  await pilot.press("enter") # begin
342
  await pilot.pause()
343
- assert isinstance(app.screen, OrientationScreen)
344
- await pilot.press("backspace") # can't back off the first page
345
- assert app.screen.page == 0
346
- for _ in range(len(PAGES)): # read every page to the end
347
- await pilot.press("enter")
348
  await pilot.pause()
349
- assert isinstance(app.screen, BoardScreen)
 
 
 
 
 
 
 
350
  assert legacy_store.load()["initiated"] is True
351
 
352
- # Same installation, new game: straight to the table.
353
  again = ScryptApp(seed=2)
354
  async with again.run_test() as pilot:
355
  await pilot.pause()
356
  await pilot.press("enter")
357
  await pilot.pause()
358
- assert isinstance(again.screen, BoardScreen)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
 
360
 
361
  async def test_board_inspects_foes_and_opens_help():
 
117
  assert app.return_value is None
118
 
119
 
120
+ async def test_settings_screen_saves_byo_api(monkeypatch, tmp_path):
121
+ """A player picks 'My own API endpoint', fills it in, Save persists it."""
122
+ from textual.widgets import Button, Input, RadioSet
123
+
124
+ from scrypt.inference import config
125
+ from scrypt.ui.settings import SettingsScreen
126
+
127
+ monkeypatch.setenv("SCRYPT_HOME", str(tmp_path))
128
+ result = []
129
+
130
+ class Host(App):
131
+ def on_mount(self):
132
+ self.push_screen(SettingsScreen(), result.append)
133
+
134
+ async with Host().run_test(size=(100, 44)) as pilot:
135
+ await pilot.pause()
136
+ scr = pilot.app.screen
137
+ scr.query_one("#mode", RadioSet).focus()
138
+ await pilot.click("#mode-api")
139
+ scr.query_one("#base", Input).value = "http://localhost:8080/v1"
140
+ scr.query_one("#key", Input).value = "sk-test"
141
+ scr.query_one("#model", Input).value = "warden"
142
+ await pilot.click("#save")
143
+ await pilot.pause()
144
+
145
+ assert result == [True]
146
+ saved = config.load()
147
+ assert saved["backend"] == "api" and saved["api_model"] == "warden"
148
+
149
+
150
+ async def test_settings_screen_cancel_changes_nothing(monkeypatch, tmp_path):
151
+ from textual.app import App as _App
152
+
153
+ from scrypt.inference import config
154
+ from scrypt.ui.settings import SettingsScreen
155
+
156
+ monkeypatch.setenv("SCRYPT_HOME", str(tmp_path))
157
+ result = []
158
+
159
+ class Host(_App):
160
+ def on_mount(self):
161
+ self.push_screen(SettingsScreen(), result.append)
162
+
163
+ async with Host().run_test(size=(100, 44)) as pilot:
164
+ await pilot.pause()
165
+ await pilot.click("#cancel")
166
+ await pilot.pause()
167
+
168
+ assert result == [False]
169
+ assert not (tmp_path / "settings.json").exists()
170
+
171
+
172
  async def test_full_victorious_run_through_all_nodes(tmp_path, monkeypatch):
173
  """Win two empty-script fights; pass through shell, altar, and draft,
174
  then the victory lap: the root epilogue and the exfiltration pick."""
 
358
 
359
  async def test_menu_flow_deck_select_and_howto(tmp_path, monkeypatch):
360
  from scrypt.ui.menu import HowToPlayScreen, MenuScreen
 
361
 
362
  monkeypatch.setenv("SCRYPT_HOME", str(tmp_path)) # a fresh installation
363
  app = ScryptApp(seed=42) # menu NOT skipped
 
372
  await pilot.pause()
373
  await pilot.press("enter") # begin with selected deck
374
  await pilot.pause()
375
+ # A first game ever opens in the guided, hard-railed tutorial fight.
376
+ assert isinstance(app.screen, BoardScreen)
377
+ assert app.screen.guided is not None
378
+ # A wrong key is refused, not obeyed: the lesson cursor doesn't move.
379
+ await pilot.press("b") # not the first step ([d])
380
+ await pilot.pause()
381
+ assert app.screen._lesson_pos == 0
382
+ await pilot.press("escape") # the impatient may skip the tutorial
383
  await pilot.pause()
384
+ # Skipping drops them straight into the real run's first fight.
385
  assert isinstance(app.screen, BoardScreen)
386
+ assert app.screen.guided is None
387
  assert app.deck_id == "forkstorm"
388
+ assert app.screen.tutorial # first real fight still shows hints
389
 
390
 
391
+ async def test_tutorial_plays_through_once_per_installation(tmp_path, monkeypatch):
392
  from scrypt.engine import legacy as legacy_store
393
+ from scrypt.ui.shell import ShellScreen
394
+ from scrypt.ui.tutorial import LESSON
395
 
396
  monkeypatch.setenv("SCRYPT_HOME", str(tmp_path))
397
+ monkeypatch.setenv("SCRYPT_REDUCED_MOTION", "1") # synchronous bell, no wipes
398
  app = ScryptApp(seed=1)
399
  async with app.run_test() as pilot:
400
  await pilot.pause()
401
  await pilot.press("enter") # begin
402
  await pilot.pause()
403
+ assert isinstance(app.screen, BoardScreen) and app.screen.guided is not None
404
+ for key, _hint in LESSON: # follow the rails to the forced win
405
+ await pilot.press(key)
406
+ await pilot.pause()
407
+ await pilot.press("enter") # dismiss the won fight
408
  await pilot.pause()
409
+ # The won tutorial fight hands off to the read-only shell.
410
+ assert isinstance(app.screen, ShellScreen)
411
+ assert sorted(app.screen.shell.available()) == ["help", "ls", "pwd"]
412
+ app.screen.query_one("#shell-prompt").value = "fight"
413
+ await pilot.press("enter")
414
+ await pilot.pause()
415
+ # `fight` leaves the tutorial and the real run's first fight begins.
416
+ assert isinstance(app.screen, BoardScreen) and app.screen.guided is None
417
  assert legacy_store.load()["initiated"] is True
418
 
419
+ # Same installation, new game: no tutorial, straight to the table.
420
  again = ScryptApp(seed=2)
421
  async with again.run_test() as pilot:
422
  await pilot.pause()
423
  await pilot.press("enter")
424
  await pilot.pause()
425
+ assert isinstance(again.screen, BoardScreen) and again.screen.guided is None
426
+
427
+
428
+ def test_tutorial_lesson_takes_damage_then_wins():
429
+ """The hard-railed lesson, played on the engine, must (a) put the player
430
+ behind so they see damage, then (b) reach a win via sacrifice — so the
431
+ rails can never strand a player in an unwinnable fight."""
432
+ from scrypt.data import load_content
433
+ from scrypt.engine.combat import Phase, Result
434
+ from scrypt.ui import tutorial as tut
435
+
436
+ state = tut.build_fight(load_content())
437
+ # Turn 1: deck draw, play the Intruder (lane 2), trade blows with the foe.
438
+ state.draw("main")
439
+ state.play(0, 1)
440
+ state.ring_bell()
441
+ # Turn 2: bit draw, set the bit down (lane 3), fall further behind.
442
+ state.draw("side")
443
+ state.play(0, 2)
444
+ state.ring_bell()
445
+ assert state.scale < 0 # the player has visibly taken damage
446
+ # Turn 3: draw the Kernel, sacrifice the Intruder + bit to afford it, win.
447
+ state.draw("main")
448
+ state.play(0, 3, sacrifices=(1, 2))
449
+ assert state.dumps == 2 # two of the player's own processes died for it
450
+ state.ring_bell()
451
+ assert state.phase is Phase.OVER and state.result is Result.PLAYER_WIN
452
 
453
 
454
  async def test_board_inspects_foes_and_opens_help():