sidechannel 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. sidechannel-1.0.0.dist-info/METADATA +550 -0
  2. sidechannel-1.0.0.dist-info/RECORD +38 -0
  3. sidechannel-1.0.0.dist-info/WHEEL +5 -0
  4. sidechannel-1.0.0.dist-info/entry_points.txt +2 -0
  5. sidechannel-1.0.0.dist-info/licenses/LICENSE +674 -0
  6. sidechannel-1.0.0.dist-info/top_level.txt +1 -0
  7. terminalgames/__init__.py +1 -0
  8. terminalgames/engine/__init__.py +0 -0
  9. terminalgames/engine/dialogue.py +198 -0
  10. terminalgames/engine/endings.py +58 -0
  11. terminalgames/engine/journal.py +71 -0
  12. terminalgames/engine/loader.py +81 -0
  13. terminalgames/engine/puzzles.py +81 -0
  14. terminalgames/engine/savefile.py +108 -0
  15. terminalgames/engine/schema.py +99 -0
  16. terminalgames/engine/session.py +181 -0
  17. terminalgames/engine/shell.py +960 -0
  18. terminalgames/engine/state.py +169 -0
  19. terminalgames/engine/story.py +312 -0
  20. terminalgames/main.py +221 -0
  21. terminalgames/stories/story_01_zero_day/chapters/chapter_01.yaml +159 -0
  22. terminalgames/stories/story_01_zero_day/chapters/chapter_02.yaml +114 -0
  23. terminalgames/stories/story_01_zero_day/chapters/chapter_03.yaml +71 -0
  24. terminalgames/stories/story_01_zero_day/manifest.yaml +7 -0
  25. terminalgames/stories/story_01_zero_day/network.yaml +118 -0
  26. terminalgames/stories/story_01_zero_day/npcs.yaml +80 -0
  27. terminalgames/stories/story_02_dead_drop/chapters/chapter_01.yaml +181 -0
  28. terminalgames/stories/story_02_dead_drop/manifest.yaml +5 -0
  29. terminalgames/stories/story_02_dead_drop/network.yaml +397 -0
  30. terminalgames/stories/story_02_dead_drop/npcs.yaml +34 -0
  31. terminalgames/stories/story_03_night_shift/chapters/chapter_01.yaml +158 -0
  32. terminalgames/stories/story_03_night_shift/manifest.yaml +5 -0
  33. terminalgames/stories/story_03_night_shift/network.yaml +158 -0
  34. terminalgames/stories/story_03_night_shift/npcs.yaml +24 -0
  35. terminalgames/tools/__init__.py +0 -0
  36. terminalgames/tools/check_story.py +483 -0
  37. terminalgames/tui.py +354 -0
  38. terminalgames/web_bridge.py +250 -0
