Compare commits

...

12 Commits

Author SHA1 Message Date
ejlewis 89bb69bcfa chore: obsidian docs
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-17 23:35:13 -05:00
ejlewis 18aa517319 feat(ui): add bug reporting integrations 2026-05-17 23:34:17 -05:00
ejlewis a51bb6f86f docs: implementation plan for bug reporting
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 23:02:46 -05:00
ejlewis 4f6460297f docs: design for Bugpin + Bugsink bug reporting
Footer link triggers Bugpin's screenshot widget (lazy-loaded) which forwards to
GitHub Issues. Bugsink captures uncaught JS errors via the Sentry SDK. Both
servers live on the half.st host.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 22:57:34 -05:00
ejlewis 03482693ea Open source markdown authoring workflow
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-13 17:59:13 -05:00
ejlewis 7b1b5d0f6c chore: add build number and release scripts
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-12 20:52:35 -05:00
ejlewis e7b74c827a fix(engine): burn lamp one segment per wait 2026-05-12 20:31:50 -05:00
ejlewis 52fb869976 feat(mystery): add altered rooms and drunk sequence 2026-05-12 20:22:20 -05:00
ejlewis 0755213d6a feat(ui): add confirmations and terminal motion
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-12 19:44:18 -05:00
ejlewis cc98aa180b feat(world): expand Halfstreet content slices
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-12 14:48:19 -05:00
ejlewis 26dd91947f feat: add kitchen and glitchtip wiring
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-10 12:03:12 -05:00
ejlewis 4d9077d586 feat(world): add light timer and indicator
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-10 10:17:42 -05:00
143 changed files with 7774 additions and 607 deletions
+39 -1
View File
@@ -22,11 +22,49 @@ npm run dev # local dev server
npm run build # type-check + production build
```
## Make Your Own Game
Halfstreet is currently meant to be forked as a complete Astro app, not consumed
as a separate engine package. To make a new story, replace the markdown vault in
`src/world/` and keep the TypeScript runtime in place.
Start with:
- `src/world/game.md` for the title, starting room, starting inventory, ending
priority, opening art, help text, and end text.
- `src/world/parser.md` for command vocabulary and aliases.
- `src/world/rooms/`, `src/world/items/`, `src/world/encounters/`, and
`src/world/endings/` for story content.
- `src/world/mechanics/` and `src/world/actions/` for configurable rules and
interactions.
- `src/world/ui.md` for page metadata, footer links, and UI feature switches.
- `src/world/templates/` for starter files.
Run `npm test` after changing world files. The loader validates wikilinks,
required sections, frontmatter shape, and references between rooms, items,
encounters, endings, mechanics, and actions.
## Releases
The footer build number comes from Woodpecker's pipeline number and increments on each CI build.
The package version is an intentional release label.
Use one of these from a clean worktree when you are ready to cut a release:
```sh
npm run release:patch # fixes, typo corrections, small polish
npm run release:minor # meaningful playable additions or mechanics
npm run release:major # disruptive changes after 1.0.0
git push --follow-tags
```
Each release script updates `package.json` and `package-lock.json`, creates a release commit, and tags it.
## Layout
- `src/engine/` — parser, dispatcher, encounter logic
- `src/ui/` — terminal renderer, theme, chips
- `src/world/`markdown content (rooms, items, encounters, endings)
- `src/world/`Obsidian-friendly authoring vault
- `src/pages/index.astro` — entry page
## Design docs
@@ -0,0 +1,653 @@
# Bug Reporting Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a "Report a Bug" footer link that opens the Bugpin screenshot widget (forwards to GitHub Issues), plus auto error capture via Bugsink (Sentry-compatible) on every page.
**Architecture:** Two independent flows. Bugpin = user-initiated, lazy-loaded `<script>` widget from `bugpin.half.st`, triggered by a footer `<button>`. Bugsink = background, `@sentry/browser` SDK initialised on page load against a DSN at `bugsink.half.st`. Both credentials are public project tokens and live in `src/world/ui.md`. Both flows are no-ops when not configured.
**Tech Stack:** Astro 6, TypeScript, Zod 4, Vitest 4 (node env, no jsdom), `@sentry/browser` (new dep), Bugpin widget script.
**Spec:** `docs/superpowers/specs/2026-05-17-bug-reporting-design.md`
---
## File map
- `src/world/schema.ts`**modify**: extend `uiFrontmatterSchema` with optional `bugReport` block.
- `src/world/schema.test.ts`**modify**: add accept/reject cases for `bugReport`.
- `src/world/types.ts`**modify**: extend `UiConfig` to expose `bugReport`.
- `src/world/ui.md`**modify**: add `bugReport` frontmatter.
- `src/ui/error-tracking.ts`**create**: reads body data attrs, calls `Sentry.init`. No-op when DSN missing.
- `src/ui/bug-report.ts`**create**: wires footer button click to lazy-load Bugpin widget, then call `BugPin.open()`. No-op when config missing.
- `src/pages/index.astro`**modify**: render the footer button conditionally, attach data attrs to `<body>`, import the two new modules.
- `package.json` / `package-lock.json`**modify**: add `@sentry/browser`.
- `src/world/TODOs.md:45`**modify**: rewrite to reflect actual approach.
No DOM tests for the two new UI modules: the project's Vitest config runs in node with no jsdom. Coverage for those modules is `astro check` (type safety) + a manual smoke step at the end of the plan. Schema changes get full unit test coverage via the existing test file.
---
## Task 1: Extend `uiFrontmatterSchema` with `bugReport` (TDD)
**Files:**
- Modify: `src/world/schema.ts`
- Modify: `src/world/schema.test.ts`
- [ ] **Step 1: Add the failing tests**
Append these inside the existing `describe('uiFrontmatterSchema', ...)` block in `src/world/schema.test.ts` (just before the closing `})` on line 120):
```ts
it('accepts a bugReport block with bugpin and bugsink subconfigs', () => {
const data = {
pageTitle: 'Halfstreet',
description: 'A gothic mystery.',
footer: {
copyright: '© 2026 Ethan J Lewis',
links: [],
},
bugReport: {
enabled: true,
label: 'Report a Bug',
bugpin: {
serverUrl: 'https://bugpin.half.st',
apiKey: 'proj_07df4bf91f12445b8ef8c723e865ed7b',
},
bugsink: {
enabled: true,
dsn: 'https://231ef18b6b4f426ca249778cfddf821c@bugsink.half.st/1',
},
},
}
expect(() => uiFrontmatterSchema.parse(data)).not.toThrow()
})
it('accepts ui config with no bugReport block at all', () => {
const data = {
pageTitle: 'Halfstreet',
description: 'A gothic mystery.',
footer: { copyright: '© 2026 Ethan J Lewis', links: [] },
}
expect(() => uiFrontmatterSchema.parse(data)).not.toThrow()
})
it('rejects a bugpin block with a non-url serverUrl', () => {
const data = {
pageTitle: 'Halfstreet',
description: 'A gothic mystery.',
footer: { copyright: '© 2026 Ethan J Lewis', links: [] },
bugReport: {
enabled: true,
bugpin: { serverUrl: 'not-a-url', apiKey: 'proj_x' },
},
}
expect(() => uiFrontmatterSchema.parse(data)).toThrow()
})
it('rejects a bugsink block with a non-url dsn', () => {
const data = {
pageTitle: 'Halfstreet',
description: 'A gothic mystery.',
footer: { copyright: '© 2026 Ethan J Lewis', links: [] },
bugReport: {
enabled: true,
bugsink: { dsn: 'whatever' },
},
}
expect(() => uiFrontmatterSchema.parse(data)).toThrow()
})
```
- [ ] **Step 2: Run tests, confirm they fail**
```
npm test -- src/world/schema.test.ts
```
Expected: the four new tests fail because `bugReport` is currently unknown (zod by default strips extras, so the "accepts" cases probably pass — but the "rejects" cases will fail because zod will accept anything for unknown fields). Either way, confirm the test runner reports at least the two "rejects" cases as failing.
- [ ] **Step 3: Add the schema fields**
In `src/world/schema.ts`, find `uiFrontmatterSchema` (starts at line 48). Add a `bugReport` field at the end of the object (after `features`, before the closing `})`). Final block:
```ts
export const uiFrontmatterSchema = z.object({
pageTitle: z.string().trim().min(1),
description: z.string().trim().min(1),
robots: z.string().trim().min(1).default('noindex'),
themeColor: z.string().trim().min(1).default('#1a0d00'),
footer: z.object({
copyright: z.string().trim().min(1),
copyrightHref: z.url().optional(),
buildLabel: z.string().trim().min(1).default('Build #'),
showBuild: z.boolean().default(true),
links: z.array(z.object({
label: z.string().trim().min(1),
href: z.url(),
})).default([]),
}),
features: z.object({
chips: z.boolean().default(true),
lightMeter: z.boolean().default(true),
typedEffect: z.boolean().default(true),
roomScroll: z.boolean().default(true),
}).default({
chips: true,
lightMeter: true,
typedEffect: true,
roomScroll: true,
}),
bugReport: z.object({
enabled: z.boolean().default(false),
label: z.string().trim().min(1).default('Report a Bug'),
bugpin: z.object({
serverUrl: z.url(),
apiKey: z.string().trim().min(1),
}).optional(),
bugsink: z.object({
enabled: z.boolean().default(true),
dsn: z.url(),
}).optional(),
}).optional(),
})
```
- [ ] **Step 4: Run tests, confirm pass**
```
npm test -- src/world/schema.test.ts
```
Expected: all `uiFrontmatterSchema` tests pass (the original two + the four new ones).
- [ ] **Step 5: Commit**
```
git add src/world/schema.ts src/world/schema.test.ts
git commit -m "feat(world): add bugReport block to ui schema"
```
---
## Task 2: Surface `bugReport` on the `UiConfig` type
**Files:**
- Modify: `src/world/types.ts:64-85`
- [ ] **Step 1: Extend the interface**
In `src/world/types.ts`, replace the existing `UiConfig` interface (currently lines 6485) with:
```ts
export interface UiConfig {
pageTitle: string
description: string
robots: string
themeColor: string
footer: {
copyright: string
copyrightHref?: string
buildLabel: string
showBuild: boolean
links: Array<{
label: string
href: string
}>
}
features: {
chips: boolean
lightMeter: boolean
typedEffect: boolean
roomScroll: boolean
}
bugReport?: {
enabled: boolean
label: string
bugpin?: {
serverUrl: string
apiKey: string
}
bugsink?: {
enabled: boolean
dsn: string
}
}
}
```
- [ ] **Step 2: Verify type check passes**
```
npx astro check
```
Expected: 0 errors, 0 warnings (or at least no new errors introduced by this change).
- [ ] **Step 3: Commit**
```
git add src/world/types.ts
git commit -m "feat(world): expose bugReport on UiConfig"
```
---
## Task 3: Add real config to `ui.md`
**Files:**
- Modify: `src/world/ui.md`
- [ ] **Step 1: Edit frontmatter**
Insert this block in `src/world/ui.md` between the `features:` block and the closing `---`, so the frontmatter ends like:
```yaml
features:
chips: true
lightMeter: true
typedEffect: true
roomScroll: true
bugReport:
enabled: true
label: "Report a Bug"
bugpin:
serverUrl: "https://bugpin.half.st"
apiKey: "proj_07df4bf91f12445b8ef8c723e865ed7b"
bugsink:
enabled: true
dsn: "https://231ef18b6b4f426ca249778cfddf821c@bugsink.half.st/1"
---
```
- [ ] **Step 2: Verify the world loads**
```
npm test -- src/world/buildWorld.test.ts
npm test -- src/world/loader.test.ts
```
Expected: all tests pass — the new frontmatter is valid.
- [ ] **Step 3: Commit**
```
git add src/world/ui.md
git commit -m "feat(world): wire bugpin + bugsink credentials in ui.md"
```
---
## Task 4: Install `@sentry/browser`
**Files:**
- Modify: `package.json`, `package-lock.json`
- [ ] **Step 1: Install**
```
npm install @sentry/browser
```
Expected: a single new dependency added under `"dependencies"` in `package.json`; lockfile updates.
- [ ] **Step 2: Verify**
```
node -e "require('@sentry/browser')"
```
Expected: no output and exit code 0.
- [ ] **Step 3: Commit**
```
git add package.json package-lock.json
git commit -m "chore: add @sentry/browser for bugsink integration"
```
---
## Task 5: Create the Bugsink init module
**Files:**
- Create: `src/ui/error-tracking.ts`
No unit test — Vitest runs in node with no jsdom, and this module only does work in a browser. Coverage is `astro check` (types) + the manual smoke at the end.
- [ ] **Step 1: Write the module**
```ts
// src/ui/error-tracking.ts
import * as Sentry from '@sentry/browser'
const dsn = document.body?.dataset.bugsinkDsn ?? ''
if (dsn) {
Sentry.init({
dsn,
tracesSampleRate: 0,
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 0,
integrations: [],
})
}
```
- [ ] **Step 2: Type-check**
```
npx astro check
```
Expected: 0 new errors.
- [ ] **Step 3: Commit**
```
git add src/ui/error-tracking.ts
git commit -m "feat(ui): initialise sentry browser sdk against bugsink dsn"
```
---
## Task 6: Create the Bugpin trigger module
**Files:**
- Create: `src/ui/bug-report.ts`
Module reads config from the footer button's `data-*` attributes, lazy-loads the Bugpin widget on first click, then calls `BugPin.open()`. Subsequent clicks just call `open()` again.
- [ ] **Step 1: Write the module**
```ts
// src/ui/bug-report.ts
interface BugPinAPI {
open: () => void
close: () => void
}
declare global {
interface Window {
BugPin?: BugPinAPI
}
}
const button = document.querySelector<HTMLButtonElement>('[data-bug-report-trigger]')
if (button) {
const serverUrl = button.dataset.bugpinServer
const apiKey = button.dataset.bugpinKey
if (serverUrl && apiKey) {
let loadPromise: Promise<void> | null = null
const ensureLoaded = (): Promise<void> => {
if (window.BugPin) return Promise.resolve()
if (loadPromise) return loadPromise
loadPromise = new Promise<void>((resolve, reject) => {
const script = document.createElement('script')
script.src = `${serverUrl.replace(/\/$/, '')}/widget.js`
script.async = true
script.dataset.apiKey = apiKey
script.addEventListener('load', () => resolve())
script.addEventListener('error', () => {
loadPromise = null
reject(new Error(`Failed to load Bugpin widget from ${script.src}`))
})
document.head.appendChild(script)
})
return loadPromise
}
button.addEventListener('click', async (event) => {
event.preventDefault()
button.disabled = true
try {
await ensureLoaded()
window.BugPin?.open()
} catch (err) {
console.error('[bug-report]', err)
} finally {
button.disabled = false
}
})
}
}
```
- [ ] **Step 2: Type-check**
```
npx astro check
```
Expected: 0 new errors.
- [ ] **Step 3: Commit**
```
git add src/ui/bug-report.ts
git commit -m "feat(ui): lazy-load bugpin widget on footer click"
```
---
## Task 7: Wire footer button and module imports in `index.astro`
**Files:**
- Modify: `src/pages/index.astro`
- [ ] **Step 1: Add config bindings to the frontmatter script**
In the `---` block at the top of `src/pages/index.astro`, after `const remainingFooterLinks = footerLinks.slice(1)`, add:
```ts
const bugReport = ui?.bugReport
const bugpinConfig = bugReport?.enabled ? bugReport.bugpin : undefined
const bugsinkDsn =
bugReport?.enabled && bugReport.bugsink?.enabled ? bugReport.bugsink.dsn : ''
```
- [ ] **Step 2: Add the `data-bugsink-dsn` attribute to `<body>`**
Replace the existing `<body>` opening tag with:
```astro
<body data-bugsink-dsn={bugsinkDsn}>
```
- [ ] **Step 3: Add the footer button**
In the `<footer class="mystery-footer">` block (currently lines 89109), append this AFTER the existing `{remainingFooterLinks.map(...)}` block, before the closing `</footer>`:
```astro
{bugpinConfig && (
<>
<span aria-hidden="true">|</span>
<button
type="button"
class="mystery-footer-bug-report"
data-bug-report-trigger
data-bugpin-server={bugpinConfig.serverUrl}
data-bugpin-key={bugpinConfig.apiKey}
>{bugReport?.label ?? 'Report a Bug'}</button>
</>
)}
```
- [ ] **Step 4: Import the two new modules**
Replace the existing module-import script block (currently lines 130133):
```astro
<script>
import '../ui/terminal.ts'
import '../ui/theme.ts'
</script>
```
with:
```astro
<script>
import '../ui/terminal.ts'
import '../ui/theme.ts'
import '../ui/error-tracking.ts'
import '../ui/bug-report.ts'
</script>
```
- [ ] **Step 5: Add CSS so the button matches the surrounding links**
Open `src/ui/crt.css` and grep for `.mystery-footer` to find the existing footer styles. Add a rule that makes the button look identical to the footer anchors. Append this block at the end of the existing `.mystery-footer` rules (search for `.mystery-footer a` and add directly after that selector's rule):
```css
.mystery-footer-bug-report {
background: none;
border: none;
padding: 0;
margin: 0;
font: inherit;
color: inherit;
text-decoration: underline;
cursor: pointer;
}
.mystery-footer-bug-report:hover,
.mystery-footer-bug-report:focus-visible {
text-decoration: none;
}
.mystery-footer-bug-report:disabled {
opacity: 0.5;
cursor: progress;
}
```
If `.mystery-footer a` styles its anchors differently (e.g. specific color, no underline by default), match those properties instead. Look at the actual `a` rule and copy its `color`/`text-decoration` declarations into `.mystery-footer-bug-report` so the two render visually identically.
- [ ] **Step 6: Run the full type check and build**
```
npx astro check
npm run build
```
Expected: 0 errors, build succeeds.
- [ ] **Step 7: Commit**
```
git add src/pages/index.astro src/ui/crt.css
git commit -m "feat(ui): add Report a Bug footer button and wire bug tracking modules"
```
---
## Task 8: Update TODOs
**Files:**
- Modify: `src/world/TODOs.md:45`
- [ ] **Step 1: Rewrite the TODO line**
Replace line 45 of `src/world/TODOs.md`:
```
- [ ] Set up BugPin as a self-hosted visual bug reporter for the site, then have incoming reports create markdown files under `src/world/bugs/` via a webhook or API bridge so bugs can be tracked in git alongside the game content. Include screenshot/annotation metadata in the markdown and decide whether these bug docs stay outside the world loader or get their own loader later.
```
with:
```
- [ ] Add a "Report a Bug" footer link backed by Bugpin (widget at bugpin.half.st → forwards to GitHub Issues). Add Bugsink (@sentry/browser → bugsink.half.st) for automatic JS error capture. Mark complete after manual verification: a test report appears in the Bugpin portal AND a GitHub issue is created AND a thrown error appears in the Bugsink portal.
```
- [ ] **Step 2: Commit**
```
git add src/world/TODOs.md
git commit -m "docs: rewrite TODO 45 to reflect Bugpin/Bugsink approach"
```
---
## Task 9: Manual smoke test
**Pre-req:** the Bugpin GitHub integration must be configured server-side before user reports will create issues. See spec §5. Steps below test the widget plumbing regardless.
- [ ] **Step 1: Start dev server**
```
npm run dev
```
Open the URL it prints (default `http://localhost:4321`).
- [ ] **Step 2: Verify the footer button renders**
In the rendered footer at the bottom of the page, confirm "Report a Bug" appears after "Source Code", styled like the surrounding links.
- [ ] **Step 3: Verify the widget lazy-loads**
Open DevTools → Network. Filter for `widget.js`. Confirm NO request to `bugpin.half.st/widget.js` on cold page load.
Click "Report a Bug". Confirm:
- A single GET to `https://bugpin.half.st/widget.js` (status 200).
- The Bugpin dialog opens.
- Take a screenshot, write "smoke test" in the note, submit.
- The dialog closes / shows a success state.
Re-click "Report a Bug". Confirm NO second `widget.js` request — only the dialog re-opens.
- [ ] **Step 4: Verify Bugpin received the report**
Open `https://bugpin.half.st/portal`. Confirm the "smoke test" report appears with the screenshot attached.
If the GitHub integration is configured: confirm a matching issue appears in the halfstreet GitHub repo.
- [ ] **Step 5: Verify Bugsink captures an uncaught error**
In the dev server's running page, open DevTools console and run:
```js
setTimeout(() => { throw new Error('bugsink smoke test') }, 0)
```
Open `https://bugsink.half.st`. Confirm a "bugsink smoke test" event appears within ~30 seconds.
- [ ] **Step 6: Verify the launcher styling is acceptable**
After step 3, the Bugpin widget's built-in floating launcher may now also be visible on the page. If its presence is visually objectionable, add a CSS rule to `src/ui/crt.css` targeting the Bugpin host element (inspect the DOM to find the selector — Bugpin uses Shadow DOM with a custom-element host like `<bugpin-widget>` or similar). Example:
```css
bugpin-widget,
[data-bugpin-launcher] {
display: none !important;
}
```
If this turns out to be needed, commit the CSS as `style(ui): hide bugpin built-in launcher (we have our own trigger)`. If the launcher's appearance is fine as-is, skip this step.
- [ ] **Step 7: Mark TODO #45 complete**
Once steps 15 all pass, edit `src/world/TODOs.md` line 45, change `- [ ]` to `- [x]`, and commit:
```
git add src/world/TODOs.md
git commit -m "chore: mark TODO 45 complete"
```
---
## Done criteria
All of the following are true:
- `npm test` passes.
- `npx astro check` reports 0 errors.
- `npm run build` succeeds.
- Manual smoke (Task 9 steps 15) all pass.
- TODO #45 is `[x]`.
@@ -0,0 +1,152 @@
# Bug reporting: Bugpin (user) + Bugsink (auto)
**Status:** Approved design, ready for implementation plan.
**Date:** 2026-05-17
## Goal
Let visitors report bugs in the Halfstreet site with a screenshot, and capture uncaught JavaScript errors automatically. Reports should flow to systems already running on the `half.st` host so there is no new infrastructure to manage.
## Two distinct flows
| | Bugpin | Bugsink |
|---|---|---|
| Trigger | Visitor clicks "Report a Bug" in footer | Uncaught JS error |
| Capture | Screenshot, annotations, free-text note, console/network metadata | Stack trace, breadcrumbs |
| Destination | Bugpin portal → auto-creates a GitHub Issue in the halfstreet repo | Bugsink portal only |
| Library | `bugpin.half.st/widget.js` (lazy-loaded on click) | `@sentry/browser` npm package (loads on every page) |
The two are intentionally separate. Bugpin owns user-facing reports; Bugsink owns automatic exception capture. Sentry's user-feedback widget is **not** used — that would overlap Bugpin and muddle the responsibilities.
## Components
### 1. Footer link
`src/pages/index.astro`
Add "Report a Bug" to the footer chain after the existing links. Render as a `<button>` styled to match the surrounding `<a>` elements so it can dispatch JS without a hash-link navigation.
Final order:
`© 2026 Ethan J Lewis | GNU 3.0 | Build #<n> | Source Code | Report a Bug`
The button only renders when `world.ui.bugReport?.enabled` is true and a Bugpin server URL is configured. If either is missing, the button is omitted (local dev stays clean).
### 2. Config
`src/world/ui.md` — add a `bugReport` frontmatter block:
```yaml
bugReport:
enabled: true
label: "Report a Bug"
bugpin:
serverUrl: "https://bugpin.half.st"
apiKey: "proj_07df4bf91f12445b8ef8c723e865ed7b"
bugsink:
enabled: true
dsn: "https://231ef18b6b4f426ca249778cfddf821c@bugsink.half.st/1"
```
The Bugpin project API key and the Bugsink DSN are both designed to ship to clients (they are public credentials that authenticate the project, not the operator), so checking them into the repo is acceptable.
`src/world/schema.ts` — extend `uiFrontmatterSchema` with an optional `bugReport` object:
```ts
bugReport: z.object({
enabled: z.boolean().default(false),
label: z.string().trim().min(1).default('Report a Bug'),
bugpin: z.object({
serverUrl: z.url(),
apiKey: z.string().trim().min(1),
}).optional(),
bugsink: z.object({
enabled: z.boolean().default(true),
dsn: z.url(),
}).optional(),
}).optional(),
```
`src/world/types.ts` — extend `UiConfig` with the matching TypeScript shape.
### 3. Bugpin lazy loader
New module: `src/ui/bug-report.ts`
Responsibilities:
- Read Bugpin config from a `data-*` attribute on the footer button (so the module stays free of Astro imports).
- On first click, inject `<script src="${serverUrl}/widget.js" data-api-key="${apiKey}" async>` into `<head>`. Hide Bugpin's built-in floating button using whatever option the widget exposes (likely `data-hidden="true"` or a CSS rule scoped via the widget's Shadow DOM hook — confirm against the install guide during implementation).
- Once the script resolves, call `window.BugPin.open()`.
- Cache the loaded state on a module-level flag; subsequent clicks just call `open()` again.
- If the script fails to load (offline, server down), log to the console and surface a minimal inline message ("Couldn't open bug reporter — try refreshing"). Do not break the page.
Wire-up in `src/pages/index.astro`:
- The footer button gets `data-bug-report-trigger`, `data-bugpin-server`, `data-bugpin-key` attributes.
- A new `<script>` block imports `../ui/bug-report.ts` alongside the existing terminal/theme imports.
The lazy-load is deliberate: ~150 KB of widget code stays off the cold load for visitors who never click.
### 4. Bugsink init
New module: `src/ui/error-tracking.ts`
Responsibilities:
- Add `@sentry/browser` as a dependency.
- Read the DSN from a `data-bugsink-dsn` attribute set on `<body>` (or a meta tag) so the module is server-render-agnostic.
- On import, call:
```ts
Sentry.init({
dsn,
tracesSampleRate: 0,
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 0,
integrations: [],
})
```
- If `dsn` is missing or `bugsink.enabled` is false, no-op.
Error-capture only — no performance tracing, no session replay. Keeps the bundle small and avoids accidentally shipping a replay tool we didn't design for.
Wire-up in `src/pages/index.astro`:
- A new `<script>` block imports `../ui/error-tracking.ts` near the existing imports.
### 5. Bugpin GitHub wiring
Out of scope for the code change, but recorded here so it isn't lost during implementation:
1. In the halfstreet GitHub repo, create a classic personal access token (or fine-grained equivalent) with `repo` scope.
2. In Bugpin admin (https://bugpin.half.st/portal): Projects → halfstreet → Integrations → GitHub. Paste the token, select the halfstreet repo, set default labels (e.g., `bug`, `user-report`).
3. Verify by submitting a test report from the footer link and confirming an issue appears in GitHub.
## Out of scope
- **Markdown files under `src/world/bugs/`.** Original TODO #45 proposed this; explicitly dropped — Bugpin's portal + GitHub Issues replace that storage path.
- **Gitea mirroring.** Bugpin does not natively support Gitea. Deferred until there's clear value in a Bugpin → Gitea bridge. The front-end design does not preclude this; it would be a server-side addition on the Bugpin host.
- **Sentry user-feedback widget.** Bugpin owns user reports; Bugsink stays auto-only.
- **PII redaction policy.** Bugpin's widget includes a built-in blur tool; Bugsink only captures stack traces, not request bodies. No additional scrubbing layer.
## Risks / things to verify during implementation
- **Bugpin "hide default button" mechanism.** Docs mention a floating launcher; the exact opt-out attribute needs to be confirmed against the install guide or the widget source. If no opt-out exists, fall back to CSS that hides the widget's launcher selector inside its Shadow DOM root (or wrap with a custom-element rule).
- **Bugsink Sentry SDK compatibility.** Bugsink claims Sentry-SDK compatibility but tested feature surface varies. Verify with `@sentry/browser` v9 (current stable as of 2026-05) — if a specific minor version is needed, pin it.
- **Cold-start cost.** Bugsink adds ~3040 KB gzipped to the page. Acceptable for an authored, low-traffic site; flag if budget gets tight.
## TODOs.md update
Replace line 45 with:
```
- [ ] Add a "Report a Bug" footer link backed by Bugpin (widget at bugpin.half.st → forwards to GitHub Issues). Add Bugsink (@sentry/browser → bugsink.half.st) for automatic JS error capture.
```
Mark complete once the implementation lands and a test report has appeared in both Bugpin's portal and GitHub.
## Files touched (preview)
- `src/world/ui.md` — add `bugReport` block.
- `src/world/schema.ts` — extend `uiFrontmatterSchema`.
- `src/world/types.ts` — extend `UiConfig`.
- `src/pages/index.astro` — render the button conditionally, wire data attributes, import the two new UI modules.
- `src/ui/bug-report.ts` — **new**, lazy loads the Bugpin widget on click.
- `src/ui/error-tracking.ts` — **new**, initializes Sentry SDK against Bugsink DSN.
- `package.json` / `package-lock.json` — add `@sentry/browser`.
- `src/world/TODOs.md` — rewrite line 45.
+78 -2
View File
@@ -1,14 +1,15 @@
{
"name": "halfstreet",
"version": "0.0.1",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "halfstreet",
"version": "0.0.1",
"version": "0.1.0",
"license": "GPL-3.0-or-later",
"dependencies": {
"@sentry/browser": "^10.53.1",
"astro": "^6.1.9",
"yaml": "^2.8.4",
"zod": "^4.4.3"
@@ -1766,6 +1767,81 @@
"win32"
]
},
"node_modules/@sentry-internal/browser-utils": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.53.1.tgz",
"integrity": "sha512-X4d6y8sBMjmNhcDW4eMBU3ASsNIMz8dqaFkhyIMN/dkYr/yZKnbRZPaVuVUGvHKjnlficPpIH0/HK9KBjrYxPw==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.53.1.tgz",
"integrity": "sha512-vVpTI/aEYN5d9IgZeYJWMqVaN0+iFgidSrYNAsZTh1US5sJUzF/wrl+68KdpmCtFROrN3jiAn1oPSwL5CKvEJA==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.53.1.tgz",
"integrity": "sha512-wZNzTBYkgGUPWMuUQv7L64+OJmoCnz7GQNiTrTFK6EVAjJXFBCSsPp/nhif0bLhbk8+0g4xz633uOhpXuQbFdw==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.53.1",
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.53.1.tgz",
"integrity": "sha512-aueLaf/2prExwA76BGU5/bOXCKWqtt6jQXWA6WJQNrmKpPEtZJB4ypnpsou0McXQCF8tur2Y8U0TEkwQP13yJQ==",
"license": "MIT",
"dependencies": {
"@sentry-internal/replay": "10.53.1",
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/browser": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.53.1.tgz",
"integrity": "sha512-zXF373hzUOGzUOrqd8xb1U3LQi5uYC3mwv+z5OMKUUinQlu30tTWBs7ypy6YTchtix9QlYaHWlayUF8vBZ5UjA==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.53.1",
"@sentry-internal/feedback": "10.53.1",
"@sentry-internal/replay": "10.53.1",
"@sentry-internal/replay-canvas": "10.53.1",
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/core": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.53.1.tgz",
"integrity": "sha512-XG4ezlkyuAPjBC5+9kXC94rXXuqYTw9NRhfaDHssbTFaGnqBR8vQX2UUgZfY7ucbeelRDGfBu1sywoU+mB04uA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@shikijs/core": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz",
+5 -1
View File
@@ -1,7 +1,7 @@
{
"name": "halfstreet",
"type": "module",
"version": "0.0.1",
"version": "0.1.0",
"license": "GPL-3.0-or-later",
"engines": {
"node": ">=22.12.0"
@@ -11,10 +11,14 @@
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro",
"release:patch": "npm version patch -m \"chore(release): %s\"",
"release:minor": "npm version minor -m \"chore(release): %s\"",
"release:major": "npm version major -m \"chore(release): %s\"",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@sentry/browser": "^10.53.1",
"astro": "^6.1.9",
"yaml": "^2.8.4",
"zod": "^4.4.3"
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path d="m1065.9 452.47v-64.941h66.352v-304.94h67.766v-62.824h-78.352v54.352h-96v75.531h-105.88v105.18h-118.59v62.824h-60.707v64.941h-57.883v56.469h-76.234v108.71h-59.293v172.94h-53.648v-241.41h-52.941v-92.469h-49.41v-53.648h49.41v-67.059h97.41v-112.23h-67.059v45.176h-94.59v55.059h-48v62.824l-194.82-0.003906v-62.824h-66.352v-100.23h-67.059v112.23h54.352v67.059h51.531v53.648h-66.352v153.18h50.117v63.531h-50.117v127.77h53.648v56.469h166.59v-56.469h60.707v-60h107.29v146.12h-156v276h-17.648v86.824h280.23v-164.47h149.65v49.41h56.469v39.531h-59.293v75.531h139.77v-71.293h59.293v-93.176h45.176v93.176h67.059v71.293h176.47v-83.293h-113.65v-117.88h-62.824v-52.234h-59.293v-67.059h-60v-42.352h-121.41v-41.648h182.12v-67.059h-182.12v-45.176h280.23v-67.059h-230.12v-45.176h230.12v-50.117h-153.88v-48.707h188.47zm-750.35 151.06h-113.65v-63.531h113.65zm106.59 496.94h-40.234v-97.41h40.234z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path d="m1200 431.4v-157.8h-77.398v49.199h-750v-49.199h-106.8v49.199h53.398v24.602h53.398v39.602h-54v13.801h-60.602v52.801h-81v60h-61.199v52.801h-57.602v85.199h-57.602v274.8h265.8v-254.4h57.602v-45h67.199v105.6h54v-105.6h43.199v67.801h54v-70.199h43.199v108h54v-64.801h54v-72h114.6v-70.199h237v-49.199h-234v-45.602h390.6zm-502.8-1.8008v96.602h-237v-121.8h237z"/>
<path d="m506.4 443.4h147v49.199h-147z"/>
<path d="m444.6 732.6h139.8v48.602h-139.8z"/>
</svg>

After

Width:  |  Height:  |  Size: 610 B

+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path d="m770.13 1200v-116.11h-69.797v-100.67h-67.785v100.67h-72.484v-100.67h-67.785v100.67h-69.797v116.11z"/>
<path d="m284.9 903.36h137.58v180.54h-137.58z"/>
<path d="m770.13 903.36h137.58v180.54h-137.58z"/>
<path d="m667.45 910.74v-132.89h206.04v-176.51h-226.17v176.51h-95.973v-176.51h-226.18v176.51h204.7v132.89z"/>
<path d="m915.1 232.21v-116.11h-144.97v-116.11h-340.27v116.11h-144.97v116.11h-116.11v671.14h116.11v-555.04h116.11v-116.11h397.98v116.11h116.11v555.04h116.11v-671.14z"/>
</svg>

After

Width:  |  Height:  |  Size: 650 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path d="m473.64 170.52h50.52v50.52h-50.52z"/>
<path d="m574.68 170.52h50.52v50.52h-50.52z"/>
<path d="m473.64 221.04h-50.52v101.04h50.52z"/>
<path d="m625.2 322.08h50.641v-101.04h-50.641z"/>
<path d="m524.16 473.64h151.68v-50.52h-101.04v-50.52h50.398v-50.523h-151.56v50.523h50.52v50.52h-101.04v50.52z"/>
<path d="m524.16 120h50.52v50.52h-50.52z"/>
<path d="m574.8 625.2v50.641h50.398v-101.04h-50.398z"/>
<path d="m877.92 928.44h-151.56v-454.8h-50.52v50.52h-50.641v50.641h50.641v353.64h-252.72v-353.64h50.52v-50.641h-50.52v-50.52h-50.52v454.8h-151.56v50.52h50.52v50.52h50.52v-50.52h505.32v50.52h50.523z"/>
<path d="m524.16 524.16h50.52v50.52h-50.52z"/>
<path d="m473.64 574.68h50.52v50.52h-50.52z"/>
<path d="m877.92 827.4h-50.523v50.523h101.04v-50.523z"/>
<path d="m928.44 928.44v101.04h50.52v-151.56h-50.52z"/>
<path d="m726.36 1029.5h-404.28v50.52h505.32v-50.52z"/>
<path d="m877.92 1029.5h50.52v50.52h-50.52z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path d="m536.04 152.04h63.961v63.961h-63.961z"/>
<path d="m792 152.04h63.961v63.961h-63.961z"/>
<path d="m663.96 279.96v-63.961h-63.961v128.04h63.961z"/>
<path d="m600 408v-63.961h-128.04v63.961z"/>
<path d="m855.96 408v-63.961h-127.92v63.961z"/>
<path d="m408 408h63.961v63.961h-63.961z"/>
<path d="m663.96 408h63.961v63.961h-63.961z"/>
<path d="m920.04 279.96v-63.961h-64.078v128.04h64.078z"/>
<path d="m152.04 600h127.92v128.04h-127.92v63.961h192v63.961h63.961v64.078h-63.961v63.961h639.96v-63.961h-63.961v-64.078h63.961v-63.961h63.961v-255.96h-895.92zm639.96 192h63.961v-128.04h64.078v128.04h-64.078v63.961h-63.961z"/>
<path d="m87.961 663.96v64.078h64.078v-128.04h-64.078z"/>
</svg>

After

Width:  |  Height:  |  Size: 848 B

+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path d="m684.72 854.16h-282.36v56.398h395.28v-56.398z"/>
<path d="m684.72 684.72h-282.36v56.52h395.28v-56.52z"/>
<path d="m571.8 515.28h-169.44v56.52h395.28v-56.52z"/>
<path d="m684.72 120h-451.8v960h734.16v-734.16h-56.52v-56.398h-56.398v-56.52h-56.52v-56.398l-56.52-0.003907v-56.52zm0 112.92v169.44h225.84v621.12h-621.12v-846.96h395.28z"/>
</svg>

After

Width:  |  Height:  |  Size: 502 B

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path d="m1100 274.87v-50.016h-49.969v50.016h-550.08v-50.016h-199.97v50.016h-150v50.016h-49.969v150.05h49.969v100.03h-49.969v100.03h-49.969v100.03l-50.062 0.09375v150.05h49.969v50.016h300v-50.016h-49.969v-200.06h49.969v-50.016h199.97v-50.016h-150v-100.03h49.969v50.016h49.969v-100.03h49.969v150.05h49.969v-150.05h600l0.09375-200.16h-100.03zm-900.05 600.24h-49.969v50.016h-49.969v-50.016h-49.969v-50.016h49.969v-50.016h49.969v50.016h49.969zm450.05-450.19h-49.969v-50.016h49.969zm99.984 0h-49.969v-50.016h49.969zm100.03 0h-49.969v-50.016h49.969zm99.984 0h-49.969v-50.016h49.969zm99.984 0h-49.969v-50.016h49.969zm100.03 0h-49.969v-50.016h49.969z"/>
</svg>

After

Width:  |  Height:  |  Size: 803 B

+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path d="m862.4 1125v-224.95h-74.953v-75l-374.9-0.046875v75h-74.953v224.95h74.953v75h374.86v-75h74.953zm-374.81-75v-150h224.9v150z"/>
<path d="m487.6 0v75h-150v75h74.953v75l-74.953 0.046875v75h149.95v75h-149.95v75h149.95v300h149.95l0.046875-750.05z"/>
</svg>

After

Width:  |  Height:  |  Size: 410 B

+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path d="m555 120-30 30 30 30-30 30 30 30-30 30 30 30-30 30 30 30-30 30 30 30-30 30 30 30-30 30 30 30-30 30 30 30-30 30 30 30-30 30 30 30-30 30 30 30-30 30-30-30c-9.9375 9.9844-19.969 19.969-29.953 30l30 30-30 30 30 30-30 30 30 30-30 30 30 30-30 30 30 30-30 30 30 30-30 30 30 30c30-30 59.953-60 89.953-90l-30-30 30-30-30-30 30-30-30-30 30-30-30-30 30-30-30-30 30-30 30 30 30-30 30 30c20.016-20.016 39.984-39.984 59.953-60l-30-30 30-30-30-30 30-30-30-30 30-30-30-30 30-30-30-30 30-30-30-30 30-30-30-30 30-30c-20.016-20.016-39.984-39.984-59.953-60l30-30c-20.016-20.016-39.984-39.984-59.953-60l30-30c-60.047-60-120.05-120-180-180l-30 30 30 30-30 30 30 30 30-30 30 30z"/>
<path d="m495.05 180 30-30-30-30-30 30 30 30z"/>
<path d="m495.05 180-30 30 30 30 30-30-30-30z"/>
<path d="m495.05 240-30 30 30 30 30-30-30-30z"/>
<path d="m495.05 300c-9.9844 10.031-20.016 20.016-30 30l30 30 30-30-30-30z"/>
<path d="m495.05 360c-9.9844 10.031-20.016 20.016-30 30l30 30 30-30-30-30z"/>
<path d="m495.05 420-30 30 30 30 30-30-30-30z"/>
<path d="m495.05 480c-9.9844 10.031-20.016 20.016-30 30l30 30 30-30-30-30z"/>
<path d="m495.05 540c-9.9844 10.031-20.016 20.016-30 30l30 30 30-30-30-30z"/>
<path d="m495.05 600-30 30 30 30 30-30-30-30z"/>
<path d="m495.05 660-30 30 30 30 30-30-30-30z"/>
<path d="m495.05 720c-9.9844 9.9844-20.016 19.969-30 30l30 30 30-30-30-30z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path d="m971.44 257.02v-57.141h-285.74v57.188h-57.141v57.188h-57.141v114.33h-57.141v171.47h-57.141v171.47h-57.141v114.33h-57.141v57.188h-57.141v57.188h914.29v-57.188h-57.141v-57.188h-57.141v-57.188h-57.141v-171.47h-57.141v114.33h-57.141v57.188h-57.141v-57.188h-57.141v57.188h-57.141v-57.188h-57.141v-171.47h57.141v-57.188h57.141v57.188h57.141v-114.33h57.141v114.33h57.141v-57.188h57.141v114.33h57.141l0.14062-342.98h-57.141v114.33h-114.28v-57.188h57.141v-57.188h57.141v-57.188h-57.141zm-114.28 171.47h-114.28v-57.188h57.141v-57.188h57.141z"/>
<path d="m0 1000.1h57.141v-57.188l-57.141 0.046875v57.188z"/>
<path d="m114.28 1000.1h114.28v-57.188h-114.28z"/>
</svg>

After

Width:  |  Height:  |  Size: 816 B

+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path d="m162.94 9.0469h411.79v65.625h-411.79z"/>
<path d="m531.74 254.21h394.45v65.625h-394.45z"/>
<path d="m531.74 144.79h394.45v65.625h-394.45z"/>
<path d="m162.94 552.84h288.14v70.125h-288.14z"/>
<path d="m825.14 552.84h101.06v70.125h-101.06z"/>
<path d="m751.22 497.06v-116.16h-219.47v242.11h219.47v-70.172h-71.625v-55.781z"/>
<path d="m162.94 679.6h288.14v70.125h-288.14z"/>
<path d="m1109.5 240.61v-73.172h-83.719v-92.766h-73.172v92.766h66.375v794.21h-66.375v59.578l-866.63 0.046876v-791.95h76.172v276.05h288.14v-371.11h-288.14v-59.578h-86.719v92.766h-75.422v862.08h85.969v79.219h106.36v82.219h1008.4v-950.34zm-24.141 883.22h-778.36v-24.891h779.11v24.891z"/>
<path d="m531.74 679.6h394.45v70.125h-394.45z"/>
<path d="m531.74 788.21h394.45v70.125h-394.45z"/>
<path d="m531.74 891.52h394.45v70.125h-394.45z"/>
<path d="m162.94 805.55h288.14v156.14h-288.14z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path d="m1043.8 831.94-85.5-483.94c-9.5625-53.719-56.25-92.859-110.81-92.812h-33.938c-9.6094-32.719-41.062-54-75-50.812l-1.125-37.5c-1.6406-51.656-44.297-92.531-96-91.875h-82.875c-51.516-0.23438-93.844 40.734-95.25 92.25l-1.125 37.5c-33.891-3.0469-65.25 18.188-75 50.812h-34.688c-54.562-0.046875-101.25 39.094-110.81 92.812l-85.5 483.56c-15.609 88.219 40.453 173.26 127.69 193.69l67.688 15.938v27.188c0 31.078 25.172 56.25 56.25 56.25h383.63c31.078 0 56.25-25.172 56.25-56.25v-27.188l67.688-15.938c87.516-20.109 144-105.23 128.44-193.69zm-355.13 81.75 18.75 56.25h-215.06l18.75-56.25zm6.9375-37.5h-76.875v-63.188c33.938-6.6094 62.391-29.672 75.844-61.547 13.5-31.828 10.219-68.297-8.7188-97.266l-53.812-80.812c-6.7969-11.203-18.938-18.047-32.062-18.047s-25.266 6.8438-32.062 18.047l-53.812 80.812c-18.938 28.969-22.219 65.438-8.7188 97.266 13.453 31.875 41.906 54.938 75.844 61.547v63.188h-76.875l-70.312-53.812c-33.797-25.969-58.312-62.203-69.844-103.27s-9.4219-84.75 5.9062-124.55l98.625-251.26h264.37l97.688 251.26c15.328 39.797 17.438 83.484 5.9062 124.55s-36.047 77.297-69.844 103.27zm-194.81-707.44c0.70312-31.359 26.391-56.344 57.75-56.25h82.875c31.359-0.09375 57.047 24.891 57.75 56.25l1.125 36h-200.63zm-208.31 820.31c-67.594-15.844-111.14-81.609-99.375-150l85.5-483.94c6.375-35.812 37.5-61.875 73.875-61.875h33.938c6.0469 21.375 22.031 38.578 42.938 46.125l-94.312 241.87c-18.188 47.391-20.578 99.375-6.8438 148.26 13.688 48.891 42.797 92.016 82.969 123.05l64.688 49.5-23.062 67.875h-44.25c-22.641 0.046875-43.125 13.406-52.312 34.125zm615 0-63.75 15c-9.0469-20.859-29.578-34.359-52.312-34.5h-44.25l-23.062-67.875 64.688-49.5c40.078-30.984 69.141-74.109 82.875-122.9 13.688-48.75 11.344-100.69-6.75-148.03 0 0-93.75-242.26-93.75-242.06 20.812-7.6406 36.703-24.797 42.75-46.125h33.562c36.375 0 67.5 26.062 73.875 61.875l85.5 483.56c12.094 68.625-31.547 134.72-99.375 150.56z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path d="m524.16 675.84h202.2v-50.641h101.04v-101.04h50.523v-252.6h-50.523v-101.04h-101.04v-50.52h-252.72v50.52h-101.04v101.04h-50.523v252.6h50.523v101.04h101.04v50.641z"/>
<path d="m221.04 1029.5v50.52h757.92v-101.04h-50.52v-101.04h-50.52v-101.04h-101.04v-50.52h-353.76v50.52h-101.04v101.04h-50.52v101.04h-50.52z"/>
</svg>

After

Width:  |  Height:  |  Size: 475 B

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path d="m1200 202.92h-1200v794.16h1200zm-366 206.06v263.34h52.406v183h-160.64v-183h52.406v-263.34h55.875zm-206.76 0v263.34h52.406v183h-160.64v-183h52.406v-263.34h55.875zm-206.76 0v263.34h52.406v183h-160.64v-183h52.406v-263.34h55.875zm-298.26 0h90.797v263.34h52.406v183h-143.86v-446.34zm956.26 446.34h-145.26v-183h52.406v-263.34h92.906v446.34z"/>
</svg>

After

Width:  |  Height:  |  Size: 504 B

+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path d="m563.04 489.24v-221.52h73.922v295.32h73.801v-295.32h73.918v-147.72h-369.24v147.72h73.797v295.32h73.801z"/>
<path d="m415.44 563.04h73.801v73.801h-73.801z"/>
<path d="m710.76 563.04h73.801v73.801h-73.801z"/>
<path d="m341.52 636.96h73.801v73.801h-73.801z"/>
<path d="m784.56 636.96h73.801v73.801h-73.801z"/>
<path d="m267.72 710.76h73.801v73.801h-73.801z"/>
<path d="m858.48 710.76h73.801v73.801h-73.801z"/>
<path d="m193.8 784.56h73.801v73.801h-73.801z"/>
<path d="m784.68 784.68h-73.918v-73.918h-221.52v73.918h-73.797v73.801h-73.922v73.801h516.96v-73.801h-73.801z"/>
<path d="m932.28 784.56h73.801v73.801h-73.801z"/>
<path d="m193.8 858.48h-73.801v147.72h73.801z"/>
<path d="m1006.2 932.28v73.918h73.801v-147.72h-73.801z"/>
<path d="m858.48 1006.2h-664.68v73.801h812.4v-73.801z"/>
</svg>

After

Width:  |  Height:  |  Size: 960 B

+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path d="m969.3 600.06v-92.398h-46.332v-46.199h-45.961v-46.078h-92.27v-46.211h-46.309v-92.277h46.309v-184.57h-46.309v-46.078h-276.84v46.078h-46.309v184.58h46.309v92.277h-46.309v46.211h-92.281v46.078h-45.961v46.199h-46.32v92.398h-45.949v276.86h45.949v92.277h46.32v92.289h45.961v46.199h92.281v46.078h369.46v-46.078h92.27v-46.199h45.961v-92.289h46.332v-92.277h45.961v-276.86l-45.961 0.011719zm-92.293-46.32v46.32h-46.309v46.199h-45.961v46.078h-92.27v46.211h-184.92v-46.211h-92.27v-46.078h-45.961v-46.199h-46.32v-92.398h46.32v-46.199h92.27v-46.078h45.961v-184.58h-45.961v-92.277h45.961l0.003906-46.195h184.92v46.211h45.961v92.277h-45.961v184.58h45.961v46.078h92.27v46.199h46.309z"/>
<path d="m646.13 138.54h46.332v46.078h-46.332z"/>
<path d="m553.86 184.62v46.188h92.27v-46.188z"/>
<path d="m507.53 138.54h46.32v46.078h-46.32z"/>
</svg>

After

Width:  |  Height:  |  Size: 986 B

+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path d="m540 60v-60h-59.953v60z"/>
<path d="m719.95 60h-59.953v60h59.953z"/>
<path d="m959.9 60h-59.953v60h59.953z"/>
<path d="m599.95 120v60h59.953v-60z"/>
<path d="m779.9 180v-60h-59.953v60z"/>
<path d="m360.1 240v-60h-59.953v60z"/>
<path d="m719.95 240v-60h-59.953v60z"/>
<path d="m899.86 240h-59.953v60h59.953z"/>
<path d="m779.9 300v60h59.953v-60z"/>
<path d="m959.9 300h-59.953v60h59.953z"/>
<path d="m360.1 360v60h299.9v-60z"/>
<path d="m839.9 420h59.953v-60h-59.953z"/>
<path d="m300.14 480h59.953v-60h-59.953z"/>
<path d="m660 420v60h59.953v-60z"/>
<path d="m240.1 540h59.953v-60h-59.953z"/>
<path d="m719.95 480v60h59.953v-60z"/>
<path d="m180.14 1200h539.81v-60h-479.86v-600h-59.953z"/>
<path d="m839.9 1200v-660h-59.953v660z"/>
<path d="m1019.9 540h-59.953v60h59.953z"/>
<path d="m719.95 780h-179.95v60h179.95z"/>
<path d="m899.86 840h59.953v-60h-59.953z"/>
<path d="m719.95 900h-299.9v60h299.9z"/>
<path d="m300.14 1080h419.81v-60l-419.81-0.046875z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path d="m627.24 163.64v-54.562h-109.08v-54.562h54.516v-54.516h218.11v54.562h163.6v54.562h54.516v54.562h54.516v54.562h-54.516v54.562h109.08v54.562h-490.74v-54.562h-54.516v-54.562h109.08v-54.562h-54.516z"/>
<path d="m790.82 600v54.562h218.11v-54.562h54.516v-54.562h54.516v-163.64h-436.22v163.64h54.516v54.562zm163.6-109.08v-54.562h109.08v54.562zm-54.562 54.562h54.516v54.562h-54.516zm-109.03-54.562v-54.562h109.08v54.562z"/>
<path d="m354.61 1145.5v54.562h-163.55v-54.562h109.08v-54.562h-218.16v-54.562h54.516v-54.562h54.516v-54.562h54.516v-54.562h54.516v-54.562h54.516v-109.12h54.516v-109.12h54.516v-54.562h54.516v-54.562h54.516v-54.562h54.516v109.12h54.516v54.562h54.516v54.562h54.516v54.562h54.516v163.64h-54.516v109.12h-54.516v109.12h-54.516v54.562h-54.516v54.562h-54.516v-163.64h-54.516v54.562h-54.516v54.562h-109.08z"/>
<path d="m899.86 763.64h54.516v54.562h-54.516z"/>
<path d="m954.42 818.21h54.516v109.12h-54.516z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+393 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { dispatch, initialStateFor } from './dispatcher'
import { dispatch, getLightStatus, initialStateFor } from './dispatcher'
import type { World } from '../world/types'
import type { GameState, ParsedCommand } from './types'
import { SCHEMA_VERSION } from './types'
@@ -193,6 +193,21 @@ describe('dispatcher — take and drop', () => {
const r = dispatch(s, { kind: 'verb-target', verb: 'drop', target: { canonical: 'torch', raw: 'torch' } }, world)
expect(r.state.inventory.find((i) => i.id === 'torch')).toBeUndefined()
})
it('refuses to drop a lit lightable item', () => {
const lightWorld: World = {
...world,
items: {
...world.items,
lamp: { id: 'lamp', names: ['lamp'], short: 'an oil lamp', long: 'An oil lamp.', initialState: { lit: false }, takeable: true, lightable: true },
},
}
let s = initialStateFor(lightWorld)
s = { ...s, inventory: [{ id: 'lamp', state: { lit: true, burn: 6 } }] }
const r = dispatch(s, { kind: 'verb-target', verb: 'drop', target: { canonical: 'lamp', raw: 'lamp' } }, lightWorld)
expect(r.appended.at(-1)?.text).toBe('Extinguish it first.')
expect(r.state.inventory.find((i) => i.id === 'lamp')).toBeDefined()
})
})
describe('dispatcher — examine', () => {
@@ -241,6 +256,151 @@ describe('dispatcher — inventory', () => {
})
})
describe('light status', () => {
it('shows the meter when carrying a lightable item even before it is lit', () => {
const lightWorld: World = {
...world,
items: {
...world.items,
torch: {
id: 'torch',
names: ['torch', 'lamp'],
short: 'an oil lamp',
long: 'An iron oil lamp, unlit.',
initialState: { lit: false },
takeable: true,
lightable: true,
},
},
}
const state: GameState = {
...initialStateFor(lightWorld),
inventory: [{ id: 'torch', state: { lit: false } }],
}
expect(getLightStatus(state, lightWorld)).toEqual({
itemId: 'torch',
lit: false,
turnsLeft: 0,
maxTurns: 6,
})
})
it('prefers a lit lightable item over an unlit one in inventory order', () => {
const lightWorld: World = {
...world,
items: {
...world.items,
torch: {
id: 'torch',
names: ['torch', 'lamp'],
short: 'an oil lamp',
long: 'An iron oil lamp, unlit.',
initialState: { lit: false },
takeable: true,
lightable: true,
},
candlestick: {
id: 'candlestick',
names: ['candlestick', 'candle'],
short: 'a brass candlestick',
long: 'A brass candlestick.',
initialState: { lit: false },
takeable: true,
lightable: true,
},
},
}
const state: GameState = {
...initialStateFor(lightWorld),
inventory: [
{ id: 'candlestick', state: { lit: false } },
{ id: 'torch', state: { lit: true, burn: 6 } },
],
}
expect(getLightStatus(state, lightWorld)).toEqual({
itemId: 'torch',
lit: true,
turnsLeft: 6,
maxTurns: 6,
})
})
it('uses the configured light meter length and state keys', () => {
const lightWorld: World = {
...world,
mechanics: {
light: {
enabled: true,
handler: 'light',
maxTurns: 3,
burnOn: ['wait'],
stateKeys: { lit: 'isLit', burn: 'fuel' },
ui: { meter: true, icon: 'candle' },
},
},
items: {
...world.items,
torch: {
id: 'torch',
names: ['torch', 'lamp'],
short: 'an oil lamp',
long: 'An iron oil lamp, unlit.',
initialState: { isLit: false },
takeable: true,
lightable: true,
},
},
}
const state: GameState = {
...initialStateFor(lightWorld),
inventory: [{ id: 'torch', state: { isLit: true, fuel: 2 } }],
}
expect(getLightStatus(state, lightWorld)).toEqual({
itemId: 'torch',
lit: true,
turnsLeft: 2,
maxTurns: 3,
})
})
it('hides the meter when the light mechanic is disabled', () => {
const lightWorld: World = {
...world,
mechanics: {
light: {
enabled: false,
handler: 'light',
maxTurns: 6,
burnOn: ['move', 'wait'],
stateKeys: { lit: 'lit', burn: 'burn' },
ui: { meter: true, icon: 'candle' },
},
},
items: {
...world.items,
torch: {
id: 'torch',
names: ['torch', 'lamp'],
short: 'an oil lamp',
long: 'An iron oil lamp, unlit.',
initialState: { lit: false },
takeable: true,
lightable: true,
},
},
}
const state: GameState = {
...initialStateFor(lightWorld),
inventory: [{ id: 'torch', state: { lit: true, burn: 6 } }],
}
expect(getLightStatus(state, lightWorld)).toBeNull()
})
})
describe('ambiguous → disambiguation flow', () => {
function makeAmbiguousWorld(): World {
return {
@@ -414,6 +574,152 @@ describe('light/extinguish verbs (implicit lighter)', () => {
expect(texts).toContain('The book is empty.')
})
it('burns one segment on each wait and extinguishes when the last segment expires', () => {
const world = w()
let state = initialStateFor(world)
state = { ...state, inventory: [{ id: 'lamp', state: { lit: true, burn: 6 } }] }
const first = dispatch(state, { kind: 'verb-only', verb: 'wait' }, world)
expect(first.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(5)
expect(first.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(true)
const second = dispatch(first.state, { kind: 'verb-only', verb: 'wait' }, world)
expect(second.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(4)
const third = dispatch(second.state, { kind: 'verb-only', verb: 'wait' }, world)
expect(third.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(3)
const fourth = dispatch(third.state, { kind: 'verb-only', verb: 'wait' }, world)
expect(fourth.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(2)
const fifth = dispatch(fourth.state, { kind: 'verb-only', verb: 'wait' }, world)
expect(fifth.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(1)
expect(fifth.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(true)
const sixth = dispatch(fifth.state, { kind: 'verb-only', verb: 'wait' }, world)
expect(sixth.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(0)
expect(sixth.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(false)
expect(sixth.appended.map((l) => l.text)).toContain('The flame dies.')
})
it('burns one segment on movement', () => {
const world = w()
const movingWorld: World = {
...world,
rooms: {
...world.rooms,
r: {
id: 'r',
title: '[ R ]',
descriptions: { firstVisit: '.', revisit: '.', examined: '.' },
exits: { n: 'r2' },
items: [],
},
r2: { id: 'r2', title: '[ R2 ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] },
},
}
let state = initialStateFor(movingWorld)
state = { ...state, inventory: [{ id: 'lamp', state: { lit: true, burn: 6 } }] }
const result = dispatch(state, { kind: 'go', direction: 'n' }, movingWorld)
expect(result.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(5)
expect(result.state.location).toBe('r2')
})
it('uses configured maxTurns when lighting and burning down', () => {
const baseWorld = w()
const world: World = {
...baseWorld,
mechanics: {
light: {
enabled: true,
handler: 'light',
maxTurns: 3,
burnOn: ['wait'],
stateKeys: { lit: 'lit', burn: 'burn' },
ui: { meter: true, icon: 'candle' },
messages: { flameDies: 'The configured light dies.' },
},
},
items: {
...baseWorld.items,
lamp: {
id: 'lamp',
names: ['lamp'],
short: 'an oil lamp',
long: '.',
initialState: { lit: false },
takeable: true,
lightable: true,
litText: 'The wick catches.',
},
},
}
let state = initialStateFor(world)
state = { ...state, inventory: [
{ id: 'lamp', state: { lit: false } },
{ id: 'matches', state: { uses: 2 } },
] }
const lit = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' } }, world)
expect(lit.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(3)
const first = dispatch(lit.state, { kind: 'verb-only', verb: 'wait' }, world)
expect(first.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(2)
const second = dispatch(first.state, { kind: 'verb-only', verb: 'wait' }, world)
expect(second.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(1)
const third = dispatch(second.state, { kind: 'verb-only', verb: 'wait' }, world)
expect(third.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(false)
expect(third.appended.map((l) => l.text)).toContain('The configured light dies.')
})
it('does not burn down on movement when move is not configured', () => {
const world: World = {
...w(),
mechanics: {
light: {
enabled: true,
handler: 'light',
maxTurns: 3,
burnOn: ['wait'],
stateKeys: { lit: 'lit', burn: 'burn' },
ui: { meter: true, icon: 'candle' },
},
},
rooms: {
r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: { n: 'r2' }, items: [] },
r2: { id: 'r2', title: '[ R2 ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] },
},
}
let state = initialStateFor(world)
state = { ...state, inventory: [{ id: 'lamp', state: { lit: true, burn: 3 } }] }
const result = dispatch(state, { kind: 'go', direction: 'n' }, world)
expect(result.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(3)
})
it('disabling the light mechanic removes burn-down behavior', () => {
const world: World = {
...w(),
mechanics: {
light: {
enabled: false,
handler: 'light',
maxTurns: 6,
burnOn: ['move', 'wait'],
stateKeys: { lit: 'lit', burn: 'burn' },
ui: { meter: true, icon: 'candle' },
},
},
}
let state = initialStateFor(world)
state = { ...state, inventory: [{ id: 'lamp', state: { lit: true, burn: 1 } }] }
const result = dispatch(state, { kind: 'verb-only', verb: 'wait' }, world)
expect(result.state.inventory.find((i) => i.id === 'lamp')?.state).toEqual({ lit: true, burn: 1 })
expect(result.appended.map((l) => l.text)).not.toContain('The flame dies.')
})
it('extinguishes a lit lamp', () => {
const world = w()
let state = initialStateFor(world)
@@ -494,6 +800,21 @@ describe('use verb routing', () => {
letter: { id: 'letter', names: ['letter'], short: 'a letter', long: '.', initialState: {}, takeable: true, readable: true, readableText: 'Read me.' },
'broken-cigarette': { id: 'broken-cigarette', names: ['cigarette', 'broken cigarette'], short: 'a broken cigarette', long: '.', initialState: { lit: false }, takeable: true, lightable: true, litText: 'The end glows once, then steadies. The smoke is bitter.' },
},
actions: {
'burn-letter': {
id: 'burn-letter',
verbs: ['use'],
requires: { allVisibleOrHeld: ['letter', 'matches'] },
consumes: { inventory: ['letter'] },
decrements: { item: 'matches', stateKey: 'uses' },
setsFlags: { letterBurned: true },
messages: {
success: 'The letter catches at one corner. In a few breaths it is ash.',
spent: 'The matchbook is empty.',
missingRequired: "You don't see the letter here.",
},
},
},
encounters: {},
endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } },
}
@@ -556,6 +877,77 @@ describe('use verb routing', () => {
})
})
describe('handler-backed drink action', () => {
function w(): World {
return {
startingRoom: 'r',
startingInventory: ['whiskey'],
rooms: {
r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] },
'drunk-start': { id: 'drunk-start', title: '[ Drunk Start ]', descriptions: { firstVisit: 'The hall tips.', revisit: 'The hall tips again.', examined: '.' }, exits: { n: 'drunk-next' }, items: [] },
'drunk-next': { id: 'drunk-next', title: '[ Drunk Next ]', descriptions: { firstVisit: 'The room doubles.', revisit: 'The room doubles again.', examined: '.' }, exits: { s: 'drunk-start' }, items: [] },
vestibule: { id: 'vestibule', title: '[ Vestibule ]', descriptions: { firstVisit: '.', revisit: 'You wake somewhere else.', examined: '.' }, exits: {}, items: [] },
pantry: { id: 'pantry', title: '[ Pantry ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: ['whiskey'] },
},
items: {
whiskey: { id: 'whiskey', names: ['whiskey'], short: 'a bottle of whiskey', long: '.', initialState: {}, takeable: true },
},
actions: {
'drink-whiskey': {
id: 'drink-whiskey',
verbs: ['drink'],
handler: 'drunk-transition',
requires: { allHeld: ['whiskey'] },
consumes: { inventory: ['whiskey'] },
drunkTransition: {
destinationRoom: 'drunk-start',
maxMoves: 2,
wakeRoom: 'vestibule',
resetRoom: 'pantry',
},
messages: {
success: 'Custom drink text.',
missingRequired: 'Hold it first.',
tooManyMovesPassOut: 'Custom pass out.',
reset: 'Custom reset.',
},
},
},
encounters: {},
endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } },
}
}
it('uses markdown action config for destination, move cap, wake room, and reset room', () => {
const world = w()
let state = initialStateFor(world)
let result = dispatch(state, { kind: 'verb-target', verb: 'drink', target: { canonical: 'whiskey', raw: 'whiskey' } }, world)
expect(result.state.location).toBe('drunk-start')
expect(result.state.inventory.find((i) => i.id === 'whiskey')).toBeUndefined()
expect(result.appended.map((l) => l.text)).toContain('Custom drink text.')
state = {
...result.state,
roomState: {
...result.state.roomState,
pantry: { takenItems: ['whiskey'], droppedItems: ['whiskey'] },
},
}
result = dispatch(state, { kind: 'go', direction: 'n' }, world)
expect(result.state.location).toBe('drunk-next')
expect(result.state.flags['drunkMoves']).toBe(1)
result = dispatch(result.state, { kind: 'go', direction: 's' }, world)
expect(result.state.location).toBe('vestibule')
expect(result.state.flags['drunk']).toBe(false)
expect(result.state.roomState['pantry']?.['takenItems']).toEqual([])
expect(result.state.roomState['pantry']?.['droppedItems']).toEqual([])
expect(result.appended.map((l) => l.text)).toContain('Custom pass out.')
expect(result.appended.map((l) => l.text)).toContain('Custom reset.')
})
})
describe('ending detection', () => {
function makeWorld(): World {
return {
+442 -107
View File
@@ -1,15 +1,83 @@
import type { World } from '../world/types'
import type { GameState, ParsedCommand, DispatchResult, ItemInstance, TranscriptLine } from './types'
import { SCHEMA_VERSION, TRANSCRIPT_CAP } from './types'
import { DEFAULT_WORLD_MESSAGES, type DeclarativeAction, type LightMechanicMessageKey, type World, type WorldMessageKey } from '../world/types'
import type { GameState, ParsedCommand, DispatchResult, ItemInstance, TranscriptLine, ResolveLevel } from './types'
import { SCHEMA_VERSION, TRANSCRIPT_CAP, RESOLVE_LEVELS } from './types'
import { applyVerbToEncounter, maybeTriggerEncounter } from './encounters'
const HALFSTREET_ASCII = String.raw`
_ _ _ __ ____ _ _
| | | | __ _| |/ _| / ___|| |_ _ __ ___ ___| |_
| |_| |/ _\` | | |_ \___ \| __| '__/ _ \/ _ \ __|
| _ | (_| | | _| ___) | |_| | | __/ __/ |_
|_| |_|\__,_|_|_| |____/ \__|_| \___|\___|\__|
`.trim()
type ActiveLightMechanic = NonNullable<NonNullable<World['mechanics']>['light']>
type ActiveResolveMechanic = NonNullable<NonNullable<World['mechanics']>['resolve']>
const DEFAULT_LIGHT_MECHANIC: ActiveLightMechanic = {
enabled: true,
handler: 'light',
maxTurns: 6,
burnOn: ['move', 'wait'],
stateKeys: { lit: 'lit', burn: 'burn' },
ui: { meter: true, icon: 'candle' },
messages: {},
}
const DEFAULT_RESOLVE_MECHANIC: ActiveResolveMechanic = {
enabled: true,
handler: 'resolve',
ladder: RESOLVE_LEVELS,
wrongVerbCost: 1,
safeRooms: { recoverySteps: 1 },
failure: { retreatAt: 'returning', afterRetreat: 'shaken' },
}
const DEFAULT_DRUNK_ACTION: DeclarativeAction = {
id: 'drink-whiskey',
verbs: ['drink'],
handler: 'drunk-transition',
requires: { allHeld: ['whiskey'] },
consumes: { inventory: ['whiskey'] },
drunkTransition: {
destinationRoom: 'drunk-hall',
maxMoves: 20,
wakeRoom: 'foyer',
resetRoom: 'kitchen',
},
messages: {
success: 'You drink from the bottle. It tastes of smoke, sugar, and rainwater left too long in a pipe.',
secretFoundPassOut: 'The faceless man steps backward into the dark. The floor rises under you, or you fall toward it.',
tooManyMovesPassOut: 'The rooms keep turning until they become one room. Then even that room is gone.',
reset: 'The bottle is not with you. Somewhere in the kitchen, it is half full again.',
},
}
export interface LightStatus {
itemId: string
lit: boolean
turnsLeft: number
maxTurns: number
}
function message(world: World, key: WorldMessageKey): string {
return world.messages?.[key] ?? DEFAULT_WORLD_MESSAGES[key]
}
function lightMechanic(world: World): ActiveLightMechanic {
return world.mechanics?.light ?? DEFAULT_LIGHT_MECHANIC
}
function resolveMechanic(world: World): ActiveResolveMechanic {
return world.mechanics?.resolve ?? DEFAULT_RESOLVE_MECHANIC
}
function drunkAction(world: World): DeclarativeAction {
return Object.values(world.actions ?? {}).find((action) => action.handler === 'drunk-transition') ?? DEFAULT_DRUNK_ACTION
}
function recoverResolve(level: ResolveLevel, world: World): ResolveLevel {
const mechanic = resolveMechanic(world)
if (!mechanic.enabled || mechanic.safeRooms.recoverySteps === 0) return level
const idx = mechanic.ladder.indexOf(level)
if (idx <= 0) return level
return mechanic.ladder[Math.max(0, idx - mechanic.safeRooms.recoverySteps)] ?? level
}
function lightMessage(world: World, key: LightMechanicMessageKey, fallback: WorldMessageKey): string {
const mechanic = lightMechanic(world)
return mechanic?.messages?.[key] ?? message(world, fallback)
}
export function initialStateFor(world: World): GameState {
const startingRoom = world.rooms[world.startingRoom]
@@ -22,13 +90,14 @@ export function initialStateFor(world: World): GameState {
})
const opening: TranscriptLine[] = [
{ kind: 'system', text: HALFSTREET_ASCII },
...(world.game?.openingArt ? [{ kind: 'system' as const, text: world.game.openingArt }] : []),
{ kind: 'system', text: startingRoom.title },
{ kind: 'narration', text: startingRoom.descriptions.firstVisit },
]
return {
schemaVersion: SCHEMA_VERSION,
transcriptCap: world.game?.transcriptCap,
location: world.startingRoom,
inventory,
roomState: { [world.startingRoom]: { visited: true } },
@@ -37,14 +106,38 @@ export function initialStateFor(world: World): GameState {
encounterState: {},
lastNoun: null,
pendingDisambiguation: null,
pendingConfirmation: null,
transcript: opening,
endedWith: null,
}
}
export function getLightStatus(state: GameState, world: World): LightStatus | null {
const mechanic = lightMechanic(world)
if (!mechanic?.enabled || mechanic.ui?.meter === false) return null
let fallback: LightStatus | null = null
for (const inst of state.inventory) {
const def = world.items[inst.id]
if (!def?.lightable) continue
const lit = inst.state[mechanic.stateKeys.lit] === true
const turnsLeft = lit ? getLightTurnsLeft(inst, world) : 0
const status = {
itemId: inst.id,
lit,
turnsLeft,
maxTurns: mechanic.maxTurns,
}
if (lit) return status
fallback = fallback ?? status
}
return fallback
}
function append(state: GameState, lines: TranscriptLine[]): GameState {
const transcript = [...state.transcript, ...lines]
return { ...state, transcript: transcript.slice(-TRANSCRIPT_CAP) }
const cap = state.transcriptCap ?? TRANSCRIPT_CAP
return { ...state, transcript: transcript.slice(-cap) }
}
export function getItemsInRoom(state: GameState, world: World, roomId: string): string[] {
@@ -64,12 +157,12 @@ function setRoomFlag(state: GameState, roomId: string, key: string, value: strin
}
}
const ENDING_PRIORITY: ('true' | 'wrong' | 'bad')[] = ['true', 'wrong', 'bad']
function evaluateEndings(state: GameState, world: World): GameState | null {
if (state.endedWith) return null
for (const id of ENDING_PRIORITY) {
const priority = world.endingPriority ?? Object.keys(world.endings)
for (const id of priority) {
const ending = world.endings[id]
if (!ending) continue
const flags = ending.whenFlags
let allMatch = true
for (const [k, v] of Object.entries(flags)) {
@@ -86,37 +179,78 @@ function evaluateEndings(state: GameState, world: World): GameState | null {
}
function withEndingCheck(result: DispatchResult, world: World): DispatchResult {
result = maybeResolveDrunkState(result, world)
const updated = evaluateEndings(result.state, world)
if (!updated) return result
const endingLine: TranscriptLine = updated.transcript[updated.transcript.length - 1]!
return { state: updated, appended: [...result.appended, endingLine] }
}
export function dispatch(state: GameState, command: ParsedCommand, world: World): DispatchResult {
const CRITICAL_VERBS = new Set(['attack'])
function isCriticalCommand(command: ParsedCommand): boolean {
if (command.kind !== 'verb-target' && command.kind !== 'verb-target-prep') return false
return CRITICAL_VERBS.has(command.verb)
}
function confirmationPrompt(command: ParsedCommand): string {
if (command.kind === 'verb-target') {
return `Are you sure you want to ${command.verb} ${command.target.raw}? Type yes to continue, or no to stop.`
}
if (command.kind === 'verb-target-prep') {
return `Are you sure you want to ${command.verb} ${command.target.raw} ${command.preposition} ${command.indirect.raw}? Type yes to continue, or no to stop.`
}
return 'Are you sure? Type yes to continue, or no to stop.'
}
export function dispatch(state: GameState, command: ParsedCommand, world: World, confirmed = false): DispatchResult {
if (command.kind === 'confirmation') {
const pending = state.pendingConfirmation
if (!pending) {
return narrate(state, [{ kind: 'narration', text: message(world, 'nothing-to-confirm') }])
}
const cleared: GameState = { ...state, pendingConfirmation: null }
if (!command.confirmed) {
return narrate(cleared, [{ kind: 'narration', text: message(world, 'cancelled') }])
}
return dispatch(cleared, pending.command, world, true)
}
if (state.pendingConfirmation) {
state = { ...state, pendingConfirmation: null }
}
// Once the game has ended, only restart/undo (handled by the UI) can clear state.
if (state.endedWith) {
return narrate(state, [{ kind: 'narration', text: world.game?.endedText ?? 'The story has ended. Type `restart` or `undo`.' }])
}
if (!confirmed && isCriticalCommand(command)) {
const prompt = confirmationPrompt(command)
const next: GameState = { ...state, pendingConfirmation: { command, prompt } }
return narrate(next, [{ kind: 'narration', text: prompt }])
}
// Disambiguation reply: re-issue the original verb with the chosen target.
if (command.kind === 'disambiguation') {
const pending = state.pendingDisambiguation
if (!pending) {
return narrate(state, [{ kind: 'narration', text: 'Nothing to choose between.' }])
return narrate(state, [{ kind: 'narration', text: message(world, 'nothing-to-choose') }])
}
const cleared: GameState = { ...state, pendingDisambiguation: null }
return dispatch(
cleared,
{ kind: 'verb-target', verb: pending.verb, target: { canonical: command.chosen, raw: command.chosen } },
world,
confirmed,
)
}
// Once the game has ended, only restart/undo (handled by the UI) can clear state.
if (state.endedWith) {
return narrate(state, [{ kind: 'narration', text: 'The story has ended. Type `restart` or `undo`.' }])
}
if (command.kind === 'unknown') {
const text =
command.reason === 'unknown-verb' ? 'You consider the words, but they don\'t fit this place.'
: command.reason === 'unknown-noun' ? 'You don\'t see anything like that here.'
: 'You hesitate.'
command.reason === 'unknown-verb' ? message(world, 'unknown-verb')
: command.reason === 'unknown-noun' ? message(world, 'unknown-noun')
: message(world, 'malformed')
return narrate(state, [{ kind: 'narration', text }])
}
@@ -149,7 +283,8 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World)
}
if (command.verb === 'look') return withEndingCheck(handleLook(state, world), world)
if (command.verb === 'inventory') return withEndingCheck(handleInventory(state, world), world)
if (command.verb === 'wait') return withEndingCheck(narrate(state, [{ kind: 'narration', text: 'Time passes.' }]), world)
if (command.verb === 'wait') return withEndingCheck(handleWait(state, world), world)
if (command.verb === 'listen') return withEndingCheck(narrate(state, [{ kind: 'narration', text: message(world, 'listen') }]), world)
}
if (command.kind === 'verb-target') {
@@ -163,14 +298,15 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World)
if (command.verb === 'drop') return withEndingCheck(handleDrop(stateWithNoun, command.target.canonical, world), world)
if (command.verb === 'examine' || command.verb === 'look') return withEndingCheck(handleExamine(stateWithNoun, command.target.canonical, world), world)
if (command.verb === 'read') return withEndingCheck(handleRead(stateWithNoun, command.target.canonical, world), world)
if (command.verb === 'drink') return withEndingCheck(handleDrink(stateWithNoun, command.target.canonical, world), world)
if (command.verb === 'light') return withEndingCheck(handleLight(stateWithNoun, command.target.canonical, null, world), world)
if (command.verb === 'extinguish') return withEndingCheck(handleExtinguish(stateWithNoun, command.target.canonical, world), world)
if (command.verb === 'use') {
const target = world.items[command.target.canonical]
if (target?.lighter && !target.lightable) {
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: 'Use match with what?' }]), world)
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: message(world, 'use-lighter-with-what') }]), world)
}
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }]), world)
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: message(world, 'use-unknown') }]), world)
}
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]), world)
}
@@ -186,16 +322,16 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World)
return withEndingCheck(handleLight(stateWithNoun, command.target.canonical, command.indirect.canonical, world), world)
}
if (command.verb === 'use') {
const burnResult = handleBurnLetter(stateWithNoun, command.target.canonical, command.indirect.canonical, world)
if (burnResult) return withEndingCheck(burnResult, world)
const actionResult = handleDeclarativeAction(stateWithNoun, command, world)
if (actionResult) return withEndingCheck(actionResult, world)
const lightResult = handleUseAsLight(stateWithNoun, command.target.canonical, command.indirect.canonical, world)
if (lightResult) return withEndingCheck(lightResult, world)
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }]), world)
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: message(world, 'use-unknown') }]), world)
}
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]), world)
}
return narrate(state, [{ kind: 'narration', text: 'Nothing happens.' }])
return narrate(state, [{ kind: 'narration', text: message(world, 'nothing-happens') }])
}
function narrate(state: GameState, lines: TranscriptLine[]): DispatchResult {
@@ -217,11 +353,11 @@ function handleMeta(state: GameState, verb: 'restart' | 'undo' | 'hint' | 'save'
function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd', world: World): DispatchResult {
const room = world.rooms[state.location]
if (!room) return narrate(state, [{ kind: 'narration', text: 'You are nowhere.' }])
if (!room) return narrate(state, [{ kind: 'narration', text: message(world, 'nowhere') }])
const dest = room.exits[direction]
if (!dest) {
return narrate(state, [{ kind: 'narration', text: 'You can\'t go that way.' }])
return narrate(state, [{ kind: 'narration', text: message(world, 'no-exit') }])
}
const lock = room.lockedExits?.[direction]
@@ -233,7 +369,7 @@ function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd'
}
const destRoom = world.rooms[dest]
if (!destRoom) return narrate(state, [{ kind: 'narration', text: 'The way ahead is unfinished.' }])
if (!destRoom) return narrate(state, [{ kind: 'narration', text: message(world, 'unfinished-exit') }])
const visited = !!state.roomState[dest]?.['visited']
const description = visited ? destRoom.descriptions.revisit : destRoom.descriptions.firstVisit
@@ -242,16 +378,24 @@ function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd'
next = setRoomFlag(next, dest, 'visited', true)
if (destRoom.safe) {
const ladder = ['steady', 'shaken', 'reeling', 'returning'] as const
const idx = ladder.indexOf(state.resolveLevel)
if (idx > 0) next = { ...next, resolveLevel: ladder[idx - 1]! }
next = { ...next, resolveLevel: recoverResolve(state.resolveLevel, world) }
}
const lightTick = advanceLightState(next, 'move', world)
next = lightTick.state
const arrivalLines: TranscriptLine[] = [
{ kind: 'system', text: destRoom.title },
{ kind: 'narration', text: description },
...lightTick.lines,
]
const result = narrate(next, arrivalLines)
let result = narrate(next, arrivalLines)
if (state.flags['drunk'] === true && dest.startsWith('drunk-')) {
const moved = advanceDrunkTurns(result.state, world)
if (moved.appended.length > 0) return { state: moved.state, appended: [...arrivalLines, ...moved.appended] }
result = { state: moved.state, appended: arrivalLines }
}
// Trigger any encounter waiting in this room.
const triggered = maybeTriggerEncounter(result.state, world)
@@ -261,9 +405,115 @@ function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd'
return result
}
function handleDrink(state: GameState, itemId: string, world: World): DispatchResult {
const action = drunkAction(world)
const targetItems = new Set([
...(action.requires?.allHeld ?? []),
...(action.requires?.allVisibleOrHeld ?? []),
...(action.consumes?.inventory ?? []),
])
if (!action.verbs.includes('drink') || !targetItems.has(itemId)) {
return narrate(state, [{ kind: 'narration', text: message(world, 'cannot-drink') }])
}
const requiredHeld = action.requires?.allHeld ?? [...targetItems]
const held = requiredHeld.every((requiredId) => state.inventory.some((i) => i.id === requiredId))
if (!held) {
return narrate(state, [{ kind: 'narration', text: action.messages.missingRequired ?? message(world, 'need-carrying') }])
}
const config = action.drunkTransition ?? DEFAULT_DRUNK_ACTION.drunkTransition!
const consumed = new Set(action.consumes?.inventory ?? [itemId])
const dest = world.rooms[config.destinationRoom]
const next: GameState = {
...state,
location: config.destinationRoom,
inventory: state.inventory.filter((i) => !consumed.has(i.id)),
flags: { ...state.flags, drunk: true, drunkMoves: 0, drunkSecretFound: false },
}
const visited = !!next.roomState[config.destinationRoom]?.['visited']
const withVisit = setRoomFlag(next, config.destinationRoom, 'visited', true)
const lines: TranscriptLine[] = [
{ kind: 'narration', text: action.messages.success },
]
if (dest) {
lines.push(
{ kind: 'system', text: dest.title },
{ kind: 'narration', text: visited ? dest.descriptions.revisit : dest.descriptions.firstVisit },
)
}
return narrate(withVisit, lines)
}
function maybeResolveDrunkState(result: DispatchResult, world: World): DispatchResult {
if (result.state.flags['drunk'] !== true) return result
if (result.state.flags['drunkSecretFound'] === true) {
const action = drunkAction(world)
const passed = passOutFromDrunk(
result.state,
world,
action.messages.secretFoundPassOut ?? DEFAULT_DRUNK_ACTION.messages.secretFoundPassOut!,
)
return { state: passed.state, appended: [...result.appended, ...passed.appended] }
}
return result
}
function advanceDrunkTurns(state: GameState, world: World): DispatchResult {
const action = drunkAction(world)
const config = action.drunkTransition ?? DEFAULT_DRUNK_ACTION.drunkTransition!
const current = typeof state.flags['drunkMoves'] === 'number' ? state.flags['drunkMoves'] : 0
const moves = current + 1
const next = { ...state, flags: { ...state.flags, drunkMoves: moves } }
if (moves < config.maxMoves) return { state: next, appended: [] }
return passOutFromDrunk(
next,
world,
action.messages.tooManyMovesPassOut ?? DEFAULT_DRUNK_ACTION.messages.tooManyMovesPassOut!,
)
}
function passOutFromDrunk(state: GameState, world: World, preface: string): DispatchResult {
const action = drunkAction(world)
const config = action.drunkTransition ?? DEFAULT_DRUNK_ACTION.drunkTransition!
const resetItem = action.consumes?.inventory?.[0] ?? 'whiskey'
const wakeRoom = world.rooms[config.wakeRoom]
const resetRoomState = state.roomState[config.resetRoom] ?? {}
const resetTaken = ((resetRoomState['takenItems'] ?? []) as string[]).filter((id) => id !== resetItem)
const resetDropped = ((resetRoomState['droppedItems'] ?? []) as string[]).filter((id) => id !== resetItem)
const next: GameState = {
...state,
location: config.wakeRoom,
inventory: state.inventory.filter((i) => i.id !== resetItem),
flags: { ...state.flags, drunk: false, drunkMoves: 0, drunkSecretFound: false },
roomState: {
...state.roomState,
[config.resetRoom]: {
...resetRoomState,
takenItems: resetTaken,
droppedItems: resetDropped,
},
[config.wakeRoom]: { ...(state.roomState[config.wakeRoom] ?? {}), visited: true },
},
}
const lines: TranscriptLine[] = [
{ kind: 'narration', text: preface },
{ kind: 'system', text: wakeRoom?.title ?? `[ ${config.wakeRoom} ]` },
{ kind: 'narration', text: wakeRoom?.descriptions.revisit ?? `You wake in ${config.wakeRoom}.` },
{ kind: 'narration', text: action.messages.reset ?? DEFAULT_DRUNK_ACTION.messages.reset! },
]
return narrate(next, lines)
}
function handleWait(state: GameState, world: World): DispatchResult {
const lightTick = advanceLightState(state, 'wait', world)
return narrate(lightTick.state, [
{ kind: 'narration', text: message(world, 'time-passes') },
...lightTick.lines,
])
}
function handleLook(state: GameState, world: World): DispatchResult {
const room = world.rooms[state.location]
if (!room) return narrate(state, [{ kind: 'narration', text: 'You see nothing.' }])
if (!room) return narrate(state, [{ kind: 'narration', text: message(world, 'see-nothing') }])
const items = getItemsInRoom(state, world, state.location)
const itemNarration = describeRoomItems(items.map((id) => world.items[id]?.short ?? id))
return narrate(state, [
@@ -292,30 +542,31 @@ function joinList(values: string[]): string {
function handleInventory(state: GameState, world: World): DispatchResult {
if (state.inventory.length === 0) {
return narrate(state, [{ kind: 'narration', text: 'You are empty-handed.' }])
return narrate(state, [{ kind: 'narration', text: message(world, 'inventory-empty') }])
}
const lines = state.inventory.map((inst) => {
const item = world.items[inst.id]
const litSuffix = inst.state['lit'] === true ? ' (lit)' : ''
const mechanic = lightMechanic(world)
const litSuffix = mechanic?.enabled && inst.state[mechanic.stateKeys.lit] === true ? ' (lit)' : ''
return ` ${item?.short ?? inst.id}${litSuffix}`
})
return narrate(state, [
{ kind: 'narration', text: 'You are carrying:' },
{ kind: 'narration', text: message(world, 'inventory-heading') },
{ kind: 'narration', text: lines.join('\n') },
])
}
function handleTake(state: GameState, itemId: string, world: World): DispatchResult {
const item = world.items[itemId]
if (!item) return narrate(state, [{ kind: 'narration', text: 'You don\'t see that here.' }])
if (!item.takeable) return narrate(state, [{ kind: 'narration', text: 'You can\'t take that.' }])
if (!item) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-here') }])
if (!item.takeable) return narrate(state, [{ kind: 'narration', text: message(world, 'cannot-take') }])
const itemsHere = getItemsInRoom(state, world, state.location)
if (!itemsHere.includes(itemId)) {
return narrate(state, [{ kind: 'narration', text: 'You don\'t see that here.' }])
return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-here') }])
}
if (state.inventory.find((i) => i.id === itemId)) {
return narrate(state, [{ kind: 'narration', text: 'You already have it.' }])
return narrate(state, [{ kind: 'narration', text: message(world, 'already-have') }])
}
const wasInRoomBase = (world.rooms[state.location]?.items ?? []).includes(itemId)
@@ -330,12 +581,18 @@ function handleTake(state: GameState, itemId: string, world: World): DispatchRes
const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[]
next = setRoomFlag(next, state.location, 'droppedItems', dropped.filter((id) => id !== itemId))
}
return narrate(next, [{ kind: 'narration', text: 'Taken.' }])
return narrate(next, [{ kind: 'narration', text: message(world, 'taken') }])
}
function handleDrop(state: GameState, itemId: string, _world: World): DispatchResult {
function handleDrop(state: GameState, itemId: string, world: World): DispatchResult {
if (!state.inventory.find((i) => i.id === itemId)) {
return narrate(state, [{ kind: 'narration', text: 'You don\'t have that.' }])
return narrate(state, [{ kind: 'narration', text: message(world, 'dont-have') }])
}
const itemDef = world.items[itemId]
const itemInst = state.inventory.find((i) => i.id === itemId) ?? null
const mechanic = lightMechanic(world)
if (mechanic?.enabled && itemDef?.lightable && itemInst?.state[mechanic.stateKeys.lit] === true) {
return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'dropLit', 'drop-lit') }])
}
let next: GameState = {
...state,
@@ -343,17 +600,17 @@ function handleDrop(state: GameState, itemId: string, _world: World): DispatchRe
}
const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[]
next = setRoomFlag(next, state.location, 'droppedItems', [...dropped, itemId])
return narrate(next, [{ kind: 'narration', text: 'Dropped.' }])
return narrate(next, [{ kind: 'narration', text: message(world, 'dropped') }])
}
function handleExamine(state: GameState, itemId: string, world: World): DispatchResult {
const item = world.items[itemId]
if (!item) return narrate(state, [{ kind: 'narration', text: 'You don\'t see anything like that.' }])
if (!item) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-anything') }])
const inventoryInst = state.inventory.find((i) => i.id === itemId) ?? null
const visible =
inventoryInst ||
getItemsInRoom(state, world, state.location).includes(itemId)
if (!visible) return narrate(state, [{ kind: 'narration', text: 'You don\'t see anything like that.' }])
if (!visible) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-anything') }])
return narrate(state, [{ kind: 'narration', text: describeItem(itemId, item.long, inventoryInst) }])
}
@@ -372,46 +629,49 @@ function spellSmallCount(value: number): string {
function handleRead(state: GameState, itemId: string, world: World): DispatchResult {
const item = world.items[itemId]
if (!item) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }])
if (!item) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-anything') }])
const visible =
state.inventory.find((i) => i.id === itemId) ||
getItemsInRoom(state, world, state.location).includes(itemId)
if (!visible) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }])
if (!visible) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-anything') }])
if (!item.readable || !item.readableText) {
return narrate(state, [{ kind: 'narration', text: "There's nothing to read on it." }])
return narrate(state, [{ kind: 'narration', text: message(world, 'nothing-to-read') }])
}
return narrate(state, [{ kind: 'narration', text: item.readableText }])
}
function handleLight(state: GameState, targetId: string, instrumentId: string | null, world: World): DispatchResult {
const mechanic = lightMechanic(world)
if (!mechanic?.enabled) return narrate(state, [{ kind: 'narration', text: message(world, 'nothing-happens') }])
const target = world.items[targetId]
if (!target) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }])
if (target.lighter && !target.lightable) return narrate(state, [{ kind: 'narration', text: 'Use match with what?' }])
if (!target.lightable) return narrate(state, [{ kind: 'narration', text: "You can't light that." }])
if (!target) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-anything') }])
if (target.lighter && !target.lightable) return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'useLighterWithWhat', 'use-lighter-with-what') }])
if (!target.lightable) return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'cannotLight', 'cannot-light') }])
const targetInst = state.inventory.find((i) => i.id === targetId) ?? null
const visibleInRoom = getItemsInRoom(state, world, state.location).includes(targetId)
if (!targetInst && !visibleInRoom) {
return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }])
return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-anything') }])
}
// The 'lit' state lives on the inventory instance for inventory items, or
// (eventually) on roomState for items left in a room. For now we only
// support lighting items the player is carrying.
if (!targetInst) {
return narrate(state, [{ kind: 'narration', text: "You'd have to be carrying it." }])
return narrate(state, [{ kind: 'narration', text: message(world, 'need-carrying') }])
}
if (targetInst.state['lit'] === true) {
return narrate(state, [{ kind: 'narration', text: "It's already lit." }])
if (targetInst.state[mechanic.stateKeys.lit] === true) {
return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'alreadyLit', 'already-lit') }])
}
// Pick an instrument. If explicit, validate it; if implicit, find any.
let lighterInst = null as typeof state.inventory[number] | null
if (instrumentId) {
lighterInst = state.inventory.find((i) => i.id === instrumentId) ?? null
if (!lighterInst) return narrate(state, [{ kind: 'narration', text: "You don't have that." }])
if (!lighterInst) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-have') }])
const lighterDef = world.items[instrumentId]
if (!lighterDef?.lighter) return narrate(state, [{ kind: 'narration', text: "That isn't going to help." }])
if (!lighterDef?.lighter) return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'notHelpful', 'not-helpful') }])
if (typeof lighterInst.state['uses'] === 'number' && lighterInst.state['uses'] <= 0) {
return narrate(state, [{ kind: 'narration', text: "It is spent." }])
return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'spent', 'spent') }])
}
} else {
for (const inst of state.inventory) {
@@ -422,7 +682,7 @@ function handleLight(state: GameState, targetId: string, instrumentId: string |
break
}
if (!lighterInst) {
return narrate(state, [{ kind: 'narration', text: 'You have nothing to light it with.' }])
return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'noLighter', 'no-lighter') }])
}
}
@@ -431,64 +691,105 @@ function handleLight(state: GameState, targetId: string, instrumentId: string |
const lighterUsesField = typeof lighterInst.state['uses'] === 'number' ? lighterInst.state['uses'] : null
const newLighterUses = lighterUsesField === null ? null : lighterUsesField - 1
const newInventory = state.inventory.map((i) => {
if (i.id === targetInst.id) return { ...i, state: { ...i.state, lit: true } }
if (i.id === targetInst.id) return { ...i, state: { ...i.state, [mechanic.stateKeys.lit]: true, [mechanic.stateKeys.burn]: mechanic.maxTurns } }
if (i.id === lighterInst!.id && newLighterUses !== null) return { ...i, state: { ...i.state, uses: newLighterUses } }
return i
})
const lines: TranscriptLine[] = [{ kind: 'narration', text: target.litText ?? 'It catches.' }]
const lines: TranscriptLine[] = [{ kind: 'narration', text: target.litText ?? lightMessage(world, 'flameCatches', 'flame-catches') }]
if (newLighterUses === 0) {
lines.push({ kind: 'narration', text: lighterDef.lighterEmptyText ?? 'It is spent.' })
lines.push({ kind: 'narration', text: lighterDef.lighterEmptyText ?? lightMessage(world, 'spent', 'spent') })
}
return narrate({ ...state, inventory: newInventory }, lines)
}
function handleBurnLetter(state: GameState, firstId: string, secondId: string, world: World): DispatchResult | null {
const ids = [firstId, secondId]
if (!ids.includes('letter') || !ids.includes('matches')) return null
function handleDeclarativeAction(
state: GameState,
command: Extract<ParsedCommand, { kind: 'verb-target-prep' }>,
world: World,
): DispatchResult | null {
const action = findDeclarativeAction(command, world)
if (!action) return null
const matches = state.inventory.find((i) => i.id === 'matches')
if (!matches) return narrate(state, [{ kind: 'narration', text: "You don't have a match." }])
if (typeof matches.state['uses'] === 'number' && matches.state['uses'] <= 0) {
return narrate(state, [{ kind: 'narration', text: 'The matchbook is empty.' }])
for (const itemId of action.requires?.allVisibleOrHeld ?? []) {
if (!isVisibleOrHeld(state, world, itemId)) {
return narrate(state, [{ kind: 'narration', text: action.messages.missingRequired ?? message(world, 'dont-see-anything') }])
}
}
for (const itemId of action.requires?.allHeld ?? []) {
if (!state.inventory.some((i) => i.id === itemId)) {
return narrate(state, [{ kind: 'narration', text: action.messages.missingRequired ?? message(world, 'dont-have') }])
}
}
const letterHeld = state.inventory.some((i) => i.id === 'letter')
const letterInRoom = getItemsInRoom(state, world, state.location).includes('letter')
if (!letterHeld && !letterInRoom) {
return narrate(state, [{ kind: 'narration', text: "You don't see the letter here." }])
const decrement = action.decrements
const decremented = decrement ? state.inventory.find((i) => i.id === decrement.item) : null
if (decrement && !decremented) {
return narrate(state, [{ kind: 'narration', text: action.messages.missingRequired ?? message(world, 'dont-have') }])
}
const decrementStateValue = decrement ? decremented?.state[decrement.stateKey] : null
const decrementedValue: number | null = typeof decrementStateValue === 'number' ? decrementStateValue : null
if (decrementedValue !== null && decrementedValue <= 0) {
return narrate(state, [{ kind: 'narration', text: action.messages.spent ?? message(world, 'spent') }])
}
const newMatchesUses = typeof matches.state['uses'] === 'number' ? matches.state['uses'] - 1 : null
const consumed = new Set(action.consumes?.inventory ?? [])
let next: GameState = {
...state,
inventory: state.inventory
.filter((i) => i.id !== 'letter')
.map((i) => i.id === 'matches' && newMatchesUses !== null ? { ...i, state: { ...i.state, uses: newMatchesUses } } : i),
flags: { ...state.flags, letterBurned: true },
.filter((i) => !consumed.has(i.id))
.map((i) =>
decrement && i.id === decrement.item && decrementedValue !== null
? { ...i, state: { ...i.state, [decrement.stateKey]: decrementedValue - 1 } }
: i,
),
flags: { ...state.flags, ...(action.setsFlags ?? {}) },
}
if (letterInRoom) {
const baseItems = world.rooms[state.location]?.items ?? []
const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[]
if (baseItems.includes('letter')) {
const taken = (next.roomState[state.location]?.['takenItems'] ?? []) as string[]
next = setRoomFlag(next, state.location, 'takenItems', [...new Set([...taken, 'letter'])])
}
if (dropped.includes('letter')) {
next = setRoomFlag(next, state.location, 'droppedItems', dropped.filter((id) => id !== 'letter'))
}
for (const itemId of consumed) {
next = removeVisibleRoomItem(next, world, itemId)
}
const lines: TranscriptLine[] = [
{ kind: 'narration', text: 'The letter catches at one corner. In a few breaths it is ash.' },
]
if (newMatchesUses === 0) {
lines.push({ kind: 'narration', text: world.items['matches']?.lighterEmptyText ?? 'The matchbook is empty.' })
const lines: TranscriptLine[] = [{ kind: 'narration', text: action.messages.success }]
if (decrementedValue === 1 && action.messages.spent) {
lines.push({ kind: 'narration', text: action.messages.spent })
}
return narrate(next, lines)
}
function findDeclarativeAction(
command: Extract<ParsedCommand, { kind: 'verb-target-prep' }>,
world: World,
): DeclarativeAction | null {
const commandItems = new Set([command.target.canonical, command.indirect.canonical])
for (const action of Object.values(world.actions ?? {})) {
if (!action.verbs.includes(command.verb)) continue
const required = [...(action.requires?.allVisibleOrHeld ?? []), ...(action.requires?.allHeld ?? [])]
if (required.length > 0 && required.every((itemId) => commandItems.has(itemId))) return action
}
return null
}
function isVisibleOrHeld(state: GameState, world: World, itemId: string): boolean {
return state.inventory.some((i) => i.id === itemId) || getItemsInRoom(state, world, state.location).includes(itemId)
}
function removeVisibleRoomItem(state: GameState, world: World, itemId: string): GameState {
if (!getItemsInRoom(state, world, state.location).includes(itemId)) return state
const baseItems = world.rooms[state.location]?.items ?? []
let next = state
if (baseItems.includes(itemId)) {
const taken = (next.roomState[state.location]?.['takenItems'] ?? []) as string[]
next = setRoomFlag(next, state.location, 'takenItems', [...new Set([...taken, itemId])])
}
const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[]
if (dropped.includes(itemId)) {
next = setRoomFlag(next, state.location, 'droppedItems', dropped.filter((id) => id !== itemId))
}
return next
}
function handleUseAsLight(state: GameState, firstId: string, secondId: string, world: World): DispatchResult | null {
if (!lightMechanic(world)?.enabled) return null
const first = world.items[firstId]
const second = world.items[secondId]
if (first?.lighter && second?.lightable) return handleLight(state, secondId, firstId, world)
@@ -497,16 +798,50 @@ function handleUseAsLight(state: GameState, firstId: string, secondId: string, w
}
function handleExtinguish(state: GameState, targetId: string, world: World): DispatchResult {
const mechanic = lightMechanic(world)
if (!mechanic?.enabled) return narrate(state, [{ kind: 'narration', text: message(world, 'nothing-happens') }])
const target = world.items[targetId]
if (!target) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }])
if (!target.lightable) return narrate(state, [{ kind: 'narration', text: "You can't extinguish that." }])
if (!target) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-anything') }])
if (!target.lightable) return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'cannotExtinguish', 'cannot-extinguish') }])
const targetInst = state.inventory.find((i) => i.id === targetId)
if (!targetInst) return narrate(state, [{ kind: 'narration', text: "You'd have to be carrying it." }])
if (targetInst.state['lit'] !== true) {
return narrate(state, [{ kind: 'narration', text: "It isn't lit." }])
if (!targetInst) return narrate(state, [{ kind: 'narration', text: message(world, 'need-carrying') }])
if (targetInst.state[mechanic.stateKeys.lit] !== true) {
return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'notLit', 'not-lit') }])
}
const newInventory = state.inventory.map((i) =>
i.id === targetId ? { ...i, state: { ...i.state, lit: false } } : i,
i.id === targetId ? { ...i, state: { ...i.state, [mechanic.stateKeys.lit]: false, [mechanic.stateKeys.burn]: 0 } } : i,
)
return narrate({ ...state, inventory: newInventory }, [{ kind: 'narration', text: target.extinguishedText ?? 'The flame dies.' }])
return narrate({ ...state, inventory: newInventory }, [{ kind: 'narration', text: target.extinguishedText ?? lightMessage(world, 'flameDies', 'flame-dies') }])
}
function advanceLightState(state: GameState, trigger: 'move' | 'wait', world: World): { state: GameState; lines: TranscriptLine[] } {
const mechanic = lightMechanic(world)
if (!mechanic?.enabled || !mechanic.burnOn.includes(trigger)) return { state, lines: [] }
let changed = false
const lines: TranscriptLine[] = []
const inventory = state.inventory.map((inst) => {
const def = world.items[inst.id]
if (!def?.lightable || inst.state[mechanic.stateKeys.lit] !== true) return inst
const turnsLeft = getLightTurnsLeft(inst, world)
const nextTurns = Math.max(0, turnsLeft - 1)
changed = true
if (nextTurns === 0) {
lines.push({ kind: 'narration', text: def.extinguishedText ?? lightMessage(world, 'flameDies', 'flame-dies') })
return { ...inst, state: { ...inst.state, [mechanic.stateKeys.lit]: false, [mechanic.stateKeys.burn]: 0 } }
}
return { ...inst, state: { ...inst.state, [mechanic.stateKeys.burn]: nextTurns } }
})
return changed ? { state: { ...state, inventory }, lines } : { state, lines }
}
function getLightTurnsLeft(inst: ItemInstance, world: World): number {
const mechanic = lightMechanic(world)
const turns = inst.state[mechanic.stateKeys.burn]
if (typeof turns === 'number') return Math.max(0, turns)
return inst.state[mechanic.stateKeys.lit] === true ? mechanic.maxTurns : 0
}
+111 -3
View File
@@ -10,7 +10,7 @@ const world: World = {
id: 'foyer',
title: '[ Foyer ]',
descriptions: { firstVisit: 'Foyer.', revisit: 'Foyer.', examined: 'Foyer.' },
exits: { n: 'stair' },
exits: { n: 'stair', e: 'chapel' },
items: [],
safe: true,
},
@@ -29,10 +29,19 @@ const world: World = {
exits: { u: 'stair' },
items: [],
},
chapel: {
id: 'chapel',
title: '[ Chapel ]',
descriptions: { firstVisit: 'Chapel.', revisit: 'Chapel.', examined: 'Chapel.' },
exits: { s: 'foyer' },
items: ['vial'],
encounter: 'basilisk',
},
},
items: {
mirror: { id: 'mirror', names: ['mirror', 'tarnished mirror'], short: 'a tarnished mirror', long: 'A small mirror, tarnished black.', initialState: {}, takeable: true },
sword: { id: 'sword', names: ['sword', 'cane sword'], short: 'a cane sword', long: 'A slim cane sword.', initialState: {}, takeable: true },
vial: { id: 'vial', names: ['vial'], short: 'a vial', long: 'A small vial.', initialState: {}, takeable: true },
},
encounters: {
revenant: {
@@ -59,6 +68,22 @@ const world: World = {
onFailed: { narration: 'You stagger back.', retreatTo: 'foyer' },
defaultWrongVerbNarration: 'The revenant does not seem to notice.',
},
basilisk: {
id: 'basilisk',
aliases: ['basilisk'],
startsIn: 'chapel',
initialPhase: 'sleeping',
phases: {
sleeping: {
description: 'An eye opens beneath the altar.',
transitions: [
{ verb: 'pour', target: 'vial', requires: { item: 'vial' }, narration: 'The eye closes.', to: 'resolved' },
],
},
},
onResolved: { setFlags: { basiliskSpared: true } },
defaultWrongVerbNarration: 'The eye watches.',
},
},
endings: {
true: { whenFlags: { _never: true }, narration: '' },
@@ -67,6 +92,23 @@ const world: World = {
},
}
function withResolveMechanic(overrides: Partial<NonNullable<NonNullable<World['mechanics']>['resolve']>>): World {
return {
...world,
mechanics: {
resolve: {
enabled: true,
handler: 'resolve',
ladder: ['steady', 'shaken', 'reeling', 'returning'],
wrongVerbCost: 1,
safeRooms: { recoverySteps: 1 },
failure: { retreatAt: 'returning', afterRetreat: 'shaken' },
...overrides,
},
},
}
}
describe('encounters — phase advancement', () => {
it('triggers an encounter on entering its room', () => {
let s = initialStateFor(world)
@@ -87,11 +129,25 @@ describe('encounters — phase advancement', () => {
it('wrong verb costs resolve and surfaces a clue', () => {
let s = initialStateFor(world)
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
const r = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, world)
const prompt = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, world)
expect(prompt.state.pendingConfirmation).toBeDefined()
expect(prompt.appended.at(-1)?.text).toContain('Are you sure')
const r = dispatch(prompt.state, { kind: 'confirmation', confirmed: true }, world)
expect(r.state.resolveLevel).toBe('shaken')
expect(r.state.encounterState['revenant']).toBe('shaken')
})
it('cancels a confirmed attack when the player says no', () => {
let s = initialStateFor(world)
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
const prompt = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, world)
const r = dispatch(prompt.state, { kind: 'confirmation', confirmed: false }, world)
expect(r.state.pendingConfirmation).toBeNull()
expect(r.state.resolveLevel).toBe('steady')
expect(r.state.encounterState['revenant']).toBe('wary')
expect(r.appended.at(-1)?.text).toBe('Cancelled.')
})
it('falls back to defaultWrongVerbNarration for unrecognized verbs', () => {
let s = initialStateFor(world)
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
@@ -104,7 +160,8 @@ describe('encounters — phase advancement', () => {
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
// Force resolve to 'returning' so the next failure retreats.
s = { ...s, resolveLevel: 'returning' }
const r = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, world)
s = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, world).state
const r = dispatch(s, { kind: 'confirmation', confirmed: true }, world)
expect(r.state.location).toBe('foyer')
expect(r.appended.some((l) => l.text.includes('stagger back'))).toBe(true)
})
@@ -116,4 +173,55 @@ describe('encounters — phase advancement', () => {
s = dispatch(s, { kind: 'go', direction: 's' }, world).state
expect(s.resolveLevel).toBe('steady')
})
it('uses markdown resolve config for wrong-verb cost', () => {
const configured = withResolveMechanic({ wrongVerbCost: 2 })
let s = initialStateFor(configured)
s = dispatch(s, { kind: 'go', direction: 'n' }, configured).state
const r = dispatch(s, { kind: 'verb-target', verb: 'push', target: { canonical: 'revenant', raw: 'revenant' } }, configured)
expect(r.state.resolveLevel).toBe('reeling')
})
it('uses markdown resolve config for safe-room recovery', () => {
const configured = withResolveMechanic({ safeRooms: { recoverySteps: 2 } })
let s = initialStateFor(configured)
s = { ...s, resolveLevel: 'reeling' }
s = dispatch(s, { kind: 'go', direction: 'n' }, configured).state
s = dispatch(s, { kind: 'go', direction: 's' }, configured).state
expect(s.resolveLevel).toBe('steady')
})
it('uses markdown resolve config for post-retreat level', () => {
const configured = withResolveMechanic({ failure: { retreatAt: 'returning', afterRetreat: 'reeling' } })
let s = initialStateFor(configured)
s = dispatch(s, { kind: 'go', direction: 'n' }, configured).state
s = { ...s, resolveLevel: 'returning' }
s = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, configured).state
const r = dispatch(s, { kind: 'confirmation', confirmed: true }, configured)
expect(r.state.location).toBe('foyer')
expect(r.state.resolveLevel).toBe('reeling')
})
it('allows a required item to be the direct target in a target-preposition encounter command', () => {
let s = initialStateFor(world)
s = {
...s,
inventory: [...s.inventory, { id: 'vial', state: {} }],
roomState: { ...s.roomState, chapel: { takenItems: ['vial'] } },
}
s = dispatch(s, { kind: 'go', direction: 'e' }, world).state
const r = dispatch(
s,
{
kind: 'verb-target-prep',
verb: 'pour',
target: { canonical: 'vial', raw: 'vial' },
preposition: 'on',
indirect: { canonical: 'basilisk', raw: 'basilisk' },
},
world,
)
expect(r.state.flags['basiliskSpared']).toBe(true)
expect(r.appended.some((l) => l.text.includes('eye closes'))).toBe(true)
})
})
+41 -13
View File
@@ -2,6 +2,17 @@ import type { World } from '../world/types'
import type { GameState, ParsedCommand, DispatchResult, TranscriptLine, ResolveLevel } from './types'
import { TRANSCRIPT_CAP, RESOLVE_LEVELS } from './types'
type ActiveResolveMechanic = NonNullable<NonNullable<World['mechanics']>['resolve']>
const DEFAULT_RESOLVE_MECHANIC: ActiveResolveMechanic = {
enabled: true,
handler: 'resolve',
ladder: RESOLVE_LEVELS,
wrongVerbCost: 1,
safeRooms: { recoverySteps: 1 },
failure: { retreatAt: 'returning', afterRetreat: 'shaken' },
}
function append(state: GameState, lines: TranscriptLine[]): GameState {
const transcript = [...state.transcript, ...lines]
return { ...state, transcript: transcript.slice(-TRANSCRIPT_CAP) }
@@ -38,11 +49,26 @@ export function maybeTriggerEncounter(state: GameState, world: World): DispatchR
return narrate(next, [{ kind: 'narration', text: phase.description }])
}
function bumpResolve(level: ResolveLevel, cost: 0 | 1 | 2 | undefined): ResolveLevel {
if (!cost) return level
const idx = RESOLVE_LEVELS.indexOf(level)
const newIdx = Math.min(RESOLVE_LEVELS.length - 1, idx + cost)
return RESOLVE_LEVELS[newIdx]!
function resolveMechanic(world: World): ActiveResolveMechanic {
return world.mechanics?.resolve ?? DEFAULT_RESOLVE_MECHANIC
}
function bumpResolve(level: ResolveLevel, cost: 0 | 1 | 2 | undefined, world: World): ResolveLevel {
const mechanic = resolveMechanic(world)
if (!mechanic.enabled || !cost) return level
const idx = mechanic.ladder.indexOf(level)
if (idx < 0) return level
const newIdx = Math.min(mechanic.ladder.length - 1, idx + cost)
return mechanic.ladder[newIdx]!
}
function shouldRetreat(level: ResolveLevel, cost: 0 | 1 | 2 | undefined, world: World): boolean {
const mechanic = resolveMechanic(world)
return mechanic.enabled && !!cost && level === mechanic.failure.retreatAt
}
function afterRetreatResolve(world: World): ResolveLevel {
return resolveMechanic(world).failure.afterRetreat
}
export interface EncounterResolution {
@@ -96,19 +122,20 @@ export function applyVerbToEncounter(
}
}
}
if (t.requires && instrumentId && t.requires.item !== instrumentId) return false
if (t.requires && instrumentId && t.requires.item !== instrumentId && t.requires.item !== targetId) return false
return true
})
if (!transition) {
// Wrong verb — apply default narration and resolve cost.
if (!verb || (targetId !== null && targetId !== encId)) return null // verb is unrelated to this encounter
const newResolve = bumpResolve(state.resolveLevel, 1)
if (state.resolveLevel === 'returning') {
const wrongVerbCost = resolveMechanic(world).wrongVerbCost
const newResolve = bumpResolve(state.resolveLevel, wrongVerbCost, world)
if (shouldRetreat(state.resolveLevel, wrongVerbCost, world)) {
// Retreat.
const retreat = def.onFailed
if (retreat) {
const next: GameState = { ...state, location: retreat.retreatTo, resolveLevel: 'shaken' }
const next: GameState = { ...state, location: retreat.retreatTo, resolveLevel: afterRetreatResolve(world) }
const dest = world.rooms[retreat.retreatTo]
const lines: TranscriptLine[] = [
{ kind: 'narration', text: retreat.narration },
@@ -125,10 +152,10 @@ export function applyVerbToEncounter(
}
// Right verb — but if it has a resolve cost and player is already at 'returning', retreat.
if (transition.resolveCost && transition.resolveCost > 0 && state.resolveLevel === 'returning') {
if (shouldRetreat(state.resolveLevel, transition.resolveCost, world)) {
const retreat = def.onFailed
if (retreat) {
const next: GameState = { ...state, location: retreat.retreatTo, resolveLevel: 'shaken' }
const next: GameState = { ...state, location: retreat.retreatTo, resolveLevel: afterRetreatResolve(world) }
const dest = world.rooms[retreat.retreatTo]
const lines: TranscriptLine[] = [
{ kind: 'narration', text: transition.narration },
@@ -142,13 +169,14 @@ export function applyVerbToEncounter(
// Right verb — narrate and transition.
let next: GameState = { ...state }
if (transition.resolveCost) {
next = { ...next, resolveLevel: bumpResolve(next.resolveLevel, transition.resolveCost) }
next = { ...next, resolveLevel: bumpResolve(next.resolveLevel, transition.resolveCost, world) }
}
if (transition.to === 'resolved') {
const newEncState = { ...next.encounterState }
delete newEncState[encId]
let resolvedFlags = { ...next.flags, [`${encId}.resolved`]: true }
if (transition.setFlags) resolvedFlags = { ...resolvedFlags, ...transition.setFlags }
if (def.onResolved?.setFlags) resolvedFlags = { ...resolvedFlags, ...def.onResolved.setFlags }
next = { ...next, encounterState: newEncState, flags: resolvedFlags }
} else if (transition.to === 'failed') {
@@ -157,7 +185,7 @@ export function applyVerbToEncounter(
const dest = world.rooms[retreat.retreatTo]
const newEncState = { ...next.encounterState }
delete newEncState[encId]
next = { ...next, location: retreat.retreatTo, encounterState: newEncState, resolveLevel: 'shaken' }
next = { ...next, location: retreat.retreatTo, encounterState: newEncState, resolveLevel: afterRetreatResolve(world) }
const lines: TranscriptLine[] = [
{ kind: 'narration', text: transition.narration },
{ kind: 'narration', text: retreat.narration },
+74 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'
import { parse } from './parser'
import type { ParserContext } from './parser'
import type { ParserContext, ParserVocabulary } from './parser'
const emptyCtx: ParserContext = {
knownItems: [],
@@ -16,6 +16,10 @@ describe('parser — verb-only commands', () => {
expect(parse('look', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'look' })
})
it('recognizes bare "listen"', () => {
expect(parse('listen', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'listen' })
})
it('recognizes bare "inventory" and short forms', () => {
expect(parse('inventory', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'inventory' })
expect(parse('inv', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'inventory' })
@@ -32,6 +36,18 @@ describe('parser — verb-only commands', () => {
})
})
describe('parser — confirmations', () => {
it('recognizes yes and no confirmation replies', () => {
expect(parse('yes', emptyCtx)).toEqual({ kind: 'confirmation', confirmed: true })
expect(parse('y', emptyCtx)).toEqual({ kind: 'confirmation', confirmed: true })
expect(parse('no', emptyCtx)).toEqual({ kind: 'confirmation', confirmed: false })
})
it('keeps n as the north direction shortcut', () => {
expect(parse('n', emptyCtx)).toEqual({ kind: 'go', direction: 'n' })
})
})
describe('parser — direction shortcuts', () => {
it('maps single-letter directions', () => {
expect(parse('n', emptyCtx)).toEqual({ kind: 'go', direction: 'n' })
@@ -76,6 +92,45 @@ describe('parser — unknown input', () => {
})
describe('parser — verb + target', () => {
it('uses vocabulary supplied by world markdown', () => {
const vocabulary: ParserVocabulary = {
directions: {
n: ['n', 'northward'],
s: ['s'],
e: ['e'],
w: ['w'],
u: ['u'],
d: ['d'],
},
prepositions: ['beside'],
stopWords: ['the'],
noTargetVerbs: ['look'],
metaVerbs: ['restart'],
verbs: {
go: ['go'],
look: ['look', 'observe'],
take: ['take hold of'],
},
}
const ctx: ParserContext = {
knownItems: ['lamp'],
knownEncounters: [],
visibleNouns: [{ id: 'lamp', aliases: ['lamp'] }],
inventoryItemIds: [],
lastNoun: null,
awaitingDisambiguation: null,
vocabulary,
}
expect(parse('observe', ctx)).toEqual({ kind: 'verb-only', verb: 'look' })
expect(parse('northward', ctx)).toEqual({ kind: 'go', direction: 'n' })
expect(parse('go northward', ctx)).toEqual({ kind: 'go', direction: 'n' })
expect(parse('take hold of the lamp', ctx)).toEqual({
kind: 'verb-target',
verb: 'take',
target: { canonical: 'lamp', raw: 'lamp' },
})
})
it('recognizes slice-two encounter verbs', () => {
const ctx: ParserContext = {
knownItems: [],
@@ -100,6 +155,24 @@ describe('parser — verb + target', () => {
})
})
it('recognizes pour commands with an indirect target', () => {
const ctx: ParserContext = {
knownItems: ['silver-vial'],
knownEncounters: ['basilisk'],
visibleNouns: [{ id: 'basilisk', aliases: ['basilisk'] }],
inventoryItemIds: ['silver-vial'],
lastNoun: null,
awaitingDisambiguation: null,
}
expect(parse('pour silver-vial on basilisk', ctx)).toEqual({
kind: 'verb-target-prep',
verb: 'pour',
target: { canonical: 'silver-vial', raw: 'silver-vial' },
preposition: 'on',
indirect: { canonical: 'basilisk', raw: 'basilisk' },
})
})
it('resolves a single visible noun', () => {
const ctx: ParserContext = {
knownItems: ['torch'],
+138 -68
View File
@@ -1,5 +1,14 @@
import type { ParsedCommand, NounRef, Verb, MetaVerb, Direction, PendingDisambiguation } from './types'
export interface ParserVocabulary {
directions: Record<Direction, string[]>
prepositions: string[]
stopWords: string[]
noTargetVerbs: Verb[]
metaVerbs: MetaVerb[]
verbs: Partial<Record<Verb, string[]>>
}
export interface ParserContext {
/** All item ids that exist in the world (for noun matching). */
knownItems: string[]
@@ -11,61 +20,115 @@ export interface ParserContext {
inventoryItemIds: string[]
lastNoun: NounRef | null
awaitingDisambiguation: PendingDisambiguation | null
vocabulary?: ParserVocabulary
}
/** Verb synonym table: each entry maps an alias to the canonical Verb. */
const VERB_SYNONYMS: Record<string, Verb> = {
// movement
go: 'go', walk: 'go', move: 'go',
// perception
look: 'look', l: 'look',
examine: 'examine', x: 'examine', inspect: 'examine',
// inventory
inventory: 'inventory', inv: 'inventory', i: 'inventory',
// manipulation
take: 'take', get: 'take', grab: 'take', 'pick up': 'take',
drop: 'drop', put: 'drop', leave: 'drop',
use: 'use', combine: 'use',
open: 'open', close: 'close',
read: 'read', light: 'light', extinguish: 'extinguish', douse: 'extinguish',
attack: 'attack', kill: 'attack', fight: 'attack', strike: 'attack',
hold: 'hold', show: 'hold',
push: 'push', press: 'push',
pull: 'pull',
cut: 'cut', trim: 'cut',
play: 'play',
uncover: 'open',
wait: 'wait', z: 'wait',
export const SUPPORTED_VERBS: Verb[] = [
'go',
'look',
'examine',
'take',
'drop',
'use',
'open',
'close',
'read',
'light',
'extinguish',
'attack',
'inventory',
'wait',
'hold',
'push',
'pull',
'cut',
'play',
'listen',
'pour',
'drink',
]
export const SUPPORTED_META_VERBS: MetaVerb[] = ['restart', 'undo', 'hint', 'save', 'quit', 'theme']
export const DEFAULT_PARSER_VOCABULARY: ParserVocabulary = {
directions: {
n: ['n', 'north'],
s: ['s', 'south'],
e: ['e', 'east'],
w: ['w', 'west'],
u: ['u', 'up'],
d: ['d', 'down'],
},
prepositions: ['with', 'on', 'in', 'to'],
stopWords: ['at', 'the', 'a', 'an'],
noTargetVerbs: ['look', 'inventory', 'wait', 'listen'],
metaVerbs: ['restart', 'undo', 'hint', 'save', 'quit', 'theme'],
verbs: {
go: ['go', 'walk', 'move'],
look: ['look', 'l'],
examine: ['examine', 'x', 'inspect'],
inventory: ['inventory', 'inv', 'i'],
take: ['take', 'get', 'grab', 'pick up'],
drop: ['drop', 'put', 'leave'],
use: ['use', 'combine'],
open: ['open', 'uncover'],
close: ['close'],
drink: ['drink', 'sip'],
read: ['read'],
light: ['light'],
extinguish: ['extinguish', 'douse'],
attack: ['attack', 'kill', 'fight', 'strike'],
hold: ['hold', 'show'],
push: ['push', 'press'],
pull: ['pull'],
cut: ['cut', 'trim'],
play: ['play'],
listen: ['listen'],
pour: ['pour'],
wait: ['wait', 'z'],
},
}
const DIRECTION_WORDS: Record<string, Direction> = {
n: 'n', north: 'n',
s: 's', south: 's',
e: 'e', east: 'e',
w: 'w', west: 'w',
u: 'u', up: 'u',
d: 'd', down: 'd',
interface CompiledVocabulary {
directionWords: Record<string, Direction>
metaVerbs: Record<string, MetaVerb>
verbSynonyms: Record<string, Verb>
multiWordVerbs: string[]
noTargetVerbs: Set<string>
stopWords: Set<string>
prepositions: Set<string>
}
const META_VERBS: Record<string, MetaVerb> = {
restart: 'restart',
undo: 'undo',
hint: 'hint',
save: 'save',
quit: 'quit',
theme: 'theme',
function normalizeAlias(value: string): string {
return value.trim().toLowerCase().replace(/\s+/g, ' ')
}
/** Verbs that legally take no target. */
const VERB_ONLY_VERBS = new Set<string>(['look', 'inventory', 'wait'])
function compileVocabulary(vocabulary: ParserVocabulary): CompiledVocabulary {
const directionWords: Record<string, Direction> = {}
for (const [direction, aliases] of Object.entries(vocabulary.directions) as [Direction, string[]][]) {
for (const alias of aliases) directionWords[normalizeAlias(alias)] = direction
}
/** Two-word verb prefixes (e.g. "pick up X"). */
const TWO_WORD_VERBS = ['pick up']
const metaVerbs: Record<string, MetaVerb> = {}
for (const verb of vocabulary.metaVerbs) metaVerbs[normalizeAlias(verb)] = verb
/** Leading stop-words stripped from the noun phrase before matching. */
const STOP_WORDS = new Set(['at', 'the', 'a', 'an'])
const verbSynonyms: Record<string, Verb> = {}
for (const [verb, aliases] of Object.entries(vocabulary.verbs) as [Verb, string[]][]) {
for (const alias of aliases) verbSynonyms[normalizeAlias(alias)] = verb
}
const PREPOSITIONS = new Set(['with', 'on', 'in', 'to'])
return {
directionWords,
metaVerbs,
verbSynonyms,
multiWordVerbs: Object.keys(verbSynonyms)
.filter((alias) => alias.includes(' '))
.sort((a, b) => b.split(' ').length - a.split(' ').length),
noTargetVerbs: new Set(vocabulary.noTargetVerbs),
stopWords: new Set(vocabulary.stopWords.map(normalizeAlias)),
prepositions: new Set(vocabulary.prepositions.map(normalizeAlias)),
}
}
function resolveNoun(rawTokens: string[], ctx: ParserContext): { id: string; alias: string } | null {
const phrase = rawTokens.join(' ')
@@ -87,39 +150,41 @@ function tokenize(input: string): string[] {
return input.trim().toLowerCase().split(/\s+/).filter(Boolean)
}
function matchTwoWordVerb(tokens: string[]): { verb: Verb; rest: string[] } | null {
if (tokens.length < 2) return null
const head = tokens.slice(0, 2).join(' ')
for (const phrase of TWO_WORD_VERBS) {
if (phrase === head) {
const verb = VERB_SYNONYMS[phrase]
if (verb) return { verb, rest: tokens.slice(2) }
function matchMultiWordVerb(tokens: string[], vocabulary: CompiledVocabulary): { verb: Verb; rest: string[] } | null {
for (const phrase of vocabulary.multiWordVerbs) {
const phraseTokens = phrase.split(' ')
if (tokens.length >= phraseTokens.length && tokens.slice(0, phraseTokens.length).join(' ') === phrase) {
const verb = vocabulary.verbSynonyms[phrase]
if (verb) return { verb, rest: tokens.slice(phraseTokens.length) }
}
}
return null
}
export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
const vocabulary = compileVocabulary(ctx.vocabulary ?? DEFAULT_PARSER_VOCABULARY)
const trimmed = rawInput.trim()
if (!trimmed) return { kind: 'unknown', raw: '', reason: 'malformed' }
const tokens = tokenize(trimmed)
const head = tokens[0]!
if (tokens.length === 1 && ['yes', 'y'].includes(head)) {
return { kind: 'confirmation', confirmed: true }
}
if (tokens.length === 1 && head === 'no') {
return { kind: 'confirmation', confirmed: false }
}
// Meta-commands take precedence (single-word).
if (META_VERBS[head] && tokens.length === 1) {
return { kind: 'meta', verb: META_VERBS[head]! }
if (vocabulary.metaVerbs[head] && tokens.length === 1) {
return { kind: 'meta', verb: vocabulary.metaVerbs[head]! }
}
// Direction shortcuts: "n", "north", "go n", "go north".
if (DIRECTION_WORDS[head] && tokens.length === 1) {
return { kind: 'go', direction: DIRECTION_WORDS[head]! }
if (vocabulary.directionWords[head] && tokens.length === 1) {
return { kind: 'go', direction: vocabulary.directionWords[head]! }
}
if (head === 'go' && tokens.length === 2) {
const dir = DIRECTION_WORDS[tokens[1]!]
if (dir) return { kind: 'go', direction: dir }
}
// Disambiguation reply: a single-word answer matching one of the candidates.
// Must be checked before verb resolution so "brass" / "iron" etc. are caught.
if (ctx.awaitingDisambiguation && tokens.length === 1) {
@@ -134,15 +199,15 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
}
}
// Two-word verb (e.g. "pick up X").
const twoWord = matchTwoWordVerb(tokens)
// Multi-word verb aliases (e.g. "pick up X").
const twoWord = matchMultiWordVerb(tokens, vocabulary)
let verb: Verb | undefined
let rest: string[]
if (twoWord) {
verb = twoWord.verb
rest = twoWord.rest
} else {
verb = VERB_SYNONYMS[head]
verb = vocabulary.verbSynonyms[head]
rest = tokens.slice(1)
}
@@ -150,26 +215,31 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
return { kind: 'unknown', raw: trimmed, reason: 'unknown-verb' }
}
if (verb === 'go' && rest.length === 1) {
const dir = vocabulary.directionWords[rest[0]!]
if (dir) return { kind: 'go', direction: dir }
}
// Strip leading stop-words from the noun phrase (e.g. "at", "the", "a", "an").
while (rest.length > 0 && STOP_WORDS.has(rest[0]!)) {
while (rest.length > 0 && vocabulary.stopWords.has(rest[0]!)) {
rest = rest.slice(1)
}
if (rest.length === 0) {
if (VERB_ONLY_VERBS.has(verb)) {
return { kind: 'verb-only', verb: verb as 'look' | 'inventory' | 'wait' }
if (vocabulary.noTargetVerbs.has(verb)) {
return { kind: 'verb-only', verb: verb as 'look' | 'inventory' | 'wait' | 'listen' }
}
return { kind: 'unknown', raw: trimmed, reason: 'malformed' }
}
// Detect a preposition splitting target | indirect.
const prepIdx = rest.findIndex((tok) => PREPOSITIONS.has(tok))
const prepIdx = rest.findIndex((tok) => vocabulary.prepositions.has(tok))
if (prepIdx > 0 && prepIdx < rest.length - 1) {
const targetTokens = rest.slice(0, prepIdx)
const prep = rest[prepIdx]!
let indirectTokens = rest.slice(prepIdx + 1)
// Strip stop-words at the head of the indirect phrase too ("on the table").
while (indirectTokens.length > 0 && STOP_WORDS.has(indirectTokens[0]!)) {
while (indirectTokens.length > 0 && vocabulary.stopWords.has(indirectTokens[0]!)) {
indirectTokens = indirectTokens.slice(1)
}
if (indirectTokens.length > 0) {
+209 -1
View File
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'
import { parse } from './parser'
import type { ParserContext } from './parser'
import { dispatch, initialStateFor } from './dispatcher'
import { dispatch, getItemsInRoom, initialStateFor } from './dispatcher'
import { world } from '../world'
import type { GameState } from './types'
@@ -30,6 +30,7 @@ function ctxFor(state: GameState): ParserContext {
inventoryItemIds: state.inventory.map((i) => i.id),
lastNoun: state.lastNoun,
awaitingDisambiguation: state.pendingDisambiguation,
vocabulary: world.parser,
}
}
@@ -51,6 +52,7 @@ describe('playthrough — sample world', () => {
'take lamp',
'e', // hallway → cellar-stair (triggers rat encounter)
'attack rat',
'yes',
])
expect(state.flags['ratGone']).toBe(true)
expect(state.location).toBe('cellar-stair')
@@ -128,4 +130,210 @@ describe('playthrough — sample world', () => {
expect(state.location).toBe('attic')
expect(state.inventory.map((i) => i.id)).toContain('toy-dog')
})
it('plays through the garden and grounds slice', () => {
const state = play([
'n', // gate → foyer
'n', // foyer → hallway
'u', // hallway → parlor
'u', // parlor → upper stair
'wait',
'u', // upper stair → bedroom
'e', // bedroom → nursery
'take dog',
'w',
'd', // bedroom → upper stair
'd', // upper stair → parlor
'd', // parlor → hallway
'n', // hallway → dining-room
'close curtains',
'e', // dining-room → kitchen
'e', // kitchen → back-door
'e', // back-door → garden
'wait',
'n', // garden → well
'd', // well → well-shaft
'hold dog',
])
expect(state.flags['garden-procession.resolved']).toBe(true)
expect(state.flags['child-beneath-well.resolved']).toBe(true)
expect(state.flags['gardenQuiet']).toBe(true)
expect(state.flags['childPassedWell']).toBe(true)
expect(state.location).toBe('well-shaft')
})
it('plays through the lower-passages slice', () => {
const state = play([
'n', // gate → foyer
'n', // foyer → hallway
'n', // hallway → dining-room
'close curtains',
'n', // dining-room → conservatory
'take shears',
'cut vines with shears',
's', // conservatory → dining-room
'w', // dining-room → hallway
'd', // hallway → music-room
'play note',
'n', // music-room → servants-passage
'wait',
'e', // servants-passage → laundry
'wait',
'take damp sheet',
'w', // laundry → servants-passage
's', // servants-passage → music-room
'u', // music-room → hallway
'n', // hallway → dining-room
'e', // dining-room → kitchen
'e', // kitchen → back-door
'e', // back-door → garden
'wait',
'n', // garden → well
'd', // well → well-shaft
'wait',
'd', // well-shaft → tunnel
'n', // tunnel → ossuary
'take ring',
'leave ring',
'e', // ossuary → flooded-passage
'use water with sheet',
'take boat',
'n', // flooded-passage → root-chamber
'listen',
'e', // root-chamber → burial-gallery
'examine portraits',
'take register',
'read register',
'e', // burial-gallery → antechamber
'e', // antechamber → vault
])
expect(state.flags['bone-keeper.resolved']).toBe(true)
expect(state.flags['reflection.resolved']).toBe(true)
expect(state.flags['root-movement.resolved']).toBe(true)
expect(state.flags['portrait-woman.resolved']).toBe(true)
expect(state.flags['burialRingPlaced']).toBe(true)
expect(state.flags['reflectionObscured']).toBe(true)
expect(state.flags['rootsListenedTo']).toBe(true)
expect(state.flags['familyResemblanceSeen']).toBe(true)
expect(state.location).toBe('vault')
expect(state.inventory.map((i) => i.id)).toEqual(expect.arrayContaining([
'damp-sheet',
'toy-boat',
'family-register',
]))
})
it('plays through the conditional rain-room branch', () => {
const state = play([
'n', // gate → foyer
'n', // foyer → hallway
'u', // hallway → parlor
'u', // parlor → upper stair
'wait',
'd', // upper stair → parlor
's', // parlor → wrong hallway
'wait',
'n', // wrong hallway → rain room
'look basin',
])
expect(state.flags['distant-steps.resolved']).toBe(true)
expect(state.flags['rainwater-basin.resolved']).toBe(true)
expect(state.flags['rainRoomEntered']).toBe(true)
expect(state.endedWith).toBe('replacement')
})
it('reaches the expanded true ending through the vault choice', () => {
const state = play([
'n', // gate → foyer
'n', // foyer → hallway
'n', // hallway → dining-room
'close curtains',
'n', // dining-room → conservatory
'take shears',
'cut vines with shears',
's', // conservatory → dining-room
'w', // dining-room → hallway
'd', // hallway → music-room
'play note',
'n', // music-room → servants-passage
'wait',
'e', // servants-passage → laundry
'wait',
'take damp sheet',
'w', // laundry → servants-passage
's', // servants-passage → music-room
'u', // music-room → hallway
'n', // hallway → dining-room
'e', // dining-room → kitchen
'e', // kitchen → back-door
'e', // back-door → garden
'wait',
'n', // garden → well
'd', // well → well-shaft
'wait',
'd', // well-shaft → tunnel
'n', // tunnel → ossuary
'take ring',
'leave ring',
'e', // ossuary → flooded-passage
'use water with sheet',
'n', // flooded-passage → root-chamber
'listen',
'e', // root-chamber → burial-gallery
'examine portraits',
'take register',
'e', // burial-gallery → antechamber
'e', // antechamber → vault
'n', // vault → chapel
'take vial',
'pour vial on basilisk',
's', // chapel → vault
'read register',
])
expect(state.flags['basiliskSpared']).toBe(true)
expect(state.flags['nameSpoken']).toBe(true)
expect(state.endedWith).toBe('true')
})
it('passes out after wandering the drunk rooms too long', () => {
const state = play([
'n', // gate → foyer
'n', // foyer → hallway
'n', // hallway → dining-room
'e', // dining-room → kitchen
'take whiskey',
'drink whiskey',
'e', 'w', 'e', 'w', 'e',
'w', 'e', 'w', 'e', 'w',
'e', 'w', 'e', 'w', 'e',
'w', 'e', 'w', 'e', 'w',
])
expect(state.location).toBe('foyer')
expect(state.flags['drunk']).toBe(false)
expect(state.flags['drunkMoves']).toBe(0)
expect(getItemsInRoom(state, world, 'kitchen')).toContain('whiskey')
})
it('finds the faceless man in the drunk rooms and wakes in the foyer', () => {
const state = play([
'n', // gate → foyer
'n', // foyer → hallway
'n', // hallway → dining-room
'e', // dining-room → kitchen
'take whiskey',
'drink whiskey',
'u', // drunk hall → drunk landing
'listen',
])
expect(state.location).toBe('foyer')
expect(state.flags['facelessManMet']).toBe(true)
expect(state.flags['houseDebtNamed']).toBe(true)
expect(state.flags['drunk']).toBe(false)
})
})
+1
View File
@@ -13,6 +13,7 @@ const baseState = (overrides: Partial<GameState> = {}): GameState => ({
encounterState: {},
lastNoun: null,
pendingDisambiguation: null,
pendingConfirmation: null,
transcript: [],
endedWith: null,
...overrides,
+13 -3
View File
@@ -9,7 +9,7 @@ export type Direction = 'n' | 's' | 'e' | 'w' | 'u' | 'd'
export type Verb =
| 'go' | 'look' | 'examine' | 'take' | 'drop' | 'use' | 'open' | 'close'
| 'read' | 'light' | 'extinguish' | 'attack' | 'inventory' | 'wait'
| 'hold' | 'push' | 'pull' | 'cut' | 'play'
| 'hold' | 'push' | 'pull' | 'cut' | 'play' | 'listen' | 'pour' | 'drink'
export type MetaVerb = 'restart' | 'undo' | 'hint' | 'save' | 'quit' | 'theme'
@@ -21,7 +21,8 @@ export interface NounRef {
}
export type ParsedCommand =
| { kind: 'verb-only'; verb: Verb | 'look' | 'inventory' | 'wait' }
| { kind: 'confirmation'; confirmed: boolean }
| { kind: 'verb-only'; verb: Verb | 'look' | 'inventory' | 'wait' | 'listen' }
| { kind: 'verb-target'; verb: Verb; target: NounRef }
| { kind: 'verb-target-prep'; verb: Verb; target: NounRef; preposition: string; indirect: NounRef }
| { kind: 'ambiguous'; verb: Verb; rawNoun: string; candidates: string[] }
@@ -39,6 +40,7 @@ export interface ItemInstance {
}
export type EncounterPhase = string // phase names are encounter-specific
export type EndingId = string
export interface TranscriptLine {
kind: 'narration' | 'player' | 'system' | 'ending'
@@ -51,8 +53,14 @@ export interface PendingDisambiguation {
prompt: string
}
export interface PendingConfirmation {
command: ParsedCommand
prompt: string
}
export interface GameState {
schemaVersion: number
transcriptCap?: number
location: RoomId
inventory: ItemInstance[]
/** Per-room state: visited, items dropped, descriptive flags. */
@@ -66,10 +74,12 @@ export interface GameState {
lastNoun: NounRef | null
/** Pending multi-word disambiguation, set when the parser cannot decide. */
pendingDisambiguation: PendingDisambiguation | null
/** Pending confirmation for dangerous/game-changing commands. */
pendingConfirmation: PendingConfirmation | null
/** Capped at 200 entries; older entries are dropped on append. */
transcript: TranscriptLine[]
/** Set true when the player has reached an ending. UI shows ending screen. */
endedWith: 'true' | 'wrong' | 'bad' | null
endedWith: EndingId | null
}
export interface DispatchResult {
+63 -9
View File
@@ -1,22 +1,33 @@
---
import '../ui/crt.css'
import { world } from '../world'
const buildNumber = process.env.CI_PIPELINE_NUMBER ?? 'local'
const ui = world.ui
const footerLinks = ui?.footer.links ?? []
const firstFooterLink = footerLinks[0]
const remainingFooterLinks = footerLinks.slice(1)
const bugReport = ui?.bugReport
const bugpinConfig = bugReport?.enabled ? bugReport.bugpin : undefined
const bugsinkDsn =
bugReport?.enabled && bugReport.bugsink?.enabled ? bugReport.bugsink.dsn : ''
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Halfstreet Ethan J Lewis</title>
<meta name="description" content="A gothic mystery." />
<meta name="robots" content="noindex" />
<title>{ui?.pageTitle ?? `${world.game?.title ?? 'Halfstreet'} - Ethan J Lewis`}</title>
<meta name="description" content={ui?.description ?? world.game?.description ?? 'A gothic mystery.'} />
<meta name="robots" content={ui?.robots ?? 'noindex'} />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon-96x96.png" type="image/png" sizes="96x96" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#1a0d00" />
<meta name="theme-color" content={ui?.themeColor ?? '#1a0d00'} />
</head>
<body>
<body data-bugsink-dsn={bugsinkDsn}>
<div class="mystery-root" data-mystery-root>
<div class="mystery-bezel">
<div class="mystery-options" data-mystery-options>
@@ -50,12 +61,21 @@ import '../ui/crt.css'
</div>
<div class="mystery-options-group" aria-label="Game">
<div class="mystery-options-label">Game</div>
{ui?.features.chips !== false && (
<>
<button type="button" data-chips-choice="on" aria-pressed="true">Chips On</button>
<button type="button" data-chips-choice="off" aria-pressed="false">Chips Off</button>
</>
)}
<button type="button" data-restart-choice>Restart</button>
</div>
</div>
</div>
<div class="mystery-transcript" data-mystery-transcript aria-live="polite" aria-atomic="false"></div>
<div class="mystery-chips" data-mystery-chips></div>
<div class="mystery-controls">
{ui?.features.chips !== false && <div class="mystery-chips" data-mystery-chips></div>}
{ui?.features.lightMeter !== false && <div class="mystery-light-meter" data-mystery-light-meter aria-hidden="true"></div>}
</div>
<div class="mystery-input-row">
<input
class="mystery-input"
@@ -71,11 +91,39 @@ import '../ui/crt.css'
</div>
</div>
<footer class="mystery-footer">
© 2026 <a href="https://ethanjlewis.com">Ethan J Lewis</a>
{ui?.footer.copyrightHref ? <a href={ui.footer.copyrightHref}>{ui.footer.copyright}</a> : <span>{ui?.footer.copyright ?? '© 2026 Ethan J Lewis'}</span>}
{firstFooterLink && (
<>
<span aria-hidden="true">|</span>
<a href="https://half.st/ejlewis/halfstreet/src/branch/main/LICENSE">GNU 3.0</a>
<a href={firstFooterLink.href}>{firstFooterLink.label}</a>
</>
)}
{ui?.footer.showBuild !== false && (
<>
<span aria-hidden="true">|</span>
<a href="https://half.st/ejlewis/halfstreet">Source Code</a>
<span>{ui?.footer.buildLabel ?? 'Build #'}{buildNumber}</span>
</>
)}
{remainingFooterLinks.map((link) => (
<>
<span aria-hidden="true">|</span>
<a href={link.href}>{link.label}</a>
</>
))}
{bugpinConfig && (
<>
<span aria-hidden="true">|</span>
<button
type="button"
class="mystery-footer-bug-report"
data-bug-report-trigger
data-bugpin-server={bugpinConfig.serverUrl}
data-bugpin-key={bugpinConfig.apiKey}
aria-describedby="bug-report-status"
>{bugReport?.label ?? 'Report a Bug'}</button>
<span id="bug-report-status" class="mystery-footer-status" aria-live="polite"></span>
</>
)}
</footer>
</div>
<script>
@@ -87,15 +135,21 @@ import '../ui/crt.css'
const storedCursor = (() => {
try { return localStorage.getItem('halfstreet:cursor:v1') } catch { return null }
})()
const storedChips = (() => {
try { return localStorage.getItem('halfstreet:chips:v1') } catch { return null }
})()
document.documentElement.setAttribute('data-mystery-theme', stored === 'ansi' ? 'ansi' : 'amber')
document.documentElement.setAttribute(
'data-mystery-cursor',
storedCursor === 'block' || storedCursor === 'underscore' ? storedCursor : 'bar',
)
document.documentElement.setAttribute('data-mystery-chips-state', storedChips === 'off' ? 'off' : 'on')
</script>
<script>
import '../ui/terminal.ts'
import '../ui/theme.ts'
import '../ui/error-tracking.ts'
import '../ui/bug-report.ts'
</script>
</body>
</html>
+126
View File
@@ -0,0 +1,126 @@
interface BugPinAPI {
init: (config: { apiKey: string, serverUrl: string }) => Promise<void> | void
open: () => void
close: () => void
}
declare global {
interface Window {
BugPin?: BugPinAPI
}
}
const launcherSelector = [
'.fixed.bottom-5.right-5',
'.fixed.bottom-5.left-5',
'.fixed.top-5.right-5',
'.fixed.top-5.left-5',
].join(',')
const button = document.querySelector<HTMLButtonElement>('[data-bug-report-trigger]')
let loadPromise: Promise<void> | null = null
let initialized = false
let launcherObserver: MutationObserver | null = null
function setStatus(message: string): void {
const targetId = button?.getAttribute('aria-describedby')
const target = targetId ? document.getElementById(targetId) : null
if (target) target.textContent = message
}
function buildWidgetSrc(serverUrl: string): string {
return `${serverUrl.replace(/\/$/, '')}/widget.js`
}
function loadWidget(serverUrl: string): Promise<void> {
if (window.BugPin) return Promise.resolve()
if (loadPromise) return loadPromise
loadPromise = new Promise<void>((resolve, reject) => {
const script = document.createElement('script')
script.src = buildWidgetSrc(serverUrl)
script.async = true
script.addEventListener('load', () => resolve())
script.addEventListener('error', () => {
loadPromise = null
reject(new Error(`Failed to load BugPin widget from ${script.src}`))
})
document.head.appendChild(script)
})
return loadPromise
}
function hideBugPinLauncher(): void {
const host = document.getElementById('bugpin-widget')
const root = host?.shadowRoot
if (!root) return
for (const launcher of root.querySelectorAll<HTMLElement>(launcherSelector)) {
launcher.hidden = true
launcher.style.display = 'none'
launcher.setAttribute('aria-hidden', 'true')
}
}
function watchBugPinLauncher(): void {
const host = document.getElementById('bugpin-widget')
const root = host?.shadowRoot
if (!root || launcherObserver) return
hideBugPinLauncher()
launcherObserver = new MutationObserver(() => hideBugPinLauncher())
launcherObserver.observe(root, { childList: true, subtree: true })
}
function waitForBugPinEffects(): Promise<void> {
return new Promise((resolve) => {
requestAnimationFrame(() => setTimeout(resolve, 0))
})
}
async function openBugReport(serverUrl: string, apiKey: string): Promise<void> {
await loadWidget(serverUrl)
if (!window.BugPin) {
throw new Error('BugPin widget loaded, but window.BugPin is unavailable')
}
if (!initialized) {
await window.BugPin.init({ apiKey, serverUrl })
initialized = true
watchBugPinLauncher()
await waitForBugPinEffects()
}
hideBugPinLauncher()
window.BugPin.open()
hideBugPinLauncher()
}
if (button) {
const serverUrl = button.dataset.bugpinServer
const apiKey = button.dataset.bugpinKey
if (serverUrl && apiKey) {
button.addEventListener('click', async (event) => {
event.preventDefault()
setStatus('')
button.disabled = true
try {
await openBugReport(serverUrl, apiKey)
} catch (err) {
console.error('[bug-report]', err)
setStatus("Couldn't open bug reporter - try refreshing.")
} finally {
button.disabled = false
}
})
}
}
if (window.BugPin) {
requestAnimationFrame(() => watchBugPinLauncher())
}
export {}
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Chip } from './chips'
const CHIP_CONTAINER = '[data-mystery-chips]'
const CHIP_CONTAINER = '.mystery-chips[data-mystery-chips]'
export function renderChips(chips: Chip[], onSelect: (command: string) => void): void {
const container = document.querySelector<HTMLDivElement>(CHIP_CONTAINER)
+15
View File
@@ -48,4 +48,19 @@ describe('computeChips — sample world', () => {
expect(chips.find((c) => c.command === 'wait')).toBeTruthy()
expect(chips.find((c) => c.command === 'help')).toBeTruthy()
})
it('shows only confirmation chips while a dangerous command is pending', () => {
const s = {
...initialStateFor(world),
pendingConfirmation: {
command: { kind: 'verb-target' as const, verb: 'attack' as const, target: { canonical: 'rat', raw: 'rat' } },
prompt: 'Are you sure?',
},
}
const chips = computeChips(s, world)
expect(chips).toEqual([
{ kind: 'meta', label: 'YES', command: 'yes', disabled: false },
{ kind: 'meta', label: 'NO', command: 'no', disabled: false },
])
})
})
+7
View File
@@ -20,6 +20,13 @@ export function computeChips(state: GameState, world: World): Chip[] {
const room = world.rooms[state.location]
if (!room) return out
if (state.pendingConfirmation) {
return [
{ kind: 'meta', label: 'YES', command: 'yes', disabled: false },
{ kind: 'meta', label: 'NO', command: 'no', disabled: false },
]
}
// Direction chips: enabled if exit exists, dimmed otherwise.
const dirs: Direction[] = ['n', 's', 'e', 'w', 'u', 'd']
for (const d of dirs) {
+272 -4
View File
@@ -89,11 +89,35 @@ body {
text-align: center;
}
.mystery-footer a {
.mystery-footer a,
.mystery-footer-bug-report {
color: var(--m-fg);
text-decoration: none;
}
.mystery-footer-bug-report {
background: none;
border: none;
padding: 0;
margin: 0;
font: inherit;
cursor: pointer;
}
.mystery-footer-bug-report:hover,
.mystery-footer-bug-report:focus-visible {
text-decoration: underline;
}
.mystery-footer-bug-report:disabled {
cursor: progress;
opacity: 0.55;
}
.mystery-footer-status {
color: var(--m-fg);
}
.mystery-footer span {
margin: 0 0.5ch;
}
@@ -115,6 +139,35 @@ body {
z-index: 1;
}
.mystery-bezel::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: 2;
opacity: 0;
}
:root[data-mystery-drunk] .mystery-bezel::after {
background:
linear-gradient(
93deg,
transparent 0 48%,
color-mix(in srgb, var(--m-fg) 12%, transparent) 49% 50%,
transparent 51% 100%
),
repeating-linear-gradient(
to bottom,
transparent 0 9px,
color-mix(in srgb, var(--m-fg) 8%, transparent) 9px 10px,
transparent 10px 23px
);
mix-blend-mode: screen;
opacity: 0.32;
transform: translateX(0.32ch) skewX(-1.4deg);
animation: mystery-drunk-screen 5.8s ease-in-out infinite;
}
.mystery-options {
position: absolute;
top: 8px;
@@ -204,6 +257,7 @@ body {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-anchor: none;
overscroll-behavior: contain;
scrollbar-color: var(--m-dim) var(--m-bg);
scrollbar-width: thin;
@@ -229,12 +283,26 @@ body {
border: 0;
}
.mystery-transcript > div {
max-width: 80ch;
}
.mystery-transcript .system {
color: var(--m-accent-1);
font-weight: bold;
margin-top: 0.6em;
}
.mystery-transcript .room-title {
scroll-margin-top: 0;
}
.mystery-transcript .room-scroll-spacer {
max-width: none;
margin: 0;
pointer-events: none;
}
.mystery-transcript .ascii-art {
max-width: 100%;
overflow-x: auto;
@@ -247,6 +315,7 @@ body {
}
.mystery-transcript .help {
position: relative;
color: var(--m-fg);
font-weight: normal;
border: 1px var(--m-divider-style) var(--m-dim);
@@ -254,6 +323,34 @@ body {
margin: 0.75em 0;
}
.mystery-transcript .help .mystery-help-body {
padding-right: 3ch;
}
.mystery-transcript .help .mystery-help-close {
position: absolute;
top: 6px;
right: 6px;
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
color: var(--m-fg);
border: 1px solid var(--m-dim);
border-radius: 2px;
font: inherit;
line-height: 1;
cursor: pointer;
}
.mystery-transcript .help .mystery-help-close:hover,
.mystery-transcript .help .mystery-help-close:focus-visible {
border-color: var(--m-fg);
outline: none;
}
.mystery-transcript .player {
color: var(--m-accent-2);
}
@@ -264,6 +361,30 @@ body {
margin: 0.25em 0;
}
:root[data-mystery-drunk] .mystery-transcript {
filter: blur(0.35px) contrast(1.05);
text-shadow:
0.055em 0.02em currentColor,
-0.075em -0.015em color-mix(in srgb, var(--m-fg) 50%, transparent),
0.14em 0 color-mix(in srgb, var(--m-accent-2) 26%, transparent),
0 0 4px currentColor;
animation: mystery-drunk-copy 6.4s ease-in-out infinite;
}
:root[data-mystery-drunk] .mystery-transcript .player,
:root[data-mystery-drunk] .mystery-transcript .system,
:root[data-mystery-drunk] .mystery-transcript .narration {
transform: translateX(0.12ch) skewX(-1.2deg);
}
:root[data-mystery-drunk] .mystery-transcript > div:nth-child(2n) {
transform: translateX(-0.18ch) skewX(0.8deg);
}
:root[data-mystery-drunk] .mystery-transcript > div:nth-child(3n) {
transform: translateX(0.28ch) skewX(-1.4deg);
}
.mystery-input-row {
display: flex;
align-items: center;
@@ -326,19 +447,142 @@ body {
animation: mystery-cursor-blink 1.05s steps(1, end) infinite;
}
:root[data-mystery-drunk] .mystery-input-row {
filter: blur(0.2px);
transform: translateX(0.2ch);
text-shadow:
0.12em 0 currentColor,
-0.16em 0 color-mix(in srgb, var(--m-accent-2) 42%, transparent),
0 0 5px currentColor;
}
:root[data-mystery-drunk] .mystery-input-row::before {
content: '>>';
letter-spacing: -0.4ch;
}
:root[data-mystery-drunk] .mystery-input-display::after {
text-shadow:
0.22em 0 currentColor,
-0.18em 0 color-mix(in srgb, var(--m-fg) 50%, transparent);
}
:root[data-mystery-input-focused] .mystery-input-display::after {
animation: mystery-cursor-blink 1.05s steps(1, end) infinite;
}
@keyframes mystery-cursor-blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
@keyframes mystery-drunk-copy {
0%, 18%, 100% {
filter: blur(0.2px) contrast(1.03);
transform: translate(0, 0) skewX(0);
}
36% {
filter: blur(0.8px) contrast(1.08);
transform: translate(0.12ch, -0.04em) skewX(-0.25deg);
}
58% {
filter: blur(0.3px) contrast(1.04);
transform: translate(-0.06ch, 0.02em) skewX(0.08deg);
}
76% {
filter: blur(0.65px) contrast(1.07);
transform: translate(-0.18ch, 0.05em) skewX(0.32deg);
}
}
@keyframes mystery-drunk-screen {
0%, 100% { transform: translateX(0.32ch) skewX(-1.4deg); }
45% { transform: translateX(-0.22ch) skewX(1.1deg); }
72% { transform: translateX(0.5ch) skewX(-0.6deg); }
}
.mystery-controls {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
position: relative;
z-index: 2;
margin-top: 8px;
}
.mystery-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px 0 4px;
position: relative;
z-index: 2;
margin-top: 8px;
min-width: 0;
flex: 1 1 auto;
}
:root[data-mystery-chips-state='off'] .mystery-chips {
display: none;
}
.mystery-light-meter {
flex: 0 0 auto;
width: 98px;
min-height: 58px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 4px;
color: var(--m-fg);
opacity: 0.8;
}
.mystery-light-meter[data-lit='true'] {
color: var(--m-fg);
opacity: 1;
}
.mystery-light-icon {
width: 30px;
height: 30px;
display: block;
color: var(--m-dim);
opacity: 0.85;
}
.mystery-light-icon svg {
width: 100%;
height: 100%;
display: block;
fill: currentColor;
}
.mystery-light-leds {
width: 100%;
display: flex;
gap: 4px;
justify-content: center;
}
.mystery-light-segment {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--m-dim);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15) inset;
opacity: 0.45;
}
.mystery-light-segment[data-segment-state='lit'] {
background: currentColor;
opacity: 1;
box-shadow: 0 0 7px currentColor;
}
.mystery-light-meter[data-lit='true'] .mystery-light-icon {
color: var(--m-fg);
opacity: 1;
filter: drop-shadow(0 0 2px currentColor) drop-shadow(0 0 6px currentColor);
}
.mystery-chip {
@@ -357,6 +601,17 @@ body {
cursor: not-allowed;
}
:root[data-mystery-drunk] .mystery-chip {
transform: rotate(-0.6deg);
text-shadow:
0.1em 0 currentColor,
-0.1em 0 color-mix(in srgb, var(--m-fg) 35%, transparent);
}
:root[data-mystery-drunk] .mystery-chip:nth-child(2n) {
transform: translateY(1px) rotate(0.8deg);
}
.mystery-transcript .ending {
margin-top: 2em;
margin-bottom: 1em;
@@ -383,6 +638,19 @@ body {
.mystery-chip {
padding: 4px 7px;
}
.mystery-controls {
gap: 10px;
}
.mystery-light-meter {
width: 90px;
}
.mystery-light-icon {
width: 27px;
height: 27px;
}
}
[data-mystery-input].ended {
+12
View File
@@ -0,0 +1,12 @@
import * as Sentry from '@sentry/browser'
const dsn = document.body?.dataset.bugsinkDsn ?? ''
if (dsn) {
Sentry.init({
dsn,
tracesSampleRate: 0,
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 0,
})
}
+253 -44
View File
@@ -1,35 +1,31 @@
import { parse } from '../engine/parser'
import type { ParserContext } from '../engine/parser'
import { dispatch, initialStateFor, getItemsInRoom } from '../engine/dispatcher'
import { dispatch, initialStateFor, getItemsInRoom, getLightStatus } from '../engine/dispatcher'
import { saveState, loadState, clearSave } from '../engine/save'
import { world } from '../world'
import { DEFAULT_WORLD_MESSAGES, type WorldMessageKey } from '../world/types'
import type { GameState, TranscriptLine } from '../engine/types'
import { TRANSCRIPT_CAP } from '../engine/types'
import { computeChips } from './chips'
import { renderChips } from './chip-render'
import LIGHT_ICON_SVG from '../assets/noun-candle-6409709.svg?raw'
const transcriptEl = document.querySelector<HTMLDivElement>('[data-mystery-transcript]')
const inputEl = document.querySelector<HTMLInputElement>('[data-mystery-input]')
const inputDisplayEl = document.querySelector<HTMLSpanElement>('[data-mystery-input-display]')
const lightMeterEl = document.querySelector<HTMLDivElement>('[data-mystery-light-meter]')
const HELP_TEXT = `You arrive at the address, but you do not remember what has happened. The road behind you is gone...
const HELP_TEXT = world.game?.helpText ?? `This is a text adventure. Type short commands to act.`
const UI_FEATURES = world.ui?.features ?? {
chips: true,
lightMeter: true,
typedEffect: true,
roomScroll: true,
}
This is a text adventure. Type short commands to act in the house.
Common commands:
look describe the room again
n, s, e, w, u, d move by direction
take lamp pick something up
examine letter inspect something nearby or held
read letter read a readable object
inventory see what you carry
light lamp with matches use one thing with another
wait let the room continue
undo step back once
restart begin again
theme change the terminal colors
Most commands are verb first, then the thing: examine gate, take lamp, use key on door.`
function message(key: WorldMessageKey): string {
return world.messages?.[key] ?? DEFAULT_WORLD_MESSAGES[key]
}
if (!transcriptEl || !inputEl || !inputDisplayEl) {
console.error('[halfstreet] terminal mount points missing')
@@ -42,6 +38,51 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
let historyIndex: number | null = null
let historyDraft = ''
let idleHintTimer: number | null = null
let renderQueue: Promise<void> = Promise.resolve()
let renderGeneration = 0
let roomScrollSpacer: HTMLDivElement | null = null
const TYPE_INTERVAL_MS = 8
const TYPE_CHARS_PER_TICK = 3
const ROOM_SCROLL_MS = 180
const syncLightMeter = (): void => {
if (!UI_FEATURES.lightMeter) return
if (!lightMeterEl) return
const status = getLightStatus(state, world)
lightMeterEl.hidden = !status
if (!status) {
lightMeterEl.innerHTML = ''
lightMeterEl.dataset['lit'] = 'false'
lightMeterEl.dataset['turnsLeft'] = '0'
return
}
lightMeterEl.innerHTML = ''
lightMeterEl.dataset['lit'] = 'true'
lightMeterEl.dataset['turnsLeft'] = String(status.turnsLeft)
const icon = document.createElement('div')
icon.className = 'mystery-light-icon'
icon.setAttribute('aria-hidden', 'true')
icon.innerHTML = LIGHT_ICON_SVG
lightMeterEl.appendChild(icon)
const leds = document.createElement('div')
leds.className = 'mystery-light-leds'
const turnsLeft = Math.max(0, Math.min(status.maxTurns, status.turnsLeft))
for (let i = 0; i < status.maxTurns; i++) {
const segment = document.createElement('span')
segment.className = 'mystery-light-segment'
const lit = i < turnsLeft
segment.dataset['segmentState'] = lit ? 'lit' : 'dim'
segment.style.backgroundColor = lit ? 'var(--m-fg)' : 'var(--m-dim)'
segment.style.boxShadow = lit ? '0 0 7px var(--m-fg)' : '0 0 0 1px rgba(0, 0, 0, 0.15) inset'
segment.style.opacity = lit ? '1' : '0.45'
leds.appendChild(segment)
}
lightMeterEl.appendChild(leds)
}
if (!restored) {
// Fresh state already includes the opening narration in its transcript.
@@ -52,6 +93,7 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
}
function refreshChips(): void {
if (!UI_FEATURES.chips) return
renderChips(computeChips(state, world), (command) => {
clearIdleHint()
inputEl!.value = command
@@ -73,12 +115,20 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
inputEl!.classList.toggle('ended', state.endedWith !== null)
}
const syncDrunkEffect = (): void => {
document.documentElement.toggleAttribute('data-mystery-drunk', state.flags['drunk'] === true)
}
const syncCommandLine = (): void => {
const visibleText = inputEl.value || inputEl.placeholder
inputDisplayEl.textContent = visibleText
inputDisplayEl.dataset['placeholder'] = inputEl.value ? 'false' : inputEl.placeholder ? 'true' : 'false'
}
const syncInputFocus = (focused: boolean): void => {
document.documentElement.toggleAttribute('data-mystery-input-focused', focused)
}
const buildParserContext = (s: GameState): ParserContext => {
const room = world.rooms[s.location]
const visibleNouns: { id: string; aliases: string[] }[] = []
@@ -106,21 +156,127 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
inventoryItemIds: s.inventory.map((i) => i.id),
lastNoun: s.lastNoun,
awaitingDisambiguation: s.pendingDisambiguation,
vocabulary: world.parser,
}
}
const renderAll = (lines: TranscriptLine[]): void => {
if (!transcriptEl) return
for (const line of lines) {
const el = document.createElement('div')
el.className = line.kind
if (line.kind === 'system' && line.text.includes('|_| |_|')) {
el.classList.add('ascii-art')
const wait = (ms: number): Promise<void> =>
new Promise((resolve) => {
window.setTimeout(resolve, ms)
})
const isAsciiArtLine = (line: TranscriptLine): boolean =>
line.kind === 'system' && line.text.includes('|_| |_|')
const isRoomTitleLine = (line: TranscriptLine): boolean => {
if (line.kind !== 'system') return false
const trimmed = line.text.trim()
return /^\[\s*.+\s*\]$/.test(trimmed) && !trimmed.includes('|')
}
const typeLine = async (el: HTMLDivElement, text: string): Promise<void> => {
el.textContent = ''
for (let i = TYPE_CHARS_PER_TICK; i < text.length; i += TYPE_CHARS_PER_TICK) {
el.textContent = text.slice(0, i)
await wait(TYPE_INTERVAL_MS)
}
el.textContent = text
}
const scrollToTopOf = async (el: HTMLElement): Promise<void> => {
if (!transcriptEl) return
transcriptEl.scrollTo({ top: Math.max(0, el.offsetTop), behavior: 'smooth' })
await wait(ROOM_SCROLL_MS)
}
const updateRoomScrollSpacer = (anchor?: HTMLElement): void => {
if (!transcriptEl) return
if (!roomScrollSpacer) {
roomScrollSpacer = document.createElement('div')
roomScrollSpacer.className = 'room-scroll-spacer'
roomScrollSpacer.setAttribute('aria-hidden', 'true')
}
const spacerHeight = Math.max(0, transcriptEl.clientHeight - (anchor?.offsetHeight ?? 0))
roomScrollSpacer.style.height = `${spacerHeight}px`
if (roomScrollSpacer.parentElement !== transcriptEl) {
transcriptEl.appendChild(roomScrollSpacer)
}
}
const scrollToContentBottom = (): void => {
if (!transcriptEl) return
updateRoomScrollSpacer()
const contentBottom = roomScrollSpacer?.offsetTop ?? transcriptEl.scrollHeight
transcriptEl.scrollTop = Math.max(0, contentBottom - transcriptEl.clientHeight)
}
const appendTranscriptElement = (el: HTMLDivElement): void => {
if (!transcriptEl) return
updateRoomScrollSpacer()
if (roomScrollSpacer?.parentElement === transcriptEl) {
transcriptEl.insertBefore(el, roomScrollSpacer)
return
}
el.textContent = line.text
transcriptEl.appendChild(el)
}
transcriptEl.scrollTop = transcriptEl.scrollHeight
const renderLines = async (
lines: TranscriptLine[],
animate: boolean,
shouldScroll: boolean,
generation: number,
): Promise<void> => {
if (!transcriptEl) return
const roomTitleInBatch = shouldScroll && animate && lines.some(isRoomTitleLine)
let roomTitleEl: HTMLDivElement | null = null
for (const line of lines) {
if (generation !== renderGeneration) return
const el = document.createElement('div')
el.className = line.kind
const asciiArt = isAsciiArtLine(line)
const roomTitle = roomTitleInBatch && isRoomTitleLine(line)
if (asciiArt) {
el.classList.add('ascii-art')
}
if (isRoomTitleLine(line)) {
el.classList.add('room-title')
}
const shouldType = animate && line.kind !== 'player' && !asciiArt && !roomTitle
el.textContent = shouldType ? '' : line.text
appendTranscriptElement(el)
if (roomTitle) {
roomTitleEl = el
updateRoomScrollSpacer(el)
await scrollToTopOf(el)
if (generation !== renderGeneration) return
}
if (shouldType) {
await typeLine(el, line.text)
if (generation !== renderGeneration) return
}
if (shouldScroll && !roomTitleInBatch) {
scrollToContentBottom()
}
}
if (shouldScroll && !roomTitleEl) {
scrollToContentBottom()
}
}
const renderAll = (lines: TranscriptLine[], options: { animate?: boolean; scroll?: boolean } = {}): void => {
const animate = (options.animate ?? true) && UI_FEATURES.typedEffect
const shouldScroll = options.scroll ?? true
const generation = renderGeneration
renderQueue = renderQueue.then(() => renderLines(lines, animate, shouldScroll, generation)).catch((err) => {
console.error('[halfstreet] render error', err)
})
}
const clearIdleHint = (): void => {
@@ -150,42 +306,78 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
clearTransientHelp()
const el = document.createElement('div')
el.className = 'system help'
el.dataset.transientHelp = 'true'
el.textContent = HELP_TEXT
transcriptEl.appendChild(el)
el.dataset['transientHelp'] = 'true'
const close = document.createElement('button')
close.type = 'button'
close.className = 'mystery-help-close'
close.dataset['helpClose'] = 'true'
close.setAttribute('aria-label', 'Close help')
close.textContent = 'x'
close.addEventListener('click', (e) => {
e.stopPropagation()
clearTransientHelp()
return
})
const text = document.createElement('div')
text.className = 'mystery-help-body'
text.textContent = HELP_TEXT
el.append(close, text)
appendTranscriptElement(el)
transientHelpEl = el
transcriptEl.scrollTop = transcriptEl.scrollHeight
scrollToContentBottom()
}
document.addEventListener('pointerdown', (e) => {
if (!transientHelpEl) return
const target = e.target as Node | null
if (target && transientHelpEl.contains(target)) return
clearTransientHelp()
})
const hideHelpOnInput = (): void => {
if (!transientHelpEl) return
window.setTimeout(() => {
if (inputEl.value.trim().length > 0) clearTransientHelp()
})
}
// For UI-originated lines (player input, restart/undo/quit messages, error
// notices). Pushes into state.transcript so they survive reload, then renders.
// Engine-originated lines (from dispatch) are already in state.transcript;
// those use renderAll directly.
const appendLines = (lines: TranscriptLine[]): void => {
const appendLines = (lines: TranscriptLine[], options: { animate?: boolean; scroll?: boolean } = {}): void => {
state = { ...state, transcript: [...state.transcript, ...lines].slice(-TRANSCRIPT_CAP) }
renderAll(lines)
renderAll(lines, options)
}
const restart = (): void => {
const confirmed = confirm('Restart? Your progress will be lost.')
if (!confirmed) {
appendLines([{ kind: 'system', text: '(restart cancelled)' }])
appendLines([{ kind: 'system', text: '(restart cancelled)' }], { scroll: false })
return
}
clearSave()
state = initialStateFor(world)
renderGeneration += 1
renderQueue = Promise.resolve()
transcriptEl.innerHTML = ''
inputEl.value = ''
syncCommandLine()
renderAll(state.transcript)
renderAll(state.transcript, { animate: false })
saveState(state)
refreshChips()
syncLightMeter()
syncEndedUI()
syncDrunkEffect()
}
renderAll(state.transcript)
renderAll(state.transcript, { animate: false })
refreshChips()
syncLightMeter()
syncEndedUI()
syncDrunkEffect()
syncCommandLine()
scheduleIdleHint()
@@ -218,13 +410,13 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
commandHistory = [...commandHistory, raw].slice(-50)
historyIndex = null
historyDraft = ''
appendLines([{ kind: 'player', text: raw }])
appendLines([{ kind: 'player', text: raw }], { scroll: false })
// Once the game has ended, only restart and undo are allowed.
if (state.endedWith !== null) {
const lower = raw.trim().toLowerCase()
if (lower !== 'restart' && lower !== 'undo') {
appendLines([{ kind: 'system', text: 'The story has ended. Type `restart` or `undo`.' }])
appendLines([{ kind: 'system', text: 'The story has ended. Type `restart` or `undo`.' }], { scroll: false })
return
}
}
@@ -243,12 +435,14 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
if (lastState) {
state = lastState
lastState = null
appendLines([{ kind: 'system', text: '(undone)' }])
appendLines([{ kind: 'system', text: '(undone)' }], { scroll: false })
saveState(state)
refreshChips()
syncLightMeter()
syncEndedUI()
syncDrunkEffect()
} else {
appendLines([{ kind: 'system', text: 'There is no further back.' }])
appendLines([{ kind: 'system', text: message('no-undo') }], { scroll: false })
}
return
}
@@ -263,24 +457,33 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
const ctx = buildParserContext(state)
const command = parse(raw, ctx)
lastState = state
const previousLocation = state.location
const result = dispatch(state, command, world)
state = result.state
renderAll(result.appended) // dispatch already pushed these into state.transcript
const shouldScrollToRoom = UI_FEATURES.roomScroll && command.kind === 'go' && state.location !== previousLocation
renderAll(result.appended, { scroll: shouldScrollToRoom }) // dispatch already pushed these into state.transcript
saveState(state)
transcriptEl.scrollTop = transcriptEl.scrollHeight
if (raw.trim().toLowerCase() === 'theme') {
document.dispatchEvent(new CustomEvent('halfstreet-toggle-theme'))
}
refreshChips()
syncLightMeter()
syncEndedUI()
syncDrunkEffect()
} catch (err) {
console.error('[halfstreet] dispatch error', err)
appendLines([{ kind: 'system', text: '[ The terminal hums and resets. ]' }])
appendLines([{ kind: 'system', text: '[ The terminal hums and resets. ]' }], { scroll: false })
}
})
inputEl.addEventListener('input', syncCommandLine)
inputEl.addEventListener('focus', clearIdleHint)
inputEl.addEventListener('focus', () => {
syncInputFocus(true)
clearIdleHint()
})
inputEl.addEventListener('blur', () => {
syncInputFocus(false)
})
inputEl.addEventListener('pointerdown', clearIdleHint)
inputEl.parentElement?.addEventListener('pointerdown', () => {
@@ -301,10 +504,16 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
return
}
if (e.key === 'Escape') {
if (transientHelpEl) {
e.preventDefault()
clearTransientHelp()
return
}
saveState(state)
window.location.href = '/'
}
})
document.addEventListener('halfstreet-restart', restart)
inputEl.addEventListener('input', hideHelpOnInput)
}
+30
View File
@@ -1,8 +1,10 @@
const STORAGE_KEY = 'halfstreet:theme:v1'
const CURSOR_STORAGE_KEY = 'halfstreet:cursor:v1'
const CHIPS_STORAGE_KEY = 'halfstreet:chips:v1'
type Theme = 'amber' | 'ansi'
type Cursor = 'bar' | 'block' | 'underscore'
type Chips = 'on' | 'off'
function getStored(): Theme {
try {
@@ -21,6 +23,14 @@ function getStoredCursor(): Cursor {
}
}
function getStoredChips(): Chips {
try {
return localStorage.getItem(CHIPS_STORAGE_KEY) === 'off' ? 'off' : 'on'
} catch {
return 'on'
}
}
function setTheme(theme: Theme): void {
document.documentElement.setAttribute('data-mystery-theme', theme)
try {
@@ -45,9 +55,22 @@ function setCursor(cursor: Cursor): void {
}
}
function setChips(chips: Chips): void {
document.documentElement.setAttribute('data-mystery-chips-state', chips)
try {
localStorage.setItem(CHIPS_STORAGE_KEY, chips)
} catch {
// ignore
}
for (const btn of document.querySelectorAll<HTMLButtonElement>('[data-chips-choice]')) {
btn.setAttribute('aria-pressed', btn.dataset['chipsChoice'] === chips ? 'true' : 'false')
}
}
const initial = getStored()
setTheme(initial)
setCursor(getStoredCursor())
setChips(getStoredChips())
const optionsRoot = document.querySelector<HTMLElement>('[data-mystery-options]')
const optionsToggle = document.querySelector<HTMLButtonElement>('[data-options-toggle]')
@@ -88,6 +111,13 @@ document.querySelectorAll<HTMLButtonElement>('[data-cursor-choice]').forEach((bt
})
})
document.querySelectorAll<HTMLButtonElement>('[data-chips-choice]').forEach((btn) => {
btn.addEventListener('click', () => {
const next = (btn.dataset['chipsChoice'] as Chips | undefined) ?? 'on'
setChips(next)
})
})
document.querySelector<HTMLButtonElement>('[data-restart-choice]')?.addEventListener('click', () => {
setOptionsOpen(false)
document.dispatchEvent(new CustomEvent('halfstreet-restart'))
+4 -4
View File
@@ -4,7 +4,7 @@
"showTags": false,
"showAttachments": false,
"hideUnresolved": false,
"showOrphans": true,
"showOrphans": false,
"collapse-color-groups": false,
"colorGroups": [
{
@@ -37,9 +37,9 @@
}
],
"collapse-display": false,
"showArrow": false,
"textFadeMultiplier": 0,
"nodeSizeMultiplier": 1.82265625,
"showArrow": true,
"textFadeMultiplier": -3,
"nodeSizeMultiplier": 2.2138427734375,
"lineSizeMultiplier": 5,
"collapse-forces": false,
"centerStrength": 0.518713248970312,
+11
View File
@@ -0,0 +1,11 @@
{
"markdown:add-metadata-property": [],
"editor:toggle-source": [
{
"modifiers": [
"Mod"
],
"key": ";"
}
]
}
+6
View File
@@ -0,0 +1,6 @@
---
short:
readable:
takeable:
names:
---
+9
View File
@@ -17,6 +17,7 @@ views:
- exitW
- exitU
- exitD
- items
- type: cards
name: Items
filters:
@@ -29,3 +30,11 @@ views:
- takeable
- names
- formula.Room
- type: cards
name: Items By Room
filters:
and:
- file.inFolder("items")
order:
- file.name
- formula.Room
+23 -9
View File
@@ -1,18 +1,12 @@
- [x] Need to add help text when user types "help". This should give them some common commands and explain the concepts behind text adventure games. It should also include an exceprt at the beginning from the opening text. "You arrive at the address, but you do not remember what has happened. The road behind you is gone...". The help text should disappear after the user types a new prompt (i.e., it's not persistent).
- [x] Need to add the tiles from mobile to desktop view.
- [x] Enhance tiles with contextual awareness, enabling tiles to appear in rooms when appropriate (e.g. "attack rat").
- [ ] Create a mechanic that asks "Are you sure?" before taking critical actions like attacking or other game-changing mechanics that might affect the final ending.
- [x] Add a tile for USE
- [ ] Add contextual awareness and autocomplete. For example a popup that appears above the USE text or when a user types "use". The user is able to autofill the rest of the thing by using the keyboard to toggle through the inv list and tab-complete the option, (or tap on mobile) "e.g. use (matches, light, letter) *on* (lamp)" - the word "on" there being suggested. Suggestions for autocomplete are in italics. This is one modern design element we're going to add.
- [x] Add a footer with "By [Ethan J Lewis](https://ethanjlewis.com) | [Source Code](https://half.st/ejlewis/halfstreet) | [GNU General Public License v3.0](https://half.st/ejlewis/halfstreet/src/branch/main/LICENSE)"
- [x] Add "Half Street" as ASCII Art to the intro text.
- [x] Add logic to make the last sentence in the examined description conditional. This is where we'll list items in the room. (e.g., "The hallway runs further than the house should be wide. The dust on the floor is undisturbed except where you have walked. *The oil lamp is on the side table.*")
- [ ] Create a new item with a mechanic: whiskey bottle. When the user drinks it they get drunk and are transported to the "drunk rooms" which are a bit of a maze and things get a little topsy-turvy. The player chances to lose an item (returning to its original spot) when they get drunk and wakes up several turns later somewhere else predetermined.
- [ ] Add lightened descriptions to darkened rooms. About half the rooms should be too dark to see anything (affects ability to move forward, can't see exits or entounters, except for maybe hints at the encounters, like sounds or shapes in the dark) Add frontmatter property to all rooms: (dark: true/false). Make text in darkened rooms a grey color.
- [x] Fix mobile - scrolling issue (page grows as the terminal grows).
- [x] Fix mobile - ascii text art at beginning too big to render
- [ ] Implement a simple "stealth mechanic", where sometimes it's advantageous to have the light out.
- [ ] Implement a simple (optional?) minimap in the UI? - Maybe tied to an item? Once you get the map the minimap appears? Can we POC it?
- [x] Feature: Ability to retain console history, e.g., scroll through previous commands with up and down arrows.
- [x] Feature: Grey italicized "type here..." text that appears near the terminal if the user doesn't click into the terminal within 30 seconds of entering the game or click the help button. The text disappears once a user clicks in the terminal, or selects a card.
- [x] Change footer to "Copyright (C) 2026 [Ethan J Lewis](https://ethanjlewis.com) | [GNU 3.0](https://half.st/ejlewis/halfstreet/src/branch/main/LICENSE)| [Source Code](https://half.st/ejlewis/halfstreet)"
@@ -27,9 +21,29 @@
- [x] Feature: Add "Restart" option to option menu
- [x] Bug: gear icon is still wayyyyyyy toooo smallllll it needs to be like 4x larger at least.
- [x] Add a "wait" tile.
- [ ] Add a mechanic where after the player waits 3 times or moves six times the light goes out and needs to be relit.
- [ ] Add attack options for most encounters. Rarely this will be a good idea though.
- [ ] Add a failure condition for attacking at the wrong time. Make the reasons for the failure condition contextual, for example, when they attack the stair sleeper they might trip on the stair and get injured.
- [x] If the user says "light match" or "light match" the response should be "use match with what?"
- [x] If the user says "use match with letter" they should burn the letter.
- [x] There should be a lighter in the smoking room that allows unlimited lighting.
- [x] Create a mechanic that asks "Are you sure?" before taking critical actions like attacking or other game-changing mechanics that might affect the final ending.
- [ ] Add lightened descriptions to darkened rooms. About half the rooms should be too dark to see anything (affects ability to move forward, can't see exits or encounters, except for maybe hints at the encounters, like sounds or shapes in the dark) Add frontmatter property to all rooms: (dark: true/false). Make text in darkened rooms a grey color.
- [ ] Implement a simple "stealth mechanic", where sometimes it's advantageous to have the light out.
- [ ] Implement a simple (optional?) minimap in the UI? - Maybe tied to an item? Once you get the map the minimap appears? Can we POC it?
- [x] Add a mechanic where after the player waits 3 times or moves six times the light goes out and needs to be relit. Or something along those lines. We need a sense of time. Maybe some situations blow out the light
- [ ] Add attack options for most encounters. Rarely this will be a good idea though.
- [ ] Add a failure condition for attacking at the wrong time. Make the reasons for the failure condition contextual, for example, when they attack the stair sleeper they might trip on the stair and get injured.
- [ ] Add contextual awareness and autocomplete. For example a popup that appears above the USE text or when a user types "use". The user is able to autofill the rest of the thing by using the keyboard to toggle through the inv list and tab-complete the option, (or tap on mobile) "e.g. use (matches, light, letter) *on* (lamp)" - the word "on" there being suggested. Suggestions for autocomplete are in italics. This is one modern design element we're going to add.
- [ ] Add a Notebook function. Automatically make notes as the game progresses.
- [ ] Implement a carry mechanic. Decide whether we should have a limited carry ability (only able to carry a few things?) or we night need a full inventory system, where items are assigned to pockets or hand carry and we can only hand carry a couple of items?
- [x] Implement a "drop" mechanic in order to drop items. Dropped items remain in the room in which they were dropped and can be picked up again.
- [x] We need a light indicator that shows when the light is lit and how much time is left on the light. Use the svg file I dropped in the src/assets folder for the indicator. The indicator should be a 6-segment led that runs in a dotted line underneath the light indicator and burns out right to left. The color of the indicator should be bright when it's lit and dim when it's not. The indicator should be to the right of the tiles and sized appropriately.
- [x] FEATURE: Add an option to disable the chips in the options menu.
- [x] BUG: The new cursor doesn't appear on mobile.
- [ ] FEATURE: Add a Safe to somewhere that it makes sense (the bedroom?) We can add a safe-cracking mini-game. The safe contains a single bullet, which can be used with the revolver.
- [ ] FEATURE: Add item Revolver, which can be used to kill the thing at the end. It comes with no bullets, so players need to defeat the safe-cracking minjgame. Place the revolver somewhere it takes the player some effort to find.
- [ ] Feature: A faceless voice that speaks in a whisper in the smoking room, which demands whiskey and dispenses riddles if the player finds a bottle of whiskey (but hasn't drunk it yet). The riddles are randomly chosen from a list of 25 difficult riddles (source them). If the riddle is answered correctly there needs to be some reward and maybe a major plot point is revealed.
- [x] Add item Whiskey bottle, half full of something smoky. In the kitchen. Drinking it gets the player drunk, which causes them to unlock the drunk rooms, which are a series of rooms where they can go many directions, but somehow end up in seemingly back in the same spot. They are essentially a maze of doors and hallways, ladders and levels. There should be boundaries established though, it shouldn't be endless. After 20 or so moves the player passes out and wakes up back in the foyer, with the whiskey bottle returned to the kitchen, somehow still half full. Add a random encounter in the dark rooms, a creaking floorboard helps you find a secret door that opens with a faceless man inside who gives you major plot info. After this encounter you pass out and wake up in the lobby as before. When the player is "drunk" the text on the terminal should appear blurry, like the user has double vision.
- [ ] Add a "Report a Bug" footer link backed by Bugpin (widget at bugpin.half.st -> forwards to GitHub Issues). Add Bugsink (@sentry/browser -> bugsink.half.st) for automatic JS error capture. Mark complete after manual verification: a test report appears in the Bugpin portal AND a GitHub issue is created AND a thrown error appears in the Bugsink portal.
- [ ] BUG: It says the door closes behind you when you enter the lobby, but you can still exit S to the gate.
- [x] FEATURE: Add a short "typed" effect to the text. Make it look like it's being typed out, if that makes sense, one character at a time. The effect should be brief.
- [x] FEATURE: Whenever you change rooms, scroll the text so the name of the room you're in is at the top. Users can scroll up to see the history. This should be an effect where the old text slides up to make room for the new text, and this should happen before the "typed" effect.
- [ ] Open-source authoring architecture: follow [[open-source-authoring-roadmap]] to move as much story, parser, mechanic, action, and UI configuration as practical into markdown under `src/world` so authors can control it from Obsidian.
+27
View File
@@ -0,0 +1,27 @@
---
id: burn-letter
verbs: [use]
requires:
allVisibleOrHeld:
- "[[letter]]"
- "[[matches]]"
consumes:
inventory:
- "[[letter]]"
decrements:
item: "[[matches]]"
stateKey: uses
setsFlags:
letterBurned: true
---
# Burn Letter
## success
The letter catches at one corner. In a few breaths it is ash.
## spent
The matchbook is empty.
## missingRequired
You don't see the letter here.
+33
View File
@@ -0,0 +1,33 @@
---
id: drink-whiskey
verbs: [drink]
handler: drunk-transition
requires:
allHeld:
- "[[whiskey]]"
consumes:
inventory:
- "[[whiskey]]"
drunkTransition:
destinationRoom: "[[drunk-hall]]"
maxMoves: 20
wakeRoom: "[[foyer]]"
resetRoom: "[[kitchen]]"
---
# Drink Whiskey
## success
You drink from the bottle. It tastes of smoke, sugar, and rainwater left too long in a pipe.
## missingRequired
You'd have to be carrying it.
## secretFoundPassOut
The faceless man steps backward into the dark. The floor rises under you, or you fall toward it.
## tooManyMovesPassOut
The rooms keep turning until they become one room. Then even that room is gone.
## reset
The bottle is not with you. Somewhere in the kitchen, it is half full again.
+155
View File
@@ -0,0 +1,155 @@
# Authoring Halfstreet
Halfstreet is authored from this `src/world` vault. The TypeScript runtime loads and validates the markdown; ordinary story changes should start here.
Use wikilinks for references when you can. The loader accepts `[[foyer]]` and stores it as `foyer`, so Obsidian links stay useful without changing runtime ids.
## Forking The Game
The current open-source shape is a forkable Astro app. Keep `src/engine`, `src/ui`, and `src/pages` in place, then replace the markdown in this vault with your own rooms, items, encounters, endings, mechanics, actions, parser vocabulary, and UI labels.
For a new game, update these files first:
- `game.md`: title, starting room, starting inventory, ending priority, opening art, help text, and end text.
- `parser.md`: direction words, verb aliases, prepositions, stop words, no-target verbs, and meta verbs.
- `ui.md`: page title, meta description, footer labels and links, build label, and UI feature switches.
- `templates/`: copy these starter files when adding new world content.
After edits, run `npm test` from the repo root. Validation errors are meant to point back to the markdown field or section that needs fixing.
## Rooms
Room files live in `rooms/`. Every room needs frontmatter plus three prose sections:
- `## first-visit`
- `## revisit`
- `## examined`
Set unused exits to `null`. Set exits to `[[room-id]]` when they lead somewhere. Put item ids in `items`, and set `encounter` when a room starts an encounter.
```yaml
id: sample-room
title: "[ Sample Room ]"
exitN: "[[other-room]]"
exitS: null
exitE: null
exitW: null
exitU: null
exitD: null
items:
- "[[sample-key]]"
encounter: null
safe: true
```
Locked exits use a matching `exitXRequires` and `exitXLockedText` pair. The requirement must be an item id or a known flag.
```yaml
exitN: "[[locked-room]]"
exitNRequires: sample-key
exitNLockedText: The door will not move.
```
## Items
Item files live in `items/`. The prose before the first `##` header is the long description shown by `examine`.
Use `readable: true` only when the item has a `## read` section. Use `lightable: true` for items that can be lit, and `lighter: true` for items that can light other items.
```yaml
id: sample-key
names: ["key", "sample key"]
short: "a sample key"
takeable: true
initialState: {}
```
Allowed optional item sections are:
- `## read`
- `## lit`
- `## extinguished`
- `## lighter-empty`
## Encounters
Encounter files live in `encounters/`. Encounters are state machines: `initialPhase` points to a phase, each phase points to a description section, and each transition points to a narration section.
```yaml
id: sample-encounter
startsIn: "[[sample-room]]"
initialPhase: waiting
aliases: [figure, shape]
defaultWrongVerbNarration: wrong-verb
phases:
waiting:
description: waiting
transitions:
- verb: wait
chipLabel: WAIT
narration: waited
to: resolved
```
Use `onResolved.setFlags` for flags that endings or locked exits can read. Use `requires.item` on a transition when the player must hold a specific item.
## Endings
Ending files live in `endings/`. `whenFlags` controls when an ending is available, and the markdown body is the ending narration.
Quote ids that look like booleans:
```yaml
id: "true"
whenFlags:
familySecretKnown: true
```
Bare `id: true` is parsed as a boolean by YAML before the loader sees it.
## Mechanics
Mechanic files live in `mechanics/`. They configure named TypeScript handlers without moving the algorithms into markdown.
`mechanics/light.md` controls the light timer, burn triggers, state keys, meter setting, and fallback light prose. `mechanics/resolve.md` controls resolve levels, wrong-verb cost, safe-room recovery, and retreat behavior.
Keep `handler` set to the supported handler name. Set `enabled: false` to disable a mechanic cleanly.
## Actions
Action files live in `actions/`. Simple actions can require visible or held items, consume inventory, decrement item state, set flags, and narrate success.
```yaml
id: sample-action
verbs: [use]
requires:
allVisibleOrHeld:
- "[[sample-key]]"
setsFlags:
sampleActionDone: true
```
Every default action needs `## success`. Handler-backed actions may require extra sections. For example, the `drunk-transition` handler requires `## success`, `## secretFoundPassOut`, `## tooManyMovesPassOut`, and `## reset`.
## Game, Parser, Messages, And UI
- `game.md` controls the title, description, starting room, starting inventory, ending priority, transcript cap, opening art, help text, and ended text.
- `parser.md` controls directions, verb aliases, prepositions, stop words, no-target verbs, and meta verbs.
- `messages.md` controls common system prose.
- `ui.md` controls page metadata, footer labels and links, build label visibility, and UI feature toggles.
## Common Validation Errors
`missing required section "## first-visit"`: add the exact section header to the room file. Section names must use only letters, digits, hyphens, and underscores.
`## read section is required when readable: true`: either add `## read` to the item or remove `readable: true`.
`frontmatter references missing section`: an encounter phase, transition, or failure references a prose key that does not exist. Add the section or fix the key.
`exitNRequires is set but exitNLockedText is missing`: locked exits need both the requirement and locked narration fields.
`references unknown item` or `references unknown room`: check the wikilink/id spelling and make sure the referenced file exists in the matching folder.
`endingPriority references "true" but endings/true.md is missing`: quote boolean-like ids in YAML and make sure the ending file exists.
`unknown message section` or `unknown item section`: the loader only accepts known section keys. Rename the header to one of the allowed keys for that file type.
+438 -2
View File
@@ -1,7 +1,83 @@
import { describe, it, expect } from 'vitest'
import { world } from './index'
import { assembleWorld, world } from './index'
import type { GameManifest, Item, Room, World } from './types'
const manifest: GameManifest = {
id: 'test',
title: 'Test',
description: 'A tiny test world.',
startingRoom: 'foyer',
startingInventory: ['letter'],
endingPriority: ['true'],
}
const rooms: Record<string, Room> = {
foyer: {
id: 'foyer',
title: '[ Foyer ]',
descriptions: {
firstVisit: 'You are here.',
revisit: 'Still here.',
examined: 'A foyer.',
},
exits: {},
items: [],
},
}
const items: Record<string, Item> = {
letter: {
id: 'letter',
names: ['letter'],
short: 'a letter',
long: 'A folded letter.',
initialState: {},
takeable: true,
},
}
const endings: World['endings'] = {
true: { whenFlags: {}, narration: 'The end.' },
}
function build(overrides: Partial<Parameters<typeof assembleWorld>[0]> = {}) {
return assembleWorld({
game: manifest,
rooms,
items,
endings,
encounters: {},
encounterDocs: [],
...overrides,
})
}
describe('assembled world', () => {
it('assembles a minimal manifest-backed world', () => {
const result = build()
expect(result.startingRoom).toBe('foyer')
expect(result.startingInventory).toEqual(['letter'])
expect(result.endingPriority).toEqual(['true'])
})
it('rejects a game manifest with an unknown starting room', () => {
expect(() => build({
game: { ...manifest, startingRoom: 'missing-room' },
})).toThrow(/startingRoom references "missing-room"/)
})
it('rejects a game manifest with an unknown starting inventory item', () => {
expect(() => build({
game: { ...manifest, startingInventory: ['missing-item'] },
})).toThrow(/startingInventory references unknown item "missing-item"/)
})
it('rejects a game manifest with an unknown ending priority entry', () => {
expect(() => build({
game: { ...manifest, endingPriority: ['missing-ending'] },
})).toThrow(/endingPriority references "missing-ending"/)
})
it('contains the authored opening and main-floor rooms', () => {
expect(Object.keys(world.rooms)).toEqual(expect.arrayContaining([
'outside-gate',
@@ -20,6 +96,7 @@ describe('assembled world', () => {
'bedroom',
'nursery',
'attic',
'chapel',
]))
})
@@ -40,6 +117,7 @@ describe('assembled world', () => {
'childs-drawing',
'music-box',
'toy-dog',
'silver-vial',
]))
})
@@ -51,6 +129,24 @@ describe('assembled world', () => {
}
})
it('hallway prose names every enabled exit', () => {
const hallway = world.rooms['hallway']
expect(hallway).toBeDefined()
if (!hallway) throw new Error('hallway room is missing')
expect(hallway.exits).toEqual({
n: 'dining-room',
s: 'foyer',
e: 'cellar-stair',
w: 'smoking-room',
u: 'parlor',
d: 'music-room',
})
const prose = `${hallway.descriptions.firstVisit}\n${hallway.descriptions.examined}`.toLowerCase()
for (const word of ['north', 'south', 'east', 'west', 'up', 'down']) {
expect(prose, `hallway prose should mention ${word}`).toContain(word)
}
})
it('all room item refs resolve to known items', () => {
for (const room of Object.values(world.rooms)) {
for (const itemId of room.items) {
@@ -68,16 +164,356 @@ describe('assembled world', () => {
})
it('startingRoom is a known room', () => {
expect(world.game?.startingRoom).toBe(world.startingRoom)
expect(world.rooms[world.startingRoom]).toBeDefined()
})
it('startingInventory items are known', () => {
expect(world.game?.startingInventory).toEqual(world.startingInventory)
for (const itemId of world.startingInventory) {
expect(world.items[itemId]).toBeDefined()
}
})
it('game manifest text is loaded from markdown', () => {
expect(world.game?.title).toBe('Halfstreet')
expect(world.game?.openingArt).toContain('____')
expect(world.game?.helpText).toContain('This is a text adventure.')
expect(world.game?.endedText).toContain('The story has ended.')
})
it('parser vocabulary is loaded from markdown', () => {
expect(world.parser?.verbs.take).toContain('pick up')
expect(world.parser?.verbs.open).toContain('uncover')
expect(world.parser?.directions.n).toContain('north')
})
it('UI config is loaded from markdown', () => {
expect(world.ui?.pageTitle).toBe('Halfstreet - Ethan J Lewis')
expect(world.ui?.footer.links.map((link) => link.label)).toEqual(['GNU 3.0', 'Source Code'])
expect(world.ui?.footer.showBuild).toBe(true)
expect(world.ui?.features.typedEffect).toBe(true)
})
it('system messages are loaded from markdown', () => {
expect(world.messages?.['unknown-verb']).toContain("don't fit this place")
expect(world.messages?.taken).toBe('Taken.')
})
it('light mechanic config is loaded from markdown', () => {
expect(world.mechanics?.light?.enabled).toBe(true)
expect(world.mechanics?.light?.maxTurns).toBe(6)
expect(world.mechanics?.light?.burnOn).toEqual(['move', 'wait'])
expect(world.mechanics?.light?.messages?.flameDies).toBe('The flame dies.')
})
it('resolve mechanic config is loaded from markdown', () => {
expect(world.mechanics?.resolve?.enabled).toBe(true)
expect(world.mechanics?.resolve?.ladder).toEqual(['steady', 'shaken', 'reeling', 'returning'])
expect(world.mechanics?.resolve?.safeRooms.recoverySteps).toBe(1)
expect(world.mechanics?.resolve?.failure.afterRetreat).toBe('shaken')
})
it('declarative actions are loaded from markdown', () => {
expect(world.actions?.['burn-letter']?.verbs).toEqual(['use'])
expect(world.actions?.['burn-letter']?.requires?.allVisibleOrHeld).toEqual(['letter', 'matches'])
expect(world.actions?.['burn-letter']?.messages.success).toContain('ash')
})
it('handler-backed actions are loaded from markdown', () => {
expect(world.actions?.['drink-whiskey']?.verbs).toEqual(['drink'])
expect(world.actions?.['drink-whiskey']?.handler).toBe('drunk-transition')
expect(world.actions?.['drink-whiskey']?.requires?.allHeld).toEqual(['whiskey'])
expect(world.actions?.['drink-whiskey']?.drunkTransition).toEqual({
destinationRoom: 'drunk-hall',
maxMoves: 20,
wakeRoom: 'foyer',
resetRoom: 'kitchen',
})
})
it('loads a markdown-owned encounter phase machine', () => {
expect(world.encounters.rat).toMatchObject({
id: 'rat',
startsIn: 'cellar-stair',
initialPhase: 'lurking',
onResolved: { setFlags: { ratGone: true } },
defaultWrongVerbNarration: 'The rat watches.',
})
const lurking = world.encounters.rat?.phases.lurking
expect(lurking).toBeDefined()
expect(lurking?.transitions).toEqual([
{
verb: 'attack',
target: 'rat',
chipLabel: 'ATTACK RAT',
chipCommand: 'attack rat',
narration: 'You stamp. The rat squeals and is gone into the dark.',
to: 'resolved',
},
{
verb: 'wait',
chipLabel: 'WAIT',
narration: 'The rat does not move. Neither do you.',
to: 'lurking',
},
])
})
it('loads the first migrated encounter batch from markdown', () => {
expect(world.encounters['window-guest']).toMatchObject({
aliases: ['guest', 'window guest', 'curtains', 'curtain', 'window'],
onResolved: { setFlags: { curtainsClosed: true } },
onFailed: { retreatTo: 'hallway' },
})
expect(world.encounters['covered-cage']).toMatchObject({
aliases: ['covered cage', 'cage', 'birdcage', 'cloth'],
onResolved: { setFlags: { cageUncovered: true } },
onFailed: { retreatTo: 'hallway' },
})
expect(world.encounters['piano-echo']).toMatchObject({
aliases: ['piano echo', 'piano', 'note', 'key'],
onResolved: { setFlags: { musicSolved: true } },
onFailed: { retreatTo: 'hallway' },
})
expect(world.encounters['window-guest']?.phases['standing-outside']?.transitions[0]?.chipCommand).toBe('close curtains')
expect(world.encounters['covered-cage']?.phases.rustling?.transitions[0]?.chipCommand).toBe('uncover cage')
expect(world.encounters['piano-echo']?.phases.listening?.transitions[0]?.chipCommand).toBe('play note')
})
it('loads the wait-resolved encounter batch from markdown', () => {
expect(world.encounters['breathing-wall']).toMatchObject({
aliases: ['breathing wall', 'wall', 'walls', 'breathing'],
onResolved: { setFlags: { breathingWallPassed: true } },
onFailed: { retreatTo: 'music-room' },
})
expect(world.encounters['linen-shape']).toMatchObject({
aliases: ['linen shape', 'shape', 'sheet', 'sheets', 'linen'],
onResolved: { setFlags: { linenShapeEmpty: true } },
onFailed: { retreatTo: 'servants-passage' },
})
expect(world.encounters['stair-sleeper']).toMatchObject({
aliases: ['stair sleeper', 'sleeper', 'figure', 'person', 'body'],
onResolved: { setFlags: { hallwayShifted: true } },
onFailed: { retreatTo: 'parlor' },
})
expect(world.encounters['breathing-wall']?.phases.audible?.transitions[0]).toMatchObject({ verb: 'wait', chipLabel: 'WAIT', to: 'resolved' })
expect(world.encounters['linen-shape']?.phases.hanging?.transitions[0]).toMatchObject({ verb: 'wait', chipLabel: 'WAIT', to: 'resolved' })
expect(world.encounters['stair-sleeper']?.phases.seated?.transitions[0]).toMatchObject({ verb: 'wait', chipLabel: 'WAIT', to: 'resolved' })
})
it('loads the item-gated encounter batch from markdown', () => {
expect(world.encounters['ivy-figure']).toMatchObject({
aliases: ['ivy figure', 'figure', 'ivy', 'vines', 'vine'],
onResolved: { setFlags: { conservatoryVinesCut: true } },
onFailed: { retreatTo: 'dining-room' },
})
expect(world.encounters['child-beneath-well']).toMatchObject({
aliases: ['child', 'well child', 'child beneath well', 'barefoot child'],
onResolved: { setFlags: { childPassedWell: true } },
onFailed: { retreatTo: 'well' },
})
expect(world.encounters['bone-keeper']).toMatchObject({
aliases: ['bone keeper', 'keeper', 'hands', 'bones', 'ribs'],
onResolved: { setFlags: { burialRingPlaced: true } },
onFailed: { retreatTo: 'tunnel' },
})
expect(world.encounters['ivy-figure']?.phases.hidden?.transitions).toEqual([
expect.objectContaining({ verb: 'cut', requires: { item: 'pruning-shears' }, chipCommand: 'cut vines' }),
expect.objectContaining({ verb: 'use', requires: { item: 'pruning-shears' }, chipCommand: 'use vines with shears' }),
])
expect(world.encounters['child-beneath-well']?.phases.climbing?.transitions).toEqual([
expect.objectContaining({ verb: 'hold', requires: { item: 'toy-dog' }, setFlags: { woofReturned: true }, chipCommand: 'hold dog' }),
expect.objectContaining({ verb: 'wait', chipLabel: 'WAIT', to: 'resolved' }),
])
expect(world.encounters['bone-keeper']?.phases.arranging?.transitions[0]).toMatchObject({
verb: 'drop',
target: 'burial-ring',
requires: { item: 'burial-ring' },
chipCommand: 'leave ring',
})
})
it('loads the garden and lower-passage encounter batch from markdown', () => {
expect(world.encounters['garden-procession']).toMatchObject({
aliases: ['garden procession', 'procession', 'lanterns', 'lantern', 'lights', 'hedge'],
onResolved: { setFlags: { gardenQuiet: true } },
onFailed: { retreatTo: 'back-door' },
})
expect(world.encounters.reflection).toMatchObject({
aliases: ['reflection', 'water', 'black water', 'face', 'reflected figure'],
onResolved: { setFlags: { reflectionObscured: true } },
onFailed: { retreatTo: 'ossuary' },
})
expect(world.encounters['root-movement']).toMatchObject({
aliases: ['root movement', 'roots', 'root', 'opening'],
onResolved: { setFlags: { rootsListenedTo: true } },
onFailed: { retreatTo: 'flooded-passage' },
})
expect(world.encounters['garden-procession']?.phases.passing?.transitions[0]).toMatchObject({
verb: 'wait',
chipLabel: 'WAIT',
to: 'resolved',
})
expect(world.encounters.reflection?.phases.following?.transitions[0]).toMatchObject({
verb: 'use',
target: 'reflection',
requires: { item: 'damp-sheet' },
chipCommand: 'use water with sheet',
})
expect(world.encounters['root-movement']?.phases.shifting?.transitions[0]).toMatchObject({
verb: 'listen',
chipCommand: 'listen',
to: 'resolved',
})
})
it('loads the final encounter batch from markdown', () => {
expect(world.encounters['portrait-woman']).toMatchObject({
aliases: ['portrait woman', 'woman', 'portrait', 'portraits', 'veil', 'funeral veil'],
onResolved: { setFlags: { familyResemblanceSeen: true } },
onFailed: { retreatTo: 'root-chamber' },
})
expect(world.encounters.basilisk).toMatchObject({
aliases: ['basilisk', 'creature', 'eye', 'altar', 'coil'],
onResolved: { setFlags: { basiliskSpared: true } },
onFailed: { retreatTo: 'vault' },
})
expect(world.encounters['vault-memory']).toMatchObject({
aliases: ['vault memory', 'memory', 'bed', 'photograph', 'photo', 'thing', 'buried thing'],
})
expect(world.encounters['creaking-floorboard']).toMatchObject({
aliases: ['creaking floorboard', 'floorboard', 'board', 'creak', 'secret door', 'faceless man', 'man', 'voice'],
})
expect(world.encounters['distant-steps']).toMatchObject({
aliases: ['distant steps', 'steps', 'footsteps', 'hallway'],
onResolved: { setFlags: { distantStepsPassed: true } },
onFailed: { retreatTo: 'parlor' },
})
expect(world.encounters['rainwater-basin']).toMatchObject({
aliases: ['rainwater basin', 'basin', 'water', 'rainwater', 'reflection'],
onResolved: { setFlags: { rainRoomEntered: true, houseAcceptedYou: true } },
onFailed: { retreatTo: 'wrong-hallway' },
})
expect(world.encounters['portrait-woman']?.phases.watching?.transitions[0]).toMatchObject({
verb: 'examine',
chipCommand: 'examine portraits',
})
expect(world.encounters.basilisk?.phases.sleeping?.transitions).toEqual([
expect.objectContaining({ verb: 'pour', requires: { item: 'silver-vial' }, chipCommand: 'pour vial on basilisk' }),
expect.objectContaining({ verb: 'use', requires: { item: 'silver-vial' }, chipCommand: 'use basilisk with vial' }),
])
expect(world.encounters['vault-memory']?.phases.buried?.transitions).toEqual([
expect.objectContaining({ verb: 'read', requires: { item: 'family-register' }, setFlags: { nameSpoken: true } }),
expect.objectContaining({ verb: 'take', setFlags: { tookPhotograph: true } }),
expect.objectContaining({ verb: 'attack', setFlags: { disturbedVault: true } }),
])
expect(world.encounters['creaking-floorboard']?.phases.creaking?.transitions).toEqual([
expect.objectContaining({ verb: 'listen', setFlags: { drunkSecretFound: true, facelessManMet: true, houseDebtNamed: true } }),
expect.objectContaining({ verb: 'open', setFlags: { drunkSecretFound: true, facelessManMet: true, houseDebtNamed: true } }),
])
expect(world.encounters['distant-steps']?.phases.approaching?.transitions[0]).toMatchObject({
verb: 'wait',
to: 'resolved',
})
expect(world.encounters['rainwater-basin']?.phases.reflecting?.transitions).toEqual([
expect.objectContaining({ verb: 'look', chipCommand: 'look basin' }),
expect.objectContaining({ verb: 'examine', chipCommand: 'examine basin' }),
])
})
it('rejects duplicate handler-backed action owners', () => {
expect(() => build({
actions: {
first: {
id: 'first',
verbs: ['drink'],
handler: 'drunk-transition',
requires: { allHeld: ['letter'] },
drunkTransition: {
destinationRoom: 'foyer',
maxMoves: 1,
wakeRoom: 'foyer',
resetRoom: 'foyer',
},
messages: {
success: 'ok',
secretFoundPassOut: 'secret',
tooManyMovesPassOut: 'moves',
reset: 'reset',
},
},
second: {
id: 'second',
verbs: ['drink'],
handler: 'drunk-transition',
requires: { allHeld: ['letter'] },
drunkTransition: {
destinationRoom: 'foyer',
maxMoves: 1,
wakeRoom: 'foyer',
resetRoom: 'foyer',
},
messages: {
success: 'ok',
secretFoundPassOut: 'secret',
tooManyMovesPassOut: 'moves',
reset: 'reset',
},
},
},
})).toThrow(/handler "drunk-transition" is already used by actions\/first\.md/)
})
it('reports the action field that references an unknown item', () => {
expect(() => build({
actions: {
burn: {
id: 'burn',
verbs: ['use'],
requires: { allVisibleOrHeld: ['letter', 'missing-match'] },
messages: { success: 'ok' },
},
},
})).toThrow(/actions\/burn\.md: requires\.allVisibleOrHeld references unknown item "missing-match"/)
})
it('reports the drunk transition field that references an unknown room', () => {
expect(() => build({
actions: {
drink: {
id: 'drink',
verbs: ['drink'],
handler: 'drunk-transition',
requires: { allHeld: ['letter'] },
drunkTransition: {
destinationRoom: 'missing-room',
maxMoves: 1,
wakeRoom: 'foyer',
resetRoom: 'foyer',
},
messages: {
success: 'ok',
secretFoundPassOut: 'secret',
tooManyMovesPassOut: 'moves',
reset: 'reset',
},
},
},
})).toThrow(/actions\/drink\.md: drunkTransition\.destinationRoom references unknown room "missing-room"/)
})
it('ending priority references loaded endings', () => {
expect(world.endingPriority).toEqual(world.game?.endingPriority)
for (const endingId of world.endingPriority ?? []) {
expect(world.endings[endingId], endingId).toBeDefined()
}
})
it('endings have non-empty narration where the original did', () => {
expect(world.endings.true.narration.length).toBeGreaterThan(0)
expect(world.endings['true']?.narration.length).toBeGreaterThan(0)
})
})
-205
View File
@@ -1,205 +0,0 @@
import type { EncounterDef } from './types'
import { narration } from './loader'
export const encounters: Record<string, EncounterDef> = {
rat: {
id: 'rat',
startsIn: 'cellar-stair',
initialPhase: 'lurking',
phases: {
lurking: {
description: narration('rat', 'lurking'),
transitions: [
{
verb: 'attack',
target: 'rat',
chipLabel: 'ATTACK RAT',
chipCommand: 'attack rat',
narration: narration('rat', 'attack-rat-resolved'),
to: 'resolved',
},
{
verb: 'wait',
chipLabel: 'WAIT',
narration: narration('rat', 'wait-stays'),
to: 'lurking',
},
],
},
},
onResolved: { setFlags: { ratGone: true } },
defaultWrongVerbNarration: 'The rat watches.',
},
'window-guest': {
id: 'window-guest',
aliases: ['guest', 'window guest', 'curtains', 'curtain', 'window'],
startsIn: 'dining-room',
initialPhase: 'standing-outside',
phases: {
'standing-outside': {
description: narration('window-guest', 'standing-outside'),
transitions: [
{
verb: 'close',
target: 'window-guest',
chipLabel: 'CLOSE CURTAINS',
chipCommand: 'close curtains',
narration: narration('window-guest', 'close-window-guest-resolved'),
to: 'resolved',
},
],
},
},
onResolved: { setFlags: { curtainsClosed: true } },
onFailed: { narration: narration('window-guest', 'failed'), retreatTo: 'hallway' },
defaultWrongVerbNarration: narration('window-guest', 'wrong-verb'),
},
'ivy-figure': {
id: 'ivy-figure',
aliases: ['ivy figure', 'figure', 'ivy', 'vines', 'vine'],
startsIn: 'conservatory',
initialPhase: 'hidden',
phases: {
hidden: {
description: narration('ivy-figure', 'hidden'),
transitions: [
{
verb: 'cut',
target: 'ivy-figure',
chipLabel: 'CUT VINES',
chipCommand: 'cut vines',
requires: { item: 'pruning-shears' },
narration: narration('ivy-figure', 'cut-ivy-figure-resolved'),
to: 'resolved',
},
{
verb: 'use',
target: 'ivy-figure',
chipLabel: 'USE SHEARS',
chipCommand: 'use vines with shears',
requires: { item: 'pruning-shears' },
narration: narration('ivy-figure', 'cut-ivy-figure-resolved'),
to: 'resolved',
},
],
},
},
onResolved: { setFlags: { conservatoryVinesCut: true } },
onFailed: { narration: narration('ivy-figure', 'failed'), retreatTo: 'dining-room' },
defaultWrongVerbNarration: narration('ivy-figure', 'wrong-verb'),
},
'covered-cage': {
id: 'covered-cage',
aliases: ['covered cage', 'cage', 'birdcage', 'cloth'],
startsIn: 'smoking-room',
initialPhase: 'rustling',
phases: {
rustling: {
description: narration('covered-cage', 'rustling'),
transitions: [
{
verb: 'open',
target: 'covered-cage',
chipLabel: 'UNCOVER CAGE',
chipCommand: 'uncover cage',
narration: narration('covered-cage', 'open-covered-cage-resolved'),
to: 'resolved',
},
],
},
},
onResolved: { setFlags: { cageUncovered: true } },
onFailed: { narration: narration('covered-cage', 'failed'), retreatTo: 'hallway' },
defaultWrongVerbNarration: narration('covered-cage', 'wrong-verb'),
},
'piano-echo': {
id: 'piano-echo',
aliases: ['piano echo', 'piano', 'note', 'key'],
startsIn: 'music-room',
initialPhase: 'listening',
phases: {
listening: {
description: narration('piano-echo', 'listening'),
transitions: [
{
verb: 'play',
target: 'piano-echo',
chipLabel: 'PLAY NOTE',
chipCommand: 'play note',
narration: narration('piano-echo', 'play-piano-echo-resolved'),
to: 'resolved',
},
],
},
},
onResolved: { setFlags: { musicSolved: true } },
onFailed: { narration: narration('piano-echo', 'failed'), retreatTo: 'hallway' },
defaultWrongVerbNarration: narration('piano-echo', 'wrong-verb'),
},
'breathing-wall': {
id: 'breathing-wall',
aliases: ['breathing wall', 'wall', 'walls', 'breathing'],
startsIn: 'servants-passage',
initialPhase: 'audible',
phases: {
audible: {
description: narration('breathing-wall', 'audible'),
transitions: [
{
verb: 'wait',
chipLabel: 'WAIT',
narration: narration('breathing-wall', 'wait-resolved'),
to: 'resolved',
},
],
},
},
onResolved: { setFlags: { breathingWallPassed: true } },
onFailed: { narration: narration('breathing-wall', 'failed'), retreatTo: 'music-room' },
defaultWrongVerbNarration: narration('breathing-wall', 'wrong-verb'),
},
'linen-shape': {
id: 'linen-shape',
aliases: ['linen shape', 'shape', 'sheet', 'sheets', 'linen'],
startsIn: 'laundry',
initialPhase: 'hanging',
phases: {
hanging: {
description: narration('linen-shape', 'hanging'),
transitions: [
{
verb: 'wait',
chipLabel: 'WAIT',
narration: narration('linen-shape', 'wait-resolved'),
to: 'resolved',
},
],
},
},
onResolved: { setFlags: { linenShapeEmpty: true } },
onFailed: { narration: narration('linen-shape', 'failed'), retreatTo: 'servants-passage' },
defaultWrongVerbNarration: narration('linen-shape', 'wrong-verb'),
},
'stair-sleeper': {
id: 'stair-sleeper',
aliases: ['stair sleeper', 'sleeper', 'figure', 'person', 'body'],
startsIn: 'stair-up',
initialPhase: 'seated',
phases: {
seated: {
description: narration('stair-sleeper', 'seated'),
transitions: [
{
verb: 'wait',
chipLabel: 'WAIT',
narration: narration('stair-sleeper', 'wait-resolved'),
to: 'resolved',
},
],
},
},
onResolved: { setFlags: { hallwayShifted: true } },
onFailed: { narration: narration('stair-sleeper', 'failed'), retreatTo: 'parlor' },
defaultWrongVerbNarration: narration('stair-sleeper', 'wrong-verb'),
},
}
+55
View File
@@ -0,0 +1,55 @@
---
id: basilisk
startsIn: "[[chapel]]"
initialPhase: sleeping
aliases: [basilisk, creature, eye, altar, coil]
onResolved:
setFlags:
basiliskSpared: true
onFailed:
narration: failed
retreatTo: "[[vault]]"
defaultWrongVerbNarration: wrong-verb
phases:
sleeping:
description: sleeping
transitions:
- verb: pour
target: silver-vial
chipLabel: POUR VIAL
chipCommand: pour vial on basilisk
requires:
item: "[[silver-vial]]"
narration: pour-vial-resolved
to: resolved
- verb: use
target: basilisk
chipLabel: USE VIAL
chipCommand: use basilisk with vial
requires:
item: "[[silver-vial]]"
narration: pour-vial-resolved
to: resolved
---
## sleeping
Something large is coiled beneath the altar.
You become aware of the eye first.
Not glowing. Merely open.
## pour-vial-resolved
The wax breaks beneath your thumb.
You pour the vial over the cracked altar stone. The clear liquid disappears into it without running. Beneath the altar, the coil withdraws slowly, scale against stone, until there is only the smell of rain on dust.
The eye closes last.
## wrong-verb
The open eye receives the motion without interest.
Your own eyes begin to water. You look away too late.
## failed
The chapel floor tilts under you. When you find the wall, you are back in the vault with the taste of stone in your mouth.
+39
View File
@@ -0,0 +1,39 @@
---
id: bone-keeper
startsIn: "[[ossuary]]"
initialPhase: arranging
aliases: [bone keeper, keeper, hands, bones, ribs]
onResolved:
setFlags:
burialRingPlaced: true
onFailed:
narration: failed
retreatTo: "[[tunnel]]"
defaultWrongVerbNarration: wrong-verb
phases:
arranging:
description: arranging
transitions:
- verb: drop
target: "[[burial-ring]]"
chipLabel: LEAVE RING
chipCommand: leave ring
requires:
item: "[[burial-ring]]"
narration: leave-burial-ring-resolved
to: resolved
---
## arranging
Something kneels before the shelves of bone. It has no face you can make out, only hands, and the hands are placing ribs in order of size.
## leave-burial-ring-resolved
You set the ring among the bones.
The hands stop their work. One finger touches the crest, gently, and the shelves settle as if relieved of a small but unbearable error.
## wrong-verb
The arranging hands pause. A rib turns slowly in their grip.
## failed
The bones clatter all at once. You retreat to the tunnel with the sound following you in pieces.
+18 -2
View File
@@ -2,6 +2,22 @@
id: breathing-wall
startsIn: "[[servants-passage]]"
initialPhase: audible
aliases: [breathing wall, wall, walls, breathing]
onResolved:
setFlags:
breathingWallPassed: true
onFailed:
narration: failed
retreatTo: "[[music-room]]"
defaultWrongVerbNarration: wrong-verb
phases:
audible:
description: audible
transitions:
- verb: wait
chipLabel: WAIT
narration: wait-resolved
to: resolved
---
## audible
@@ -10,10 +26,10 @@ The wall beside your shoulder breathes in. The opposite wall answers.
## wait-resolved
You stand still.
The passage narrows, then forgets to. The breathing passes on ahead of you.
The passage narrows, then forgets to. The breathing passes on ahead of you, down the wall and out of reach.
## wrong-verb
The walls take a slower breath.
## failed
The boards lean close enough to touch your sleeves. You retreat into the music room.
The boards lean close enough to touch your sleeves. You retreat into the music room before they can close around you.
@@ -0,0 +1,60 @@
---
id: child-beneath-well
startsIn: "[[well-shaft]]"
initialPhase: climbing
aliases: [child, well child, child beneath well, barefoot child]
onResolved:
setFlags:
childPassedWell: true
onFailed:
narration: failed
retreatTo: "[[well]]"
defaultWrongVerbNarration: wrong-verb
phases:
climbing:
description: climbing
transitions:
- verb: hold
target: "[[toy-dog]]"
chipLabel: SHOW DOG
chipCommand: hold dog
requires:
item: "[[toy-dog]]"
narration: hold-toy-dog-resolved
setFlags:
woofReturned: true
to: resolved
- verb: wait
chipLabel: WAIT
narration: wait-resolved
to: resolved
---
## climbing
Something moves below before you do.
A child emerges from the tunnel beneath the well, barefoot and breathless, one hand against the stone wall as it climbs past you. It does not stop. A draft of cold air follows after it.
Then it is gone upward toward the garden.
## hold-toy-dog-resolved
The child pauses at the garden gate.
"You found Woof."
Or perhaps: "Wolf."
The child takes the toy carefully and disappears into the overgrowth.
The garden grows quieter afterward.
## wait-resolved
You let the child climb past.
Small bare feet find the rungs without looking. The cold draft follows upward, and after a while the shaft is only stone again.
## wrong-verb
The child does not look at you. It climbs as if something below still has its name.
## failed
The cold rises too quickly. You climb back to the well with your hands numb around the rungs.
+20 -2
View File
@@ -2,6 +2,24 @@
id: covered-cage
startsIn: "[[smoking-room]]"
initialPhase: rustling
aliases: [covered cage, cage, birdcage, cloth]
onResolved:
setFlags:
cageUncovered: true
onFailed:
narration: failed
retreatTo: "[[hallway]]"
defaultWrongVerbNarration: wrong-verb
phases:
rustling:
description: rustling
transitions:
- verb: open
target: covered-cage
chipLabel: UNCOVER CAGE
chipCommand: uncover cage
narration: open-covered-cage-resolved
to: resolved
---
## rustling
@@ -15,7 +33,7 @@ The cage is empty. A few pale feathers cling to the wire, though no bird could h
Somewhere far above you, wings beat once inside a wall.
## wrong-verb
The cloth trembles.
The cloth trembles, then goes still again.
## failed
The rustling grows too close to your ear. You leave the room before you decide to.
The rustling grows too close to your ear. You leave the room before you decide to, and the cage is still covered when you look back.
@@ -0,0 +1,45 @@
---
id: creaking-floorboard
startsIn: "[[drunk-landing]]"
initialPhase: creaking
aliases: [creaking floorboard, floorboard, board, creak, secret door, faceless man, man, voice]
defaultWrongVerbNarration: wrong-verb
phases:
creaking:
description: creaking
transitions:
- verb: listen
chipLabel: LISTEN
chipCommand: listen
narration: listen-resolved
setFlags:
drunkSecretFound: true
facelessManMet: true
houseDebtNamed: true
to: resolved
- verb: open
target: creaking-floorboard
chipLabel: OPEN BOARD
chipCommand: open floorboard
narration: listen-resolved
setFlags:
drunkSecretFound: true
facelessManMet: true
houseDebtNamed: true
to: resolved
---
## creaking
One floorboard creaks after you have stopped moving.
It creaks again, softer, from under the wrong foot.
## listen-resolved
You listen.
The board lifts by itself. Beneath it is a narrow door, and behind the door a man without a face sits with his hands folded.
"Halfstreet keeps what is owed," he whispers. "It does not know the difference between a debt and a child."
## wrong-verb
The floorboard waits until you breathe, then creaks beneath that too.
+37
View File
@@ -0,0 +1,37 @@
---
id: distant-steps
startsIn: "[[wrong-hallway]]"
initialPhase: approaching
aliases: [distant steps, steps, footsteps, hallway]
onResolved:
setFlags:
distantStepsPassed: true
onFailed:
narration: failed
retreatTo: "[[parlor]]"
defaultWrongVerbNarration: wrong-verb
phases:
approaching:
description: approaching
transitions:
- verb: wait
chipLabel: WAIT
narration: wait-resolved
to: resolved
---
## approaching
The footsteps come closer without growing louder.
They stop whenever you move.
## wait-resolved
You stand still.
The steps pass through you with the cold, careful pressure of someone carrying a tray through a dark room. When they are gone, the hallway is shorter by one door.
## wrong-verb
The hallway lengthens. The footsteps begin again from farther away.
## failed
You turn back too quickly and find the parlor waiting with all its chairs facing you.
+35
View File
@@ -0,0 +1,35 @@
---
id: garden-procession
startsIn: "[[garden]]"
initialPhase: passing
aliases: [garden procession, procession, lanterns, lantern, lights, hedge]
onResolved:
setFlags:
gardenQuiet: true
onFailed:
narration: failed
retreatTo: "[[back-door]]"
defaultWrongVerbNarration: wrong-verb
phases:
passing:
description: passing
transitions:
- verb: wait
chipLabel: WAIT
narration: wait-resolved
to: resolved
---
## passing
Lanterns pass behind the hedge in a slow line. Each flame is held at the height of a face, but the leaves show no faces through them.
## wait-resolved
You remain silent.
The lanterns go by one after another, counting themselves in light. When the last has passed, the hedge exhales and the garden belongs to the rain again.
## wrong-verb
The nearest lantern stops.
## failed
The procession turns with one motion. You are back at the kitchen door before you remember retreating, and something in the hedge has learned your footstep.
+30 -2
View File
@@ -2,6 +2,34 @@
id: ivy-figure
startsIn: "[[conservatory]]"
initialPhase: hidden
aliases: [ivy figure, figure, ivy, vines, vine]
onResolved:
setFlags:
conservatoryVinesCut: true
onFailed:
narration: failed
retreatTo: "[[dining-room]]"
defaultWrongVerbNarration: wrong-verb
phases:
hidden:
description: hidden
transitions:
- verb: cut
target: ivy-figure
chipLabel: CUT VINES
chipCommand: cut vines
requires:
item: "[[pruning-shears]]"
narration: cut-ivy-figure-resolved
to: resolved
- verb: use
target: ivy-figure
chipLabel: USE SHEARS
chipCommand: use vines with shears
requires:
item: "[[pruning-shears]]"
narration: cut-ivy-figure-resolved
to: resolved
---
## hidden
@@ -13,7 +41,7 @@ The shears close with a sound like teeth.
The figure falls apart leaf by leaf. Behind it, the glass is only glass.
## wrong-verb
The vines tighten without moving.
The vines tighten without moving. Their silence feels deliberate.
## failed
The ivy catches at your wrists. When you pull free, you are back among the cold plates of the dining room.
The ivy catches at your wrists. When you pull free, you are back among the cold plates of the dining room, with leaves clinging to your sleeves.
+19 -3
View File
@@ -2,10 +2,26 @@
id: linen-shape
startsIn: "[[laundry]]"
initialPhase: hanging
aliases: [linen shape, shape, sheet, sheets, linen]
onResolved:
setFlags:
linenShapeEmpty: true
onFailed:
narration: failed
retreatTo: "[[servants-passage]]"
defaultWrongVerbNarration: wrong-verb
phases:
hanging:
description: hanging
transitions:
- verb: wait
chipLabel: WAIT
narration: wait-resolved
to: resolved
---
## hanging
One hanging sheet has the weight and outline of a person standing behind it.
One hanging sheet has the weight and outline of a person standing behind it. The shape shifts when you look straight at it.
## wait-resolved
You wait.
@@ -13,7 +29,7 @@ You wait.
The sheet stirs. Nothing stands behind it. Nothing had stood behind it.
## wrong-verb
The shape seems to lean toward you.
The shape seems to lean toward you, then settles back into stillness.
## failed
You push through the hanging sheets and come out in the servants' passage, breathing hard.
You push through the hanging sheets and come out in the servants' passage, breathing hard, with damp cloth brushing your face.
+19 -1
View File
@@ -2,6 +2,24 @@
id: piano-echo
startsIn: "[[music-room]]"
initialPhase: listening
aliases: [piano echo, piano, note, key]
onResolved:
setFlags:
musicSolved: true
onFailed:
narration: failed
retreatTo: "[[hallway]]"
defaultWrongVerbNarration: wrong-verb
phases:
listening:
description: listening
transitions:
- verb: play
target: piano-echo
chipLabel: PLAY NOTE
chipCommand: play note
narration: play-piano-echo-resolved
to: resolved
---
## listening
@@ -13,7 +31,7 @@ You play the waiting note.
The answer comes at once, nearer than before. A narrow part of the wall settles back into shadow.
## wrong-verb
The answering note repeats, patient and exact.
The answering note repeats, patient and exact. It sounds as if it has not moved at all.
## failed
The wrong chord goes through the floorboards. You climb back to the hallway before the echo finishes.
+37
View File
@@ -0,0 +1,37 @@
---
id: portrait-woman
startsIn: "[[burial-gallery]]"
initialPhase: watching
aliases: [portrait woman, woman, portrait, portraits, veil, funeral veil]
onResolved:
setFlags:
familyResemblanceSeen: true
onFailed:
narration: failed
retreatTo: "[[root-chamber]]"
defaultWrongVerbNarration: wrong-verb
phases:
watching:
description: watching
transitions:
- verb: examine
target: portrait-woman
chipLabel: EXAMINE PORTRAITS
chipCommand: examine portraits
narration: examine-portraits-resolved
to: resolved
---
## watching
One ruined portrait has not lost its eyes. The woman in it watches from behind a funeral veil, though the paint around her face has split to canvas.
## examine-portraits-resolved
You examine the portraits one by one.
Damage has made a family resemblance where blood may not have. Then the veiled woman's mouth, still painted, shows you the part you did not want to see: your own expression, waiting inside hers.
## wrong-verb
The veiled portrait watches you with patient, damaged eyes.
## failed
The veil lifts though nothing touches it. You step back into the root chamber before the face beneath can finish becoming familiar.
+46
View File
@@ -0,0 +1,46 @@
---
id: rainwater-basin
startsIn: "[[rain-room]]"
initialPhase: reflecting
aliases: [rainwater basin, basin, water, rainwater, reflection]
onResolved:
setFlags:
rainRoomEntered: true
houseAcceptedYou: true
onFailed:
narration: failed
retreatTo: "[[wrong-hallway]]"
defaultWrongVerbNarration: wrong-verb
phases:
reflecting:
description: reflecting
transitions:
- verb: look
target: rainwater-basin
chipLabel: LOOK BASIN
chipCommand: look basin
narration: look-resolved
to: resolved
- verb: examine
target: rainwater-basin
chipLabel: EXAMINE BASIN
chipCommand: examine basin
narration: look-resolved
to: resolved
---
## reflecting
The basin shows no ceiling. It shows a hallway instead, and in that hallway a lamp going out.
## look-resolved
You look into the rainwater.
Rooms gather there one beneath another: nursery, chapel, vault, gate. For a moment they fit together correctly. Then your reflection enters them from the wrong side, and every door in the image opens inward.
When you look up, the room has learned you.
## wrong-verb
Rain touches your hands, though you are not beneath it.
## failed
The rain rises without filling the basin. You step back into the wrong hallway before it reaches your mouth.
+21 -1
View File
@@ -2,8 +2,25 @@
id: rat
startsIn: "[[cellar-stair]]"
initialPhase: lurking
onResolved:
setFlags:
ratGone: true
defaultWrongVerbNarration: wrong-verb
phases:
lurking:
description: lurking
transitions:
- verb: attack
target: rat
chipLabel: ATTACK RAT
chipCommand: attack rat
narration: attack-rat-resolved
to: resolved
- verb: wait
chipLabel: WAIT
narration: wait-stays
to: lurking
---
![[Pasted image 20260509213136.png]]
## lurking
A heavy rat watches you from the third step. Its eyes catch the light.
@@ -12,3 +29,6 @@ You stamp. The rat squeals and is gone into the dark.
## wait-stays
The rat does not move. Neither do you.
## wrong-verb
The rat watches.
+39
View File
@@ -0,0 +1,39 @@
---
id: reflection
startsIn: "[[flooded-passage]]"
initialPhase: following
aliases: [reflection, water, black water, face, reflected figure]
onResolved:
setFlags:
reflectionObscured: true
onFailed:
narration: failed
retreatTo: "[[ossuary]]"
defaultWrongVerbNarration: wrong-verb
phases:
following:
description: following
transitions:
- verb: use
target: reflection
chipLabel: USE SHEET
chipCommand: use water with sheet
requires:
item: "[[damp-sheet]]"
narration: obscure-water-resolved
to: resolved
---
## following
Your reflection in the black water is a half-second late. When you stop, it finishes the motion after you.
## obscure-water-resolved
You spread the damp sheet across the water.
The cloth drinks the reflection into itself. For a moment a face presses up beneath the linen, then slackens into ordinary wet cloth.
## wrong-verb
The reflected figure takes a step you have not taken.
## failed
The water rises without rising. You stumble back into the ossuary before the face below reaches the surface.
+36
View File
@@ -0,0 +1,36 @@
---
id: root-movement
startsIn: "[[root-chamber]]"
initialPhase: shifting
aliases: [root movement, roots, root, opening]
onResolved:
setFlags:
rootsListenedTo: true
onFailed:
narration: failed
retreatTo: "[[flooded-passage]]"
defaultWrongVerbNarration: wrong-verb
phases:
shifting:
description: shifting
transitions:
- verb: listen
chipLabel: LISTEN
chipCommand: listen
narration: listen-resolved
to: resolved
---
## shifting
The roots stir whenever your light touches them. Their movement is slow, but every opening in the room is narrower than it was a breath ago.
## listen-resolved
You put out the light and listen.
In darkness, the roots stop pretending to be wood. They creak like a house settling around a sleeping child, then draw back from the eastern opening.
## wrong-verb
The roots tighten against one another, dry fibers whispering overhead.
## failed
The roots drop like ropes. You force your way back to the flooded passage with bark dust in your mouth.
+16
View File
@@ -2,6 +2,22 @@
id: stair-sleeper
startsIn: "[[stair-up]]"
initialPhase: seated
aliases: [stair sleeper, sleeper, figure, person, body]
onResolved:
setFlags:
hallwayShifted: true
onFailed:
narration: failed
retreatTo: "[[parlor]]"
defaultWrongVerbNarration: wrong-verb
phases:
seated:
description: seated
transitions:
- verb: wait
chipLabel: WAIT
narration: wait-resolved
to: resolved
---
## seated
+60
View File
@@ -0,0 +1,60 @@
---
id: vault-memory
startsIn: "[[vault]]"
initialPhase: buried
aliases: [vault memory, memory, bed, photograph, photo, thing, buried thing]
defaultWrongVerbNarration: wrong-verb
phases:
buried:
description: buried
transitions:
- verb: read
target: family-register
chipLabel: READ REGISTER
chipCommand: read register
requires:
item: "[[family-register]]"
narration: read-register-resolved
setFlags:
nameSpoken: true
to: resolved
- verb: take
target: vault-memory
chipLabel: TAKE PHOTO
chipCommand: take photograph
narration: take-photograph-resolved
setFlags:
tookPhotograph: true
to: resolved
- verb: attack
target: vault-memory
chipLabel: ATTACK BED
chipCommand: attack bed
narration: attack-bed-resolved
setFlags:
disturbedVault: true
to: resolved
---
## buried
The bed waits in the center of the vault. The photograph lies face down on the coverlet.
The family register feels heavier here, as if all its missing pages have returned at once.
## read-register-resolved
You read the name from the register.
The letters are difficult only until you say them. Then they seem to have been waiting in your mouth since the gate.
## take-photograph-resolved
You take the photograph.
It is dry and light and terribly easy to lift.
## attack-bed-resolved
You strike the little bed.
The sound is small. The house hears it anyway.
## wrong-verb
The photograph remains face down. The bedclothes do not stir.
+21 -3
View File
@@ -2,10 +2,28 @@
id: window-guest
startsIn: "[[dining-room]]"
initialPhase: standing-outside
aliases: [guest, window guest, curtains, curtain, window]
onResolved:
setFlags:
curtainsClosed: true
onFailed:
narration: failed
retreatTo: "[[hallway]]"
defaultWrongVerbNarration: wrong-verb
phases:
standing-outside:
description: standing-outside
transitions:
- verb: close
target: window-guest
chipLabel: CLOSE CURTAINS
chipCommand: close curtains
narration: close-window-guest-resolved
to: resolved
---
## standing-outside
Rain touches the dining-room window from the wrong side. Someone stands beyond the glass with their head bowed.
Rain touches the dining-room window from the wrong side. Someone stands beyond the glass with their head bowed. Their coat is too dark to make out properly.
## close-window-guest-resolved
You draw the curtains together before you look closely.
@@ -13,7 +31,7 @@ You draw the curtains together before you look closely.
For a moment, cloth and rain hold the same shape. Then there is only the table behind you.
## wrong-verb
The figure outside lifts its face a little.
The figure outside lifts its face a little, as if disappointed by your hesitation.
## failed
The glass shows too much. You find yourself back in the hallway with the taste of rain in your mouth.
The glass shows too much. For one instant there is a second dining room beyond it. Then you are back in the hallway with rain in your mouth.
+8 -1
View File
@@ -1,6 +1,13 @@
---
id: bad
whenFlags:
_never: true
tookPhotograph: true
---
You take the photograph.
The child in it is you.
Behind you, on the stairs, someone has come up to meet you.
You do not go down again.
+17
View File
@@ -0,0 +1,17 @@
---
id: mercy
whenFlags:
nameSpoken: true
woofReturned: true
basiliskSpared: true
burialRingPlaced: true
familyResemblanceSeen: true
---
You remain in the vault until the lamp goes out.
The house is quiet.
Somewhere above, floorboards settle one final time.
By morning, Halfstreet contains one fewer restless thing.
+15
View File
@@ -0,0 +1,15 @@
---
id: replacement
whenFlags:
houseAcceptedYou: true
rainRoomEntered: true
hallwayShifted: true
---
You open the front door.
Someone is standing at the gate outside.
They look tired. Rain-darkened. Lost.
After a moment, you understand they have arrived for you.
+13 -2
View File
@@ -1,7 +1,18 @@
---
id: true
whenFlags:
ratGone: true
nameSpoken: true
basiliskSpared: true
burialRingPlaced: true
familyResemblanceSeen: true
---
You stand at the top of the stair. The thing below has settled. The door behind you opens, and outside, finally, is morning.
You stand in the vault. What is buried at Halfstreet is buried because it was loved, and grieved, and finally let go.
You set the lamp beside it.
You speak the name aloud.
The house settles around you like a long exhalation.
Outside, the road exists again.
+10 -1
View File
@@ -1,6 +1,15 @@
---
id: wrong
whenFlags:
_never: true
disturbedVault: true
---
You disturb what should have remained untouched.
The house rearranges itself around your leaving.
Corridors repeat.
Doors return you to themselves.
The hallway grows longer.
+47
View File
@@ -0,0 +1,47 @@
---
id: halfstreet
title: Halfstreet
description: A gothic mystery.
startingRoom: "[[outside-gate]]"
startingInventory:
- "[[letter]]"
- "[[matches]]"
- "[[broken-cigarette]]"
endingPriority:
- mercy
- "true"
- replacement
- bad
- wrong
transcriptCap: 200
---
## opening-art
_ _ _ __ ____ _ _
| | | | __ _| |/ _| / ___|| |_ _ __ ___ ___| |_
| |_| |/ _` | | |_ \___ \| __| '__/ _ \/ _ \ __|
| _ | (_| | | _| ___) | |_| | | __/ __/ |_
|_| |_|\__,_|_|_| |____/ \__|_| \___|\___|\__|
## help
You arrive at the address, but you do not remember what has happened. The road behind you is gone...
This is a text adventure. Type short commands to act in the house.
Common commands:
look describe the room again
n, s, e, w, u, d move by direction
take lamp pick something up
examine letter inspect something nearby or held
read letter read a readable object
inventory see what you carry
light lamp with matches use one thing with another
wait let the room continue
undo step back once
restart begin again
theme change the terminal colors
Most commands are verb first, then the thing: examine gate, take lamp, use key on door.
## ended
The story has ended. Type `restart` or `undo`.
+190 -37
View File
@@ -1,15 +1,37 @@
import type { World, Room, Item } from './types'
import type { World, Room, Item, GameManifest, EncounterDef } from './types'
import {
parseAction,
parseGame,
parseLightMechanic,
parseMessages,
parseParser,
parseUi,
parseResolveMechanic,
parseRoom,
parseItem,
parseEnding,
parseEncounterNarration,
type ParsedEncounterNarration,
} from './loader'
// Importing loader (above) triggers auto-registration of encounter narrations.
// ESM evaluates dependencies first, so by the time encounters.ts is evaluated below,
// narration() can resolve all keys.
import { encounters } from './encounters'
const gameFiles = import.meta.glob<string>('./game.md', {
eager: true, query: '?raw', import: 'default',
})
const parserFiles = import.meta.glob<string>('./parser.md', {
eager: true, query: '?raw', import: 'default',
})
const uiFiles = import.meta.glob<string>('./ui.md', {
eager: true, query: '?raw', import: 'default',
})
const messageFiles = import.meta.glob<string>('./messages.md', {
eager: true, query: '?raw', import: 'default',
})
const mechanicFiles = import.meta.glob<string>('./mechanics/*.md', {
eager: true, query: '?raw', import: 'default',
})
const actionFiles = import.meta.glob<string>('./actions/*.md', {
eager: true, query: '?raw', import: 'default',
})
const roomFiles = import.meta.glob<string>('./rooms/*.md', {
eager: true, query: '?raw', import: 'default',
})
@@ -23,11 +45,48 @@ const encounterFiles = import.meta.glob<string>('./encounters/*.md', {
eager: true, query: '?raw', import: 'default',
})
// Re-parse encounter docs here so we can validate startsIn / initialPhase against encounters.ts.
// (The loader already auto-registered narrations from these same files at module init.)
const encounterDocs = Object.entries(encounterFiles).map(([path, raw]) =>
parseEncounterNarration(raw, path),
)
const markdownEncounters: Record<string, EncounterDef> = {}
for (const doc of encounterDocs) {
if (!doc.encounter) {
throw new Error(`encounters/${doc.id}.md is missing phases frontmatter`)
}
if (markdownEncounters[doc.id]) throw new Error(`encounters/${doc.id}.md: duplicate markdown encounter id "${doc.id}"`)
markdownEncounters[doc.id] = doc.encounter
}
const encounters: Record<string, EncounterDef> = markdownEncounters
const gameEntry = Object.entries(gameFiles)[0]
if (!gameEntry) {
throw new Error('world/game.md is missing')
}
const game = parseGame(gameEntry[1], gameEntry[0])
const parserEntry = Object.entries(parserFiles)[0]
if (!parserEntry) {
throw new Error('world/parser.md is missing')
}
const parser = parseParser(parserEntry[1], parserEntry[0])
const uiEntry = Object.entries(uiFiles)[0]
const ui = uiEntry ? parseUi(uiEntry[1], uiEntry[0]) : undefined
const messageEntry = Object.entries(messageFiles)[0]
const messages = messageEntry ? parseMessages(messageEntry[1], messageEntry[0]) : undefined
const mechanics: World['mechanics'] = {}
for (const [path, raw] of Object.entries(mechanicFiles)) {
if (path.endsWith('/light.md')) {
mechanics.light = parseLightMechanic(raw, path)
} else if (path.endsWith('/resolve.md')) {
mechanics.resolve = parseResolveMechanic(raw, path)
}
}
const actions: NonNullable<World['actions']> = {}
for (const [path, raw] of Object.entries(actionFiles)) {
const action = parseAction(raw, path)
if (actions[action.id]) throw new Error(`${path}: duplicate action id "${action.id}"`)
actions[action.id] = action
}
// Build rooms map.
const rooms: Record<string, Room> = {}
@@ -46,37 +105,65 @@ for (const [path, raw] of Object.entries(itemFiles)) {
}
// Build endings.
const endings: World['endings'] = {
true: { whenFlags: {}, narration: '' },
wrong: { whenFlags: {}, narration: '' },
bad: { whenFlags: {}, narration: '' },
}
const endings: World['endings'] = {}
const seenEndings = new Set<string>()
for (const [path, raw] of Object.entries(endingFiles)) {
const { id, ending } = parseEnding(raw, path)
if (seenEndings.has(id)) throw new Error(`${path}: duplicate ending id "${id}"`)
endings[id] = ending
seenEndings.add(id)
}
const requiredEndings = ['true', 'wrong', 'bad'] as const
for (const id of requiredEndings) {
if (!seenEndings.has(id)) {
throw new Error(`endings/${id}.md is missing — every ending id must have a markdown file.`)
}
interface AssembleWorldInput {
game: GameManifest
ui?: World['ui']
parser?: World['parser']
messages?: World['messages']
mechanics?: World['mechanics']
actions?: World['actions']
rooms: Record<string, Room>
items: Record<string, Item>
endings: World['endings']
encounters: Record<string, EncounterDef>
encounterDocs: ParsedEncounterNarration[]
}
// Cross-reference validation.
// Build set of all known flag names from encounter setFlags and ending whenFlags.
const knownFlags = new Set<string>()
for (const enc of Object.values(encounters)) {
export function assembleWorld({
game,
ui,
parser,
messages,
mechanics,
actions,
rooms,
items,
endings,
encounters,
encounterDocs,
}: AssembleWorldInput): World {
// Build set of all known flag names from encounter setFlags and ending whenFlags.
const knownFlags = new Set<string>()
for (const enc of Object.values(encounters)) {
if (enc.onResolved?.setFlags) {
for (const flagName of Object.keys(enc.onResolved.setFlags)) knownFlags.add(flagName)
}
}
for (const ending of Object.values(endings)) {
}
for (const action of Object.values(actions ?? {})) {
if (action.setsFlags) {
for (const flagName of Object.keys(action.setsFlags)) knownFlags.add(flagName)
}
}
for (const ending of Object.values(endings)) {
for (const flagName of Object.keys(ending.whenFlags)) knownFlags.add(flagName)
}
}
for (const room of Object.values(rooms)) {
for (const id of game.endingPriority) {
if (!endings[id]) {
throw new Error(`game.md: endingPriority references "${id}" but endings/${id}.md is missing.`)
}
}
for (const room of Object.values(rooms)) {
for (const [dir, dest] of Object.entries(room.exits)) {
if (!rooms[dest!]) {
throw new Error(`rooms/${room.id}.md: exit${dir.toUpperCase()} references "${dest}" but no such room exists.`)
@@ -104,27 +191,93 @@ for (const room of Object.values(rooms)) {
}
}
}
}
}
// Validate encounter narration registry: every encounter in TS has a markdown doc.
for (const enc of Object.values(encounters)) {
if (!rooms[game.startingRoom]) {
throw new Error(`game.md: startingRoom references "${game.startingRoom}" but no such room exists.`)
}
for (const itemId of game.startingInventory) {
if (!items[itemId]) {
throw new Error(`game.md: startingInventory references unknown item "${itemId}"`)
}
}
const actionHandlers = new Map<string, string>()
for (const action of Object.values(actions ?? {})) {
if (action.handler) {
const previous = actionHandlers.get(action.handler)
if (previous) {
throw new Error(
`actions/${action.id}.md: handler "${action.handler}" is already used by actions/${previous}.md. ` +
'Only one action may own a handler-backed behavior.',
)
}
actionHandlers.set(action.handler, action.id)
}
const referencedItems: Array<[string, string]> = [
...(action.requires?.allHeld ?? []).map((id): [string, string] => ['requires.allHeld', id]),
...(action.requires?.allVisibleOrHeld ?? []).map((id): [string, string] => ['requires.allVisibleOrHeld', id]),
...(action.consumes?.inventory ?? []).map((id): [string, string] => ['consumes.inventory', id]),
...(action.decrements ? [['decrements.item', action.decrements.item] as [string, string]] : []),
]
for (const [field, itemId] of referencedItems) {
if (!items[itemId]) {
throw new Error(`actions/${action.id}.md: ${field} references unknown item "${itemId}"`)
}
}
if (action.handler === 'drunk-transition') {
const config = action.drunkTransition
if (!config) {
throw new Error(`actions/${action.id}.md uses drunk-transition but is missing drunkTransition config`)
}
for (const [key, roomId] of Object.entries({
destinationRoom: config.destinationRoom,
wakeRoom: config.wakeRoom,
resetRoom: config.resetRoom,
})) {
if (!rooms[roomId]) {
throw new Error(`actions/${action.id}.md: drunkTransition.${key} references unknown room "${roomId}"`)
}
}
}
}
// Validate encounter narration registry: every encounter has a markdown doc.
for (const enc of Object.values(encounters)) {
const doc = encounterDocs.find(d => d.id === enc.id)
if (!doc) {
throw new Error(`encounters/${enc.id}.md: missing narration markdown for encounter "${enc.id}"`)
}
if (doc.startsIn !== enc.startsIn) {
throw new Error(`encounters/${enc.id}.md: startsIn "${doc.startsIn}" does not match encounters.ts "${enc.startsIn}"`)
}
if (doc.initialPhase !== enc.initialPhase) {
throw new Error(`encounters/${enc.id}.md: initialPhase "${doc.initialPhase}" does not match encounters.ts "${enc.initialPhase}"`)
}
}
export const world: World = {
startingRoom: 'outside-gate',
startingInventory: ['letter', 'matches', 'broken-cigarette'],
return {
game,
ui,
parser,
messages,
mechanics,
actions,
startingRoom: game.startingRoom,
startingInventory: game.startingInventory,
endingPriority: game.endingPriority,
rooms,
items,
encounters,
endings,
}
}
export const world: World = assembleWorld({
game,
ui,
parser,
messages,
mechanics,
actions,
rooms,
items,
encounters,
endings,
encounterDocs,
})
+9
View File
@@ -0,0 +1,9 @@
---
id: altar-stone
names: ["altar", "altar stone", "cracked altar", "altar-stone"]
short: "a cracked altar stone"
takeable: false
initialState: {}
---
The altar has split across the center. The crack is narrow enough to hide a thread and deep enough to swallow light.
+9
View File
@@ -0,0 +1,9 @@
---
id: burial-ring
names: ["ring", "burial ring", "crest ring", "burial-ring"]
short: "a burial ring"
takeable: true
initialState: {}
---
The ring is too small for your hand. A worn crest remains on its face: a gate, a vine, and something below them that may be a well or an eye.
+3 -3
View File
@@ -8,10 +8,10 @@ initialState:
lit: false
---
A brass candlestick, heavy at the base. The candle inside it is burned low but not spent.
A brass candlestick, heavy at the base. The candle inside it is burned low but not spent. It holds a little light very steadily, as if it dislikes being noticed.
## lit
The wick takes. The flame bends toward the nearest doorway.
The wick takes. The flame stands upright for a moment, then bends toward the nearest doorway.
## extinguished
The candle gutters out. For a moment the smoke leans against your hand.
The candle gutters out. For a moment the smoke leans against your hand before it thins.
+1 -1
View File
@@ -6,4 +6,4 @@ takeable: true
initialState: {}
---
A sheet still wet from some old washing. It smells of rainwater and closed rooms.
A sheet still wet from some old washing. It smells of rainwater and closed rooms. It keeps the cold longer than cloth should.
+15
View File
@@ -0,0 +1,15 @@
---
id: family-register
names: ["register", "ledger", "family register", "family-register"]
short: "a family register"
takeable: true
readable: true
initialState: {}
---
The register is bound in black cloth, swollen at the corners from damp. Several pages have been cut out so neatly that their absence feels official.
## read
Names repeat across three generations. Births answer deaths. Deaths answer births.
One name appears in a different hand each time, always beside the same date, always corrected and written again beneath itself.
+1 -1
View File
@@ -6,4 +6,4 @@ takeable: true
initialState: {}
---
A tiny key on a black ribbon. It is too small for any door in the house.
A tiny key on a black ribbon. It is too small for any door in the house, and yet the ribbon has been tied and retied as if someone meant to keep it safe.
+9
View File
@@ -0,0 +1,9 @@
---
id: portrait-frame
names: ["frame", "portrait frame", "shattered frame", "portrait-frame"]
short: "a shattered portrait frame"
takeable: false
initialState: {}
---
The backing has warped away from the frame. Someone wrote beneath it, then scratched the writing into harmless lines.
+1 -1
View File
@@ -6,4 +6,4 @@ takeable: true
initialState: {}
---
Iron pruning shears with dark soil in the hinge. The handles are cold through your palm.
Iron pruning shears with dark soil in the hinge. The handles are cold through your palm. They have been used often enough to remember the shape of branches.
+11
View File
@@ -0,0 +1,11 @@
---
id: rainwater-basin
names: ["basin", "rainwater basin", "rainwater", "water", "rainwater-basin"]
short: "a rainwater basin"
takeable: false
initialState: {}
---
The basin is cut directly into the floor. Rain strikes its surface without making rings.
If you lean near it, the water shows rooms you have already left.
+11
View File
@@ -0,0 +1,11 @@
---
id: rusted-key
names: ["key", "rusted key", "rusted-key"]
short: "a rusted key"
takeable: true
initialState: {}
---
The key is furred with rust except where fingers have worried the bow clean. Its teeth are thin, almost eaten through.
It smells faintly of rain.
+1 -1
View File
@@ -7,4 +7,4 @@ lighter: true
initialState: {}
---
A silver lighter with a worn crest on one side. It opens with a small reluctant click.
A silver lighter with a worn crest on one side. It opens with a small reluctant click, then catches as if it had been waiting to be asked.
+11
View File
@@ -0,0 +1,11 @@
---
id: silver-vial
names: ["vial", "silver vial", "silver-vial"]
short: "a silver vial"
takeable: true
initialState: {}
---
The vial is cold enough to sting your fingers. Its stopper has been sealed with black wax and pressed with the same worn crest: gate, vine, and the shape beneath them.
Something clear moves inside, though the vial is still.
+9
View File
@@ -0,0 +1,9 @@
---
id: toy-boat
names: ["toy boat", "wooden boat", "boat", "toy-boat"]
short: "a wooden toy boat"
takeable: true
initialState: {}
---
The boat has been carved from a single piece of soft wood. Its mast is broken. Along one side, a child's thumbnail has pressed a row of moons into the paint.
+9
View File
@@ -0,0 +1,9 @@
---
id: well-rope-wheel
names: ["wheel", "rope wheel", "well wheel", "iron wheel", "well-rope-wheel"]
short: "an iron rope wheel"
takeable: false
initialState: {}
---
The wheel is bolted to a post beside the well. Rust has gathered around the handle in small dark flowers. When you touch it, the rope below answers with a slow movement too deep to hear clearly.
+9
View File
@@ -0,0 +1,9 @@
---
id: whiskey
names: ["whiskey", "bottle of whiskey", "bottle"]
short: "a bottle of whiskey"
takeable: true
initialState: {}
---
A bottle of whiskey with a cloudy label and a dark amber level inside. It has the look of something set down in a hurry and never touched again.
+381 -1
View File
@@ -1,5 +1,330 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { parseRoom, parseItem, parseEnding, parseEncounterNarration, narration, registerEncounterNarrations, _resetEncounterNarrationRegistry } from './loader'
import {
parseGame,
parseAction,
parseLightMechanic,
parseResolveMechanic,
parseMessages,
parseParser,
parseUi,
parseRoom,
parseItem,
parseEnding,
parseEncounterNarration,
narration,
registerEncounterNarrations,
_resetEncounterNarrationRegistry,
} from './loader'
const GAME_MD = `---
id: halfstreet
title: Halfstreet
description: A gothic mystery.
startingRoom: "[[outside-gate]]"
startingInventory:
- "[[letter]]"
endingPriority:
- "true"
- wrong
transcriptCap: 200
---
## opening-art
HALFSTREET
## help
Help text.
## ended
The story has ended.
`
describe('parseGame', () => {
it('parses manifest frontmatter and sections', () => {
const game = parseGame(GAME_MD, 'game.md')
expect(game).toEqual({
id: 'halfstreet',
title: 'Halfstreet',
description: 'A gothic mystery.',
startingRoom: 'outside-gate',
startingInventory: ['letter'],
endingPriority: ['true', 'wrong'],
transcriptCap: 200,
openingArt: 'HALFSTREET',
helpText: 'Help text.',
endedText: 'The story has ended.',
})
})
it('throws when a required section is missing', () => {
const incomplete = GAME_MD.replace('## help\nHelp text.\n\n', '')
expect(() => parseGame(incomplete, 'game.md')).toThrow(/missing required section.*help/i)
})
})
const PARSER_MD = `---
directions:
n: [n, north]
s: [s, south]
e: [e, east]
w: [w, west]
u: [u, up]
d: [d, down]
prepositions: [with, on]
stopWords: [the]
noTargetVerbs: [look]
metaVerbs: [restart]
verbs:
go: [go]
look: [look, observe]
take: [take, pick up]
---
# Parser Vocabulary
`
describe('parseParser', () => {
it('parses parser vocabulary frontmatter', () => {
const parser = parseParser(PARSER_MD, 'parser.md')
expect(parser.verbs.look).toEqual(['look', 'observe'])
expect(parser.verbs.take).toContain('pick up')
expect(parser.directions.n).toEqual(['n', 'north'])
expect(parser.noTargetVerbs).toEqual(['look'])
})
it('rejects unsupported verb keys', () => {
const invalid = PARSER_MD.replace(' take: [take, pick up]', ' dance: [dance]')
expect(() => parseParser(invalid, 'parser.md')).toThrow()
})
})
const UI_MD = `---
pageTitle: Halfstreet - Ethan J Lewis
description: A gothic mystery.
robots: noindex
themeColor: "#1a0d00"
footer:
copyright: "© 2026 Ethan J Lewis"
copyrightHref: https://ethanjlewis.com
buildLabel: "Build #"
showBuild: true
links:
- label: GNU 3.0
href: https://half.st/ejlewis/halfstreet/src/branch/main/LICENSE
- label: Source Code
href: https://half.st/ejlewis/halfstreet
features:
chips: true
lightMeter: true
typedEffect: true
roomScroll: true
---
# UI
`
describe('parseUi', () => {
it('parses site metadata, footer config, and feature toggles', () => {
const ui = parseUi(UI_MD, 'ui.md')
expect(ui.pageTitle).toBe('Halfstreet - Ethan J Lewis')
expect(ui.footer.buildLabel).toBe('Build #')
expect(ui.footer.links.map((link) => link.label)).toEqual(['GNU 3.0', 'Source Code'])
expect(ui.features).toEqual({
chips: true,
lightMeter: true,
typedEffect: true,
roomScroll: true,
})
})
})
const LIGHT_MECHANIC_MD = `---
enabled: true
handler: light
maxTurns: 3
burnOn: [wait]
stateKeys:
lit: isLit
burn: fuel
ui:
meter: true
icon: candle
---
## noLighter
No flame.
## flameDies
Dark again.
`
describe('parseLightMechanic', () => {
it('parses configurable light mechanic frontmatter and messages', () => {
const light = parseLightMechanic(LIGHT_MECHANIC_MD, 'mechanics/light.md')
expect(light.enabled).toBe(true)
expect(light.maxTurns).toBe(3)
expect(light.burnOn).toEqual(['wait'])
expect(light.stateKeys).toEqual({ lit: 'isLit', burn: 'fuel' })
expect(light.messages?.noLighter).toBe('No flame.')
expect(light.messages?.flameDies).toBe('Dark again.')
})
it('rejects unknown light message sections', () => {
const invalid = `${LIGHT_MECHANIC_MD}
## typo
No.
`
expect(() => parseLightMechanic(invalid, 'mechanics/light.md')).toThrow(/unknown light mechanic section "## typo"/)
})
})
const RESOLVE_MECHANIC_MD = `---
enabled: true
handler: resolve
ladder: [steady, shaken, reeling, returning]
wrongVerbCost: 2
safeRooms:
recoverySteps: 2
failure:
retreatAt: returning
afterRetreat: reeling
---
# Resolve
`
describe('parseResolveMechanic', () => {
it('parses configurable resolve mechanic frontmatter', () => {
const resolve = parseResolveMechanic(RESOLVE_MECHANIC_MD, 'mechanics/resolve.md')
expect(resolve.enabled).toBe(true)
expect(resolve.ladder).toEqual(['steady', 'shaken', 'reeling', 'returning'])
expect(resolve.wrongVerbCost).toBe(2)
expect(resolve.safeRooms.recoverySteps).toBe(2)
expect(resolve.failure.afterRetreat).toBe('reeling')
})
it('rejects failure levels outside the ladder', () => {
const invalid = RESOLVE_MECHANIC_MD.replace('ladder: [steady, shaken, reeling, returning]', 'ladder: [steady, shaken]')
expect(() => parseResolveMechanic(invalid, 'mechanics/resolve.md')).toThrow(/failure\.retreatAt must be present in ladder/)
})
})
const ACTION_MD = `---
id: burn-letter
verbs: [use]
requires:
allVisibleOrHeld:
- "[[letter]]"
- "[[matches]]"
consumes:
inventory:
- "[[letter]]"
decrements:
item: "[[matches]]"
stateKey: uses
setsFlags:
letterBurned: true
---
## success
The letter catches at one corner. In a few breaths it is ash.
## spent
The matchbook is empty.
`
const HANDLER_ACTION_MD = `---
id: drink-whiskey
verbs: [drink]
handler: drunk-transition
requires:
allHeld:
- "[[whiskey]]"
consumes:
inventory:
- "[[whiskey]]"
drunkTransition:
destinationRoom: "[[drunk-hall]]"
maxMoves: 20
wakeRoom: "[[foyer]]"
resetRoom: "[[kitchen]]"
---
## success
You drink from the bottle.
## secretFoundPassOut
The faceless man steps backward.
## tooManyMovesPassOut
The rooms keep turning.
## reset
The bottle is not with you.
`
describe('parseAction', () => {
it('parses a declarative action with wikilinks and sections', () => {
const action = parseAction(ACTION_MD, 'actions/burn-letter.md')
expect(action.id).toBe('burn-letter')
expect(action.verbs).toEqual(['use'])
expect(action.requires?.allVisibleOrHeld).toEqual(['letter', 'matches'])
expect(action.consumes?.inventory).toEqual(['letter'])
expect(action.decrements).toEqual({ item: 'matches', stateKey: 'uses' })
expect(action.setsFlags).toEqual({ letterBurned: true })
expect(action.messages.success).toContain('ash')
})
it('parses a handler-backed drunk transition action', () => {
const action = parseAction(HANDLER_ACTION_MD, 'actions/drink-whiskey.md')
expect(action.id).toBe('drink-whiskey')
expect(action.handler).toBe('drunk-transition')
expect(action.requires?.allHeld).toEqual(['whiskey'])
expect(action.consumes?.inventory).toEqual(['whiskey'])
expect(action.drunkTransition).toEqual({
destinationRoom: 'drunk-hall',
maxMoves: 20,
wakeRoom: 'foyer',
resetRoom: 'kitchen',
})
expect(action.messages.tooManyMovesPassOut).toContain('turning')
})
it('requires a success section', () => {
const invalid = ACTION_MD.replace(/## success[\s\S]*?## spent/, '## spent')
expect(() => parseAction(invalid, 'actions/burn-letter.md')).toThrow(/missing required section "## success"/)
})
it('requires handler-specific sections for drunk transition actions', () => {
const invalid = HANDLER_ACTION_MD.replace(/## tooManyMovesPassOut[\s\S]*?## reset/, '## reset')
expect(() => parseAction(invalid, 'actions/drink-whiskey.md')).toThrow(
/missing required section "## tooManyMovesPassOut" for handler "drunk-transition"/,
)
})
})
const MESSAGES_MD = `## unknown-verb
No.
## inventory-empty
Nothing held.
`
describe('parseMessages', () => {
it('parses keyed message sections', () => {
expect(parseMessages(MESSAGES_MD, 'messages.md')).toEqual({
'unknown-verb': 'No.',
'inventory-empty': 'Nothing held.',
})
})
it('rejects unknown message keys', () => {
const invalid = `## typo-key
No.
`
expect(() => parseMessages(invalid, 'messages.md')).toThrow(/unknown message section "## typo-key"/)
})
})
const FOYER_MD = `---
id: foyer
@@ -251,6 +576,24 @@ const RAT_MD = `---
id: rat
startsIn: "[[cellar-stair]]"
initialPhase: lurking
onResolved:
setFlags:
ratGone: true
defaultWrongVerbNarration: wrong-verb
phases:
lurking:
description: lurking
transitions:
- verb: attack
target: rat
chipLabel: ATTACK RAT
chipCommand: attack rat
narration: attack-resolved
to: resolved
- verb: wait
chipLabel: WAIT
narration: wait-stays
to: lurking
---
## lurking
@@ -261,6 +604,9 @@ You stamp. The rat squeals and is gone into the dark.
## wait-stays
The rat does not move. Neither do you.
## wrong-verb
The rat watches.
`
describe('parseEncounterNarration', () => {
@@ -273,7 +619,41 @@ describe('parseEncounterNarration', () => {
lurking: 'A heavy rat watches you from the third step. Its eyes catch the light.',
'attack-resolved': 'You stamp. The rat squeals and is gone into the dark.',
'wait-stays': 'The rat does not move. Neither do you.',
'wrong-verb': 'The rat watches.',
})
expect(doc.encounter).toMatchObject({
id: 'rat',
startsIn: 'cellar-stair',
initialPhase: 'lurking',
onResolved: { setFlags: { ratGone: true } },
defaultWrongVerbNarration: 'The rat watches.',
})
const lurking = doc.encounter?.phases.lurking
expect(lurking).toBeDefined()
expect(lurking?.description).toContain('heavy rat')
expect(lurking?.transitions).toEqual([
{
verb: 'attack',
target: 'rat',
chipLabel: 'ATTACK RAT',
chipCommand: 'attack rat',
narration: 'You stamp. The rat squeals and is gone into the dark.',
to: 'resolved',
},
{
verb: 'wait',
chipLabel: 'WAIT',
narration: 'The rat does not move. Neither do you.',
to: 'lurking',
},
])
})
it('throws when encounter frontmatter points at a missing prose section', () => {
const invalid = RAT_MD.replace('narration: attack-resolved', 'narration: missing-key')
expect(() => parseEncounterNarration(invalid, 'encounters/rat.md')).toThrow(
/frontmatter references missing section "## missing-key"/,
)
})
it('throws when no sections are present', () => {
+203 -6
View File
@@ -18,9 +18,20 @@ function matter(raw: string): ParsedFile {
const data = (parsed && typeof parsed === 'object' ? parsed : {}) as Record<string, unknown>
return { data, content }
}
import type { Room, RoomDescriptions, Item } from './types'
import { DEFAULT_WORLD_MESSAGES, type DeclarativeAction, type EncounterDef, type Room, type RoomDescriptions, type Item, type WorldMessages } from './types'
import type { Direction } from '../engine/types'
import { roomFrontmatterSchema, itemFrontmatterSchema, endingFrontmatterSchema, encounterFrontmatterSchema } from './schema'
import {
gameFrontmatterSchema,
actionFrontmatterSchema,
lightMechanicFrontmatterSchema,
parserFrontmatterSchema,
resolveMechanicFrontmatterSchema,
roomFrontmatterSchema,
itemFrontmatterSchema,
endingFrontmatterSchema,
encounterFrontmatterSchema,
uiFrontmatterSchema,
} from './schema'
const WIKILINK = /^\[\[([^\]|]+)(?:\|[^\]]*)?\]\]$/
@@ -78,6 +89,139 @@ const DIR_KEYS: Record<Direction, { exit: string; requires: string; locked: stri
const REQUIRED_ROOM_SECTIONS = ['first-visit', 'revisit', 'examined'] as const
const REQUIRED_GAME_SECTIONS = ['opening-art', 'help', 'ended'] as const
export function parseGame(raw: string, sourcePath: string) {
const parsed = matter(raw)
const frontmatter = stripWikilink(parsed.data) as Record<string, unknown>
const fm = gameFrontmatterSchema.parse(frontmatter)
const sections = splitSections(parsed.content)
for (const key of REQUIRED_GAME_SECTIONS) {
if (!(key in sections)) {
throw new Error(`${sourcePath}: missing required section "## ${key}"`)
}
}
return {
id: fm.id,
title: fm.title,
description: fm.description,
startingRoom: fm.startingRoom,
startingInventory: fm.startingInventory,
endingPriority: fm.endingPriority,
transcriptCap: fm.transcriptCap,
openingArt: sections['opening-art']!,
helpText: sections['help']!,
endedText: sections['ended']!,
}
}
export function parseParser(raw: string, _sourcePath: string) {
const parsed = matter(raw)
const frontmatter = stripWikilink(parsed.data) as Record<string, unknown>
return parserFrontmatterSchema.parse(frontmatter)
}
export function parseUi(raw: string, _sourcePath: string) {
const parsed = matter(raw)
const frontmatter = stripWikilink(parsed.data) as Record<string, unknown>
return uiFrontmatterSchema.parse(frontmatter)
}
export function parseMessages(raw: string, sourcePath: string): WorldMessages {
const parsed = matter(raw)
const sections = splitSections(parsed.content)
const allowed = Object.keys(DEFAULT_WORLD_MESSAGES)
for (const key of Object.keys(sections)) {
if (!allowed.includes(key)) {
throw new Error(`${sourcePath}: unknown message section "## ${key}". Allowed: ${allowed.join(', ')}`)
}
}
return sections as WorldMessages
}
const LIGHT_MESSAGE_KEYS = [
'useLighterWithWhat',
'cannotLight',
'alreadyLit',
'notHelpful',
'spent',
'noLighter',
'cannotExtinguish',
'notLit',
'dropLit',
'flameCatches',
'flameDies',
] as const
export function parseLightMechanic(raw: string, sourcePath: string) {
const parsed = matter(raw)
const frontmatter = stripWikilink(parsed.data) as Record<string, unknown>
const fm = lightMechanicFrontmatterSchema.parse(frontmatter)
const sections = splitSections(parsed.content)
for (const key of Object.keys(sections)) {
if (!LIGHT_MESSAGE_KEYS.includes(key as typeof LIGHT_MESSAGE_KEYS[number])) {
throw new Error(`${sourcePath}: unknown light mechanic section "## ${key}". Allowed: ${LIGHT_MESSAGE_KEYS.join(', ')}`)
}
}
return {
...fm,
messages: sections,
}
}
export function parseResolveMechanic(raw: string, _sourcePath: string) {
const parsed = matter(raw)
const frontmatter = stripWikilink(parsed.data) as Record<string, unknown>
return resolveMechanicFrontmatterSchema.parse(frontmatter)
}
const ACTION_SECTION_KEYS = ['success', 'spent', 'missingRequired', 'secretFoundPassOut', 'tooManyMovesPassOut', 'reset'] as const
type ActionSectionKey = typeof ACTION_SECTION_KEYS[number]
const ACTION_REQUIRED_SECTIONS: Record<NonNullable<DeclarativeAction['handler']> | 'default', ActionSectionKey[]> = {
default: ['success'],
'drunk-transition': ['success', 'secretFoundPassOut', 'tooManyMovesPassOut', 'reset'],
}
export function parseAction(raw: string, sourcePath: string): DeclarativeAction {
const parsed = matter(raw)
const frontmatter = stripWikilink(parsed.data) as Record<string, unknown>
const fm = actionFrontmatterSchema.parse(frontmatter)
const sections = splitSections(parsed.content)
for (const key of Object.keys(sections)) {
if (!ACTION_SECTION_KEYS.includes(key as ActionSectionKey)) {
throw new Error(`${sourcePath}: unknown action section "## ${key}". Allowed: ${ACTION_SECTION_KEYS.join(', ')}`)
}
}
const requiredSections = ACTION_REQUIRED_SECTIONS[fm.handler ?? 'default']
for (const key of requiredSections) {
if (!sections[key]) {
const scope = fm.handler ? ` for handler "${fm.handler}"` : ''
throw new Error(`${sourcePath}: missing required section "## ${key}"${scope}`)
}
}
return {
id: fm.id,
verbs: fm.verbs,
handler: fm.handler,
requires: fm.requires,
consumes: fm.consumes,
decrements: fm.decrements,
setsFlags: fm.setsFlags,
drunkTransition: fm.drunkTransition,
messages: {
success: sections.success!,
spent: sections.spent,
missingRequired: sections.missingRequired,
secretFoundPassOut: sections.secretFoundPassOut,
tooManyMovesPassOut: sections.tooManyMovesPassOut,
reset: sections.reset,
},
}
}
export function parseRoom(raw: string, sourcePath: string): Room {
const parsed = matter(raw)
const frontmatter = stripWikilink(parsed.data) as Record<string, unknown>
@@ -179,7 +323,7 @@ export function parseItem(raw: string, sourcePath: string): Item {
}
export interface ParsedEnding {
id: 'true' | 'wrong' | 'bad'
id: string
ending: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string }
}
@@ -199,6 +343,7 @@ export interface ParsedEncounterNarration {
startsIn: string
initialPhase: string
narrations: Record<string, string>
encounter?: EncounterDef
}
export function parseEncounterNarration(raw: string, sourcePath: string): ParsedEncounterNarration {
@@ -209,14 +354,67 @@ export function parseEncounterNarration(raw: string, sourcePath: string): Parsed
if (Object.keys(narrations).length === 0) {
throw new Error(`${sourcePath}: no narration sections found`)
}
const encounter = fm.phases
? buildEncounterFromMarkdown(fm, narrations, sourcePath)
: undefined
return {
id: fm.id,
startsIn: fm.startsIn,
initialPhase: fm.initialPhase,
narrations,
encounter,
}
}
function proseSection(sections: Record<string, string>, key: string, sourcePath: string): string {
const text = sections[key]
if (text === undefined) {
const available = Object.keys(sections).join(', ') || '(none)'
throw new Error(`${sourcePath}: frontmatter references missing section "## ${key}". Available: ${available}`)
}
return text
}
function buildEncounterFromMarkdown(
fm: ReturnType<typeof encounterFrontmatterSchema.parse>,
sections: Record<string, string>,
sourcePath: string,
): EncounterDef {
const phases: EncounterDef['phases'] = {}
for (const [phaseId, phase] of Object.entries(fm.phases ?? {})) {
phases[phaseId] = {
description: proseSection(sections, phase.description, sourcePath),
transitions: phase.transitions.map((transition) => ({
...transition,
narration: proseSection(sections, transition.narration, sourcePath),
})),
}
}
if (!phases[fm.initialPhase]) {
throw new Error(`${sourcePath}: initialPhase "${fm.initialPhase}" is not defined in phases`)
}
const encounter: EncounterDef = {
id: fm.id,
startsIn: fm.startsIn,
initialPhase: fm.initialPhase,
phases,
}
if (fm.aliases) encounter.aliases = fm.aliases
if (fm.onResolved) encounter.onResolved = fm.onResolved
if (fm.onFailed) {
encounter.onFailed = {
narration: proseSection(sections, fm.onFailed.narration, sourcePath),
retreatTo: fm.onFailed.retreatTo,
}
}
if (fm.defaultWrongVerbNarration) {
encounter.defaultWrongVerbNarration = proseSection(sections, fm.defaultWrongVerbNarration, sourcePath)
}
return encounter
}
const encounterNarrationRegistry = new Map<string, Map<string, string>>()
export function registerEncounterNarrations(docs: ParsedEncounterNarration[]): void {
@@ -246,9 +444,8 @@ export function narration(encounterId: string, key: string): string {
}
// Auto-register encounter narrations from co-located markdown files at module init.
// This populates the registry BEFORE encounters.ts is evaluated (ESM evaluates dependencies first),
// so encounters.ts can call narration() at top level without explicit ordering.
// While src/mystery/world/encounters/ does not yet exist (Task 8 creates it), this is a no-op.
// This keeps the narration() helper available for tests and any remaining
// TypeScript escape hatches that need to reference encounter prose by key.
const _encounterFiles = import.meta.glob<string>('./encounters/*.md', {
eager: true, query: '?raw', import: 'default',
})
+51
View File
@@ -0,0 +1,51 @@
---
enabled: true
handler: light
maxTurns: 6
burnOn:
- move
- wait
stateKeys:
lit: lit
burn: burn
ui:
meter: true
icon: candle
---
# Light
The light mechanic controls lightable inventory items, the burn timer, and the terminal meter.
## useLighterWithWhat
Use match with what?
## cannotLight
You can't light that.
## alreadyLit
It's already lit.
## notHelpful
That isn't going to help.
## spent
It is spent.
## noLighter
You have nothing to light it with.
## cannotExtinguish
You can't extinguish that.
## notLit
It isn't lit.
## dropLit
Extinguish it first.
## flameCatches
It catches.
## flameDies
The flame dies.
+19
View File
@@ -0,0 +1,19 @@
---
enabled: true
handler: resolve
ladder:
- steady
- shaken
- reeling
- returning
wrongVerbCost: 1
safeRooms:
recoverySteps: 1
failure:
retreatAt: returning
afterRetreat: shaken
---
# Resolve
The resolve mechanic controls how encounters wear down the player, how safe rooms restore resolve, and which resolve level the player has after an encounter forces a retreat.
+113
View File
@@ -0,0 +1,113 @@
## unknown-verb
You consider the words, but they don't fit this place.
## unknown-noun
You don't see anything like that here.
## malformed
You hesitate.
## nothing-to-confirm
Nothing to confirm.
## cancelled
Cancelled.
## nothing-to-choose
Nothing to choose between.
## no-undo
There is no further back.
## use-lighter-with-what
Use match with what?
## use-unknown
You can't think how to use that here.
## nothing-happens
Nothing happens.
## nowhere
You are nowhere.
## no-exit
You can't go that way.
## unfinished-exit
The way ahead is unfinished.
## cannot-drink
You can't drink that.
## need-carrying
You'd have to be carrying it.
## time-passes
Time passes.
## listen
You listen. The house listens back.
## see-nothing
You see nothing.
## inventory-empty
You are empty-handed.
## inventory-heading
You are carrying:
## dont-see-here
You don't see that here.
## cannot-take
You can't take that.
## already-have
You already have it.
## taken
Taken.
## dont-have
You don't have that.
## drop-lit
Extinguish it first.
## dropped
Dropped.
## dont-see-anything
You don't see anything like that.
## nothing-to-read
There's nothing to read on it.
## cannot-light
You can't light that.
## already-lit
It's already lit.
## not-helpful
That isn't going to help.
## spent
It is spent.
## no-lighter
You have nothing to light it with.
## cannot-extinguish
You can't extinguish that.
## not-lit
It isn't lit.
## flame-catches
It catches.
## flame-dies
The flame dies.

Some files were not shown because too many files have changed in this diff Show More