@@ -0,0 +1,550 @@
1
+ Metadata-Version: 2.4
2
+ Name: sidechannel
3
+ Version: 1.0.0
4
+ Summary: Side Channel: a hacker-themed text adventure with a fake terminal, playable in a terminal or a browser
5
+ License-Expression: GPL-3.0-or-later
6
+ Project-URL: Play in your browser, https://derd1ngs.github.io/sidechannel/
7
+ Project-URL: Source, https://github.com/derd1ngs/sidechannel
8
+ Project-URL: Issues, https://github.com/derd1ngs/sidechannel/issues
9
+ Project-URL: Changelog, https://github.com/derd1ngs/sidechannel/blob/main/CHANGELOG.md
10
+ Keywords: game,text adventure,interactive fiction,hacker,terminal,textual
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Environment :: Console :: Curses
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Games/Entertainment
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: rich>=13.0
24
+ Requires-Dist: pyyaml>=6.0
25
+ Requires-Dist: textual>=0.60
26
+ Requires-Dist: platformdirs>=3.6
27
+ Provides-Extra: test
28
+ Requires-Dist: pytest; extra == "test"
29
+ Requires-Dist: pytest-asyncio; extra == "test"
30
+ Provides-Extra: lint
31
+ Requires-Dist: ruff>=0.6; extra == "lint"
32
+ Requires-Dist: mypy>=1.10; extra == "lint"
33
+ Requires-Dist: types-PyYAML; extra == "lint"
34
+ Dynamic: license-file
35
+
36
+ # Side Channel
37
+
38
+ [![Tests](https://github.com/derd1ngs/sidechannel/actions/workflows/tests.yml/badge.svg)](https://github.com/derd1ngs/sidechannel/actions/workflows/tests.yml)
39
+
40
+ *A terminal hacking thriller.* A hacker-themed text adventure with a fake terminal, in the spirit of
41
+ *Hackers*, *WarGames*, and *23*. Play it in your terminal (a full-screen
42
+ Textual app) or right in your browser at
43
+ **https://derd1ngs.github.io/sidechannel/**, with nothing to install. Ships
44
+ with three complete stories:
45
+
46
+ - **Zero Day** -- a 3-chapter campaign that also doubles as an in-fiction
47
+ tutorial: by the end you'll have used the core shell commands (`help`,
48
+ `whoami`, `scan`, `connect`/`disconnect`, `ls`/`cd`/`cat`/`grep`, `set` +
49
+ `systemctl`, `decrypt` with both cipher types, `journal`, `chat`, and
50
+ `mail`), plus trust-building, gated dialogue, and a branching set of six
51
+ endings.
52
+ - **Dead Drop** -- a short one-chapter follow-up built on the newer shell
53
+ features: filtering a years-long log with pipes (`cat ... | grep ... |
54
+ grep ...`), an `ssh` login with a password you have to dig up, a runbook
55
+ you must follow to the letter, `status`/`journal <category>`, and four
56
+ endings gated on how far you trusted the friend who sent you.
57
+ - **Night Shift** -- a one-chapter incident at 3 AM: dig a hidden,
58
+ obfuscated deploy key out of a build server (`tail`, `find`, `ls -a`,
59
+ `decrypt`), then fix the public mirror before its **trace meter** cuts you
60
+ off -- with one retry. Tools you pick up along the way (the key, a
61
+ colleague's packet sniffer) open hosts and endings.
62
+
63
+ ## Installing
64
+
65
+ ```bash
66
+ pipx install sidechannel # or: pip install sidechannel
67
+ sidechannel # play
68
+ ```
69
+
70
+ Saves go to your user data directory, e.g. `~/.local/share/sidechannel/saves/`
71
+ on Linux. (Inside, the Python package is still called `terminalgames`, the
72
+ game's original name.) Or skip installing entirely
73
+ and play in the browser: **https://derd1ngs.github.io/sidechannel/**.
74
+
75
+ ## Running from a checkout
76
+
77
+ ```bash
78
+ ./setup.sh # one-time: creates .venv and installs the package into it
79
+ ./start.sh # launches the game (re-run this any time to play)
80
+ ```
81
+
82
+ `setup.sh` is safe to re-run (e.g. after pulling changes) -- it reuses the
83
+ existing `.venv` and just reinstalls the package. `start.sh` forwards any
84
+ arguments straight to `sidechannel`, e.g. `./start.sh zero_day --new`, and
85
+ tells you to run `setup.sh` first if `.venv` doesn't exist yet.
86
+
87
+ If you'd rather manage the virtual environment yourself:
88
+
89
+ ```bash
90
+ python3 -m venv .venv
91
+ .venv/bin/pip install -e .
92
+ .venv/bin/sidechannel
93
+ # or: .venv/bin/python -m terminalgames.main
94
+ ```
95
+
96
+ With no arguments, story/save-slot selection is a plain pre-flight prompt. You
97
+ can skip it with CLI args instead (via `./start.sh` or `.venv/bin/sidechannel`
98
+ directly -- both take the same arguments):
99
+
100
+ ```bash
101
+ ./start.sh --list # list available stories, then exit
102
+ ./start.sh zero_day --list # list zero_day's save slots, then exit
103
+ ./start.sh zero_day # launch directly (story dir name or manifest id), slot "default"
104
+ ./start.sh zero_day --slot speedrun --new # launch a specific, named slot fresh
105
+ ./start.sh zero_day --slot speedrun --continue # launch that slot, failing if it doesn't exist
106
+ ```
107
+
108
+ Each story can have multiple save slots (`saves/<story_id>/<slot>.json`), so
109
+ you can run several playthroughs of the same story side by side. Launched
110
+ without `--slot`, a direct launch uses the `default` slot; the interactive
111
+ picker instead lists existing slots (with their current chapter/scene and
112
+ last-saved time) and lets you pick one to continue or name a new one.
113
+ (Saves from before slots existed, at the old flat `saves/<story_id>.json`
114
+ path, are migrated into the `default` slot automatically.)
115
+
116
+ Each story also keeps an **endings gallery** across all its slots: reaching
117
+ an ending shows "Endings found: 2/4", and `--list` (or the slot picker) names
118
+ the ones you've found without spoiling the rest. It's stored as
119
+ `saves/<story_id>/found-endings.txt`; ending names come from the
120
+ `-- ENDING: Name --` line at the end of an ending scene's text. The browser
121
+ version shows the same gallery in its slot menu.
122
+
123
+ `saves/` is the repo's own folder when you run from a checkout (as
124
+ `setup.sh` does). An installed copy (`pip install .`, or later from PyPI)
125
+ keeps its saves in your user data directory instead, e.g.
126
+ `~/.local/share/sidechannel/saves/` on Linux. If a story was edited so
127
+ that a save's scene no longer exists, continuing explains that and offers
128
+ to restart the slot.
129
+
130
+ Each slot also gets a real sandbox directory on disk
131
+ (`saves/<story_id>/<slot>_sandbox/hosts/<host_id>/`) -- every file a story's
132
+ `network.yaml` describes for a host is materialized there as an actual file,
133
+ and `cat`/`ls`/`grep`/`set`/`systemctl`/`decrypt` read and write those real
134
+ files rather than an in-memory simulation. Continuing a slot reuses its
135
+ sandbox as-is (so anything you've edited via `set` stays edited); restarting
136
+ a slot wipes it back to the story's original files.
137
+
138
+ The game itself then runs full-screen as a three-pane Textual app
139
+ (`terminalgames/tui.py`): a **story pane** (pure narration -- scene text and
140
+ choice echoes) with a small **choices pane** underneath it (narrative
141
+ scenes -- arrow keys + Enter), and a **terminal pane** filling the rest of
142
+ the screen (a command's echo/output log paired with the input line, terminal
143
+ scenes).
144
+
145
+ In-game, at any point during a terminal scene you can type:
146
+ - `:save` -- save your progress
147
+ - `:quit` / `:exit` -- save and quit
148
+ - `Ctrl+S` saves and `Ctrl+Q` saves and quits from anywhere, narrative scenes included
149
+
150
+ The terminal input behaves like a small shell: `Up`/`Down` walk your command
151
+ history, `Tab` completes command names, hosts, paths, services, contacts and
152
+ topics (listing the candidates when there's more than one), quotes group
153
+ words into one argument (`grep "failed login" /var/log/syslog`), and
154
+ `help <command>` prints that command's usage.
155
+
156
+ The current slot is also autosaved every time you cross into a new chapter,
157
+ so a crash or an accidental quit never costs you more than the current
158
+ chapter's progress.
159
+
160
+ Run the test suite with `.venv/bin/pip install -e ".[test]" && .venv/bin/pytest`
161
+ (the TUI tests drive the Textual app headlessly via `pytest-asyncio` +
162
+ `App.run_test()`, no real terminal needed).
163
+
164
+ ## Playing in a browser
165
+
166
+ The same game runs in a browser with nothing to install:
167
+ **https://derd1ngs.github.io/sidechannel/** (deployed from `main` by
168
+ `.github/workflows/pages.yml`). The unchanged Python engine runs client-side
169
+ in [Pyodide](https://pyodide.org) (CPython compiled to WebAssembly); the
170
+ first visit downloads about 10 MB. After that one visit the game **works
171
+ offline**: a service worker (`web/sw.js`) keeps the game and the Python
172
+ runtime, and the slot menu says so once it's ready. Saves are
173
+ kept in the browser's IndexedDB, so they're per browser and don't mix with
174
+ the terminal version's `saves/`. Each slot has an **Export** button that
175
+ downloads it as one JSON save file: the game state plus the slot's whole
176
+ sandbox, `set` edits and mail included. **Import a save file…** brings it
177
+ back, in any browser. The file is fully validated before anything is
178
+ written: right story, known scene, no paths outside the sandbox.
179
+
180
+ It plays like the TUI -- story, choices and terminal panes, Tab completion,
181
+ Up/Down history, `Ctrl+S` -- with three browser-specific touches: number keys
182
+ pick a choice; on phones and other touch or narrow screens, **⇥ ↑ ↓** buttons
183
+ next to the input stand in for the Tab and arrow keys that on-screen
184
+ keyboards lack; and a **Mail** button (in terminal scenes, or typing `mail
185
+ compose`) opens a form instead of writing a draft file by hand for `mail
186
+ sync`. It writes the draft into the slot's
187
+ `mail/draft/` directory and runs `mail sync`, so matching and bouncing follow
188
+ exactly the same rules.
189
+
190
+ To run it locally:
191
+
192
+ ```bash
193
+ python3 web/build.py # writes _site/
194
+ python3 -m http.server -d _site 8000 # then open http://localhost:8000
195
+ ```
196
+
197
+ `web/` holds the page (`index.html`, `app.js`, `style.css`) and `build.py`,
198
+ which zips only what the browser runs -- `engine/`, `stories/` and
199
+ `terminalgames/web_bridge.py`, the JSON facade over `GameSession` that
200
+ `app.js` calls.
201
+
202
+ `web/e2e/e2e.mjs` plays both stories in headless Firefox against a built
203
+ site and runs in CI (the `web-e2e` job). It checks:
204
+ - menus, choices and the terminal: Tab, history, pipes, `ssh` password
205
+ masking and the procedure puzzle;
206
+ - saves surviving a reload, and moving a save to a second browser with
207
+ export/import;
208
+ - the phone layout;
209
+ - that no console errors occur.
210
+
211
+ On failure, CI uploads its screenshots as an artifact. To run it locally
212
+ against the server above:
213
+
214
+ ```bash
215
+ cd web/e2e && npm ci && npx playwright install firefox
216
+ node e2e.mjs http://localhost:8000/
217
+ ```
218
+
219
+ ## How a story is put together
220
+
221
+ A **story** lives in `terminalgames/stories/<story_id>/` and has:
222
+
223
+ ```
224
+ story_dir/
225
+ manifest.yaml # id, title, start scene, list of chapter files
226
+ chapters/
227
+ chapter_01.yaml # one or more chapters -- a scene graph each
228
+ network.yaml # (optional) the virtual hosts the fake terminal exposes
229
+ npcs.yaml # (optional) chat/email contacts
230
+ ```
231
+
232
+ Story files are loaded strictly. An unknown key (a typo like `requries:`) or
233
+ an invalid value (a journal category `tracee`, a scene type, an NPC channel)
234
+ is a load error that names the file, scene and choice, and suggests the
235
+ closest valid key. Scenes must also be consistent: a terminal scene needs its
236
+ `terminal` block, an ending has no choices, and a narrative scene without
237
+ choices would be a dead end.
238
+
239
+ A story can be a single short chapter, a few (like Zero Day's three), or many chapters
240
+ spanning a long, non-linear investigation -- the engine doesn't distinguish
241
+ between the two. Scenes reference each other as `chapter_id:scene_id`, so a
242
+ lead planted in chapter 3 can pay off in chapter 12.
243
+
244
+ ### manifest.yaml
245
+
246
+ ```yaml
247
+ id: zero_day
248
+ title: "Zero Day"
249
+ start: "chapter_01:intro" # must be "chapter_id:scene_id"
250
+ chapters:
251
+ - chapter_01.yaml
252
+ ```
253
+
254
+ ### Chapters and scenes
255
+
256
+ Each chapter file is a list of scenes:
257
+
258
+ ```yaml
259
+ id: chapter_01
260
+ scenes:
261
+ - id: intro
262
+ type: narrative # narrative | terminal | ending
263
+ text: |
264
+ Your story text. Rich markup ([bold]...[/bold]) is supported.
265
+ choices:
266
+ - text: "Do the thing"
267
+ next: some_scene # same chapter, or "other_chapter:scene_id"
268
+ requires: # optional gate
269
+ flag: some_flag
270
+ sets: # optional effects on success
271
+ some_flag: true
272
+ trust.ghost: 1 # "trust.<npc_id>" adjusts trust instead of a flag
273
+ tool.sniffer: true # "tool.<tool_id>" grants a tool (false takes it away)
274
+ logs: # optional journal entries
275
+ - id: lead_1
276
+ category: lead # lead | trace | suspect | note
277
+ text: "What the player learned."
278
+ ```
279
+
280
+ `requires` supports: `flag`, `flag_equals: {key, value}`, `tool` (granted
281
+ with `tool.<id>: true` in any `sets`), `journal_has: <entry_id>`, `trust_at_least: {npc, value}`, and the
282
+ combinators `all: [...]`, `any: [...]` and `not: {...}`, which nest. Every
283
+ key in a block must hold, so a plain block is an implicit `all`:
284
+
285
+ ```yaml
286
+ requires:
287
+ flag: found_archive_report
288
+ not: {any: [{flag: reported_t}, {flag: trusted_t}]}
289
+ ```
290
+
291
+ Because choices are gated rather than strictly sequential, a **hub scene**
292
+ that offers several `requires`-gated leads (looping back to itself or to a
293
+ menu scene) is how you build a non-linear, "follow whichever thread you
294
+ want" investigation without any special engine support -- see the forward-
295
+ looking design note in the project's plan file for the long-form game this
296
+ was built to support.
297
+
298
+ ### Terminal scenes
299
+
300
+ ```yaml
301
+ - id: gateway_shell
302
+ type: terminal
303
+ text: "You're in."
304
+ terminal:
305
+ host: gateway # optional: auto-connect to this host on entry
306
+ win_flag: netmon_fixed # scene advances once this flag is set
307
+ next: discovery
308
+ logs: [...] # logged once the scene is solved
309
+ hints: # optional: revealed one at a time by `hint`
310
+ - "The dead service is netmon; its config lives under /etc/netmon/."
311
+ - "`set /etc/netmon/netmon.conf bind_address 0.0.0.0`, then restart it."
312
+ ```
313
+
314
+ Order `hints` from a gentle nudge to nearly the answer. `hint` reveals the
315
+ next one ("Hint 2/3: ..."), and once all are shown it lists them again; how
316
+ many a player has seen is saved per scene. Every shipped terminal scene has
317
+ hints, and `tests/test_hints.py` plays each story solving every terminal
318
+ scene using only the commands its hints spell out in backticks -- so a hint
319
+ that stops working after a story edit fails CI.
320
+
321
+ `win_flag` is set by whichever shell command solves the puzzle (a service's
322
+ `on_fix_flag`, a cipher file's `on_success_flag`, a host's `on_connect_flag`
323
+ -- see network.yaml below), not by the terminal block itself -- with one
324
+ exception: a **procedure puzzle** lists the exact commands to run, in order,
325
+ and the scene sets its own `win_flag` once the player's most recent commands
326
+ in it match that sequence (whitespace-normalized; any other command in
327
+ between breaks the run):
328
+
329
+ ```yaml
330
+ terminal:
331
+ win_flag: db_recovered
332
+ next: aftermath
333
+ ordered_commands:
334
+ - systemctl status replica
335
+ - set /etc/db/replica.conf mode primary
336
+ - systemctl restart replica
337
+ ```
338
+
339
+ A terminal block can also carry a **trace meter**, `trace: {limit: 8,
340
+ on_trace: caught}`. Every command that touches a host (`scan`, `connect`,
341
+ `ssh`, `ls`, `cd`, `cat`, `head`, `tail`, `grep`, `find`, `set`, `systemctl`,
342
+ `decrypt`) raises it, and the terminal shows `[trace 3/8]`. Local commands
343
+ (`help`, `man`, `hint`, `status`, `journal`, `history`, `clear`, `chat`,
344
+ `mail`) are free, so asking for help never costs anything. Reaching the limit
345
+ without solving the scene drops the connection and moves the story to
346
+ `on_trace`, which could be a retry, a setback or an ending. The count is
347
+ saved, so reloading doesn't reset it; entering the scene anew does.
348
+ `check_story` explores the traced outcome too.
349
+
350
+ If `host` is
351
+ omitted, the player stays on whatever host they were last connected to --
352
+ connection state persists across scenes and across save/continue.
353
+
354
+ ### network.yaml -- the virtual network
355
+
356
+ ```yaml
357
+ hosts:
358
+ gateway:
359
+ address: "10.44.0.1"
360
+ banner: "shown by `scan`"
361
+ requires_to_connect: # optional gate on `connect`/`ssh`
362
+ flag: some_flag
363
+ on_connect_flag: connected_gateway # optional: set when `connect`/`ssh` succeeds
364
+ logins: # optional: reach this host with `ssh user@host` + password
365
+ ops: "hunter2" # (and `connect` refuses it)
366
+ services:
367
+ netmon:
368
+ config_path: /etc/netmon/netmon.conf # must point at a `config` file below
369
+ required_config:
370
+ bind_address: "0.0.0.0"
371
+ allow_query: "allow"
372
+ on_fix_flag: netmon_fixed # set when `systemctl restart` validates
373
+ filesystem:
374
+ etc:
375
+ type: dir
376
+ entries:
377
+ netmon:
378
+ type: dir
379
+ entries:
380
+ netmon.conf:
381
+ type: config
382
+ values: {bind_address: "127.0.0.1", allow_query: "denied"}
383
+ README:
384
+ type: text
385
+ content: "a hint file"
386
+ secret.enc:
387
+ type: cipher
388
+ cipher: caesar # caesar | xor
389
+ ciphertext: "..."
390
+ plaintext: "the answer" # what a correct `decrypt` must produce
391
+ on_success_flag: found_secret
392
+ ```
393
+
394
+ A successful `decrypt` writes the plaintext to a real sibling file (same
395
+ name, `.txt` suffix -- `secret.enc` -> `secret.txt`) rather than printing it
396
+ inline, so the player then `cat`s it like any other file. A wrong key just
397
+ prints the garbled result and writes nothing.
398
+
399
+ Filesystem node types: `dir`, `text`, `config`, `cipher`.
400
+
401
+ Shell commands available to the player: `help [command]`, `man <command>`,
402
+ `hint`, `whoami`, `status`, `history`, `clear`, `scan <host>`, `connect
403
+ <host>`, `ssh <user>@<host>` (prompts for the password on the next line),
404
+ `disconnect`/`exit`, `ls [-a] [path]` (dotfiles only with `-a`), `cd <path>`,
405
+ `cat <file>`, `head`/`tail [-n N] <file>`, `find [path] [-name pattern]`,
406
+ `grep <pattern> <file>`, `set <file> <key> <value>`,
407
+ `systemctl status|restart <service>`, `decrypt <file> <key>`,
408
+ `journal`/`notebook [lead|note|suspect|trace]`, `chat <npc> [topic]`,
409
+ `mail [list|read <id>|send <npc> <topic>|sync]`. Any command's output can be
410
+ piped into `grep` (`cat /var/log/syslog | grep ssh | grep failed`); `grep`
411
+ is the only pipe target, and a quoted `"|"` still counts as a pipe.
412
+
413
+ There's deliberately no `crack`-style instant password break. The
414
+ config-edit-and-restart puzzle (`cat` a config, `set` the wrong key, `systemctl
415
+ restart`) is the sysadmin-flavored core loop; `grep` a log and `decrypt` a
416
+ cipher round out the puzzle types a story can use (see
417
+ `terminalgames/engine/puzzles.py` for the underlying, independently reusable
418
+ validators), along with `ssh` logins with a password found somewhere in the
419
+ story, and `ordered_commands` procedure puzzles.
420
+
421
+ ### npcs.yaml -- chat/email contacts
422
+
423
+ One shared mechanism for every talkable character (AI advisor, friend,
424
+ handler, or antagonist) -- a story can define as many as it wants, including
425
+ several distinct AI-advisor personas the player can choose between.
426
+
427
+ ```yaml
428
+ npcs:
429
+ - id: ghost
430
+ name: "GHOST"
431
+ channel: chat # chat (sync) | email (async, delayed reply)
432
+ ask_limit: 5 # optional: max asks before they stop responding
433
+ email_delay_scenes: 2 # email only: scenes before a sent question gets a reply
434
+ topics:
435
+ - id: netmon
436
+ prompt: "ask about netmon"
437
+ response: "The response text."
438
+ requires: {flag: some_flag} # optional gate, same shape as choices
439
+ sets: {...} # optional effects, same shape as choices
440
+ logs: [...]
441
+ reliability: truthful # truthful | misleading | evasive (informational -- write the response text accordingly)
442
+ ```
443
+
444
+ Conversations are **topic-based, not free text** -- the player sees a menu of
445
+ currently-available topics (`chat <npc>` with no topic lists them) and picks
446
+ one. This keeps every NPC fully scripted and deterministic (no LLM call, no
447
+ cost, and it's covered by `tests/test_dialogue.py`) while still supporting an
448
+ unreliable advisor (`reliability: misleading`), a friend who needs trust
449
+ built up first (`requires: {trust_at_least: {npc: ..., value: ...}}`), and a
450
+ slow-to-reply email contact -- all through the same data shape a real LLM-
451
+ backed persona could implement later behind the same `ask_topic`/
452
+ `send_topic_by_email` call shape, without touching the shell or any existing
453
+ NPC's content.
454
+
455
+ #### Mail as real files
456
+
457
+ Email works three ways. `mail send <npc> <topic>` is the guided path -- exact
458
+ topic id, no filesystem involved, same as `chat`. `mail compose` opens a form
459
+ (To, Subject, message) in the TUI and the browser; it writes the draft for you
460
+ and runs `mail sync`, so it follows the same matching and bounce rules as the
461
+ real-file path below. `mail sync` is the real-file
462
+ path: outside the game, in the slot's sandbox directory
463
+ (`saves/<story_id>/<slot>_sandbox/mail/draft/`), write a plain text file
464
+ with `To:`/`Subject:` headers and a body, e.g.
465
+
466
+ ```
467
+ To: t
468
+ Subject: cold storage backup passphrase?
469
+
470
+ Saw in the access log you re-keyed it. Any chance you remember it?
471
+ ```
472
+
473
+ then run `mail sync` in-game. It matches the `Subject:` line against any
474
+ email topic that declares `outbox_match: {subject_contains: "..."}` (a
475
+ loose, case-insensitive keyword match, not an exact topic id -- the player
476
+ writes a real message in their own words) on the recipient NPC, subject to
477
+ the same `requires`/`ask_limit` gating `chat`/`mail send` already enforce.
478
+ A match moves the draft to `mail/sent/`; no match renames it in place with a
479
+ `.bounced` suffix and a `[bounced] <reason>` line prepended, so it's always
480
+ clear what happened rather than the file just vanishing. Delivered replies
481
+ still arrive automatically on the same scene-count delay as always, and now
482
+ also land as real files in `mail/inbox/` (`mail list`/`mail read` still work
483
+ too, reading from the same underlying state).
484
+
485
+ ### Checking a story for structural bugs
486
+
487
+ ```bash
488
+ .venv/bin/python -m terminalgames.tools.check_story zero_day # one story
489
+ .venv/bin/python -m terminalgames.tools.check_story --all # every shipped story
490
+ .venv/bin/python -m terminalgames.tools.check_story dead_drop --graph # Mermaid scene graph
491
+ ```
492
+
493
+ Explores every reachable combination of choices via the real engine (not a
494
+ separate reimplementation) to report scenes and endings that can never be
495
+ reached, and terminal scenes whose `win_flag` is never actually set by
496
+ anything in that story's `network.yaml`/`npcs.yaml` -- the classic typo
497
+ between two files that's easy to introduce and hard to spot by reading
498
+ either file alone. `tests/test_story_reachability.py` runs this against
499
+ every shipped story as part of the normal test suite, so a broken story
500
+ fails CI the same way a broken test would. See
501
+ `terminalgames/tools/check_story.py`'s module docstring for how the search
502
+ works and what it deliberately doesn't model (e.g. an NPC's `ask_limit`
503
+ running out isn't factored into reachability).
504
+
505
+ It also lints references across all of a story's files. These are
506
+ **problems**, which fail the check:
507
+ - a flag some `requires` reads (in a choice, a topic or a host) that
508
+ nothing ever sets;
509
+ - a `journal_has` id that nothing ever logs;
510
+ - a `tool` that nothing ever grants.
511
+
512
+ A flag that is set, or a tool that is granted, but never read is only a
513
+ **warning**: harmless, but usually a leftover or a typo.
514
+
515
+ `--graph` prints the story's scene graph as a
516
+ [Mermaid](https://mermaid.js.org) flowchart instead of checking it. Paste
517
+ it into a ```` ```mermaid ```` block in any GitHub Markdown file (or the
518
+ Mermaid live editor) to see it drawn:
519
+ - narrative scenes are boxes, terminal scenes double-bordered boxes and
520
+ endings rounded;
521
+ - choices are arrows labelled with their text, dashed when gated by
522
+ `requires`;
523
+ - a terminal scene's exit is a thick arrow labelled with its `win_flag`.
524
+
525
+ ## Project layout
526
+
527
+ `terminalgames/engine/` holds the engine modules (`story.py`, `shell.py`,
528
+ `dialogue.py`, `journal.py`, `state.py`, `puzzles.py`, `savefile.py` for
529
+ the export/import format) -- pure game logic with no UI dependency -- plus
530
+ `session.py`, whose `GameSession` is the game
531
+ loop itself (choices, commands, scene transitions, mail delivery, autosave)
532
+ that any frontend drives (`GameSession.open` starts or continues a save
533
+ slot), and `loader.py`, which finds stories and save slots on disk. The
534
+ engine needs only PyYAML -- `tests/test_engine_is_ui_free.py` keeps Textual
535
+ and Rich out of it. `terminalgames/tui.py` is the split-pane Textual
536
+ frontend that renders it; `main.py` is just the pre-flight story/save picker
537
+ that hands off to it. `terminalgames/tools/` holds `check_story.py`, the
538
+ structural story validator described above. `terminalgames/stories/` holds
539
+ the three shipped stories as complete worked content examples:
540
+ `story_01_zero_day/` for the core features, `story_02_dead_drop/` for `ssh`
541
+ logins, pipes, `ordered_commands` and `requires` combinators, and
542
+ `story_03_night_shift/` for tool grants and the trace meter.
543
+
544
+ ## License
545
+
546
+ Side Channel is free software: you can redistribute it and/or modify it
547
+ under the terms of the GNU General Public License as published by the Free
548
+ Software Foundation, either **version 3 of the License, or (at your option)
549
+ any later version** (SPDX: `GPL-3.0-or-later`). See [LICENSE](LICENSE) for
550
+ the full text.
@@ -0,0 +1,38 @@
1
+ sidechannel-1.0.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
2
+ terminalgames/__init__.py,sha256=J-j-u0itpEFT6irdmWmixQqYMadNl1X91TxUmoiLHMI,22
3
+ terminalgames/main.py,sha256=sWz9ADUtb2CMAaRXmNV70MWNBwRC4a0VYr499xPrElg,8129
4
+ terminalgames/tui.py,sha256=KJvviljVMKtQD7C7-h7MgmXhwR_yBzXvR5OfV5gPhXc,13577
5
+ terminalgames/web_bridge.py,sha256=8OWOLZ3NH0LJeGiuPhOh-YQPXceuWqB4K8MAatIkySk,8961
6
+ terminalgames/engine/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ terminalgames/engine/dialogue.py,sha256=HLcM8ZXq6zMxBBVMtp9l3fEobAlCEFR2cWgAQKZhOi0,8149
8
+ terminalgames/engine/endings.py,sha256=QqrEkp3BXKYL9z9qQQjkFIPdEeW6progSlNQayeD88E,1947
9
+ terminalgames/engine/journal.py,sha256=R1z-i4wnRW_EQTN9C5LNiLid09jh_Dq4A-Obs9sSP2A,2183
10
+ terminalgames/engine/loader.py,sha256=K1Mmkxd80zMd0rVjaT91tWuRY_G9KOl6Fg6j9qHczjs,2874
11
+ terminalgames/engine/puzzles.py,sha256=cy_7HlNHmlpZ2I1d2ntXk_FVyJGiQIWurddycdeyJbM,2893
12
+ terminalgames/engine/savefile.py,sha256=fJ_uO1_crjNWWjAzAA8ThQKEUuEx69aUvxZP5aA-Ahc,4292
13
+ terminalgames/engine/schema.py,sha256=nKGlngSvZ0EkFulXxGASnUNbUPQ7xnFIWDBE36Z7KUg,4013
14
+ terminalgames/engine/session.py,sha256=rZVGI5dlrFwH_ENKVIMJvQuGQN0IH2-xSseyxx5l5sE,8560
15
+ terminalgames/engine/shell.py,sha256=3-rVjXkB7NnxkjIn8_2g_9J9Xrf3rD5MAN43OrXcCyk,39563
16
+ terminalgames/engine/state.py,sha256=difJiBF25B6qf1Iky7zSW5QucK8K3WmB5zKhrr0dN6c,6370
17
+ terminalgames/engine/story.py,sha256=4gg8Caq3ofsub4nM3twNPTJUC6Ta7H4wj0VWxdTyWg8,12894
18
+ terminalgames/stories/story_01_zero_day/manifest.yaml,sha256=MgvXA26Hm_pJ6yCzlbS2kfAbhsQ3JK9-JJAXNMAU9NU,127
19
+ terminalgames/stories/story_01_zero_day/network.yaml,sha256=NpRVWA5YJvEvLCpqgugp3ppnHKovmSsufMKz9dxADFo,4910
20
+ terminalgames/stories/story_01_zero_day/npcs.yaml,sha256=Lcfs_u6xl9oJEywCU6h0UBBddI2pzhEMnh_v4jX2_dU,2736
21
+ terminalgames/stories/story_01_zero_day/chapters/chapter_01.yaml,sha256=a6dA-r_rJh8y4cR8MxZITIilKR-4Kr4ASUcGDjQffSo,6665
22
+ terminalgames/stories/story_01_zero_day/chapters/chapter_02.yaml,sha256=Vs26BJfKB0rmtLBjIWyiAit8XBXl1fgcNCzzVmW658o,4573
23
+ terminalgames/stories/story_01_zero_day/chapters/chapter_03.yaml,sha256=PJj8etZNbfb-OEBCSpaaQyDzLmq1DGqW_HLh2bJd6F8,2749
24
+ terminalgames/stories/story_02_dead_drop/manifest.yaml,sha256=XWoiwwuZb0WzYmvLtvAF_rIe6whzZkYnohzKuCb-urE,89
25
+ terminalgames/stories/story_02_dead_drop/network.yaml,sha256=yf6Hg0OoPl1VHhnwYaoG3UVx8jw3tJWi_n2MaJoi2oY,21115
26
+ terminalgames/stories/story_02_dead_drop/npcs.yaml,sha256=YJA6ykkzKdrNKPBXtw9nzJ1NJODZr633oxNsNYcSgM0,1230
27
+ terminalgames/stories/story_02_dead_drop/chapters/chapter_01.yaml,sha256=8bC-Z_1YwFTQ2EWjiQPbivZ0PeKA3DagIAY9NsgnY5Y,6494
28
+ terminalgames/stories/story_03_night_shift/manifest.yaml,sha256=P8TUmEMeSWoMXQvfFilexgS7ZgWmlK7I1vQkS1Bln-Y,93
29
+ terminalgames/stories/story_03_night_shift/network.yaml,sha256=Tn0X7m3oi7bVvPWvrr9r8TJZFhE48gnfdHcAcQMLP0Y,7344
30
+ terminalgames/stories/story_03_night_shift/npcs.yaml,sha256=Qoy-eYxg0eD-d75R0LfLHcfHFZfOtogzT2Jw9BushYo,880
31
+ terminalgames/stories/story_03_night_shift/chapters/chapter_01.yaml,sha256=V6FcHBTdNboQoMft2gq4bWfttAHBfJAkF_X5cez3h94,5438
32
+ terminalgames/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
33
+ terminalgames/tools/check_story.py,sha256=L-8DL_lcXFW8jZOjXKKywqHlJYXmWeAiQwMQ0pBut6k,21088
34
+ sidechannel-1.0.0.dist-info/METADATA,sha256=OZSdp3S8V4JA92Te-ssuKbEE6r8Rddwk_-xZcItHFX4,25379
35
+ sidechannel-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
36
+ sidechannel-1.0.0.dist-info/entry_points.txt,sha256=ZVSJSGO8Hk8qZ6RBsvOR1_JA3rSl1kW_oxoy9_wg6j0,56
37
+ sidechannel-1.0.0.dist-info/top_level.txt,sha256=3Z9w_mzmzcuJ15l2kbrk3OD1S2ngWkbg-jLnOazZjBo,14
38
+ sidechannel-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sidechannel = terminalgames.main:main