Compare commits
45 Commits
e31bf0fbff
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 89bb69bcfa | |||
| 18aa517319 | |||
| a51bb6f86f | |||
| 4f6460297f | |||
| 03482693ea | |||
| 7b1b5d0f6c | |||
| e7b74c827a | |||
| 52fb869976 | |||
| 0755213d6a | |||
| cc98aa180b | |||
| 26dd91947f | |||
| 4d9077d586 | |||
| d56c0c8363 | |||
| 83e4877852 | |||
| daa5e9d655 | |||
| 33933b00d7 | |||
| 29fd371b89 | |||
| 2a9b6155ef | |||
| b3e708995b | |||
| e46b2359c0 | |||
| f9b6fc164f | |||
| 5f8e3b1a34 | |||
| e167979fa7 | |||
| 19d1efc586 | |||
| 0d9db9bb55 | |||
| b870d884ef | |||
| 8401e7d281 | |||
| dac8487dbe | |||
| 2fecc7878d | |||
| ee3cfcc00d | |||
| df50afa479 | |||
| ab8c17fdd5 | |||
| b318747840 | |||
| 46f851bc3a | |||
| b325f04b02 | |||
| 14a58481b1 | |||
| 657ed22b48 | |||
| 6cffb87a63 | |||
| e21a308e9d | |||
| bcff8a42f9 | |||
| 2b3a18f208 | |||
| 72f99295ca | |||
| e044141043 | |||
| c0061491ab | |||
| b80e4c32a5 |
@@ -1,18 +1,64 @@
|
|||||||
when:
|
when:
|
||||||
- event: [push, pull_request, manual]
|
- event: push
|
||||||
|
branch: [main]
|
||||||
|
- event: manual
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: install
|
- name: build
|
||||||
image: node:22-alpine
|
image: node:22
|
||||||
commands:
|
commands:
|
||||||
- npm ci
|
- npm ci
|
||||||
|
- npm run test
|
||||||
- name: test
|
|
||||||
image: node:22-alpine
|
|
||||||
commands:
|
|
||||||
- npm test
|
|
||||||
|
|
||||||
- name: build
|
|
||||||
image: node:22-alpine
|
|
||||||
commands:
|
|
||||||
- npm run build
|
- npm run build
|
||||||
|
|
||||||
|
- name: deploy
|
||||||
|
image: node:22
|
||||||
|
environment:
|
||||||
|
CLOUDFLARE_API_TOKEN:
|
||||||
|
from_secret: cloudflare_api_token
|
||||||
|
CLOUDFLARE_ACCOUNT_ID:
|
||||||
|
from_secret: cloudflare_account_id
|
||||||
|
commands:
|
||||||
|
- npx wrangler pages deploy ./dist --project-name halfstreet-io --branch $CI_COMMIT_BRANCH
|
||||||
|
|
||||||
|
- name: notify-success
|
||||||
|
image: curlimages/curl:latest
|
||||||
|
environment:
|
||||||
|
PUSHOVER_TOKEN:
|
||||||
|
from_secret: pushover_token
|
||||||
|
PUSHOVER_USER:
|
||||||
|
from_secret: pushover_user
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
curl -sS --fail-with-body \
|
||||||
|
--form-string "token=$PUSHOVER_TOKEN" \
|
||||||
|
--form-string "user=$PUSHOVER_USER" \
|
||||||
|
--form-string "title=halfstreet.io deployed" \
|
||||||
|
--form-string "message=Build $CI_PIPELINE_NUMBER succeeded on $CI_COMMIT_BRANCH — $CI_COMMIT_MESSAGE" \
|
||||||
|
--form-string "priority=0" \
|
||||||
|
--form-string "url=$CI_PIPELINE_URL" \
|
||||||
|
--form-string "url_title=View build" \
|
||||||
|
https://api.pushover.net/1/messages.json
|
||||||
|
when:
|
||||||
|
- status: success
|
||||||
|
|
||||||
|
- name: notify-failure
|
||||||
|
image: curlimages/curl:latest
|
||||||
|
environment:
|
||||||
|
PUSHOVER_TOKEN:
|
||||||
|
from_secret: pushover_token
|
||||||
|
PUSHOVER_USER:
|
||||||
|
from_secret: pushover_user
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
curl -sS --fail-with-body \
|
||||||
|
--form-string "token=$PUSHOVER_TOKEN" \
|
||||||
|
--form-string "user=$PUSHOVER_USER" \
|
||||||
|
--form-string "title=halfstreet.io build failed" \
|
||||||
|
--form-string "message=Build $CI_PIPELINE_NUMBER failed on $CI_COMMIT_BRANCH — $CI_COMMIT_MESSAGE" \
|
||||||
|
--form-string "priority=1" \
|
||||||
|
--form-string "url=$CI_PIPELINE_URL" \
|
||||||
|
--form-string "url_title=View build" \
|
||||||
|
https://api.pushover.net/1/messages.json
|
||||||
|
when:
|
||||||
|
- status: failure
|
||||||
|
|||||||
@@ -22,11 +22,49 @@ npm run dev # local dev server
|
|||||||
npm run build # type-check + production build
|
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
|
## Layout
|
||||||
|
|
||||||
- `src/engine/` — parser, dispatcher, encounter logic
|
- `src/engine/` — parser, dispatcher, encounter logic
|
||||||
- `src/ui/` — terminal renderer, theme, chips
|
- `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
|
- `src/pages/index.astro` — entry page
|
||||||
|
|
||||||
## Design docs
|
## 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 64–85) 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 89–109), 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 130–133):
|
||||||
|
|
||||||
|
```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 1–5 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 1–5) all pass.
|
||||||
|
- TODO #45 is `[x]`.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Phase 2 — Halfstreet Content Rewrite Roadmap
|
||||||
|
|
||||||
|
**For:** authoring the Halfstreet content bible into markdown for Obsidian editing, now that the engine prereqs have landed.
|
||||||
|
|
||||||
|
**Bible:** `docs/superpowers/specs/halfstreet-bible.md`
|
||||||
|
**Engine prereqs spec:** `docs/superpowers/specs/2026-05-09-mystery-engine-prereqs-design.md`
|
||||||
|
**Engine prereqs plan:** `docs/superpowers/plans/2026-05-09-mystery-engine-prereqs.md` (✅ shipped on `feat/engine-prereqs`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the engine can now express
|
||||||
|
|
||||||
|
After Phase 1, the markdown content layer supports every interaction the bible specifies:
|
||||||
|
|
||||||
|
- **Rooms** — frontmatter exits (n/s/e/w/u/d), locked exits with item or flag requirements, encounter triggers, safe-room flag, and three description sections (`## first-visit`, `## revisit`, `## examined`). Wikilinks (`[[room-id]]`) work for cross-references.
|
||||||
|
- **Items** — `readable` (with `## read` body section), `lightable` (with `## lit` / `## extinguished`), `lighter` + `lighterUses` (with `## lighter-empty`), arbitrary `initialState`, multiple aliases. Non-key items (clocks, music boxes, etc.) can live as room scenery referenced in encounters.
|
||||||
|
- **Encounters** — phase descriptions and transition narration in markdown sections; phase state machine in `encounters.ts` references prose via `narration('id', 'key')`. Transitions support `verb`, `target`, `requires.item` (gates the instrument), `to`, `narration`, `resolveCost`.
|
||||||
|
- **Endings** — `whenFlags` predicate plus markdown body. Priority order is `true` → `wrong` → `bad`; first match fires. `_never: true` sentinel keeps placeholders from auto-firing.
|
||||||
|
- **Verbs the parser/dispatcher handle:** `look`, `examine`, `take`, `drop`, `inventory`, `wait`, `go` + directions, `read`, `light`, `extinguish`, `use`, `attack`, plus `verb-target-prep` for `light X with Y` and `use X on Y`. Stop-words (`at`/`the`/`a`/`an`) are stripped. Ambiguous nouns prompt for disambiguation.
|
||||||
|
|
||||||
|
If the bible needs anything beyond this list, that's a Phase 1.5 engine task — surface it and we'll add a small follow-up plan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What needs authoring
|
||||||
|
|
||||||
|
A rough inventory derived from the bible. Counts include the existing 3 rooms / 3 items / 1 encounter / 3 endings (which need rewriting in the new tone, not just additions).
|
||||||
|
|
||||||
|
| kind | count | source in bible |
|
||||||
|
|---|---|---|
|
||||||
|
| Rooms | 25–28 | "Existing House Rooms" + "Additional Rooms" + "Altered / Conditional Rooms" |
|
||||||
|
| Carry-able items | ~12 | "Inventory Items" + the existing matches/lamp/letter |
|
||||||
|
| Non-key (scenery) items | ~10 | "Non-Key Items" |
|
||||||
|
| Encounters | ~17 | "Revised Encounter Notes" + "Expanded Encounters" + the existing rat |
|
||||||
|
| Endings | 5 | "Expanded Endings" — true / wrong / bad / replacement / mercy |
|
||||||
|
| Story flags | ~10 | "Expanded Story Flags" |
|
||||||
|
|
||||||
|
The bible already supplies:
|
||||||
|
- **Verbatim prose** for the opening scene, the basilisk encounter, the child-beneath-the-well encounter, the toy dog, and all five endings — paste these in as-is.
|
||||||
|
- **Summaries** for each room (1–2 sentences) — expand to full first-visit + revisit + examined prose in Obsidian.
|
||||||
|
- **Interaction descriptions** for each item — expand to full long-description prose plus relevant body sections.
|
||||||
|
- **Resolution paths** for each encounter — translate into phase + transition narration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authoring workflow in Obsidian
|
||||||
|
|
||||||
|
1. **Open the vault.** `src/world/` is configured as an Obsidian vault (see `.obsidian/`). Open that folder in Obsidian. Wikilinks to room/item/encounter ids resolve as graph edges.
|
||||||
|
2. **Branch.** Work on a Phase 2 branch (e.g. `feat/content-rewrite`) so prose iteration doesn't pile onto `main`. Commit small, frequent slices — a room or two per commit reads cleanly in PR.
|
||||||
|
3. **Edit the markdown directly.** The dev server (`npm run dev`) runs Astro's HMR; saving a `.md` file reloads the browser with the new prose.
|
||||||
|
4. **Test as you go.** `npm test` covers the engine and loader; round-trip / cross-reference validation runs at world assembly. A typo in a wikilink target throws on load — check the dev console.
|
||||||
|
5. **Use `_never: true`** on any ending whose flag conditions you haven't decided yet. Replace with real flags once authored.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested authoring sequence
|
||||||
|
|
||||||
|
Phase 2 is large enough that I recommend slicing by *region*, not by *kind*. A fully-authored region (rooms + items + encounters there) is playable end-to-end and produces a useful test loop. Mixing kinds across the whole house drags the playable surface to zero for a long time.
|
||||||
|
|
||||||
|
Suggested slices:
|
||||||
|
|
||||||
|
1. **Rewrite the opening slice** in the bible's voice. The Gate, Foyer, Hallway, Cellar Stair — The Gate is the opening room, and the player should begin there carrying the folded letter, matchbook, and broken cigarette. This is the smallest possible PR and de-risks the voice direction before scaling up.
|
||||||
|
2. **Main-floor expansion** — Parlor, Study, Dining Room, Conservatory, Smoking Room, Music Room, Servants' Passage, Laundry. These connect to the existing Hallway. Add the items each room references (candlestick, pruning-shears, silver-lighter, music-box-key, damp-sheet) and their encounters (window-guest, ivy-figure, covered-cage, piano-echo, breathing-wall, linen-shape).
|
||||||
|
3. **Upper floor** — Stair, Bedroom, Nursery, Attic. Items: child's drawing, music-box (non-key), toy dog. Encounters: stair-sleeper.
|
||||||
|
4. **Garden + grounds** — Back Door, Garden, Well, Well Shaft. Encounter: garden-procession, child-beneath-the-well (verbatim prose in bible).
|
||||||
|
5. **Lower passages** — Tunnel, Antechamber, Vault, Ossuary, Flooded Passage, Root Chamber, Burial Gallery, Cistern. Items: burial-ring, toy-boat, family-register. Encounters: bone-keeper, reflection, root-movement, portrait-woman.
|
||||||
|
6. **Chapel + basilisk** — verbatim prose in bible. Items: silver-vial. Encounter: basilisk (replaces chapel-watcher).
|
||||||
|
7. **Conditional / altered rooms** — wrong-hallway, returned-nursery, rain-room. These appear only after specific flags.
|
||||||
|
8. **Endings** — true, wrong, bad, replacement, mercy. Verbatim prose; just wire the `whenFlags` per the bible's conditions.
|
||||||
|
9. **Polish pass** — re-read the whole transcript top to bottom in the browser. Catch tonal drift, stale wikilinks, dead encounters, items with no resolution.
|
||||||
|
|
||||||
|
Within each slice, the order that minimizes broken intermediate state:
|
||||||
|
|
||||||
|
1. Author the room files first (with placeholder exits to non-existent rooms commented out or marked `null` until the destination is authored — the loader's cross-ref validation will fail otherwise).
|
||||||
|
2. Author item files referenced by the rooms.
|
||||||
|
3. Author encounter files (frontmatter + narration sections), then update `encounters.ts` with the phase/transition state machine.
|
||||||
|
4. Add any new flags to `flags.ts` or wherever they land (no central registry today — they're set/read ad hoc).
|
||||||
|
5. Set ending `whenFlags` once enough flags exist to specify the conditions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions / things to watch
|
||||||
|
|
||||||
|
- **Flag registry.** The bible introduces ~10 story flags. Today they're untyped strings flowing through `state.flags`. As the count grows, consider a `flags.ts` module exporting the canonical names so typos surface at compile time. Not required for Phase 2 to ship; might be a small Phase 1.5 task.
|
||||||
|
- **Scenery items.** Bible's "Non-Key Items" (grandfather-clock, music-box, dinner-place-setting, etc.) are objects the player interacts with but cannot carry. Decide whether to model them as untakeable items (frontmatter `takeable: false`) or as room-state with custom verbs in `encounters.ts`. The former is simpler if `examine` and one or two specific verbs cover the interaction; the latter is needed for richer state machines (e.g. setting the grandfather clock to a specific time).
|
||||||
|
- **House-altering geometry.** "wrong-hallway" and "returned-nursery" are the same physical space with different prose after a flag is set. Today rooms have one set of descriptions. Either: (a) author them as separate room ids and gate access with locked-exit + flag conditions; (b) extend the room schema to support conditional descriptions. (a) is smaller; (b) is more elegant. Pick (a) until it bites.
|
||||||
|
- **Resolve mechanic.** Bible says "violence usually worsens outcomes" and assumes a Resolve ladder. The engine has `resolveLevel` but the bible's expected costs/recoveries aren't fully specified. Phase 2 can either (a) commit to the existing levels and let prose lean on them informally, or (b) write a short addendum nailing down which transitions cost what. Lean toward (a) until inconsistencies emerge.
|
||||||
|
- **Disambiguation in practice.** With ~12 carry-able items, watch for shared aliases (`key` covers `rusted-key` / `music-box-key` / `burial-ring`'s "ring"). The disambiguation prompt is in place but the prose for it is generic ("Which X — A, or B?"). Acceptable as-is; revisit if it reads clunky.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of done for Phase 2
|
||||||
|
|
||||||
|
- All bible rooms, items, encounters, and endings live in `src/world/` markdown.
|
||||||
|
- A first playthrough reaches the true ending in 45–90 minutes.
|
||||||
|
- All five endings are reachable by some flag combination and tested at least once.
|
||||||
|
- No cross-reference errors at world load; all encounter narrations resolve.
|
||||||
|
- Prose passes a re-read by the author against the bible's voice rules.
|
||||||
|
- Polish items deferred from Phase 1 (transcript scrolling, cursor blink rate, line fade, scanline toggle) are still deferred — Phase 2 is content-only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When ready to start
|
||||||
|
|
||||||
|
Brainstorm Slice 1 (existing 3 rooms in new tone) → spec → plan → author. The brainstorming will be quick because the bible already specifies most of what's needed; the spec is mostly a checklist and a tone-test paragraph; the plan is "edit these N markdown files." After Slice 1 ships, decide whether to continue slice-by-slice in plans or shift to a looser working-mode where each region's PR is its own thing.
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# Mystery Engine Prereqs — Design
|
||||||
|
|
||||||
|
**Status:** approved 2026-05-09
|
||||||
|
**Goal:** land the engine work that the Halfstreet content bible depends on, so that Phase 2 (full bible content draft into markdown) can author every room/item/encounter/ending the bible specifies without engine gaps.
|
||||||
|
|
||||||
|
**Scope:** the hard prerequisites and "should-fix while you're in there" items from `docs/superpowers/specs/halfstreet-followon-notes.md`. Polish items (8–11) are explicitly deferred.
|
||||||
|
|
||||||
|
**Out of scope:** any new prose authoring. This round is engine-only. Phase 2 gets its own spec.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. New verbs: `read`, `light`, `extinguish`
|
||||||
|
|
||||||
|
The parser already aliases these to canonical `Verb` values; the dispatcher does not handle them.
|
||||||
|
|
||||||
|
### Schema additions (`Item`)
|
||||||
|
|
||||||
|
All optional. Items that don't set them behave as today.
|
||||||
|
|
||||||
|
| field | type | meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `readable` | `boolean` | item supports `read X` |
|
||||||
|
| `lightable` | `boolean` | item supports `light X` / `extinguish X`; toggles `state.lit` |
|
||||||
|
| `lighter` | `boolean` | item can act as the light source for another item |
|
||||||
|
| `lighterUses` | `number` | optional remaining-charges counter (matches: 4 by convention; absence = unlimited) |
|
||||||
|
|
||||||
|
Body sections (markdown):
|
||||||
|
|
||||||
|
- `## read` — prose returned by `read X`. Required iff `readable: true`.
|
||||||
|
- `## lit` — narration when `light X` succeeds. Optional; falls back to `"It catches."`
|
||||||
|
- `## extinguished` — narration when `extinguish X` succeeds. Optional; falls back to `"The flame dies."`
|
||||||
|
- `## lighter-empty` — narration when a lighter's `lighterUses` reaches 0. Optional; falls back to `"It is spent."`
|
||||||
|
|
||||||
|
### Dispatcher behavior
|
||||||
|
|
||||||
|
`read X`:
|
||||||
|
- if `!item.readable` → `"There's nothing to read on it."`
|
||||||
|
- else → narrate the item's `## read` text.
|
||||||
|
|
||||||
|
`light X` (implicit lighter):
|
||||||
|
- if `!item.lightable` → `"You can't light that."`
|
||||||
|
- if `state.lit === true` → `"It's already lit."`
|
||||||
|
- find an inventory item with `lighter: true` (and either no `lighterUses` field, or `lighterUses > 0`):
|
||||||
|
- if none and inventory contains a depleted lighter → `"You have nothing to light it with."` (with a hint that the spent lighter is depleted)
|
||||||
|
- if none at all → `"You have nothing to light it with."`
|
||||||
|
- else: decrement `lighterUses` on the chosen lighter (if present); set `target.state.lit = true`; narrate the target's `## lit` section. If decrement reached 0, additionally narrate the lighter's `## lighter-empty` section.
|
||||||
|
|
||||||
|
`light X with Y`:
|
||||||
|
- as above but Y must be the inventory item used. Errors: Y not in inventory, Y not a lighter, Y depleted.
|
||||||
|
- on success, same state mutations.
|
||||||
|
|
||||||
|
`extinguish X`:
|
||||||
|
- if `!item.lightable` → `"You can't extinguish that."`
|
||||||
|
- if `state.lit !== true` → `"It isn't lit."`
|
||||||
|
- else: set `state.lit = false`; narrate `## extinguished`.
|
||||||
|
|
||||||
|
### Parser additions
|
||||||
|
|
||||||
|
New command kind:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{ kind: 'verb-target-instrument', verb, target: NounRef, instrument: NounRef }
|
||||||
|
```
|
||||||
|
|
||||||
|
Parsing rule: when the noun-phrase tokens (after the verb and any pronoun handling) contain the literal word `with` between two recognized nouns, split into target (left of `with`) and instrument (right of `with`). Both must resolve via the same noun-resolution path used today (visible nouns + inventory). If either fails to resolve, fall back to the current `unknown-noun` behavior.
|
||||||
|
|
||||||
|
`with` is also legal as a no-op separator for verbs that don't accept instruments (e.g. `examine letter with care`) — for safety, only `light` consumes the instrument arm in this round; other verbs treat the instrument tail as part of the target phrase and re-resolve as today (no behavior change).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. `use` verb
|
||||||
|
|
||||||
|
`use X` and `use X on Y` route through the existing encounter dispatcher (`applyVerbToEncounter`). Encounters declare resolutions in their phase transitions; `use` is intentionally thin.
|
||||||
|
|
||||||
|
- `use X` → `verb-target` with verb=`use`, target=X. If no encounter consumes it: `"You can't think how to use that here."`
|
||||||
|
- `use X on Y` → `verb-target-instrument` with verb=`use`, target=X, instrument=Y. The dispatcher routes the verb+target into `applyVerbToEncounter` as today; the encounter's `requires.item` mechanism (already implemented) gates the transition on the instrument being in inventory. The parser-level instrument is therefore a UX nicety: it lets the player type the form the bible uses, and we can validate the typed instrument matches the transition's `requires.item` when present (mismatch → fallback narration "That isn't going to help.").
|
||||||
|
|
||||||
|
No special-case dispatcher handler beyond the fallback narration. No new fields on `EncounterDef`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Disambiguation
|
||||||
|
|
||||||
|
Today the parser returns `unknown-noun` when multiple visible aliases match a noun phrase. Replace with an explicit ambiguous variant.
|
||||||
|
|
||||||
|
### Parser
|
||||||
|
|
||||||
|
New `ParsedCommand` variant:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{ kind: 'ambiguous', verb: Verb, rawNoun: string, candidates: string[] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Returned when ≥2 entries in `visibleNouns ∪ inventory` match the same alias. `candidates` is the list of canonical ids.
|
||||||
|
|
||||||
|
The existing `disambiguation` reply variant stays as-is; the parser already handles single-word reply matching.
|
||||||
|
|
||||||
|
### Dispatcher
|
||||||
|
|
||||||
|
On `kind: 'ambiguous'`:
|
||||||
|
- set `state.pendingDisambiguation = { verb, candidates }`
|
||||||
|
- emit a single narration: `"Which X — A, B, or C?"` where `X` is the `rawNoun` and A/B/C are the `short` strings of each candidate item.
|
||||||
|
|
||||||
|
On `kind: 'disambiguation'` reply (already wired):
|
||||||
|
- read `state.pendingDisambiguation`, clear it, re-issue the original verb against the chosen canonical id.
|
||||||
|
|
||||||
|
### Edge cases
|
||||||
|
|
||||||
|
- If the player issues a fresh command (not a single-word disambiguation reply) while `pendingDisambiguation` is set, clear it and proceed normally.
|
||||||
|
- If candidates resolve to identical canonical ids (shouldn't happen given current schema), prefer the first; no error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Ending UI + flag matching
|
||||||
|
|
||||||
|
### Dispatcher
|
||||||
|
|
||||||
|
After every dispatched turn (and only on turns that succeeded — meta-commands and parse failures don't trigger ending evaluation), iterate `world.endings`:
|
||||||
|
|
||||||
|
- For each `endingId` in a fixed priority order (`true` > `wrong` > `bad` — declared order in `world.endings`), check that **every** key in `whenFlags` is present in `state.flags` and has the matching value. First match wins.
|
||||||
|
- On match: set `state.endedWith = endingId`; append a narration event with `kind: 'ending'` and the body prose (already loaded from the ending markdown). Mark the run as ended (subsequent turns are rejected with a "the game has ended" narration until restart).
|
||||||
|
|
||||||
|
Ending evaluation is pure — same inputs, same outputs.
|
||||||
|
|
||||||
|
### UI (terminal renderer)
|
||||||
|
|
||||||
|
- `kind: 'ending'` events render with a separator line above, the prose centered or left-aligned with extra vertical gap, no `>` prompt afterward.
|
||||||
|
- Input field disables (`disabled` attribute or readonly + faded styling).
|
||||||
|
- Footer chips replace the regular set with `[R] Restart`, `[U] Undo`.
|
||||||
|
- `restart` (typed or button) resets state and re-enables input.
|
||||||
|
- `undo` (typed or button) pops the last turn (existing behavior) and re-enables input if the popped state had no ending.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Should-fix items (in scope)
|
||||||
|
|
||||||
|
### 5a. Parser stop-word strip
|
||||||
|
|
||||||
|
Before noun resolution, strip leading occurrences of `at`, `the`, `a`, `an` from the noun-phrase tokens. So `look at the lamp` → tokens `[lamp]` for matching. The verb itself is not touched.
|
||||||
|
|
||||||
|
Affects only the noun phrase. Stop-words *between* meaningful tokens are left alone (e.g. `light lamp with the matches` strips the leading-of-instrument `the`).
|
||||||
|
|
||||||
|
### 5b. Remove `theme` from `GameState`
|
||||||
|
|
||||||
|
`theme` is a UI preference, not game state. Today, clicking `[B]/[C]` updates DOM + localStorage but not `state.theme`, then the next `theme` meta-verb toggles from a stale value.
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- delete `theme` field from `GameState` and from save format
|
||||||
|
- `theme` meta-verb becomes a UI-layer action: dispatcher emits a `kind: 'ui-toggle-theme'` event that the terminal handles
|
||||||
|
- migration: existing saves drop the `theme` field on load (forward-compatible)
|
||||||
|
|
||||||
|
### 5c. Widen `RoomState` type
|
||||||
|
|
||||||
|
Today: `Record<string, string | boolean | number>`, but `droppedItems` / `takenItems` store `string[]` via `as` casts.
|
||||||
|
|
||||||
|
Change: `Record<string, string | boolean | number | string[]>`. Remove all `as` casts in dispatcher and engine paths that touch `roomState`. No runtime change.
|
||||||
|
|
||||||
|
### 5d. Self-contained locked-exit dispatcher test
|
||||||
|
|
||||||
|
The current `dispatcher.test.ts` "opens a locked exit" test is a stub. Add a 15-line synthetic world fixture (two rooms, one key item, one locked exit) and exercise:
|
||||||
|
- locked exit blocks movement with the `lockedNarration`
|
||||||
|
- carrying the required item permits movement
|
||||||
|
- the key is not consumed by passage (unless flagged so in future; current behavior: not consumed)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Test plan
|
||||||
|
|
||||||
|
TDD throughout, per existing project conventions.
|
||||||
|
|
||||||
|
| area | test types |
|
||||||
|
|---|---|
|
||||||
|
| schema additions | Zod parse tests (valid + each invalid shape) |
|
||||||
|
| `read` verb | dispatcher unit (readable, non-readable, missing item) |
|
||||||
|
| `light` / `extinguish` | dispatcher unit (lightable + lighter, lighter-with-charges decrement, lighter-empty fallthrough, explicit `with`, error paths) |
|
||||||
|
| `use` verb | dispatcher unit (no encounter consumes → fallback; encounter consumes → routed) |
|
||||||
|
| disambiguation | parser unit (ambiguous candidates), dispatcher round-trip (prompt → reply → re-issue) |
|
||||||
|
| ending detection | dispatcher unit per ending; priority order; ended-state turn rejection |
|
||||||
|
| stop-word strip | parser unit (`look at lamp`, `read the letter`, etc.) |
|
||||||
|
| theme removal | save round-trip drops field; `theme` meta-verb emits UI event, no state mutation |
|
||||||
|
| roomState widen | type-level (compile passes); existing tests unchanged |
|
||||||
|
| locked-exit | new synthetic-world dispatcher test |
|
||||||
|
| manual playthrough | golden path on current 3-room world after all changes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Out of scope — follow-up tracker
|
||||||
|
|
||||||
|
These are tracked but not part of this round:
|
||||||
|
|
||||||
|
- Transcript scrolling (PageUp/PageDown)
|
||||||
|
- Cursor blink at 1.05 Hz
|
||||||
|
- Old-line opacity fade at top of transcript
|
||||||
|
- Scanline accessibility toggle (`[?]` settings dropdown — important for photosensitivity)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. After this lands
|
||||||
|
|
||||||
|
Phase 2 brainstorming → spec → plan: full bible content draft. All 25+ rooms, all bible items, all encounters, all 5 endings authored to markdown for Obsidian editing. Engine will support every interaction the bible specifies by then.
|
||||||
@@ -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 ~30–40 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.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "halfstreet",
|
"name": "halfstreet",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.0.1",
|
"version": "0.1.0",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.12.0"
|
"node": ">=22.12.0"
|
||||||
@@ -11,10 +11,14 @@
|
|||||||
"build": "astro check && astro build",
|
"build": "astro check && astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"astro": "astro",
|
"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": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@sentry/browser": "^10.53.1",
|
||||||
"astro": "^6.1.9",
|
"astro": "^6.1.9",
|
||||||
"yaml": "^2.8.4",
|
"yaml": "^2.8.4",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
@@ -23,6 +27,7 @@
|
|||||||
"@astrojs/check": "^0.9.8",
|
"@astrojs/check": "^0.9.8",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.1.5"
|
"vitest": "^4.1.5",
|
||||||
|
"wrangler": "^4.85.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "Half Street",
|
||||||
|
"short_name": "Half St",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-192x192.png?v=20260509",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-512x512.png?v=20260509",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#cd8001",
|
||||||
|
"background_color": "#1a0d00",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 131 KiB |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 8.4 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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="m541.45 262.22-14.297 48.703c-2.8125 8.8125-9.7031 15.703-18.469 18.609-5.8594 2.1562-12.703 4.6875-20.766 8.1562-8.3906 4.0781-18.234 4.0781-26.625 0l-46.078-25.688c-9.2812-4.9688-20.578-4.3125-29.156 1.7812-25.969 19.266-49.266 41.859-69.281 67.219-6.4219 8.4375-7.125 19.969-1.7812 29.156l25.781 44.156c4.5469 7.875 4.875 17.531 0.84375 25.688-3.6094 7.0781-6.4688 13.781-9.1406 20.531h0.046875c-3.2344 8.4844-10.312 14.953-19.078 17.391l-50.766 13.781c-10.219 2.625-17.906 11.062-19.453 21.516-4.4531 31.406-4.4531 63.328 0 94.781 1.3594 10.641 9.2344 19.266 19.688 21.609l50.297 13.781c8.9062 2.3906 16.125 8.9062 19.406 17.531 2.5312 6.6094 5.3906 13.219 8.7656 20.297 3.8438 7.9219 3.6094 17.25-0.70312 24.938l-25.781 44.625h-0.046875c-5.4375 9.1406-4.6406 20.719 1.9219 29.062 20.062 25.172 43.359 47.625 69.281 66.703 8.5312 6.3281 20.016 6.9844 29.25 1.6875l45.609-24.844c8.3906-4.2656 18.281-4.5 26.859-0.70312 7.0781 3.2344 13.781 5.8594 20.859 8.3906h0.046875c8.6719 2.9062 15.516 9.75 18.375 18.469l14.156 48.703v0.046875c2.5312 10.406 11.297 18.141 21.938 19.406 12.234 1.5 24.516 2.25 36.844 2.2969 12.047-0.28125 24.047-1.2188 36-2.8594 10.594-1.1719 19.406-8.7656 22.078-19.078l14.297-48.938v-0.046875c2.7188-8.6719 9.375-15.516 18-18.469 7.4531-2.625 14.156-5.2969 21-8.3906 8.625-3.9375 18.562-3.7031 27 0.5625l46.078 25.219c9.1406 5.3906 20.625 4.875 29.25-1.3125 26.016-19.266 49.359-41.812 69.516-67.078 6.4688-8.3906 7.1719-19.922 1.7812-29.062l-25.781-45c-4.3125-7.6406-4.5938-16.922-0.75-24.844 3.375-7.2188 6.2344-13.781 8.625-20.156h0.046875c3.2344-8.6719 10.453-15.281 19.406-17.766l50.062-13.688v0.046875c10.406-2.1562 18.375-10.594 19.922-21.141 4.4062-31.547 4.4062-63.516 0-95.062-1.5938-10.453-9.2812-18.984-19.594-21.562l-50.766-13.781 0.046875-0.046875c-8.7656-2.4375-15.891-8.8594-19.078-17.391-2.625-6.7031-5.5312-13.453-9-20.297-3.8906-8.0156-3.6094-17.391 0.70312-25.172l25.781-44.297c5.4375-9.1406 4.6875-20.625-1.7812-29.016-20.016-25.453-43.359-48.047-69.375-67.312-8.4375-6.0469-19.547-6.8438-28.781-2.0625l-45.844 25.078c-8.4844 4.0781-18.375 4.0781-26.906 0-7.9219-3.4688-14.766-6-20.625-8.1562-8.7188-2.8594-15.562-9.6562-18.469-18.375l-14.156-48.609c-2.625-10.359-11.344-18.047-21.984-19.312-12.188-1.4531-24.422-2.3438-36.703-2.625-12 0-24 1.5469-36 3.1406-10.781 1.0312-19.734 8.625-22.547 19.078zm58.547 175.18c65.766 0 125.06 39.609 150.24 100.41 25.172 60.75 11.25 130.69-35.25 177.19s-116.44 60.422-177.19 35.25c-60.797-25.172-100.41-84.469-100.41-150.24 0.09375-89.766 72.844-162.52 162.61-162.61z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
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 { World } from '../world/types'
|
||||||
import type { GameState } from './types'
|
import type { GameState, ParsedCommand } from './types'
|
||||||
import { SCHEMA_VERSION } from './types'
|
import { SCHEMA_VERSION } from './types'
|
||||||
|
|
||||||
const world: World = {
|
const world: World = {
|
||||||
@@ -94,13 +94,60 @@ describe('dispatcher — go', () => {
|
|||||||
expect(r.state.location).toBe('hallway')
|
expect(r.state.location).toBe('hallway')
|
||||||
expect(r.appended.some((l) => l.text.includes('locked'))).toBe(true)
|
expect(r.appended.some((l) => l.text.includes('locked'))).toBe(true)
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('opens a locked exit when required item is in inventory', () => {
|
describe('locked exits', () => {
|
||||||
// Locked-exit-with-key happy path is covered by the playthrough integration
|
function makeWorld(): World {
|
||||||
// test in Task 8. The sample world above doesn't have an unlocked path to
|
return {
|
||||||
// pick up the brass key without first traversing the locked door, so this
|
startingRoom: 'antechamber',
|
||||||
// test is intentionally a placeholder.
|
startingInventory: [],
|
||||||
expect(true).toBe(true)
|
rooms: {
|
||||||
|
antechamber: {
|
||||||
|
id: 'antechamber',
|
||||||
|
title: '[ Antechamber ]',
|
||||||
|
descriptions: { firstVisit: '.', revisit: '.', examined: '.' },
|
||||||
|
exits: { n: 'vault' },
|
||||||
|
lockedExits: { n: { requires: 'rusted-key', lockedNarration: 'The door is locked.' } },
|
||||||
|
items: ['rusted-key'],
|
||||||
|
},
|
||||||
|
vault: {
|
||||||
|
id: 'vault',
|
||||||
|
title: '[ Vault ]',
|
||||||
|
descriptions: { firstVisit: 'You are inside.', revisit: '.', examined: '.' },
|
||||||
|
exits: {},
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
'rusted-key': { id: 'rusted-key', names: ['rusted key', 'key'], short: 'a rusted key', long: '.', initialState: {}, takeable: true },
|
||||||
|
},
|
||||||
|
encounters: {},
|
||||||
|
endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('blocks movement without the key', () => {
|
||||||
|
const world = makeWorld()
|
||||||
|
const state = initialStateFor(world)
|
||||||
|
const result = dispatch(state, { kind: 'go', direction: 'n' }, world)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe('The door is locked.')
|
||||||
|
expect(result.state.location).toBe('antechamber')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('permits movement once the key is in inventory', () => {
|
||||||
|
const world = makeWorld()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [{ id: 'rusted-key', state: {} }] }
|
||||||
|
const result = dispatch(state, { kind: 'go', direction: 'n' }, world)
|
||||||
|
expect(result.state.location).toBe('vault')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not consume the key on passage', () => {
|
||||||
|
const world = makeWorld()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [{ id: 'rusted-key', state: {} }] }
|
||||||
|
const result = dispatch(state, { kind: 'go', direction: 'n' }, world)
|
||||||
|
expect(result.state.inventory.find((i) => i.id === 'rusted-key')).toBeDefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -110,6 +157,19 @@ describe('dispatcher — look', () => {
|
|||||||
const r = dispatch(s, { kind: 'verb-only', verb: 'look' }, world)
|
const r = dispatch(s, { kind: 'verb-only', verb: 'look' }, world)
|
||||||
expect(r.appended.some((l) => l.text.includes('peeling paper'))).toBe(true)
|
expect(r.appended.some((l) => l.text.includes('peeling paper'))).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('adds a conditional sentence for items currently in the room', () => {
|
||||||
|
const s = initialStateFor(world)
|
||||||
|
const r = dispatch(s, { kind: 'verb-only', verb: 'look' }, world)
|
||||||
|
expect(r.appended.some((l) => l.text === 'An oil lamp is here.')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes the conditional item sentence once the item is taken', () => {
|
||||||
|
let s = initialStateFor(world)
|
||||||
|
s = dispatch(s, { kind: 'verb-target', verb: 'take', target: { canonical: 'torch', raw: 'torch' } }, world).state
|
||||||
|
const r = dispatch(s, { kind: 'verb-only', verb: 'look' }, world)
|
||||||
|
expect(r.appended.some((l) => l.text === 'An oil lamp is here.')).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('dispatcher — take and drop', () => {
|
describe('dispatcher — take and drop', () => {
|
||||||
@@ -133,6 +193,21 @@ describe('dispatcher — take and drop', () => {
|
|||||||
const r = dispatch(s, { kind: 'verb-target', verb: 'drop', target: { canonical: 'torch', raw: 'torch' } }, world)
|
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()
|
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', () => {
|
describe('dispatcher — examine', () => {
|
||||||
@@ -142,6 +217,29 @@ describe('dispatcher — examine', () => {
|
|||||||
const r = dispatch(s, { kind: 'verb-target', verb: 'examine', target: { canonical: 'torch', raw: 'torch' } }, world)
|
const r = dispatch(s, { kind: 'verb-target', verb: 'examine', target: { canonical: 'torch', raw: 'torch' } }, world)
|
||||||
expect(r.appended.some((l) => l.text.includes('iron oil lamp'))).toBe(true)
|
expect(r.appended.some((l) => l.text.includes('iron oil lamp'))).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses live match count when examining matches', () => {
|
||||||
|
const matchWorld: World = {
|
||||||
|
...world,
|
||||||
|
startingInventory: ['matches'],
|
||||||
|
items: {
|
||||||
|
...world.items,
|
||||||
|
matches: {
|
||||||
|
id: 'matches',
|
||||||
|
names: ['matches', 'match'],
|
||||||
|
short: 'a matchbook',
|
||||||
|
long: 'A damp matchbook with five matches left inside.',
|
||||||
|
initialState: { uses: 4 },
|
||||||
|
takeable: true,
|
||||||
|
lighter: true,
|
||||||
|
lighterUses: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const s = initialStateFor(matchWorld)
|
||||||
|
const r = dispatch(s, { kind: 'verb-target', verb: 'examine', target: { canonical: 'matches', raw: 'matches' } }, matchWorld)
|
||||||
|
expect(r.appended.at(-1)?.text).toBe('A damp matchbook with four matches left inside.')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('dispatcher — inventory', () => {
|
describe('dispatcher — inventory', () => {
|
||||||
@@ -157,3 +255,763 @@ describe('dispatcher — inventory', () => {
|
|||||||
expect(r.appended.some((l) => /empty-handed|carrying nothing/i.test(l.text))).toBe(true)
|
expect(r.appended.some((l) => /empty-handed|carrying nothing/i.test(l.text))).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 {
|
||||||
|
startingRoom: 'r',
|
||||||
|
startingInventory: [],
|
||||||
|
rooms: {
|
||||||
|
r: {
|
||||||
|
id: 'r',
|
||||||
|
title: '[ R ]',
|
||||||
|
descriptions: { firstVisit: 'r', revisit: 'r', examined: 'r' },
|
||||||
|
exits: {},
|
||||||
|
items: ['iron-key', 'brass-key'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
'iron-key': { id: 'iron-key', names: ['key', 'iron key'], short: 'an iron key', long: '.', initialState: {}, takeable: true },
|
||||||
|
'brass-key': { id: 'brass-key', names: ['key', 'brass key'], short: 'a brass key', long: '.', initialState: {}, takeable: true },
|
||||||
|
},
|
||||||
|
encounters: {},
|
||||||
|
endings: {
|
||||||
|
true: { whenFlags: { _never: true }, narration: '' },
|
||||||
|
wrong: { whenFlags: { _never: true }, narration: '' },
|
||||||
|
bad: { whenFlags: { _never: true }, narration: '' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('sets pendingDisambiguation and prompts when the parser returns ambiguous', () => {
|
||||||
|
const world = makeAmbiguousWorld()
|
||||||
|
const state = initialStateFor(world)
|
||||||
|
const cmd: ParsedCommand = {
|
||||||
|
kind: 'ambiguous', verb: 'take', rawNoun: 'key', candidates: ['iron-key', 'brass-key'],
|
||||||
|
}
|
||||||
|
const result = dispatch(state, cmd, world)
|
||||||
|
expect(result.state.pendingDisambiguation).toEqual({
|
||||||
|
verb: 'take',
|
||||||
|
candidates: ['iron-key', 'brass-key'],
|
||||||
|
prompt: 'Which key — an iron key, or a brass key?',
|
||||||
|
})
|
||||||
|
expect(result.appended[0]?.text).toBe('Which key — an iron key, or a brass key?')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles a single-word disambiguation reply by re-issuing the verb', () => {
|
||||||
|
const world = makeAmbiguousWorld()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = {
|
||||||
|
...state,
|
||||||
|
pendingDisambiguation: { verb: 'take', candidates: ['iron-key', 'brass-key'], prompt: '...' },
|
||||||
|
}
|
||||||
|
const result = dispatch(state, { kind: 'disambiguation', chosen: 'iron-key' }, world)
|
||||||
|
expect(result.state.pendingDisambiguation).toBeNull()
|
||||||
|
expect(result.state.inventory.find((i) => i.id === 'iron-key')).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function readWorld(): World {
|
||||||
|
return {
|
||||||
|
startingRoom: 'r',
|
||||||
|
startingInventory: [],
|
||||||
|
rooms: { r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] } },
|
||||||
|
items: {
|
||||||
|
letter: { id: 'letter', names: ['letter'], short: 'a letter', long: 'A letter.', initialState: {}, takeable: true, readable: true, readableText: 'You loved Halfstreet, the letter says.' },
|
||||||
|
rock: { id: 'rock', names: ['rock'], short: 'a rock', long: 'A rock.', initialState: {}, takeable: true },
|
||||||
|
},
|
||||||
|
encounters: {},
|
||||||
|
endings: {
|
||||||
|
true: { whenFlags: { _never: true }, narration: '' },
|
||||||
|
wrong: { whenFlags: { _never: true }, narration: '' },
|
||||||
|
bad: { whenFlags: { _never: true }, narration: '' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('read verb', () => {
|
||||||
|
it('narrates readableText for a readable item in inventory', () => {
|
||||||
|
const world = readWorld()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [{ id: 'letter', state: {} }] }
|
||||||
|
const result = dispatch(state, {
|
||||||
|
kind: 'verb-target', verb: 'read', target: { canonical: 'letter', raw: 'letter' },
|
||||||
|
}, world)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe('You loved Halfstreet, the letter says.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('errors politely on non-readable items', () => {
|
||||||
|
const world = readWorld()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [{ id: 'rock', state: {} }] }
|
||||||
|
const result = dispatch(state, {
|
||||||
|
kind: 'verb-target', verb: 'read', target: { canonical: 'rock', raw: 'rock' },
|
||||||
|
}, world)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe("There's nothing to read on it.")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('light/extinguish verbs (implicit lighter)', () => {
|
||||||
|
function w(): World {
|
||||||
|
return {
|
||||||
|
startingRoom: 'r',
|
||||||
|
startingInventory: [],
|
||||||
|
rooms: { r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] } },
|
||||||
|
items: {
|
||||||
|
lamp: { id: 'lamp', names: ['lamp'], short: 'an oil lamp', long: '.', initialState: { lit: false }, takeable: true, lightable: true, litText: 'The wick catches.', extinguishedText: 'The flame dies.' },
|
||||||
|
matches: { id: 'matches', names: ['matches'], short: 'a matchbook', long: '.', initialState: {}, takeable: true, lighter: true, lighterUses: 2, lighterEmptyText: 'The book is empty.' },
|
||||||
|
rock: { id: 'rock', names: ['rock'], short: 'a rock', long: '.', initialState: {}, takeable: true },
|
||||||
|
},
|
||||||
|
encounters: {},
|
||||||
|
endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('lights a lamp using the matchbook implicitly', () => {
|
||||||
|
const world = w()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [
|
||||||
|
{ id: 'lamp', state: { lit: false } },
|
||||||
|
{ id: 'matches', state: { uses: 2 } },
|
||||||
|
] }
|
||||||
|
const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' } }, world)
|
||||||
|
expect(result.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(true)
|
||||||
|
expect(result.state.inventory.find((i) => i.id === 'matches')?.state['uses']).toBe(1)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe('The wick catches.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses when the target is already lit', () => {
|
||||||
|
const world = w()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [
|
||||||
|
{ id: 'lamp', state: { lit: true } },
|
||||||
|
{ id: 'matches', state: { uses: 2 } },
|
||||||
|
] }
|
||||||
|
const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' } }, world)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe("It's already lit.")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses when no lighter is in inventory', () => {
|
||||||
|
const world = w()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [{ id: 'lamp', state: { lit: false } }] }
|
||||||
|
const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' } }, world)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe('You have nothing to light it with.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses when the target is not lightable', () => {
|
||||||
|
const world = w()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [{ id: 'rock', state: {} }, { id: 'matches', state: { uses: 2 } }] }
|
||||||
|
const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'rock', raw: 'rock' } }, world)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe("You can't light that.")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asks what to use a match with instead of lighting the matchbook alone', () => {
|
||||||
|
const world = w()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [{ id: 'matches', state: { uses: 2 } }] }
|
||||||
|
const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'matches', raw: 'match' } }, world)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe('Use match with what?')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits the lighter-empty message when matches reach 0', () => {
|
||||||
|
const world = w()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [
|
||||||
|
{ id: 'lamp', state: { lit: false } },
|
||||||
|
{ id: 'matches', state: { uses: 1 } },
|
||||||
|
] }
|
||||||
|
const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' } }, world)
|
||||||
|
expect(result.state.inventory.find((i) => i.id === 'matches')?.state['uses']).toBe(0)
|
||||||
|
const texts = result.appended.map((l) => l.text)
|
||||||
|
expect(texts).toContain('The wick catches.')
|
||||||
|
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)
|
||||||
|
state = { ...state, inventory: [{ id: 'lamp', state: { lit: true } }] }
|
||||||
|
const result = dispatch(state, { kind: 'verb-target', verb: 'extinguish', target: { canonical: 'lamp', raw: 'lamp' } }, world)
|
||||||
|
expect(result.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(false)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe('The flame dies.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("refuses to extinguish what isn't lit", () => {
|
||||||
|
const world = w()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [{ id: 'lamp', state: { lit: false } }] }
|
||||||
|
const result = dispatch(state, { kind: 'verb-target', verb: 'extinguish', target: { canonical: 'lamp', raw: 'lamp' } }, world)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe("It isn't lit.")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('light X with Y (explicit lighter)', () => {
|
||||||
|
function w(): World {
|
||||||
|
return {
|
||||||
|
startingRoom: 'r',
|
||||||
|
startingInventory: [],
|
||||||
|
rooms: { r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] } },
|
||||||
|
items: {
|
||||||
|
lamp: { id: 'lamp', names: ['lamp'], short: 'an oil lamp', long: '.', initialState: { lit: false }, takeable: true, lightable: true, litText: 'The wick catches.', extinguishedText: 'The flame dies.' },
|
||||||
|
matches: { id: 'matches', names: ['matches'], short: 'a matchbook', long: '.', initialState: {}, takeable: true, lighter: true, lighterUses: 2, lighterEmptyText: 'The book is empty.' },
|
||||||
|
rock: { id: 'rock', names: ['rock'], short: 'a rock', long: '.', initialState: {}, takeable: true },
|
||||||
|
},
|
||||||
|
encounters: {},
|
||||||
|
endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('lights with the explicit instrument', () => {
|
||||||
|
const world = w()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [
|
||||||
|
{ id: 'lamp', state: { lit: false } },
|
||||||
|
{ id: 'matches', state: { uses: 2 } },
|
||||||
|
] }
|
||||||
|
const result = dispatch(state, {
|
||||||
|
kind: 'verb-target-prep', verb: 'light',
|
||||||
|
target: { canonical: 'lamp', raw: 'lamp' },
|
||||||
|
preposition: 'with',
|
||||||
|
indirect: { canonical: 'matches', raw: 'matches' },
|
||||||
|
}, world)
|
||||||
|
expect(result.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(true)
|
||||||
|
expect(result.state.inventory.find((i) => i.id === 'matches')?.state['uses']).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses when the named instrument is not a lighter', () => {
|
||||||
|
const world = w()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [
|
||||||
|
{ id: 'lamp', state: { lit: false } },
|
||||||
|
{ id: 'rock', state: {} },
|
||||||
|
] }
|
||||||
|
const result = dispatch(state, {
|
||||||
|
kind: 'verb-target-prep', verb: 'light',
|
||||||
|
target: { canonical: 'lamp', raw: 'lamp' },
|
||||||
|
preposition: 'with',
|
||||||
|
indirect: { canonical: 'rock', raw: 'rock' },
|
||||||
|
}, world)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe("That isn't going to help.")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('use verb routing', () => {
|
||||||
|
function w(): World {
|
||||||
|
return {
|
||||||
|
startingRoom: 'r',
|
||||||
|
startingInventory: [],
|
||||||
|
rooms: { r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] } },
|
||||||
|
items: {
|
||||||
|
rock: { id: 'rock', names: ['rock'], short: 'a rock', long: '.', initialState: {}, takeable: true },
|
||||||
|
matches: { id: 'matches', names: ['matches', 'match'], short: 'a matchbook', long: '.', initialState: { uses: 2 }, takeable: true, lighter: true, lighterUses: 2, lighterEmptyText: 'The book is empty.' },
|
||||||
|
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: '' } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('falls back when no encounter consumes use', () => {
|
||||||
|
const world = w()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [{ id: 'rock', state: {} }] }
|
||||||
|
const result = dispatch(state, {
|
||||||
|
kind: 'verb-target', verb: 'use', target: { canonical: 'rock', raw: 'rock' },
|
||||||
|
}, world)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe("You can't think how to use that here.")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asks what to use a bare match with', () => {
|
||||||
|
const world = w()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [{ id: 'matches', state: { uses: 2 } }] }
|
||||||
|
const result = dispatch(state, {
|
||||||
|
kind: 'verb-target', verb: 'use', target: { canonical: 'matches', raw: 'match' },
|
||||||
|
}, world)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe('Use match with what?')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('burns the letter when using a match with it', () => {
|
||||||
|
const world = w()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [{ id: 'matches', state: { uses: 2 } }, { id: 'letter', state: {} }] }
|
||||||
|
const result = dispatch(state, {
|
||||||
|
kind: 'verb-target-prep', verb: 'use',
|
||||||
|
target: { canonical: 'matches', raw: 'match' },
|
||||||
|
preposition: 'with',
|
||||||
|
indirect: { canonical: 'letter', raw: 'letter' },
|
||||||
|
}, world)
|
||||||
|
|
||||||
|
expect(result.state.inventory.find((i) => i.id === 'letter')).toBeUndefined()
|
||||||
|
expect(result.state.inventory.find((i) => i.id === 'matches')?.state['uses']).toBe(1)
|
||||||
|
expect(result.state.flags['letterBurned']).toBe(true)
|
||||||
|
expect(result.appended.at(-1)?.text).toContain('ash')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lights a lightable item when using a match with it', () => {
|
||||||
|
const world = w()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [
|
||||||
|
{ id: 'matches', state: { uses: 2 } },
|
||||||
|
{ id: 'broken-cigarette', state: { lit: false } },
|
||||||
|
] }
|
||||||
|
const result = dispatch(state, {
|
||||||
|
kind: 'verb-target-prep', verb: 'use',
|
||||||
|
target: { canonical: 'matches', raw: 'match' },
|
||||||
|
preposition: 'with',
|
||||||
|
indirect: { canonical: 'broken-cigarette', raw: 'cigarette' },
|
||||||
|
}, world)
|
||||||
|
|
||||||
|
expect(result.state.inventory.find((i) => i.id === 'broken-cigarette')?.state['lit']).toBe(true)
|
||||||
|
expect(result.state.inventory.find((i) => i.id === 'matches')?.state['uses']).toBe(1)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe('The end glows once, then steadies. The smoke is bitter.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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 {
|
||||||
|
startingRoom: 'r',
|
||||||
|
startingInventory: [],
|
||||||
|
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: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
items: {},
|
||||||
|
encounters: {},
|
||||||
|
endings: {
|
||||||
|
true: { whenFlags: { reachedR2: true }, narration: 'You stand at the top of the stair.' },
|
||||||
|
wrong: { whenFlags: {}, narration: 'You disturb what should not be disturbed.' },
|
||||||
|
bad: { whenFlags: { tookPhoto: true }, narration: 'The child in it is you.' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('sets endedWith and emits an ending line when flags match', () => {
|
||||||
|
const world = makeWorld()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, flags: { reachedR2: true } }
|
||||||
|
const result = dispatch(state, { kind: 'verb-only', verb: 'wait' }, world)
|
||||||
|
expect(result.state.endedWith).toBe('true')
|
||||||
|
const last = result.appended.at(-1)!
|
||||||
|
expect(last.kind).toBe('ending')
|
||||||
|
expect(last.text).toBe('You stand at the top of the stair.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honors priority order: true beats wrong beats bad', () => {
|
||||||
|
const world = makeWorld()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, flags: { reachedR2: true } }
|
||||||
|
const result = dispatch(state, { kind: 'verb-only', verb: 'wait' }, world)
|
||||||
|
expect(result.state.endedWith).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects further input once ended', () => {
|
||||||
|
const world = makeWorld()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, flags: { reachedR2: true } }
|
||||||
|
const ended = dispatch(state, { kind: 'verb-only', verb: 'wait' }, world).state
|
||||||
|
const result = dispatch(ended, { kind: 'verb-only', verb: 'wait' }, world)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe('The story has ended. Type `restart` or `undo`.')
|
||||||
|
expect(result.state.location).toBe(ended.location)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fire on unknown turns (no state mutation)', () => {
|
||||||
|
const world = makeWorld()
|
||||||
|
const state = initialStateFor(world)
|
||||||
|
const result = dispatch(state, { kind: 'unknown', raw: 'fnord', reason: 'unknown-verb' }, world)
|
||||||
|
expect(result.state.endedWith).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,8 +1,84 @@
|
|||||||
import type { World } from '../world/types'
|
import { DEFAULT_WORLD_MESSAGES, type DeclarativeAction, type LightMechanicMessageKey, type World, type WorldMessageKey } from '../world/types'
|
||||||
import type { GameState, ParsedCommand, DispatchResult, ItemInstance, TranscriptLine, NounRef } from './types'
|
import type { GameState, ParsedCommand, DispatchResult, ItemInstance, TranscriptLine, ResolveLevel } from './types'
|
||||||
import { SCHEMA_VERSION, TRANSCRIPT_CAP } from './types'
|
import { SCHEMA_VERSION, TRANSCRIPT_CAP, RESOLVE_LEVELS } from './types'
|
||||||
import { applyVerbToEncounter, maybeTriggerEncounter } from './encounters'
|
import { applyVerbToEncounter, maybeTriggerEncounter } from './encounters'
|
||||||
|
|
||||||
|
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 {
|
export function initialStateFor(world: World): GameState {
|
||||||
const startingRoom = world.rooms[world.startingRoom]
|
const startingRoom = world.rooms[world.startingRoom]
|
||||||
if (!startingRoom) throw new Error(`World has invalid startingRoom: ${world.startingRoom}`)
|
if (!startingRoom) throw new Error(`World has invalid startingRoom: ${world.startingRoom}`)
|
||||||
@@ -14,12 +90,14 @@ export function initialStateFor(world: World): GameState {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const opening: TranscriptLine[] = [
|
const opening: TranscriptLine[] = [
|
||||||
|
...(world.game?.openingArt ? [{ kind: 'system' as const, text: world.game.openingArt }] : []),
|
||||||
{ kind: 'system', text: startingRoom.title },
|
{ kind: 'system', text: startingRoom.title },
|
||||||
{ kind: 'narration', text: startingRoom.descriptions.firstVisit },
|
{ kind: 'narration', text: startingRoom.descriptions.firstVisit },
|
||||||
]
|
]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
schemaVersion: SCHEMA_VERSION,
|
schemaVersion: SCHEMA_VERSION,
|
||||||
|
transcriptCap: world.game?.transcriptCap,
|
||||||
location: world.startingRoom,
|
location: world.startingRoom,
|
||||||
inventory,
|
inventory,
|
||||||
roomState: { [world.startingRoom]: { visited: true } },
|
roomState: { [world.startingRoom]: { visited: true } },
|
||||||
@@ -28,21 +106,44 @@ export function initialStateFor(world: World): GameState {
|
|||||||
encounterState: {},
|
encounterState: {},
|
||||||
lastNoun: null,
|
lastNoun: null,
|
||||||
pendingDisambiguation: null,
|
pendingDisambiguation: null,
|
||||||
|
pendingConfirmation: null,
|
||||||
transcript: opening,
|
transcript: opening,
|
||||||
theme: 'amber',
|
|
||||||
endedWith: null,
|
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 {
|
function append(state: GameState, lines: TranscriptLine[]): GameState {
|
||||||
const transcript = [...state.transcript, ...lines]
|
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[] {
|
export function getItemsInRoom(state: GameState, world: World, roomId: string): string[] {
|
||||||
const baseItems = world.rooms[roomId]?.items ?? []
|
const baseItems = world.rooms[roomId]?.items ?? []
|
||||||
const dropped = (state.roomState[roomId]?.['droppedItems'] as string[] | undefined) ?? []
|
const dropped = (state.roomState[roomId]?.['droppedItems'] ?? []) as string[]
|
||||||
const taken = (state.roomState[roomId]?.['takenItems'] as string[] | undefined) ?? []
|
const taken = (state.roomState[roomId]?.['takenItems'] ?? []) as string[]
|
||||||
return [...baseItems.filter((i) => !taken.includes(i)), ...dropped]
|
return [...baseItems.filter((i) => !taken.includes(i)), ...dropped]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,31 +152,105 @@ function setRoomFlag(state: GameState, roomId: string, key: string, value: strin
|
|||||||
...state,
|
...state,
|
||||||
roomState: {
|
roomState: {
|
||||||
...state.roomState,
|
...state.roomState,
|
||||||
[roomId]: { ...(state.roomState[roomId] ?? {}), [key]: value as string | boolean | number },
|
[roomId]: { ...(state.roomState[roomId] ?? {}), [key]: value },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dispatch(state: GameState, command: ParsedCommand, world: World): DispatchResult {
|
function evaluateEndings(state: GameState, world: World): GameState | null {
|
||||||
|
if (state.endedWith) return null
|
||||||
|
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)) {
|
||||||
|
if (state.flags[k] !== v) { allMatch = false; break }
|
||||||
|
}
|
||||||
|
if (!allMatch) continue
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
endedWith: id,
|
||||||
|
transcript: [...state.transcript, { kind: 'ending', text: ending.narration }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 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] }
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
// Disambiguation reply: re-issue the original verb with the chosen target.
|
||||||
if (command.kind === 'disambiguation') {
|
if (command.kind === 'disambiguation') {
|
||||||
const pending = state.pendingDisambiguation
|
const pending = state.pendingDisambiguation
|
||||||
if (!pending) {
|
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 }
|
const cleared: GameState = { ...state, pendingDisambiguation: null }
|
||||||
return dispatch(
|
return dispatch(
|
||||||
cleared,
|
cleared,
|
||||||
{ kind: 'verb-target', verb: pending.verb, target: { canonical: command.chosen, raw: command.chosen } },
|
{ kind: 'verb-target', verb: pending.verb, target: { canonical: command.chosen, raw: command.chosen } },
|
||||||
world,
|
world,
|
||||||
|
confirmed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.kind === 'unknown') {
|
if (command.kind === 'unknown') {
|
||||||
const text =
|
const text =
|
||||||
command.reason === 'unknown-verb' ? 'You consider the words, but they don\'t fit this place.'
|
command.reason === 'unknown-verb' ? message(world, 'unknown-verb')
|
||||||
: command.reason === 'unknown-noun' ? 'You don\'t see anything like that here.'
|
: command.reason === 'unknown-noun' ? message(world, 'unknown-noun')
|
||||||
: 'You hesitate.'
|
: message(world, 'malformed')
|
||||||
return narrate(state, [{ kind: 'narration', text }])
|
return narrate(state, [{ kind: 'narration', text }])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,13 +259,32 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (command.kind === 'go') {
|
if (command.kind === 'go') {
|
||||||
return handleGo(state, command.direction, world)
|
return withEndingCheck(handleGo(state, command.direction, world), world)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.kind === 'ambiguous') {
|
||||||
|
const candidateShorts = command.candidates.map((id) => world.items[id]?.short ?? id)
|
||||||
|
const list =
|
||||||
|
candidateShorts.length === 2
|
||||||
|
? `${candidateShorts[0]}, or ${candidateShorts[1]}`
|
||||||
|
: candidateShorts.slice(0, -1).join(', ') + ', or ' + candidateShorts[candidateShorts.length - 1]
|
||||||
|
const prompt = `Which ${command.rawNoun} — ${list}?`
|
||||||
|
const next: GameState = {
|
||||||
|
...state,
|
||||||
|
pendingDisambiguation: { verb: command.verb, candidates: command.candidates, prompt },
|
||||||
|
}
|
||||||
|
return narrate(next, [{ kind: 'narration', text: prompt }])
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.kind === 'verb-only') {
|
if (command.kind === 'verb-only') {
|
||||||
if (command.verb === 'look') return handleLook(state, world)
|
const encResult = applyVerbToEncounter(state, command, world)
|
||||||
if (command.verb === 'inventory') return handleInventory(state, world)
|
if (encResult?.consumed) {
|
||||||
if (command.verb === 'wait') return narrate(state, [{ kind: 'narration', text: 'Time passes.' }])
|
return withEndingCheck({ state: encResult.state, appended: encResult.lines }, 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(handleWait(state, world), world)
|
||||||
|
if (command.verb === 'listen') return withEndingCheck(narrate(state, [{ kind: 'narration', text: message(world, 'listen') }]), world)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.kind === 'verb-target') {
|
if (command.kind === 'verb-target') {
|
||||||
@@ -98,15 +292,46 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World)
|
|||||||
// Try the active encounter first — it may consume verbs like 'attack', 'hold'.
|
// Try the active encounter first — it may consume verbs like 'attack', 'hold'.
|
||||||
const encResult = applyVerbToEncounter(stateWithNoun, command, world)
|
const encResult = applyVerbToEncounter(stateWithNoun, command, world)
|
||||||
if (encResult?.consumed) {
|
if (encResult?.consumed) {
|
||||||
return { state: encResult.state, appended: encResult.lines }
|
return withEndingCheck({ state: encResult.state, appended: encResult.lines }, world)
|
||||||
}
|
}
|
||||||
if (command.verb === 'take') return handleTake(stateWithNoun, command.target.canonical, world)
|
if (command.verb === 'take') return withEndingCheck(handleTake(stateWithNoun, command.target.canonical, world), world)
|
||||||
if (command.verb === 'drop') return handleDrop(stateWithNoun, command.target.canonical, world)
|
if (command.verb === 'drop') return withEndingCheck(handleDrop(stateWithNoun, command.target.canonical, world), world)
|
||||||
if (command.verb === 'examine' || command.verb === 'look') return handleExamine(stateWithNoun, command.target.canonical, world)
|
if (command.verb === 'examine' || command.verb === 'look') return withEndingCheck(handleExamine(stateWithNoun, command.target.canonical, world), world)
|
||||||
return narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }])
|
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: message(world, 'use-lighter-with-what') }]), 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.' }])
|
if (command.kind === 'verb-target-prep') {
|
||||||
|
const stateWithNoun: GameState = { ...state, lastNoun: command.target }
|
||||||
|
// Try the encounter first — it may consume verbs like 'cut vines with shears'.
|
||||||
|
const encResult = applyVerbToEncounter(stateWithNoun, command, world)
|
||||||
|
if (encResult?.consumed) {
|
||||||
|
return withEndingCheck({ state: encResult.state, appended: encResult.lines }, world)
|
||||||
|
}
|
||||||
|
if (command.verb === 'light' && command.preposition === 'with') {
|
||||||
|
return withEndingCheck(handleLight(stateWithNoun, command.target.canonical, command.indirect.canonical, world), world)
|
||||||
|
}
|
||||||
|
if (command.verb === 'use') {
|
||||||
|
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: 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: message(world, 'nothing-happens') }])
|
||||||
}
|
}
|
||||||
|
|
||||||
function narrate(state: GameState, lines: TranscriptLine[]): DispatchResult {
|
function narrate(state: GameState, lines: TranscriptLine[]): DispatchResult {
|
||||||
@@ -115,10 +340,10 @@ function narrate(state: GameState, lines: TranscriptLine[]): DispatchResult {
|
|||||||
|
|
||||||
function handleMeta(state: GameState, verb: 'restart' | 'undo' | 'hint' | 'save' | 'quit' | 'theme'): DispatchResult {
|
function handleMeta(state: GameState, verb: 'restart' | 'undo' | 'hint' | 'save' | 'quit' | 'theme'): DispatchResult {
|
||||||
if (verb === 'save') return narrate(state, [{ kind: 'system', text: '(your progress is saved automatically)' }])
|
if (verb === 'save') return narrate(state, [{ kind: 'system', text: '(your progress is saved automatically)' }])
|
||||||
if (verb === 'theme') {
|
// 'theme' is a UI preference: the terminal intercepts it before dispatch and
|
||||||
const newTheme = state.theme === 'amber' ? 'ansi' : 'amber'
|
// dispatches a 'halfstreet-toggle-theme' DOM event. The engine no-ops here so
|
||||||
return narrate({ ...state, theme: newTheme }, [{ kind: 'system', text: `Theme: ${newTheme}.` }])
|
// typing the verb still produces transcript output if the UI ever misses it.
|
||||||
}
|
if (verb === 'theme') return narrate(state, [{ kind: 'system', text: '(theme)' }])
|
||||||
// restart / undo / hint / quit are handled by the UI layer (state mutations
|
// restart / undo / hint / quit are handled by the UI layer (state mutations
|
||||||
// require coordination with the save layer and route navigation). The
|
// require coordination with the save layer and route navigation). The
|
||||||
// engine acknowledges them with a no-op narration; the UI intercepts before
|
// engine acknowledges them with a no-op narration; the UI intercepts before
|
||||||
@@ -128,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 {
|
function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd', world: World): DispatchResult {
|
||||||
const room = world.rooms[state.location]
|
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]
|
const dest = room.exits[direction]
|
||||||
if (!dest) {
|
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]
|
const lock = room.lockedExits?.[direction]
|
||||||
@@ -144,7 +369,7 @@ function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd'
|
|||||||
}
|
}
|
||||||
|
|
||||||
const destRoom = world.rooms[dest]
|
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 visited = !!state.roomState[dest]?.['visited']
|
||||||
const description = visited ? destRoom.descriptions.revisit : destRoom.descriptions.firstVisit
|
const description = visited ? destRoom.descriptions.revisit : destRoom.descriptions.firstVisit
|
||||||
@@ -153,16 +378,24 @@ function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd'
|
|||||||
next = setRoomFlag(next, dest, 'visited', true)
|
next = setRoomFlag(next, dest, 'visited', true)
|
||||||
|
|
||||||
if (destRoom.safe) {
|
if (destRoom.safe) {
|
||||||
const ladder = ['steady', 'shaken', 'reeling', 'returning'] as const
|
next = { ...next, resolveLevel: recoverResolve(state.resolveLevel, world) }
|
||||||
const idx = ladder.indexOf(state.resolveLevel)
|
|
||||||
if (idx > 0) next = { ...next, resolveLevel: ladder[idx - 1]! }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lightTick = advanceLightState(next, 'move', world)
|
||||||
|
next = lightTick.state
|
||||||
|
|
||||||
const arrivalLines: TranscriptLine[] = [
|
const arrivalLines: TranscriptLine[] = [
|
||||||
{ kind: 'system', text: destRoom.title },
|
{ kind: 'system', text: destRoom.title },
|
||||||
{ kind: 'narration', text: description },
|
{ 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.
|
// Trigger any encounter waiting in this room.
|
||||||
const triggered = maybeTriggerEncounter(result.state, world)
|
const triggered = maybeTriggerEncounter(result.state, world)
|
||||||
@@ -172,11 +405,117 @@ function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd'
|
|||||||
return result
|
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 {
|
function handleLook(state: GameState, world: World): DispatchResult {
|
||||||
const room = world.rooms[state.location]
|
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 items = getItemsInRoom(state, world, state.location)
|
||||||
const itemNarration = items.length > 0 ? `You see here: ${items.map((id) => world.items[id]?.short ?? id).join(', ')}.` : ''
|
const itemNarration = describeRoomItems(items.map((id) => world.items[id]?.short ?? id))
|
||||||
return narrate(state, [
|
return narrate(state, [
|
||||||
{ kind: 'system', text: room.title },
|
{ kind: 'system', text: room.title },
|
||||||
{ kind: 'narration', text: room.descriptions.examined },
|
{ kind: 'narration', text: room.descriptions.examined },
|
||||||
@@ -184,32 +523,50 @@ function handleLook(state: GameState, world: World): DispatchResult {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function describeRoomItems(shorts: string[]): string {
|
||||||
|
if (shorts.length === 0) return ''
|
||||||
|
const names = [sentenceCase(shorts[0]!), ...shorts.slice(1)]
|
||||||
|
const verb = names.length === 1 ? 'is' : 'are'
|
||||||
|
return `${joinList(names)} ${verb} here.`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sentenceCase(value: string): string {
|
||||||
|
return value.length === 0 ? value : value[0]!.toUpperCase() + value.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinList(values: string[]): string {
|
||||||
|
if (values.length === 1) return values[0]!
|
||||||
|
if (values.length === 2) return `${values[0]} and ${values[1]}`
|
||||||
|
return `${values.slice(0, -1).join(', ')}, and ${values[values.length - 1]}`
|
||||||
|
}
|
||||||
|
|
||||||
function handleInventory(state: GameState, world: World): DispatchResult {
|
function handleInventory(state: GameState, world: World): DispatchResult {
|
||||||
if (state.inventory.length === 0) {
|
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 lines = state.inventory.map((inst) => {
|
||||||
const item = world.items[inst.id]
|
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 ` ${item?.short ?? inst.id}${litSuffix}`
|
||||||
})
|
})
|
||||||
return narrate(state, [
|
return narrate(state, [
|
||||||
{ kind: 'narration', text: 'You are carrying:' },
|
{ kind: 'narration', text: message(world, 'inventory-heading') },
|
||||||
{ kind: 'narration', text: lines.join('\n') },
|
{ kind: 'narration', text: lines.join('\n') },
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTake(state: GameState, itemId: string, world: World): DispatchResult {
|
function handleTake(state: GameState, itemId: string, world: World): DispatchResult {
|
||||||
const item = world.items[itemId]
|
const item = world.items[itemId]
|
||||||
if (!item) return narrate(state, [{ kind: 'narration', text: 'You don\'t see that here.' }])
|
if (!item) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-here') }])
|
||||||
if (!item.takeable) return narrate(state, [{ kind: 'narration', text: 'You can\'t take that.' }])
|
if (!item.takeable) return narrate(state, [{ kind: 'narration', text: message(world, 'cannot-take') }])
|
||||||
|
|
||||||
const itemsHere = getItemsInRoom(state, world, state.location)
|
const itemsHere = getItemsInRoom(state, world, state.location)
|
||||||
if (!itemsHere.includes(itemId)) {
|
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)) {
|
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)
|
const wasInRoomBase = (world.rooms[state.location]?.items ?? []).includes(itemId)
|
||||||
@@ -218,34 +575,273 @@ function handleTake(state: GameState, itemId: string, world: World): DispatchRes
|
|||||||
inventory: [...state.inventory, { id: itemId, state: { ...item.initialState } }],
|
inventory: [...state.inventory, { id: itemId, state: { ...item.initialState } }],
|
||||||
}
|
}
|
||||||
if (wasInRoomBase) {
|
if (wasInRoomBase) {
|
||||||
const taken = (next.roomState[state.location]?.['takenItems'] as string[] | undefined) ?? []
|
const taken = (next.roomState[state.location]?.['takenItems'] ?? []) as string[]
|
||||||
next = setRoomFlag(next, state.location, 'takenItems', [...taken, itemId])
|
next = setRoomFlag(next, state.location, 'takenItems', [...taken, itemId])
|
||||||
} else {
|
} else {
|
||||||
const dropped = (next.roomState[state.location]?.['droppedItems'] as string[] | undefined) ?? []
|
const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[]
|
||||||
next = setRoomFlag(next, state.location, 'droppedItems', dropped.filter((id) => id !== itemId))
|
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)) {
|
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 = {
|
let next: GameState = {
|
||||||
...state,
|
...state,
|
||||||
inventory: state.inventory.filter((i) => i.id !== itemId),
|
inventory: state.inventory.filter((i) => i.id !== itemId),
|
||||||
}
|
}
|
||||||
const dropped = (next.roomState[state.location]?.['droppedItems'] as string[] | undefined) ?? []
|
const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[]
|
||||||
next = setRoomFlag(next, state.location, 'droppedItems', [...dropped, itemId])
|
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 {
|
function handleExamine(state: GameState, itemId: string, world: World): DispatchResult {
|
||||||
const item = world.items[itemId]
|
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: message(world, 'dont-see-anything') }])
|
||||||
|
return narrate(state, [{ kind: 'narration', text: describeItem(itemId, item.long, inventoryInst) }])
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeItem(itemId: string, longDescription: string, inst: ItemInstance | null): string {
|
||||||
|
if (itemId !== 'matches' || typeof inst?.state['uses'] !== 'number') return longDescription
|
||||||
|
const uses = inst.state['uses']
|
||||||
|
const noun = uses === 1 ? 'match' : 'matches'
|
||||||
|
const count = spellSmallCount(uses)
|
||||||
|
return longDescription.replace(/with \w+ matches? left inside\./i, `with ${count} ${noun} left inside.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function spellSmallCount(value: number): string {
|
||||||
|
const words = ['no', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten']
|
||||||
|
return words[value] ?? String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRead(state: GameState, itemId: string, world: World): DispatchResult {
|
||||||
|
const item = world.items[itemId]
|
||||||
|
if (!item) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-anything') }])
|
||||||
const visible =
|
const visible =
|
||||||
state.inventory.find((i) => i.id === itemId) ||
|
state.inventory.find((i) => i.id === itemId) ||
|
||||||
getItemsInRoom(state, world, state.location).includes(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') }])
|
||||||
return narrate(state, [{ kind: 'narration', text: item.long }])
|
if (!item.readable || !item.readableText) {
|
||||||
|
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: 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: 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: message(world, 'need-carrying') }])
|
||||||
|
}
|
||||||
|
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: message(world, 'dont-have') }])
|
||||||
|
const lighterDef = world.items[instrumentId]
|
||||||
|
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: lightMessage(world, 'spent', 'spent') }])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const inst of state.inventory) {
|
||||||
|
const def = world.items[inst.id]
|
||||||
|
if (!def?.lighter) continue
|
||||||
|
if (typeof inst.state['uses'] === 'number' && inst.state['uses'] <= 0) continue
|
||||||
|
lighterInst = inst
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (!lighterInst) {
|
||||||
|
return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'noLighter', 'no-lighter') }])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply state changes immutably.
|
||||||
|
const lighterDef = world.items[lighterInst.id]!
|
||||||
|
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, [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 ?? lightMessage(world, 'flameCatches', 'flame-catches') }]
|
||||||
|
if (newLighterUses === 0) {
|
||||||
|
lines.push({ kind: 'narration', text: lighterDef.lighterEmptyText ?? lightMessage(world, 'spent', 'spent') })
|
||||||
|
}
|
||||||
|
return narrate({ ...state, inventory: newInventory }, lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeclarativeAction(
|
||||||
|
state: GameState,
|
||||||
|
command: Extract<ParsedCommand, { kind: 'verb-target-prep' }>,
|
||||||
|
world: World,
|
||||||
|
): DispatchResult | null {
|
||||||
|
const action = findDeclarativeAction(command, world)
|
||||||
|
if (!action) return null
|
||||||
|
|
||||||
|
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 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 consumed = new Set(action.consumes?.inventory ?? [])
|
||||||
|
let next: GameState = {
|
||||||
|
...state,
|
||||||
|
inventory: state.inventory
|
||||||
|
.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 ?? {}) },
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const itemId of consumed) {
|
||||||
|
next = removeVisibleRoomItem(next, world, itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
if (second?.lighter && first?.lightable) return handleLight(state, firstId, secondId, world)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 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: 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, [mechanic.stateKeys.lit]: false, [mechanic.stateKeys.burn]: 0 } } : i,
|
||||||
|
)
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const world: World = {
|
|||||||
id: 'foyer',
|
id: 'foyer',
|
||||||
title: '[ Foyer ]',
|
title: '[ Foyer ]',
|
||||||
descriptions: { firstVisit: 'Foyer.', revisit: 'Foyer.', examined: 'Foyer.' },
|
descriptions: { firstVisit: 'Foyer.', revisit: 'Foyer.', examined: 'Foyer.' },
|
||||||
exits: { n: 'stair' },
|
exits: { n: 'stair', e: 'chapel' },
|
||||||
items: [],
|
items: [],
|
||||||
safe: true,
|
safe: true,
|
||||||
},
|
},
|
||||||
@@ -29,10 +29,19 @@ const world: World = {
|
|||||||
exits: { u: 'stair' },
|
exits: { u: 'stair' },
|
||||||
items: [],
|
items: [],
|
||||||
},
|
},
|
||||||
|
chapel: {
|
||||||
|
id: 'chapel',
|
||||||
|
title: '[ Chapel ]',
|
||||||
|
descriptions: { firstVisit: 'Chapel.', revisit: 'Chapel.', examined: 'Chapel.' },
|
||||||
|
exits: { s: 'foyer' },
|
||||||
|
items: ['vial'],
|
||||||
|
encounter: 'basilisk',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
items: {
|
items: {
|
||||||
mirror: { id: 'mirror', names: ['mirror', 'tarnished mirror'], short: 'a tarnished mirror', long: 'A small mirror, tarnished black.', initialState: {}, takeable: true },
|
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 },
|
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: {
|
encounters: {
|
||||||
revenant: {
|
revenant: {
|
||||||
@@ -59,14 +68,47 @@ const world: World = {
|
|||||||
onFailed: { narration: 'You stagger back.', retreatTo: 'foyer' },
|
onFailed: { narration: 'You stagger back.', retreatTo: 'foyer' },
|
||||||
defaultWrongVerbNarration: 'The revenant does not seem to notice.',
|
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: {
|
endings: {
|
||||||
true: { whenFlags: {}, narration: '' },
|
true: { whenFlags: { _never: true }, narration: '' },
|
||||||
wrong: { whenFlags: {}, narration: '' },
|
wrong: { whenFlags: { _never: true }, narration: '' },
|
||||||
bad: { whenFlags: {}, narration: '' },
|
bad: { whenFlags: { _never: true }, narration: '' },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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', () => {
|
describe('encounters — phase advancement', () => {
|
||||||
it('triggers an encounter on entering its room', () => {
|
it('triggers an encounter on entering its room', () => {
|
||||||
let s = initialStateFor(world)
|
let s = initialStateFor(world)
|
||||||
@@ -87,11 +129,25 @@ describe('encounters — phase advancement', () => {
|
|||||||
it('wrong verb costs resolve and surfaces a clue', () => {
|
it('wrong verb costs resolve and surfaces a clue', () => {
|
||||||
let s = initialStateFor(world)
|
let s = initialStateFor(world)
|
||||||
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
|
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.resolveLevel).toBe('shaken')
|
||||||
expect(r.state.encounterState['revenant']).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', () => {
|
it('falls back to defaultWrongVerbNarration for unrecognized verbs', () => {
|
||||||
let s = initialStateFor(world)
|
let s = initialStateFor(world)
|
||||||
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
|
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
|
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
|
||||||
// Force resolve to 'returning' so the next failure retreats.
|
// Force resolve to 'returning' so the next failure retreats.
|
||||||
s = { ...s, resolveLevel: 'returning' }
|
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.state.location).toBe('foyer')
|
||||||
expect(r.appended.some((l) => l.text.includes('stagger back'))).toBe(true)
|
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
|
s = dispatch(s, { kind: 'go', direction: 's' }, world).state
|
||||||
expect(s.resolveLevel).toBe('steady')
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,17 @@ import type { World } from '../world/types'
|
|||||||
import type { GameState, ParsedCommand, DispatchResult, TranscriptLine, ResolveLevel } from './types'
|
import type { GameState, ParsedCommand, DispatchResult, TranscriptLine, ResolveLevel } from './types'
|
||||||
import { TRANSCRIPT_CAP, RESOLVE_LEVELS } 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 {
|
function append(state: GameState, lines: TranscriptLine[]): GameState {
|
||||||
const transcript = [...state.transcript, ...lines]
|
const transcript = [...state.transcript, ...lines]
|
||||||
return { ...state, transcript: transcript.slice(-TRANSCRIPT_CAP) }
|
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 }])
|
return narrate(next, [{ kind: 'narration', text: phase.description }])
|
||||||
}
|
}
|
||||||
|
|
||||||
function bumpResolve(level: ResolveLevel, cost: 0 | 1 | 2 | undefined): ResolveLevel {
|
function resolveMechanic(world: World): ActiveResolveMechanic {
|
||||||
if (!cost) return level
|
return world.mechanics?.resolve ?? DEFAULT_RESOLVE_MECHANIC
|
||||||
const idx = RESOLVE_LEVELS.indexOf(level)
|
}
|
||||||
const newIdx = Math.min(RESOLVE_LEVELS.length - 1, idx + cost)
|
|
||||||
return RESOLVE_LEVELS[newIdx]!
|
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 {
|
export interface EncounterResolution {
|
||||||
@@ -66,12 +92,17 @@ export function applyVerbToEncounter(
|
|||||||
const phaseDef = def.phases[currentPhase]
|
const phaseDef = def.phases[currentPhase]
|
||||||
if (!phaseDef) return null
|
if (!phaseDef) return null
|
||||||
|
|
||||||
// Only verb-target and verb-only commands engage with encounters.
|
// Only verb-target, verb-target-prep, and verb-only commands engage with encounters.
|
||||||
let verb: string | null = null
|
let verb: string | null = null
|
||||||
let targetId: string | null = null
|
let targetId: string | null = null
|
||||||
|
let instrumentId: string | null = null
|
||||||
if (command.kind === 'verb-target') {
|
if (command.kind === 'verb-target') {
|
||||||
verb = command.verb
|
verb = command.verb
|
||||||
targetId = command.target.canonical
|
targetId = command.target.canonical
|
||||||
|
} else if (command.kind === 'verb-target-prep') {
|
||||||
|
verb = command.verb
|
||||||
|
targetId = command.target.canonical
|
||||||
|
instrumentId = command.indirect.canonical
|
||||||
} else if (command.kind === 'verb-only' && command.verb !== 'inventory') {
|
} else if (command.kind === 'verb-only' && command.verb !== 'inventory') {
|
||||||
verb = command.verb
|
verb = command.verb
|
||||||
} else {
|
} else {
|
||||||
@@ -91,18 +122,20 @@ export function applyVerbToEncounter(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (t.requires && instrumentId && t.requires.item !== instrumentId && t.requires.item !== targetId) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!transition) {
|
if (!transition) {
|
||||||
// Wrong verb — apply default narration and resolve cost.
|
// Wrong verb — apply default narration and resolve cost.
|
||||||
if (!verb || (targetId !== null && targetId !== encId)) return null // verb is unrelated to this encounter
|
if (!verb || (targetId !== null && targetId !== encId)) return null // verb is unrelated to this encounter
|
||||||
const newResolve = bumpResolve(state.resolveLevel, 1)
|
const wrongVerbCost = resolveMechanic(world).wrongVerbCost
|
||||||
if (state.resolveLevel === 'returning') {
|
const newResolve = bumpResolve(state.resolveLevel, wrongVerbCost, world)
|
||||||
|
if (shouldRetreat(state.resolveLevel, wrongVerbCost, world)) {
|
||||||
// Retreat.
|
// Retreat.
|
||||||
const retreat = def.onFailed
|
const retreat = def.onFailed
|
||||||
if (retreat) {
|
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 dest = world.rooms[retreat.retreatTo]
|
||||||
const lines: TranscriptLine[] = [
|
const lines: TranscriptLine[] = [
|
||||||
{ kind: 'narration', text: retreat.narration },
|
{ kind: 'narration', text: retreat.narration },
|
||||||
@@ -119,10 +152,10 @@ export function applyVerbToEncounter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Right verb — but if it has a resolve cost and player is already at 'returning', retreat.
|
// 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
|
const retreat = def.onFailed
|
||||||
if (retreat) {
|
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 dest = world.rooms[retreat.retreatTo]
|
||||||
const lines: TranscriptLine[] = [
|
const lines: TranscriptLine[] = [
|
||||||
{ kind: 'narration', text: transition.narration },
|
{ kind: 'narration', text: transition.narration },
|
||||||
@@ -136,13 +169,14 @@ export function applyVerbToEncounter(
|
|||||||
// Right verb — narrate and transition.
|
// Right verb — narrate and transition.
|
||||||
let next: GameState = { ...state }
|
let next: GameState = { ...state }
|
||||||
if (transition.resolveCost) {
|
if (transition.resolveCost) {
|
||||||
next = { ...next, resolveLevel: bumpResolve(next.resolveLevel, transition.resolveCost) }
|
next = { ...next, resolveLevel: bumpResolve(next.resolveLevel, transition.resolveCost, world) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transition.to === 'resolved') {
|
if (transition.to === 'resolved') {
|
||||||
const newEncState = { ...next.encounterState }
|
const newEncState = { ...next.encounterState }
|
||||||
delete newEncState[encId]
|
delete newEncState[encId]
|
||||||
let resolvedFlags = { ...next.flags, [`${encId}.resolved`]: true }
|
let resolvedFlags = { ...next.flags, [`${encId}.resolved`]: true }
|
||||||
|
if (transition.setFlags) resolvedFlags = { ...resolvedFlags, ...transition.setFlags }
|
||||||
if (def.onResolved?.setFlags) resolvedFlags = { ...resolvedFlags, ...def.onResolved.setFlags }
|
if (def.onResolved?.setFlags) resolvedFlags = { ...resolvedFlags, ...def.onResolved.setFlags }
|
||||||
next = { ...next, encounterState: newEncState, flags: resolvedFlags }
|
next = { ...next, encounterState: newEncState, flags: resolvedFlags }
|
||||||
} else if (transition.to === 'failed') {
|
} else if (transition.to === 'failed') {
|
||||||
@@ -151,7 +185,7 @@ export function applyVerbToEncounter(
|
|||||||
const dest = world.rooms[retreat.retreatTo]
|
const dest = world.rooms[retreat.retreatTo]
|
||||||
const newEncState = { ...next.encounterState }
|
const newEncState = { ...next.encounterState }
|
||||||
delete newEncState[encId]
|
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[] = [
|
const lines: TranscriptLine[] = [
|
||||||
{ kind: 'narration', text: transition.narration },
|
{ kind: 'narration', text: transition.narration },
|
||||||
{ kind: 'narration', text: retreat.narration },
|
{ kind: 'narration', text: retreat.narration },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { parse } from './parser'
|
import { parse } from './parser'
|
||||||
import type { ParserContext } from './parser'
|
import type { ParserContext, ParserVocabulary } from './parser'
|
||||||
|
|
||||||
const emptyCtx: ParserContext = {
|
const emptyCtx: ParserContext = {
|
||||||
knownItems: [],
|
knownItems: [],
|
||||||
@@ -16,6 +16,10 @@ describe('parser — verb-only commands', () => {
|
|||||||
expect(parse('look', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'look' })
|
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', () => {
|
it('recognizes bare "inventory" and short forms', () => {
|
||||||
expect(parse('inventory', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'inventory' })
|
expect(parse('inventory', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'inventory' })
|
||||||
expect(parse('inv', 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', () => {
|
describe('parser — direction shortcuts', () => {
|
||||||
it('maps single-letter directions', () => {
|
it('maps single-letter directions', () => {
|
||||||
expect(parse('n', emptyCtx)).toEqual({ kind: 'go', direction: 'n' })
|
expect(parse('n', emptyCtx)).toEqual({ kind: 'go', direction: 'n' })
|
||||||
@@ -76,6 +92,87 @@ describe('parser — unknown input', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('parser — verb + target', () => {
|
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: [],
|
||||||
|
knownEncounters: ['piano-echo', 'covered-cage'],
|
||||||
|
visibleNouns: [
|
||||||
|
{ id: 'piano-echo', aliases: ['piano', 'note'] },
|
||||||
|
{ id: 'covered-cage', aliases: ['cage'] },
|
||||||
|
],
|
||||||
|
inventoryItemIds: [],
|
||||||
|
lastNoun: null,
|
||||||
|
awaitingDisambiguation: null,
|
||||||
|
}
|
||||||
|
expect(parse('play note', ctx)).toEqual({
|
||||||
|
kind: 'verb-target',
|
||||||
|
verb: 'play',
|
||||||
|
target: { canonical: 'piano-echo', raw: 'note' },
|
||||||
|
})
|
||||||
|
expect(parse('uncover cage', ctx)).toEqual({
|
||||||
|
kind: 'verb-target',
|
||||||
|
verb: 'open',
|
||||||
|
target: { canonical: 'covered-cage', raw: 'cage' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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', () => {
|
it('resolves a single visible noun', () => {
|
||||||
const ctx: ParserContext = {
|
const ctx: ParserContext = {
|
||||||
knownItems: ['torch'],
|
knownItems: ['torch'],
|
||||||
@@ -158,7 +255,7 @@ describe('parser — verb + target', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('parser — disambiguation', () => {
|
describe('parser — disambiguation', () => {
|
||||||
it('returns disambiguation request when two candidates match', () => {
|
it('returns ambiguous when two candidates match', () => {
|
||||||
const ctx: ParserContext = {
|
const ctx: ParserContext = {
|
||||||
knownItems: ['brass-key', 'iron-key'],
|
knownItems: ['brass-key', 'iron-key'],
|
||||||
knownEncounters: [],
|
knownEncounters: [],
|
||||||
@@ -171,12 +268,11 @@ describe('parser — disambiguation', () => {
|
|||||||
awaitingDisambiguation: null,
|
awaitingDisambiguation: null,
|
||||||
}
|
}
|
||||||
const result = parse('take key', ctx)
|
const result = parse('take key', ctx)
|
||||||
expect(result.kind).toBe('unknown')
|
expect(result.kind).toBe('ambiguous')
|
||||||
if (result.kind === 'unknown') {
|
if (result.kind === 'ambiguous') {
|
||||||
// Parser flags ambiguity by returning unknown-noun; the dispatcher
|
expect(result.verb).toBe('take')
|
||||||
// turns this into a PendingDisambiguation. (Keeping parser pure: it
|
expect(result.rawNoun).toBe('key')
|
||||||
// signals; the dispatcher decides UI flow.)
|
expect(result.candidates).toEqual(['brass-key', 'iron-key'])
|
||||||
expect(result.reason).toBe('unknown-noun')
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -231,3 +327,138 @@ describe('parser — pronouns', () => {
|
|||||||
expect(result.kind).toBe('unknown')
|
expect(result.kind).toBe('unknown')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('stop-word stripping', () => {
|
||||||
|
const ctx: ParserContext = {
|
||||||
|
knownItems: ['lamp'],
|
||||||
|
knownEncounters: [],
|
||||||
|
visibleNouns: [{ id: 'lamp', aliases: ['lamp', 'oil lamp'] }],
|
||||||
|
inventoryItemIds: [],
|
||||||
|
lastNoun: null,
|
||||||
|
awaitingDisambiguation: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
it('strips a leading "at" from the noun phrase', () => {
|
||||||
|
const cmd = parse('look at lamp', ctx)
|
||||||
|
expect(cmd).toEqual({
|
||||||
|
kind: 'verb-target',
|
||||||
|
verb: 'look',
|
||||||
|
target: { canonical: 'lamp', raw: 'lamp' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips a leading "the"', () => {
|
||||||
|
const cmd = parse('examine the lamp', ctx)
|
||||||
|
expect(cmd.kind).toBe('verb-target')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips "a" and "an"', () => {
|
||||||
|
expect(parse('take a lamp', ctx).kind).toBe('verb-target')
|
||||||
|
expect(parse('take an oil lamp', ctx).kind).toBe('verb-target')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not strip stop-words mid-phrase', () => {
|
||||||
|
// 'oil lamp' is the alias; 'oil at lamp' is not. Stop-words only strip from
|
||||||
|
// the head of the noun phrase, not anywhere in the middle.
|
||||||
|
const cmd = parse('take oil lamp', ctx)
|
||||||
|
expect(cmd.kind).toBe('verb-target')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ambiguous noun', () => {
|
||||||
|
const ctx: ParserContext = {
|
||||||
|
knownItems: ['iron-key', 'brass-key'],
|
||||||
|
knownEncounters: [],
|
||||||
|
visibleNouns: [
|
||||||
|
{ id: 'iron-key', aliases: ['key', 'iron key'] },
|
||||||
|
{ id: 'brass-key', aliases: ['key', 'brass key'] },
|
||||||
|
],
|
||||||
|
inventoryItemIds: [],
|
||||||
|
lastNoun: null,
|
||||||
|
awaitingDisambiguation: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns ambiguous when two aliases match the same noun phrase', () => {
|
||||||
|
const cmd = parse('take key', ctx)
|
||||||
|
expect(cmd).toEqual({
|
||||||
|
kind: 'ambiguous',
|
||||||
|
verb: 'take',
|
||||||
|
rawNoun: 'key',
|
||||||
|
candidates: ['iron-key', 'brass-key'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still returns verb-target when the phrase is unambiguous', () => {
|
||||||
|
const cmd = parse('take iron key', ctx)
|
||||||
|
expect(cmd.kind).toBe('verb-target')
|
||||||
|
if (cmd.kind === 'verb-target') expect(cmd.target.canonical).toBe('iron-key')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('verb-target-prep with "with"', () => {
|
||||||
|
const ctx: ParserContext = {
|
||||||
|
knownItems: ['lamp', 'matches'],
|
||||||
|
knownEncounters: [],
|
||||||
|
visibleNouns: [
|
||||||
|
{ id: 'lamp', aliases: ['lamp'] },
|
||||||
|
{ id: 'matches', aliases: ['matches', 'match', 'matchbook'] },
|
||||||
|
],
|
||||||
|
inventoryItemIds: ['matches'],
|
||||||
|
lastNoun: null,
|
||||||
|
awaitingDisambiguation: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
it('parses "light lamp with matches" into verb-target-prep', () => {
|
||||||
|
const cmd = parse('light lamp with matches', ctx)
|
||||||
|
expect(cmd).toEqual({
|
||||||
|
kind: 'verb-target-prep',
|
||||||
|
verb: 'light',
|
||||||
|
target: { canonical: 'lamp', raw: 'lamp' },
|
||||||
|
preposition: 'with',
|
||||||
|
indirect: { canonical: 'matches', raw: 'matches' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses singular "match" aliases', () => {
|
||||||
|
const cmd = parse('use match with lamp', ctx)
|
||||||
|
expect(cmd).toEqual({
|
||||||
|
kind: 'verb-target-prep',
|
||||||
|
verb: 'use',
|
||||||
|
target: { canonical: 'matches', raw: 'match' },
|
||||||
|
preposition: 'with',
|
||||||
|
indirect: { canonical: 'lamp', raw: 'lamp' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses "use shears on vines" into verb-target-prep', () => {
|
||||||
|
const localCtx: ParserContext = {
|
||||||
|
knownItems: ['shears', 'ivy-figure'],
|
||||||
|
knownEncounters: [],
|
||||||
|
visibleNouns: [
|
||||||
|
{ id: 'shears', aliases: ['shears'] },
|
||||||
|
{ id: 'ivy-figure', aliases: ['vines', 'ivy'] },
|
||||||
|
],
|
||||||
|
inventoryItemIds: ['shears'],
|
||||||
|
lastNoun: null,
|
||||||
|
awaitingDisambiguation: null,
|
||||||
|
}
|
||||||
|
const cmd = parse('use shears on vines', localCtx)
|
||||||
|
expect(cmd).toEqual({
|
||||||
|
kind: 'verb-target-prep',
|
||||||
|
verb: 'use',
|
||||||
|
target: { canonical: 'shears', raw: 'shears' },
|
||||||
|
preposition: 'on',
|
||||||
|
indirect: { canonical: 'ivy-figure', raw: 'vines' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still parses verb-target when no preposition is present', () => {
|
||||||
|
const cmd = parse('take lamp', ctx)
|
||||||
|
expect(cmd.kind).toBe('verb-target')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to unknown-noun when one side fails to resolve', () => {
|
||||||
|
const cmd = parse('light lamp with feathers', ctx)
|
||||||
|
expect(cmd).toEqual({ kind: 'unknown', raw: 'light lamp with feathers', reason: 'unknown-noun' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import type { ParsedCommand, NounRef, Verb, MetaVerb, Direction, PendingDisambiguation } from './types'
|
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 {
|
export interface ParserContext {
|
||||||
/** All item ids that exist in the world (for noun matching). */
|
/** All item ids that exist in the world (for noun matching). */
|
||||||
knownItems: string[]
|
knownItems: string[]
|
||||||
@@ -11,91 +20,171 @@ export interface ParserContext {
|
|||||||
inventoryItemIds: string[]
|
inventoryItemIds: string[]
|
||||||
lastNoun: NounRef | null
|
lastNoun: NounRef | null
|
||||||
awaitingDisambiguation: PendingDisambiguation | null
|
awaitingDisambiguation: PendingDisambiguation | null
|
||||||
|
vocabulary?: ParserVocabulary
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Verb synonym table: each entry maps an alias to the canonical Verb. */
|
export const SUPPORTED_VERBS: Verb[] = [
|
||||||
const VERB_SYNONYMS: Record<string, Verb> = {
|
'go',
|
||||||
// movement
|
'look',
|
||||||
go: 'go', walk: 'go', move: 'go',
|
'examine',
|
||||||
// perception
|
'take',
|
||||||
look: 'look', l: 'look',
|
'drop',
|
||||||
examine: 'examine', x: 'examine', inspect: 'examine',
|
'use',
|
||||||
// inventory
|
'open',
|
||||||
inventory: 'inventory', inv: 'inventory', i: 'inventory',
|
'close',
|
||||||
// manipulation
|
'read',
|
||||||
take: 'take', get: 'take', grab: 'take', 'pick up': 'take',
|
'light',
|
||||||
drop: 'drop', put: 'drop', leave: 'drop',
|
'extinguish',
|
||||||
use: 'use', combine: 'use',
|
'attack',
|
||||||
open: 'open', close: 'close',
|
'inventory',
|
||||||
read: 'read', light: 'light', extinguish: 'extinguish', douse: 'extinguish',
|
'wait',
|
||||||
attack: 'attack', kill: 'attack', fight: 'attack', strike: 'attack',
|
'hold',
|
||||||
hold: 'hold', show: 'hold',
|
'push',
|
||||||
push: 'push', press: 'push',
|
'pull',
|
||||||
pull: 'pull',
|
'cut',
|
||||||
wait: 'wait', z: 'wait',
|
'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> = {
|
interface CompiledVocabulary {
|
||||||
n: 'n', north: 'n',
|
directionWords: Record<string, Direction>
|
||||||
s: 's', south: 's',
|
metaVerbs: Record<string, MetaVerb>
|
||||||
e: 'e', east: 'e',
|
verbSynonyms: Record<string, Verb>
|
||||||
w: 'w', west: 'w',
|
multiWordVerbs: string[]
|
||||||
u: 'u', up: 'u',
|
noTargetVerbs: Set<string>
|
||||||
d: 'd', down: 'd',
|
stopWords: Set<string>
|
||||||
|
prepositions: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
const META_VERBS: Record<string, MetaVerb> = {
|
function normalizeAlias(value: string): string {
|
||||||
restart: 'restart',
|
return value.trim().toLowerCase().replace(/\s+/g, ' ')
|
||||||
undo: 'undo',
|
|
||||||
hint: 'hint',
|
|
||||||
save: 'save',
|
|
||||||
quit: 'quit',
|
|
||||||
theme: 'theme',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Verbs that legally take no target. */
|
function compileVocabulary(vocabulary: ParserVocabulary): CompiledVocabulary {
|
||||||
const VERB_ONLY_VERBS = new Set<string>(['look', 'inventory', 'wait'])
|
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 metaVerbs: Record<string, MetaVerb> = {}
|
||||||
const TWO_WORD_VERBS = ['pick up']
|
for (const verb of vocabulary.metaVerbs) metaVerbs[normalizeAlias(verb)] = verb
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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(' ')
|
||||||
|
if (phrase === 'it' && ctx.lastNoun) {
|
||||||
|
return { id: ctx.lastNoun.canonical, alias: 'it' }
|
||||||
|
}
|
||||||
|
for (const noun of ctx.visibleNouns) {
|
||||||
|
for (const alias of noun.aliases) {
|
||||||
|
if (alias === phrase) return { id: noun.id, alias }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const itemId of ctx.inventoryItemIds) {
|
||||||
|
if (itemId === phrase) return { id: itemId, alias: phrase }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function tokenize(input: string): string[] {
|
function tokenize(input: string): string[] {
|
||||||
return input.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
return input.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchTwoWordVerb(tokens: string[]): { verb: Verb; rest: string[] } | null {
|
function matchMultiWordVerb(tokens: string[], vocabulary: CompiledVocabulary): { verb: Verb; rest: string[] } | null {
|
||||||
if (tokens.length < 2) return null
|
for (const phrase of vocabulary.multiWordVerbs) {
|
||||||
const head = tokens.slice(0, 2).join(' ')
|
const phraseTokens = phrase.split(' ')
|
||||||
for (const phrase of TWO_WORD_VERBS) {
|
if (tokens.length >= phraseTokens.length && tokens.slice(0, phraseTokens.length).join(' ') === phrase) {
|
||||||
if (phrase === head) {
|
const verb = vocabulary.verbSynonyms[phrase]
|
||||||
const verb = VERB_SYNONYMS[phrase]
|
if (verb) return { verb, rest: tokens.slice(phraseTokens.length) }
|
||||||
if (verb) return { verb, rest: tokens.slice(2) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
|
export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
|
||||||
|
const vocabulary = compileVocabulary(ctx.vocabulary ?? DEFAULT_PARSER_VOCABULARY)
|
||||||
const trimmed = rawInput.trim()
|
const trimmed = rawInput.trim()
|
||||||
if (!trimmed) return { kind: 'unknown', raw: '', reason: 'malformed' }
|
if (!trimmed) return { kind: 'unknown', raw: '', reason: 'malformed' }
|
||||||
|
|
||||||
const tokens = tokenize(trimmed)
|
const tokens = tokenize(trimmed)
|
||||||
const head = tokens[0]!
|
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).
|
// Meta-commands take precedence (single-word).
|
||||||
if (META_VERBS[head] && tokens.length === 1) {
|
if (vocabulary.metaVerbs[head] && tokens.length === 1) {
|
||||||
return { kind: 'meta', verb: META_VERBS[head]! }
|
return { kind: 'meta', verb: vocabulary.metaVerbs[head]! }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direction shortcuts: "n", "north", "go n", "go north".
|
// Direction shortcuts: "n", "north", "go n", "go north".
|
||||||
if (DIRECTION_WORDS[head] && tokens.length === 1) {
|
if (vocabulary.directionWords[head] && tokens.length === 1) {
|
||||||
return { kind: 'go', direction: DIRECTION_WORDS[head]! }
|
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.
|
// Disambiguation reply: a single-word answer matching one of the candidates.
|
||||||
// Must be checked before verb resolution so "brass" / "iron" etc. are caught.
|
// Must be checked before verb resolution so "brass" / "iron" etc. are caught.
|
||||||
if (ctx.awaitingDisambiguation && tokens.length === 1) {
|
if (ctx.awaitingDisambiguation && tokens.length === 1) {
|
||||||
@@ -110,15 +199,15 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two-word verb (e.g. "pick up X").
|
// Multi-word verb aliases (e.g. "pick up X").
|
||||||
const twoWord = matchTwoWordVerb(tokens)
|
const twoWord = matchMultiWordVerb(tokens, vocabulary)
|
||||||
let verb: Verb | undefined
|
let verb: Verb | undefined
|
||||||
let rest: string[]
|
let rest: string[]
|
||||||
if (twoWord) {
|
if (twoWord) {
|
||||||
verb = twoWord.verb
|
verb = twoWord.verb
|
||||||
rest = twoWord.rest
|
rest = twoWord.rest
|
||||||
} else {
|
} else {
|
||||||
verb = VERB_SYNONYMS[head]
|
verb = vocabulary.verbSynonyms[head]
|
||||||
rest = tokens.slice(1)
|
rest = tokens.slice(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,13 +215,50 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
|
|||||||
return { kind: 'unknown', raw: trimmed, reason: 'unknown-verb' }
|
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 && vocabulary.stopWords.has(rest[0]!)) {
|
||||||
|
rest = rest.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
if (rest.length === 0) {
|
if (rest.length === 0) {
|
||||||
if (VERB_ONLY_VERBS.has(verb)) {
|
if (vocabulary.noTargetVerbs.has(verb)) {
|
||||||
return { kind: 'verb-only', verb: verb as 'look' | 'inventory' | 'wait' }
|
return { kind: 'verb-only', verb: verb as 'look' | 'inventory' | 'wait' | 'listen' }
|
||||||
}
|
}
|
||||||
return { kind: 'unknown', raw: trimmed, reason: 'malformed' }
|
return { kind: 'unknown', raw: trimmed, reason: 'malformed' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect a preposition splitting target | indirect.
|
||||||
|
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 && vocabulary.stopWords.has(indirectTokens[0]!)) {
|
||||||
|
indirectTokens = indirectTokens.slice(1)
|
||||||
|
}
|
||||||
|
if (indirectTokens.length > 0) {
|
||||||
|
const target = resolveNoun(targetTokens, ctx)
|
||||||
|
const indirect = resolveNoun(indirectTokens, ctx)
|
||||||
|
if (target && indirect) {
|
||||||
|
return {
|
||||||
|
kind: 'verb-target-prep',
|
||||||
|
verb,
|
||||||
|
target: { canonical: target.id, raw: target.alias },
|
||||||
|
preposition: prep,
|
||||||
|
indirect: { canonical: indirect.id, raw: indirect.alias },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Either side failed to resolve → fall through to unknown-noun below.
|
||||||
|
return { kind: 'unknown', raw: trimmed, reason: 'unknown-noun' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pronoun resolution: "it" maps to lastNoun.
|
// Pronoun resolution: "it" maps to lastNoun.
|
||||||
if (rest.length === 1 && rest[0] === 'it') {
|
if (rest.length === 1 && rest[0] === 'it') {
|
||||||
if (!ctx.lastNoun) {
|
if (!ctx.lastNoun) {
|
||||||
@@ -165,11 +291,16 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
|
|||||||
return { kind: 'unknown', raw: trimmed, reason: 'unknown-noun' }
|
return { kind: 'unknown', raw: trimmed, reason: 'unknown-noun' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multiple candidates → ambiguous. Parser signals; the dispatcher records the
|
// Multiple candidates → ambiguous. Dedupe by id; if only one distinct id
|
||||||
// PendingDisambiguation in state so the next turn's input is interpreted as
|
// remains, two aliases of the same item matched — not truly ambiguous.
|
||||||
// a disambiguation reply.
|
|
||||||
if (candidates.length > 1) {
|
if (candidates.length > 1) {
|
||||||
return { kind: 'unknown', raw: trimmed, reason: 'unknown-noun' }
|
const uniqueIds = [...new Set(candidates.map((c) => c.id))]
|
||||||
|
if (uniqueIds.length === 1) {
|
||||||
|
// Two aliases of the same item — not actually ambiguous.
|
||||||
|
const id = uniqueIds[0]!
|
||||||
|
return { kind: 'verb-target', verb, target: { canonical: id, raw: candidates[0]!.alias } }
|
||||||
|
}
|
||||||
|
return { kind: 'ambiguous', verb, rawNoun: targetRaw, candidates: uniqueIds }
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = candidates[0]!
|
const target = candidates[0]!
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { parse } from './parser'
|
import { parse } from './parser'
|
||||||
import type { ParserContext } from './parser'
|
import type { ParserContext } from './parser'
|
||||||
import { dispatch, initialStateFor } from './dispatcher'
|
import { dispatch, getItemsInRoom, initialStateFor } from './dispatcher'
|
||||||
import { world } from '../world'
|
import { world } from '../world'
|
||||||
import type { GameState } from './types'
|
import type { GameState } from './types'
|
||||||
|
|
||||||
@@ -17,7 +17,11 @@ function ctxFor(state: GameState): ParserContext {
|
|||||||
if (item) visibleNouns.push({ id: inst.id, aliases: item.names })
|
if (item) visibleNouns.push({ id: inst.id, aliases: item.names })
|
||||||
}
|
}
|
||||||
if (room?.encounter) {
|
if (room?.encounter) {
|
||||||
visibleNouns.push({ id: room.encounter, aliases: [room.encounter] })
|
const encounter = world.encounters[room.encounter]
|
||||||
|
visibleNouns.push({
|
||||||
|
id: room.encounter,
|
||||||
|
aliases: [room.encounter, room.encounter.replace(/-/g, ' '), ...(encounter?.aliases ?? [])],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
knownItems: Object.keys(world.items),
|
knownItems: Object.keys(world.items),
|
||||||
@@ -26,6 +30,7 @@ function ctxFor(state: GameState): ParserContext {
|
|||||||
inventoryItemIds: state.inventory.map((i) => i.id),
|
inventoryItemIds: state.inventory.map((i) => i.id),
|
||||||
lastNoun: state.lastNoun,
|
lastNoun: state.lastNoun,
|
||||||
awaitingDisambiguation: state.pendingDisambiguation,
|
awaitingDisambiguation: state.pendingDisambiguation,
|
||||||
|
vocabulary: world.parser,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,12 +46,13 @@ function play(commands: string[]): GameState {
|
|||||||
describe('playthrough — sample world', () => {
|
describe('playthrough — sample world', () => {
|
||||||
it('reaches the rat-gone flag via the canonical command sequence', () => {
|
it('reaches the rat-gone flag via the canonical command sequence', () => {
|
||||||
const state = play([
|
const state = play([
|
||||||
'take letter',
|
|
||||||
'read letter', // verb is recognized but encounter takes priority elsewhere; here it's a no-op
|
'read letter', // verb is recognized but encounter takes priority elsewhere; here it's a no-op
|
||||||
|
'n', // gate → foyer
|
||||||
'n', // foyer → hallway
|
'n', // foyer → hallway
|
||||||
'take lamp',
|
'take lamp',
|
||||||
'e', // hallway → cellar-stair (triggers rat encounter)
|
'e', // hallway → cellar-stair (triggers rat encounter)
|
||||||
'attack rat',
|
'attack rat',
|
||||||
|
'yes',
|
||||||
])
|
])
|
||||||
expect(state.flags['ratGone']).toBe(true)
|
expect(state.flags['ratGone']).toBe(true)
|
||||||
expect(state.location).toBe('cellar-stair')
|
expect(state.location).toBe('cellar-stair')
|
||||||
@@ -55,11 +61,279 @@ describe('playthrough — sample world', () => {
|
|||||||
|
|
||||||
it('handles invalid moves gracefully', () => {
|
it('handles invalid moves gracefully', () => {
|
||||||
const state = play([
|
const state = play([
|
||||||
'go up', // foyer has no up exit
|
'go up', // gate has no up exit
|
||||||
'n',
|
'n',
|
||||||
's',
|
's',
|
||||||
'flibbertigibbet', // unknown verb
|
'flibbertigibbet', // unknown verb
|
||||||
])
|
])
|
||||||
|
expect(state.location).toBe('outside-gate')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('plays through the main-floor slice encounters', () => {
|
||||||
|
const state = play([
|
||||||
|
'n', // gate → foyer
|
||||||
|
'n', // foyer → hallway
|
||||||
|
'n', // hallway → dining-room
|
||||||
|
'close curtains',
|
||||||
|
'take candlestick',
|
||||||
|
'n', // dining-room → conservatory
|
||||||
|
'take shears',
|
||||||
|
'cut vines with shears',
|
||||||
|
's',
|
||||||
|
'w', // dining-room → hallway
|
||||||
|
'w', // hallway → smoking-room
|
||||||
|
'take lighter',
|
||||||
|
'uncover cage',
|
||||||
|
'e',
|
||||||
|
'd', // hallway → music-room
|
||||||
|
'play note',
|
||||||
|
'take tiny key',
|
||||||
|
'n', // music-room → servants-passage
|
||||||
|
'wait',
|
||||||
|
'e', // servants-passage → laundry
|
||||||
|
'wait',
|
||||||
|
'take damp sheet',
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(state.flags['window-guest.resolved']).toBe(true)
|
||||||
|
expect(state.flags['ivy-figure.resolved']).toBe(true)
|
||||||
|
expect(state.flags['covered-cage.resolved']).toBe(true)
|
||||||
|
expect(state.flags['piano-echo.resolved']).toBe(true)
|
||||||
|
expect(state.flags['breathing-wall.resolved']).toBe(true)
|
||||||
|
expect(state.flags['linen-shape.resolved']).toBe(true)
|
||||||
|
expect(state.inventory.map((i) => i.id)).toEqual(expect.arrayContaining([
|
||||||
|
'candlestick',
|
||||||
|
'pruning-shears',
|
||||||
|
'silver-lighter',
|
||||||
|
'music-box-key',
|
||||||
|
'damp-sheet',
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('plays through the upper-floor 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
|
||||||
|
'read drawing',
|
||||||
|
'take dog',
|
||||||
|
'w',
|
||||||
|
'u', // bedroom → attic
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(state.flags['stair-sleeper.resolved']).toBe(true)
|
||||||
|
expect(state.flags['hallwayShifted']).toBe(true)
|
||||||
|
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.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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ const baseState = (overrides: Partial<GameState> = {}): GameState => ({
|
|||||||
encounterState: {},
|
encounterState: {},
|
||||||
lastNoun: null,
|
lastNoun: null,
|
||||||
pendingDisambiguation: null,
|
pendingDisambiguation: null,
|
||||||
|
pendingConfirmation: null,
|
||||||
transcript: [],
|
transcript: [],
|
||||||
theme: 'amber',
|
|
||||||
endedWith: null,
|
endedWith: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ export function loadState(): GameState | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Older saves may carry fields no longer in GameState (e.g. `theme` before
|
||||||
|
// it became a UI-only preference). TypeScript ignores extra fields at runtime;
|
||||||
|
// bump SCHEMA_VERSION only when the meaning of an existing field changes.
|
||||||
return parsed as GameState
|
return parsed as GameState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export type Direction = 'n' | 's' | 'e' | 'w' | 'u' | 'd'
|
|||||||
export type Verb =
|
export type Verb =
|
||||||
| 'go' | 'look' | 'examine' | 'take' | 'drop' | 'use' | 'open' | 'close'
|
| 'go' | 'look' | 'examine' | 'take' | 'drop' | 'use' | 'open' | 'close'
|
||||||
| 'read' | 'light' | 'extinguish' | 'attack' | 'inventory' | 'wait'
|
| 'read' | 'light' | 'extinguish' | 'attack' | 'inventory' | 'wait'
|
||||||
| 'hold' | 'push' | 'pull'
|
| 'hold' | 'push' | 'pull' | 'cut' | 'play' | 'listen' | 'pour' | 'drink'
|
||||||
|
|
||||||
export type MetaVerb = 'restart' | 'undo' | 'hint' | 'save' | 'quit' | 'theme'
|
export type MetaVerb = 'restart' | 'undo' | 'hint' | 'save' | 'quit' | 'theme'
|
||||||
|
|
||||||
@@ -21,27 +21,29 @@ export interface NounRef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ParsedCommand =
|
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'; verb: Verb; target: NounRef }
|
||||||
| { kind: 'verb-target-prep'; verb: Verb; target: NounRef; preposition: string; indirect: NounRef }
|
| { kind: 'verb-target-prep'; verb: Verb; target: NounRef; preposition: string; indirect: NounRef }
|
||||||
|
| { kind: 'ambiguous'; verb: Verb; rawNoun: string; candidates: string[] }
|
||||||
| { kind: 'go'; direction: Direction }
|
| { kind: 'go'; direction: Direction }
|
||||||
| { kind: 'meta'; verb: MetaVerb }
|
| { kind: 'meta'; verb: MetaVerb }
|
||||||
| { kind: 'disambiguation'; chosen: string }
|
| { kind: 'disambiguation'; chosen: string }
|
||||||
| { kind: 'unknown'; raw: string; reason: 'unknown-verb' | 'unknown-noun' | 'malformed' }
|
| { kind: 'unknown'; raw: string; reason: 'unknown-verb' | 'unknown-noun' | 'malformed' }
|
||||||
|
|
||||||
export type ResolveLevel = 'steady' | 'shaken' | 'reeling' | 'returning'
|
export type ResolveLevel = 'steady' | 'shaken' | 'reeling' | 'returning'
|
||||||
export type Theme = 'amber' | 'ansi'
|
|
||||||
|
|
||||||
export interface ItemInstance {
|
export interface ItemInstance {
|
||||||
id: ItemId
|
id: ItemId
|
||||||
/** Per-instance state: lit/unlit, broken/whole, etc. */
|
/** Per-instance state: lit/unlit, broken/whole, etc. */
|
||||||
state: Record<string, string | boolean | number>
|
state: Record<string, string | boolean | number | string[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EncounterPhase = string // phase names are encounter-specific
|
export type EncounterPhase = string // phase names are encounter-specific
|
||||||
|
export type EndingId = string
|
||||||
|
|
||||||
export interface TranscriptLine {
|
export interface TranscriptLine {
|
||||||
kind: 'narration' | 'player' | 'system'
|
kind: 'narration' | 'player' | 'system' | 'ending'
|
||||||
text: string
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,14 +53,20 @@ export interface PendingDisambiguation {
|
|||||||
prompt: string
|
prompt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PendingConfirmation {
|
||||||
|
command: ParsedCommand
|
||||||
|
prompt: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface GameState {
|
export interface GameState {
|
||||||
schemaVersion: number
|
schemaVersion: number
|
||||||
|
transcriptCap?: number
|
||||||
location: RoomId
|
location: RoomId
|
||||||
inventory: ItemInstance[]
|
inventory: ItemInstance[]
|
||||||
/** Per-room state: visited, items dropped, descriptive flags. */
|
/** Per-room state: visited, items dropped, descriptive flags. */
|
||||||
roomState: Record<RoomId, Record<string, string | boolean | number>>
|
roomState: Record<RoomId, Record<string, string | boolean | number | string[]>>
|
||||||
/** Story-wide flags (e.g. 'gateOpened', 'mirrorTarnished'). */
|
/** Story-wide flags (e.g. 'gateOpened', 'mirrorTarnished'). */
|
||||||
flags: Record<string, string | boolean | number>
|
flags: Record<string, string | boolean | number | string[]>
|
||||||
resolveLevel: ResolveLevel
|
resolveLevel: ResolveLevel
|
||||||
/** Active encounter phase by encounter id, or null if no encounter is mid-flight. */
|
/** Active encounter phase by encounter id, or null if no encounter is mid-flight. */
|
||||||
encounterState: Record<EncounterId, EncounterPhase>
|
encounterState: Record<EncounterId, EncounterPhase>
|
||||||
@@ -66,11 +74,12 @@ export interface GameState {
|
|||||||
lastNoun: NounRef | null
|
lastNoun: NounRef | null
|
||||||
/** Pending multi-word disambiguation, set when the parser cannot decide. */
|
/** Pending multi-word disambiguation, set when the parser cannot decide. */
|
||||||
pendingDisambiguation: PendingDisambiguation | null
|
pendingDisambiguation: PendingDisambiguation | null
|
||||||
|
/** Pending confirmation for dangerous/game-changing commands. */
|
||||||
|
pendingConfirmation: PendingConfirmation | null
|
||||||
/** Capped at 200 entries; older entries are dropped on append. */
|
/** Capped at 200 entries; older entries are dropped on append. */
|
||||||
transcript: TranscriptLine[]
|
transcript: TranscriptLine[]
|
||||||
theme: Theme
|
|
||||||
/** Set true when the player has reached an ending. UI shows ending screen. */
|
/** Set true when the player has reached an ending. UI shows ending screen. */
|
||||||
endedWith: 'true' | 'wrong' | 'bad' | null
|
endedWith: EndingId | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DispatchResult {
|
export interface DispatchResult {
|
||||||
|
|||||||
@@ -1,24 +1,81 @@
|
|||||||
---
|
---
|
||||||
import '../ui/crt.css'
|
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">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<title>Halfstreet — Ethan J Lewis</title>
|
<title>{ui?.pageTitle ?? `${world.game?.title ?? 'Halfstreet'} - Ethan J Lewis`}</title>
|
||||||
<meta name="description" content="A gothic mystery." />
|
<meta name="description" content={ui?.description ?? world.game?.description ?? 'A gothic mystery.'} />
|
||||||
<meta name="robots" content="noindex" />
|
<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={ui?.themeColor ?? '#1a0d00'} />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body data-bugsink-dsn={bugsinkDsn}>
|
||||||
<div class="mystery-root" data-mystery-root>
|
<div class="mystery-root" data-mystery-root>
|
||||||
<div class="mystery-bezel">
|
<div class="mystery-bezel">
|
||||||
<div class="mystery-theme-toggle" data-mystery-theme-toggle>
|
<div class="mystery-options" data-mystery-options>
|
||||||
<button type="button" data-theme-choice="amber" aria-pressed="true">[B]</button>
|
<button
|
||||||
<button type="button" data-theme-choice="ansi" aria-pressed="false">[C]</button>
|
type="button"
|
||||||
|
class="mystery-options-toggle"
|
||||||
|
data-options-toggle
|
||||||
|
aria-label="Options"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="mystery-options-icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
viewBox="0 0 1200 1200"
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
<path d="m541.45 262.22-14.297 48.703c-2.8125 8.8125-9.7031 15.703-18.469 18.609-5.8594 2.1562-12.703 4.6875-20.766 8.1562-8.3906 4.0781-18.234 4.0781-26.625 0l-46.078-25.688c-9.2812-4.9688-20.578-4.3125-29.156 1.7812-25.969 19.266-49.266 41.859-69.281 67.219-6.4219 8.4375-7.125 19.969-1.7812 29.156l25.781 44.156c4.5469 7.875 4.875 17.531 0.84375 25.688-3.6094 7.0781-6.4688 13.781-9.1406 20.531h0.046875c-3.2344 8.4844-10.312 14.953-19.078 17.391l-50.766 13.781c-10.219 2.625-17.906 11.062-19.453 21.516-4.4531 31.406-4.4531 63.328 0 94.781 1.3594 10.641 9.2344 19.266 19.688 21.609l50.297 13.781c8.9062 2.3906 16.125 8.9062 19.406 17.531 2.5312 6.6094 5.3906 13.219 8.7656 20.297 3.8438 7.9219 3.6094 17.25-0.70312 24.938l-25.781 44.625h-0.046875c-5.4375 9.1406-4.6406 20.719 1.9219 29.062 20.062 25.172 43.359 47.625 69.281 66.703 8.5312 6.3281 20.016 6.9844 29.25 1.6875l45.609-24.844c8.3906-4.2656 18.281-4.5 26.859-0.70312 7.0781 3.2344 13.781 5.8594 20.859 8.3906h0.046875c8.6719 2.9062 15.516 9.75 18.375 18.469l14.156 48.703v0.046875c2.5312 10.406 11.297 18.141 21.938 19.406 12.234 1.5 24.516 2.25 36.844 2.2969 12.047-0.28125 24.047-1.2188 36-2.8594 10.594-1.1719 19.406-8.7656 22.078-19.078l14.297-48.938v-0.046875c2.7188-8.6719 9.375-15.516 18-18.469 7.4531-2.625 14.156-5.2969 21-8.3906 8.625-3.9375 18.562-3.7031 27 0.5625l46.078 25.219c9.1406 5.3906 20.625 4.875 29.25-1.3125 26.016-19.266 49.359-41.812 69.516-67.078 6.4688-8.3906 7.1719-19.922 1.7812-29.062l-25.781-45c-4.3125-7.6406-4.5938-16.922-0.75-24.844 3.375-7.2188 6.2344-13.781 8.625-20.156h0.046875c3.2344-8.6719 10.453-15.281 19.406-17.766l50.062-13.688v0.046875c10.406-2.1562 18.375-10.594 19.922-21.141 4.4062-31.547 4.4062-63.516 0-95.062-1.5938-10.453-9.2812-18.984-19.594-21.562l-50.766-13.781 0.046875-0.046875c-8.7656-2.4375-15.891-8.8594-19.078-17.391-2.625-6.7031-5.5312-13.453-9-20.297-3.8906-8.0156-3.6094-17.391 0.70312-25.172l25.781-44.297c5.4375-9.1406 4.6875-20.625-1.7812-29.016-20.016-25.453-43.359-48.047-69.375-67.312-8.4375-6.0469-19.547-6.8438-28.781-2.0625l-45.844 25.078c-8.4844 4.0781-18.375 4.0781-26.906 0-7.9219-3.4688-14.766-6-20.625-8.1562-8.7188-2.8594-15.562-9.6562-18.469-18.375l-14.156-48.609c-2.625-10.359-11.344-18.047-21.984-19.312-12.188-1.4531-24.422-2.3438-36.703-2.625-12 0-24 1.5469-36 3.1406-10.781 1.0312-19.734 8.625-22.547 19.078zm58.547 175.18c65.766 0 125.06 39.609 150.24 100.41 25.172 60.75 11.25 130.69-35.25 177.19s-116.44 60.422-177.19 35.25c-60.797-25.172-100.41-84.469-100.41-150.24 0.09375-89.766 72.844-162.52 162.61-162.61z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="mystery-options-menu" data-options-menu hidden>
|
||||||
|
<div class="mystery-options-group" aria-label="Screen">
|
||||||
|
<div class="mystery-options-label">Screen</div>
|
||||||
|
<button type="button" data-theme-choice="amber" aria-pressed="true">Amber</button>
|
||||||
|
<button type="button" data-theme-choice="ansi" aria-pressed="false">ANSI</button>
|
||||||
|
</div>
|
||||||
|
<div class="mystery-options-group" aria-label="Cursor">
|
||||||
|
<div class="mystery-options-label">Cursor</div>
|
||||||
|
<button type="button" data-cursor-choice="bar" aria-pressed="true">Bar</button>
|
||||||
|
<button type="button" data-cursor-choice="block" aria-pressed="false">Block</button>
|
||||||
|
<button type="button" data-cursor-choice="underscore" aria-pressed="false">Underline</button>
|
||||||
|
</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>
|
||||||
<div class="mystery-transcript" data-mystery-transcript aria-live="polite" aria-atomic="false"></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">
|
<div class="mystery-input-row">
|
||||||
<input
|
<input
|
||||||
class="mystery-input"
|
class="mystery-input"
|
||||||
@@ -30,8 +87,44 @@ import '../ui/crt.css'
|
|||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
aria-label="Command input"
|
aria-label="Command input"
|
||||||
/>
|
/>
|
||||||
|
<span class="mystery-input-display" data-mystery-input-display aria-hidden="true"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<footer class="mystery-footer">
|
||||||
|
{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={firstFooterLink.href}>{firstFooterLink.label}</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{ui?.footer.showBuild !== false && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden="true">|</span>
|
||||||
|
<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>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
// Theme attribute is set on :root before any rendering to avoid a flash
|
// Theme attribute is set on :root before any rendering to avoid a flash
|
||||||
@@ -39,11 +132,24 @@ import '../ui/crt.css'
|
|||||||
const stored = (() => {
|
const stored = (() => {
|
||||||
try { return localStorage.getItem('halfstreet:theme:v1') } catch { return null }
|
try { return localStorage.getItem('halfstreet:theme:v1') } catch { return null }
|
||||||
})()
|
})()
|
||||||
|
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-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>
|
||||||
<script>
|
<script>
|
||||||
import '../ui/terminal.ts'
|
import '../ui/terminal.ts'
|
||||||
import '../ui/theme.ts'
|
import '../ui/theme.ts'
|
||||||
|
import '../ui/error-tracking.ts'
|
||||||
|
import '../ui/bug-report.ts'
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
import type { Chip } from './chips'
|
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 {
|
export function renderChips(chips: Chip[], onSelect: (command: string) => void): void {
|
||||||
const container = document.querySelector<HTMLDivElement>(CHIP_CONTAINER)
|
const container = document.querySelector<HTMLDivElement>(CHIP_CONTAINER)
|
||||||
|
|||||||
@@ -14,30 +14,53 @@ describe('computeChips — sample world', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('adds TAKE chips for visible takeable items', () => {
|
it('adds TAKE chips for visible takeable items', () => {
|
||||||
const s = initialStateFor(world)
|
let s = initialStateFor(world)
|
||||||
|
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
|
||||||
|
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
|
||||||
const chips = computeChips(s, world)
|
const chips = computeChips(s, world)
|
||||||
expect(chips.find((c) => c.kind === 'item' && c.command === 'take letter')).toBeTruthy()
|
expect(chips.find((c) => c.kind === 'item' && c.command === 'take lamp')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('removes TAKE chip after item is taken', () => {
|
it('removes TAKE chip after item is taken', () => {
|
||||||
let s = initialStateFor(world)
|
let s = initialStateFor(world)
|
||||||
s = dispatch(s, { kind: 'verb-target', verb: 'take', target: { canonical: 'letter', raw: 'letter' } }, world).state
|
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
|
||||||
|
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
|
||||||
|
s = dispatch(s, { kind: 'verb-target', verb: 'take', target: { canonical: 'lamp', raw: 'lamp' } }, world).state
|
||||||
const chips = computeChips(s, world)
|
const chips = computeChips(s, world)
|
||||||
expect(chips.find((c) => c.command === 'take letter')).toBeUndefined()
|
expect(chips.find((c) => c.command === 'take lamp')).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('adds an encounter verb chip when an encounter is active', () => {
|
it('adds an encounter verb chip when an encounter is active', () => {
|
||||||
let s = initialStateFor(world)
|
let s = initialStateFor(world)
|
||||||
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
|
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
|
||||||
|
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
|
||||||
s = dispatch(s, { kind: 'go', direction: 'e' }, world).state
|
s = dispatch(s, { kind: 'go', direction: 'e' }, world).state
|
||||||
const chips = computeChips(s, world)
|
const chips = computeChips(s, world)
|
||||||
expect(chips.find((c) => c.kind === 'encounter' && c.command.includes('rat'))).toBeTruthy()
|
expect(chips.find((c) => c.kind === 'encounter' && c.label === 'ATTACK RAT' && c.command === 'attack rat')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('always includes LOOK and INV', () => {
|
it('always includes LOOK, INV, USE, WAIT, and HELP', () => {
|
||||||
const s = initialStateFor(world)
|
const s = initialStateFor(world)
|
||||||
const chips = computeChips(s, world)
|
const chips = computeChips(s, world)
|
||||||
expect(chips.find((c) => c.command === 'look')).toBeTruthy()
|
expect(chips.find((c) => c.command === 'look')).toBeTruthy()
|
||||||
expect(chips.find((c) => c.command === 'inventory')).toBeTruthy()
|
expect(chips.find((c) => c.command === 'inventory')).toBeTruthy()
|
||||||
|
expect(chips.find((c) => c.label === 'USE' && c.command === 'use ')).toBeTruthy()
|
||||||
|
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 },
|
||||||
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ export function computeChips(state: GameState, world: World): Chip[] {
|
|||||||
const room = world.rooms[state.location]
|
const room = world.rooms[state.location]
|
||||||
if (!room) return out
|
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.
|
// Direction chips: enabled if exit exists, dimmed otherwise.
|
||||||
const dirs: Direction[] = ['n', 's', 'e', 'w', 'u', 'd']
|
const dirs: Direction[] = ['n', 's', 'e', 'w', 'u', 'd']
|
||||||
for (const d of dirs) {
|
for (const d of dirs) {
|
||||||
@@ -53,12 +60,12 @@ export function computeChips(state: GameState, world: World): Chip[] {
|
|||||||
const phase = def?.phases[state.encounterState[room.encounter]!]
|
const phase = def?.phases[state.encounterState[room.encounter]!]
|
||||||
if (def && phase) {
|
if (def && phase) {
|
||||||
for (const t of phase.transitions) {
|
for (const t of phase.transitions) {
|
||||||
const targetLabel = t.target && t.target !== '*' ? ` ${t.target.toUpperCase()}` : ''
|
const targetLabel = t.target && t.target !== '*' ? ` ${t.target.replace(/-/g, ' ').toUpperCase()}` : ''
|
||||||
const command = t.target && t.target !== '*' ? `${t.verb} ${t.target}` : t.verb
|
const command = t.target && t.target !== '*' ? `${t.verb} ${t.target.replace(/-/g, ' ')}` : t.verb
|
||||||
out.push({
|
out.push({
|
||||||
kind: 'encounter',
|
kind: 'encounter',
|
||||||
label: `${t.verb.toUpperCase()}${targetLabel}`,
|
label: t.chipLabel ?? `${t.verb.toUpperCase()}${targetLabel}`,
|
||||||
command,
|
command: t.chipCommand ?? command,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -68,6 +75,9 @@ export function computeChips(state: GameState, world: World): Chip[] {
|
|||||||
// Persistent meta chips.
|
// Persistent meta chips.
|
||||||
out.push({ kind: 'meta', label: 'LOOK', command: 'look', disabled: false })
|
out.push({ kind: 'meta', label: 'LOOK', command: 'look', disabled: false })
|
||||||
out.push({ kind: 'meta', label: 'INV', command: 'inventory', disabled: false })
|
out.push({ kind: 'meta', label: 'INV', command: 'inventory', disabled: false })
|
||||||
|
out.push({ kind: 'meta', label: 'USE', command: 'use ', disabled: false })
|
||||||
|
out.push({ kind: 'meta', label: 'WAIT', command: 'wait', disabled: false })
|
||||||
|
out.push({ kind: 'meta', label: 'HELP', command: 'help', disabled: false })
|
||||||
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
:root[data-mystery-theme='amber'] {
|
:root[data-mystery-theme='amber'] {
|
||||||
--m-bg: #1a0d00;
|
--m-bg: #1a0d00;
|
||||||
--m-fg: #ffb000;
|
--m-fg: #ffb000;
|
||||||
@@ -18,9 +26,33 @@
|
|||||||
--m-divider-style: double;
|
--m-divider-style: double;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-mystery-cursor='bar'],
|
||||||
|
:root:not([data-mystery-cursor]) {
|
||||||
|
--m-cursor-preview: '|';
|
||||||
|
--m-cursor-preview-size: 1em;
|
||||||
|
--m-cursor-preview-weight: normal;
|
||||||
|
--m-cursor-preview-offset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-mystery-cursor='block'] {
|
||||||
|
--m-cursor-preview: '█';
|
||||||
|
--m-cursor-preview-size: 0.9em;
|
||||||
|
--m-cursor-preview-weight: normal;
|
||||||
|
--m-cursor-preview-offset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-mystery-cursor='underscore'] {
|
||||||
|
--m-cursor-preview: '_';
|
||||||
|
--m-cursor-preview-size: 1.15em;
|
||||||
|
--m-cursor-preview-weight: bold;
|
||||||
|
--m-cursor-preview-offset: 0.18em;
|
||||||
|
}
|
||||||
|
|
||||||
.mystery-root {
|
.mystery-root {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
height: 100dvh;
|
||||||
|
min-height: 0;
|
||||||
background: var(--m-bezel);
|
background: var(--m-bezel);
|
||||||
color: var(--m-fg);
|
color: var(--m-fg);
|
||||||
font-family: 'Courier New', 'Cascadia Mono', 'Consolas', monospace;
|
font-family: 'Courier New', 'Cascadia Mono', 'Consolas', monospace;
|
||||||
@@ -36,6 +68,7 @@
|
|||||||
|
|
||||||
.mystery-bezel {
|
.mystery-bezel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
background: var(--m-bg);
|
background: var(--m-bg);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 22px 26px 14px;
|
padding: 22px 26px 14px;
|
||||||
@@ -46,6 +79,49 @@
|
|||||||
box-shadow: inset 0 0 60px rgba(0, 0, 0, 0.55);
|
box-shadow: inset 0 0 60px rgba(0, 0, 0, 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mystery-footer {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 8px 4px 0;
|
||||||
|
color: var(--m-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
.mystery-bezel::before {
|
.mystery-bezel::before {
|
||||||
/* scanlines overlay */
|
/* scanlines overlay */
|
||||||
content: '';
|
content: '';
|
||||||
@@ -63,17 +139,101 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mystery-theme-toggle {
|
.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;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
right: 12px;
|
right: 32px;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mystery-theme-toggle button {
|
.mystery-options-toggle {
|
||||||
|
width: 34px;
|
||||||
|
height: 32px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
position: relative;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-options-icon {
|
||||||
|
width: 23px;
|
||||||
|
height: 23px;
|
||||||
|
display: block;
|
||||||
|
fill: currentColor;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-options-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--m-bg);
|
||||||
|
border: 1px solid var(--m-dim);
|
||||||
|
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-options-menu::after {
|
||||||
|
content: 'Cursor ' var(--m-cursor-preview);
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 7px;
|
||||||
|
border-top: 1px solid var(--m-dim);
|
||||||
|
color: var(--m-fg);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: var(--m-cursor-preview-weight);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-options-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-options-group + .mystery-options-group {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-options-label {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
color: var(--m-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-options button {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--m-dim);
|
color: var(--m-dim);
|
||||||
border: 1px solid var(--m-dim);
|
border: 1px solid var(--m-dim);
|
||||||
@@ -83,27 +243,114 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mystery-theme-toggle button[aria-pressed='true'] {
|
.mystery-options .mystery-options-toggle {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-options button[aria-pressed='true'],
|
||||||
|
.mystery-options-toggle[aria-expanded='true'] {
|
||||||
color: var(--m-fg);
|
color: var(--m-fg);
|
||||||
border-color: var(--m-fg);
|
border-color: var(--m-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mystery-transcript {
|
.mystery-transcript {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overflow-anchor: none;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-color: var(--m-dim) var(--m-bg);
|
||||||
|
scrollbar-width: thin;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
padding-right: 6px;
|
padding-right: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mystery-transcript::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-transcript::-webkit-scrollbar-track {
|
||||||
|
background: var(--m-bg);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-transcript::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--m-dim);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-transcript > div {
|
||||||
|
max-width: 80ch;
|
||||||
|
}
|
||||||
|
|
||||||
.mystery-transcript .system {
|
.mystery-transcript .system {
|
||||||
color: var(--m-accent-1);
|
color: var(--m-accent-1);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-top: 0.6em;
|
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;
|
||||||
|
white-space: pre;
|
||||||
|
word-break: normal;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
font-size: clamp(9px, 2vw, 14px);
|
||||||
|
line-height: 1.1;
|
||||||
|
margin-bottom: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-transcript .help {
|
||||||
|
position: relative;
|
||||||
|
color: var(--m-fg);
|
||||||
|
font-weight: normal;
|
||||||
|
border: 1px var(--m-divider-style) var(--m-dim);
|
||||||
|
padding: 0.75em;
|
||||||
|
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 {
|
.mystery-transcript .player {
|
||||||
color: var(--m-accent-2);
|
color: var(--m-accent-2);
|
||||||
}
|
}
|
||||||
@@ -114,6 +361,30 @@
|
|||||||
margin: 0.25em 0;
|
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 {
|
.mystery-input-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -121,33 +392,197 @@
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
min-height: 1.45em;
|
||||||
|
cursor: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mystery-input-row::before {
|
.mystery-input-row::before {
|
||||||
content: '>';
|
content: '>';
|
||||||
color: var(--m-accent-2);
|
color: var(--m-accent-2);
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mystery-input {
|
.mystery-input {
|
||||||
flex: 1;
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
color: transparent;
|
||||||
|
caret-color: transparent;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--m-fg);
|
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
text-shadow: inherit;
|
}
|
||||||
caret-color: var(--m-fg);
|
|
||||||
|
.mystery-input-display {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--m-fg);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-input-display[data-placeholder='true'] {
|
||||||
|
color: var(--m-dim);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-input-display::after {
|
||||||
|
content: var(--m-cursor-preview);
|
||||||
|
display: inline-block;
|
||||||
|
color: var(--m-fg);
|
||||||
|
font-size: var(--m-cursor-preview-size);
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: var(--m-cursor-preview-weight);
|
||||||
|
line-height: 1;
|
||||||
|
transform: translateY(var(--m-cursor-preview-offset));
|
||||||
|
margin-left: 0.1ch;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-input:focus + .mystery-input-display::after {
|
||||||
|
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 {
|
.mystery-chips {
|
||||||
display: none;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 6px 0 4px;
|
padding: 6px 0 4px;
|
||||||
position: relative;
|
min-width: 0;
|
||||||
z-index: 2;
|
flex: 1 1 auto;
|
||||||
border-top: 1px var(--m-divider-style) var(--m-dim);
|
}
|
||||||
margin-top: 8px;
|
|
||||||
|
: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 {
|
.mystery-chip {
|
||||||
@@ -166,6 +601,58 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (pointer: coarse) {
|
:root[data-mystery-drunk] .mystery-chip {
|
||||||
.mystery-chips { display: flex; }
|
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;
|
||||||
|
padding-top: 1em;
|
||||||
|
border-top: 1px solid currentColor;
|
||||||
|
font-style: italic;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.mystery-root {
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-bezel {
|
||||||
|
padding: 18px 14px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-transcript .ascii-art {
|
||||||
|
font-size: clamp(7px, 2.35vw, 10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,22 +1,88 @@
|
|||||||
import { parse } from '../engine/parser'
|
import { parse } from '../engine/parser'
|
||||||
import type { ParserContext } 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 { saveState, loadState, clearSave } from '../engine/save'
|
||||||
import { world } from '../world'
|
import { world } from '../world'
|
||||||
|
import { DEFAULT_WORLD_MESSAGES, type WorldMessageKey } from '../world/types'
|
||||||
import type { GameState, TranscriptLine } from '../engine/types'
|
import type { GameState, TranscriptLine } from '../engine/types'
|
||||||
import { TRANSCRIPT_CAP } from '../engine/types'
|
import { TRANSCRIPT_CAP } from '../engine/types'
|
||||||
import { computeChips } from './chips'
|
import { computeChips } from './chips'
|
||||||
import { renderChips } from './chip-render'
|
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 transcriptEl = document.querySelector<HTMLDivElement>('[data-mystery-transcript]')
|
||||||
const inputEl = document.querySelector<HTMLInputElement>('[data-mystery-input]')
|
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]')
|
||||||
|
|
||||||
if (!transcriptEl || !inputEl) {
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
function message(key: WorldMessageKey): string {
|
||||||
|
return world.messages?.[key] ?? DEFAULT_WORLD_MESSAGES[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
||||||
console.error('[halfstreet] terminal mount points missing')
|
console.error('[halfstreet] terminal mount points missing')
|
||||||
} else {
|
} else {
|
||||||
const restored = loadState()
|
const restored = loadState()
|
||||||
let state: GameState = restored ?? initialStateFor(world)
|
let state: GameState = restored ?? initialStateFor(world)
|
||||||
let lastState: GameState | null = null // for one-step undo
|
let lastState: GameState | null = null // for one-step undo
|
||||||
|
let transientHelpEl: HTMLDivElement | null = null
|
||||||
|
let commandHistory: string[] = []
|
||||||
|
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) {
|
if (!restored) {
|
||||||
// Fresh state already includes the opening narration in its transcript.
|
// Fresh state already includes the opening narration in its transcript.
|
||||||
@@ -27,12 +93,42 @@ if (!transcriptEl || !inputEl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function refreshChips(): void {
|
function refreshChips(): void {
|
||||||
|
if (!UI_FEATURES.chips) return
|
||||||
renderChips(computeChips(state, world), (command) => {
|
renderChips(computeChips(state, world), (command) => {
|
||||||
|
clearIdleHint()
|
||||||
inputEl!.value = command
|
inputEl!.value = command
|
||||||
|
syncCommandLine()
|
||||||
|
if (command.endsWith(' ')) {
|
||||||
|
inputEl!.focus()
|
||||||
|
inputEl!.setSelectionRange(command.length, command.length)
|
||||||
|
return
|
||||||
|
}
|
||||||
inputEl!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
|
inputEl!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const syncEndedUI = (): void => {
|
||||||
|
// Don't disable the input — the player still needs to type `restart` or
|
||||||
|
// `undo`. A `disabled` input rejects keydown events entirely. Use a class
|
||||||
|
// for visual styling instead; the keydown handler enforces the input
|
||||||
|
// restriction.
|
||||||
|
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 buildParserContext = (s: GameState): ParserContext => {
|
||||||
const room = world.rooms[s.location]
|
const room = world.rooms[s.location]
|
||||||
const visibleNouns: { id: string; aliases: string[] }[] = []
|
const visibleNouns: { id: string; aliases: string[] }[] = []
|
||||||
@@ -42,7 +138,11 @@ if (!transcriptEl || !inputEl) {
|
|||||||
if (it) visibleNouns.push({ id, aliases: it.names })
|
if (it) visibleNouns.push({ id, aliases: it.names })
|
||||||
}
|
}
|
||||||
if (room.encounter && s.encounterState[room.encounter]) {
|
if (room.encounter && s.encounterState[room.encounter]) {
|
||||||
visibleNouns.push({ id: room.encounter, aliases: [room.encounter] })
|
const encounter = world.encounters[room.encounter]
|
||||||
|
visibleNouns.push({
|
||||||
|
id: room.encounter,
|
||||||
|
aliases: [room.encounter, room.encounter.replace(/-/g, ' '), ...(encounter?.aliases ?? [])],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const inst of s.inventory) {
|
for (const inst of s.inventory) {
|
||||||
@@ -56,66 +156,293 @@ if (!transcriptEl || !inputEl) {
|
|||||||
inventoryItemIds: s.inventory.map((i) => i.id),
|
inventoryItemIds: s.inventory.map((i) => i.id),
|
||||||
lastNoun: s.lastNoun,
|
lastNoun: s.lastNoun,
|
||||||
awaitingDisambiguation: s.pendingDisambiguation,
|
awaitingDisambiguation: s.pendingDisambiguation,
|
||||||
|
vocabulary: world.parser,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderAll = (lines: TranscriptLine[]): void => {
|
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
|
if (!transcriptEl) return
|
||||||
for (const line of lines) {
|
transcriptEl.scrollTo({ top: Math.max(0, el.offsetTop), behavior: 'smooth' })
|
||||||
const el = document.createElement('div')
|
await wait(ROOM_SCROLL_MS)
|
||||||
el.className = line.kind
|
}
|
||||||
el.textContent = line.text
|
|
||||||
|
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
|
||||||
|
}
|
||||||
transcriptEl.appendChild(el)
|
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 => {
|
||||||
|
if (idleHintTimer !== null) {
|
||||||
|
window.clearTimeout(idleHintTimer)
|
||||||
|
idleHintTimer = null
|
||||||
|
}
|
||||||
|
inputEl.placeholder = ''
|
||||||
|
syncCommandLine()
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleIdleHint = (): void => {
|
||||||
|
clearIdleHint()
|
||||||
|
idleHintTimer = window.setTimeout(() => {
|
||||||
|
inputEl.placeholder = 'type here...'
|
||||||
|
syncCommandLine()
|
||||||
|
}, 30000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearTransientHelp = (): void => {
|
||||||
|
transientHelpEl?.remove()
|
||||||
|
transientHelpEl = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderTransientHelp = (): void => {
|
||||||
|
if (!transcriptEl) return
|
||||||
|
clearTransientHelp()
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.className = 'system help'
|
||||||
|
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
|
||||||
|
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
|
// For UI-originated lines (player input, restart/undo/quit messages, error
|
||||||
// notices). Pushes into state.transcript so they survive reload, then renders.
|
// notices). Pushes into state.transcript so they survive reload, then renders.
|
||||||
// Engine-originated lines (from dispatch) are already in state.transcript;
|
// Engine-originated lines (from dispatch) are already in state.transcript;
|
||||||
// those use renderAll directly.
|
// 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) }
|
state = { ...state, transcript: [...state.transcript, ...lines].slice(-TRANSCRIPT_CAP) }
|
||||||
renderAll(lines)
|
renderAll(lines, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
renderAll(state.transcript)
|
const restart = (): void => {
|
||||||
refreshChips()
|
|
||||||
inputEl.focus()
|
|
||||||
|
|
||||||
inputEl.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key !== 'Enter') return
|
|
||||||
e.preventDefault()
|
|
||||||
const raw = inputEl.value
|
|
||||||
inputEl.value = ''
|
|
||||||
if (!raw.trim()) return
|
|
||||||
appendLines([{ kind: 'player', text: raw }])
|
|
||||||
|
|
||||||
// Engine-level meta-commands handled here so the engine stays pure.
|
|
||||||
const trimmed = raw.trim().toLowerCase()
|
|
||||||
if (trimmed === 'restart') {
|
|
||||||
const confirmed = confirm('Restart? Your progress will be lost.')
|
const confirmed = confirm('Restart? Your progress will be lost.')
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
appendLines([{ kind: 'system', text: '(restart cancelled)' }])
|
appendLines([{ kind: 'system', text: '(restart cancelled)' }], { scroll: false })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
clearSave()
|
clearSave()
|
||||||
state = initialStateFor(world)
|
state = initialStateFor(world)
|
||||||
|
renderGeneration += 1
|
||||||
|
renderQueue = Promise.resolve()
|
||||||
transcriptEl.innerHTML = ''
|
transcriptEl.innerHTML = ''
|
||||||
renderAll(state.transcript)
|
inputEl.value = ''
|
||||||
|
syncCommandLine()
|
||||||
|
renderAll(state.transcript, { animate: false })
|
||||||
saveState(state)
|
saveState(state)
|
||||||
refreshChips()
|
refreshChips()
|
||||||
|
syncLightMeter()
|
||||||
|
syncEndedUI()
|
||||||
|
syncDrunkEffect()
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAll(state.transcript, { animate: false })
|
||||||
|
refreshChips()
|
||||||
|
syncLightMeter()
|
||||||
|
syncEndedUI()
|
||||||
|
syncDrunkEffect()
|
||||||
|
syncCommandLine()
|
||||||
|
scheduleIdleHint()
|
||||||
|
|
||||||
|
inputEl.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||||
|
if (commandHistory.length === 0) return
|
||||||
|
e.preventDefault()
|
||||||
|
if (historyIndex === null) {
|
||||||
|
historyDraft = inputEl.value
|
||||||
|
historyIndex = commandHistory.length
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
historyIndex = Math.max(0, historyIndex - 1)
|
||||||
|
} else {
|
||||||
|
historyIndex = Math.min(commandHistory.length, historyIndex + 1)
|
||||||
|
}
|
||||||
|
inputEl.value = historyIndex === commandHistory.length ? historyDraft : commandHistory[historyIndex]!
|
||||||
|
inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length)
|
||||||
|
syncCommandLine()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key !== 'Enter') return
|
||||||
|
e.preventDefault()
|
||||||
|
const raw = inputEl.value
|
||||||
|
inputEl.value = ''
|
||||||
|
syncCommandLine()
|
||||||
|
if (!raw.trim()) return
|
||||||
|
clearTransientHelp()
|
||||||
|
clearIdleHint()
|
||||||
|
commandHistory = [...commandHistory, raw].slice(-50)
|
||||||
|
historyIndex = null
|
||||||
|
historyDraft = ''
|
||||||
|
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`.' }], { scroll: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Engine-level meta-commands handled here so the engine stays pure.
|
||||||
|
const trimmed = raw.trim().toLowerCase()
|
||||||
|
if (trimmed === 'restart') {
|
||||||
|
restart()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (trimmed === 'help') {
|
||||||
|
renderTransientHelp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (trimmed === 'undo') {
|
if (trimmed === 'undo') {
|
||||||
if (lastState) {
|
if (lastState) {
|
||||||
state = lastState
|
state = lastState
|
||||||
lastState = null
|
lastState = null
|
||||||
appendLines([{ kind: 'system', text: '(undone)' }])
|
appendLines([{ kind: 'system', text: '(undone)' }], { scroll: false })
|
||||||
saveState(state)
|
saveState(state)
|
||||||
refreshChips()
|
refreshChips()
|
||||||
|
syncLightMeter()
|
||||||
|
syncEndedUI()
|
||||||
|
syncDrunkEffect()
|
||||||
} else {
|
} else {
|
||||||
appendLines([{ kind: 'system', text: 'There is no further back.' }])
|
appendLines([{ kind: 'system', text: message('no-undo') }], { scroll: false })
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -130,25 +457,63 @@ if (!transcriptEl || !inputEl) {
|
|||||||
const ctx = buildParserContext(state)
|
const ctx = buildParserContext(state)
|
||||||
const command = parse(raw, ctx)
|
const command = parse(raw, ctx)
|
||||||
lastState = state
|
lastState = state
|
||||||
|
const previousLocation = state.location
|
||||||
const result = dispatch(state, command, world)
|
const result = dispatch(state, command, world)
|
||||||
state = result.state
|
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)
|
saveState(state)
|
||||||
transcriptEl.scrollTop = transcriptEl.scrollHeight
|
|
||||||
if (raw.trim().toLowerCase() === 'theme') {
|
if (raw.trim().toLowerCase() === 'theme') {
|
||||||
document.dispatchEvent(new CustomEvent('halfstreet-toggle-theme'))
|
document.dispatchEvent(new CustomEvent('halfstreet-toggle-theme'))
|
||||||
}
|
}
|
||||||
refreshChips()
|
refreshChips()
|
||||||
|
syncLightMeter()
|
||||||
|
syncEndedUI()
|
||||||
|
syncDrunkEffect()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[halfstreet] dispatch error', 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', () => {
|
||||||
|
syncInputFocus(true)
|
||||||
|
clearIdleHint()
|
||||||
|
})
|
||||||
|
inputEl.addEventListener('blur', () => {
|
||||||
|
syncInputFocus(false)
|
||||||
|
})
|
||||||
|
inputEl.addEventListener('pointerdown', clearIdleHint)
|
||||||
|
|
||||||
|
inputEl.parentElement?.addEventListener('pointerdown', () => {
|
||||||
|
inputEl.focus()
|
||||||
|
})
|
||||||
|
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
|
const target = e.target as HTMLElement | null
|
||||||
|
const isEditable =
|
||||||
|
target instanceof HTMLInputElement ||
|
||||||
|
target instanceof HTMLTextAreaElement ||
|
||||||
|
target?.isContentEditable === true
|
||||||
|
if (e.key === '/' && !isEditable) {
|
||||||
|
e.preventDefault()
|
||||||
|
clearIdleHint()
|
||||||
|
inputEl.focus()
|
||||||
|
inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
|
if (transientHelpEl) {
|
||||||
|
e.preventDefault()
|
||||||
|
clearTransientHelp()
|
||||||
|
return
|
||||||
|
}
|
||||||
saveState(state)
|
saveState(state)
|
||||||
window.location.href = '/'
|
window.location.href = '/'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
document.addEventListener('halfstreet-restart', restart)
|
||||||
|
inputEl.addEventListener('input', hideHelpOnInput)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
const STORAGE_KEY = 'halfstreet:theme:v1'
|
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 Theme = 'amber' | 'ansi'
|
||||||
|
type Cursor = 'bar' | 'block' | 'underscore'
|
||||||
|
type Chips = 'on' | 'off'
|
||||||
|
|
||||||
function getStored(): Theme {
|
function getStored(): Theme {
|
||||||
try {
|
try {
|
||||||
@@ -10,6 +14,23 @@ function getStored(): Theme {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStoredCursor(): Cursor {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(CURSOR_STORAGE_KEY)
|
||||||
|
return stored === 'block' || stored === 'underscore' ? stored : 'bar'
|
||||||
|
} catch {
|
||||||
|
return 'bar'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoredChips(): Chips {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(CHIPS_STORAGE_KEY) === 'off' ? 'off' : 'on'
|
||||||
|
} catch {
|
||||||
|
return 'on'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setTheme(theme: Theme): void {
|
function setTheme(theme: Theme): void {
|
||||||
document.documentElement.setAttribute('data-mystery-theme', theme)
|
document.documentElement.setAttribute('data-mystery-theme', theme)
|
||||||
try {
|
try {
|
||||||
@@ -22,8 +43,59 @@ function setTheme(theme: Theme): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setCursor(cursor: Cursor): void {
|
||||||
|
document.documentElement.setAttribute('data-mystery-cursor', cursor)
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CURSOR_STORAGE_KEY, cursor)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
for (const btn of document.querySelectorAll<HTMLButtonElement>('[data-cursor-choice]')) {
|
||||||
|
btn.setAttribute('aria-pressed', btn.dataset['cursorChoice'] === cursor ? 'true' : 'false')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
const initial = getStored()
|
||||||
setTheme(initial)
|
setTheme(initial)
|
||||||
|
setCursor(getStoredCursor())
|
||||||
|
setChips(getStoredChips())
|
||||||
|
|
||||||
|
const optionsRoot = document.querySelector<HTMLElement>('[data-mystery-options]')
|
||||||
|
const optionsToggle = document.querySelector<HTMLButtonElement>('[data-options-toggle]')
|
||||||
|
const optionsMenu = document.querySelector<HTMLElement>('[data-options-menu]')
|
||||||
|
|
||||||
|
function setOptionsOpen(open: boolean): void {
|
||||||
|
if (!optionsToggle || !optionsMenu) return
|
||||||
|
optionsToggle.setAttribute('aria-expanded', open ? 'true' : 'false')
|
||||||
|
optionsMenu.hidden = !open
|
||||||
|
document.documentElement.toggleAttribute('data-mystery-options-open', open)
|
||||||
|
}
|
||||||
|
|
||||||
|
optionsToggle?.addEventListener('click', () => {
|
||||||
|
setOptionsOpen(optionsToggle.getAttribute('aria-expanded') !== 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
document.addEventListener('click', (event) => {
|
||||||
|
if (!optionsRoot || !optionsRoot.contains(event.target as Node)) {
|
||||||
|
setOptionsOpen(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') setOptionsOpen(false)
|
||||||
|
})
|
||||||
|
|
||||||
document.querySelectorAll<HTMLButtonElement>('[data-theme-choice]').forEach((btn) => {
|
document.querySelectorAll<HTMLButtonElement>('[data-theme-choice]').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
@@ -32,6 +104,25 @@ document.querySelectorAll<HTMLButtonElement>('[data-theme-choice]').forEach((btn
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
document.querySelectorAll<HTMLButtonElement>('[data-cursor-choice]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const next = (btn.dataset['cursorChoice'] as Cursor | undefined) ?? 'bar'
|
||||||
|
setCursor(next)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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'))
|
||||||
|
})
|
||||||
|
|
||||||
// Allow the engine's `theme` meta-command (handled in terminal.ts) to flip
|
// Allow the engine's `theme` meta-command (handled in terminal.ts) to flip
|
||||||
// without going through the button by listening for a custom event.
|
// without going through the button by listening for a custom event.
|
||||||
document.addEventListener('halfstreet-toggle-theme', () => {
|
document.addEventListener('halfstreet-toggle-theme', () => {
|
||||||
|
|||||||
@@ -2,5 +2,6 @@
|
|||||||
"alwaysUpdateLinks": true,
|
"alwaysUpdateLinks": true,
|
||||||
"newLinkFormat": "shortest",
|
"newLinkFormat": "shortest",
|
||||||
"useMarkdownLinks": false,
|
"useMarkdownLinks": false,
|
||||||
"attachmentFolderPath": "_attachments"
|
"attachmentFolderPath": "_attachments",
|
||||||
|
"propertiesInDocument": "hidden"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"theme": "obsidian",
|
||||||
|
"accentColor": "#50609f",
|
||||||
|
"cssTheme": "Primary"
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[
|
||||||
|
"obsidian-minimal-settings",
|
||||||
|
"obsidian-style-settings"
|
||||||
|
]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"file-explorer": true,
|
||||||
|
"global-search": true,
|
||||||
|
"switcher": true,
|
||||||
|
"graph": true,
|
||||||
|
"backlink": true,
|
||||||
|
"canvas": true,
|
||||||
|
"outgoing-link": true,
|
||||||
|
"tag-pane": true,
|
||||||
|
"footnotes": false,
|
||||||
|
"properties": true,
|
||||||
|
"page-preview": true,
|
||||||
|
"daily-notes": true,
|
||||||
|
"templates": true,
|
||||||
|
"note-composer": true,
|
||||||
|
"command-palette": true,
|
||||||
|
"slash-command": false,
|
||||||
|
"editor-status": true,
|
||||||
|
"bookmarks": true,
|
||||||
|
"markdown-importer": false,
|
||||||
|
"zk-prefixer": false,
|
||||||
|
"random-note": false,
|
||||||
|
"outline": true,
|
||||||
|
"word-count": true,
|
||||||
|
"slides": false,
|
||||||
|
"audio-recorder": false,
|
||||||
|
"workspaces": false,
|
||||||
|
"file-recovery": true,
|
||||||
|
"publish": false,
|
||||||
|
"sync": true,
|
||||||
|
"bases": true,
|
||||||
|
"webviewer": false
|
||||||
|
}
|
||||||
@@ -1,11 +1,51 @@
|
|||||||
{
|
{
|
||||||
|
"collapse-filter": false,
|
||||||
|
"search": "",
|
||||||
"showTags": false,
|
"showTags": false,
|
||||||
"showAttachments": false,
|
"showAttachments": false,
|
||||||
"showOrphans": true,
|
"hideUnresolved": false,
|
||||||
"collapse-filter": false,
|
"showOrphans": false,
|
||||||
"collapse-color-groups": false,
|
"collapse-color-groups": false,
|
||||||
|
"colorGroups": [
|
||||||
|
{
|
||||||
|
"query": "path:rooms ",
|
||||||
|
"color": {
|
||||||
|
"a": 1,
|
||||||
|
"rgb": 14701138
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "path:items ",
|
||||||
|
"color": {
|
||||||
|
"a": 1,
|
||||||
|
"rgb": 14725458
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "path:encounters ",
|
||||||
|
"color": {
|
||||||
|
"a": 1,
|
||||||
|
"rgb": 11657298
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "path:endings ",
|
||||||
|
"color": {
|
||||||
|
"a": 1,
|
||||||
|
"rgb": 5431378
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"collapse-display": false,
|
"collapse-display": false,
|
||||||
|
"showArrow": true,
|
||||||
|
"textFadeMultiplier": -3,
|
||||||
|
"nodeSizeMultiplier": 2.2138427734375,
|
||||||
|
"lineSizeMultiplier": 5,
|
||||||
"collapse-forces": false,
|
"collapse-forces": false,
|
||||||
"lineSizeMultiplier": 1,
|
"centerStrength": 0.518713248970312,
|
||||||
"nodeSizeMultiplier": 1.2
|
"repelStrength": 10,
|
||||||
|
"linkStrength": 1,
|
||||||
|
"linkDistance": 250,
|
||||||
|
"scale": 0.8332185563593782,
|
||||||
|
"close": true
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"markdown:add-metadata-property": [],
|
||||||
|
"editor:toggle-source": [
|
||||||
|
{
|
||||||
|
"modifiers": [
|
||||||
|
"Mod"
|
||||||
|
],
|
||||||
|
"key": ";"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"lightStyle": "minimal-light",
|
||||||
|
"darkStyle": "minimal-dark",
|
||||||
|
"lightScheme": "minimal-default-light",
|
||||||
|
"darkScheme": "minimal-default-dark",
|
||||||
|
"editorFont": "",
|
||||||
|
"lineHeight": 1.5,
|
||||||
|
"lineWidth": 40,
|
||||||
|
"lineWidthWide": 50,
|
||||||
|
"maxWidth": 88,
|
||||||
|
"textNormal": 16,
|
||||||
|
"textSmall": 13,
|
||||||
|
"imgGrid": false,
|
||||||
|
"imgWidth": "img-default-width",
|
||||||
|
"tableWidth": "table-default-width",
|
||||||
|
"iframeWidth": "iframe-default-width",
|
||||||
|
"mapWidth": "map-default-width",
|
||||||
|
"chartWidth": "chart-default-width",
|
||||||
|
"colorfulHeadings": false,
|
||||||
|
"colorfulFrame": false,
|
||||||
|
"colorfulActiveStates": false,
|
||||||
|
"trimNames": true,
|
||||||
|
"labeledNav": false,
|
||||||
|
"fullWidthMedia": true,
|
||||||
|
"bordersToggle": true,
|
||||||
|
"minimalStatus": true,
|
||||||
|
"focusMode": false,
|
||||||
|
"underlineInternal": true,
|
||||||
|
"underlineExternal": true,
|
||||||
|
"folding": true,
|
||||||
|
"lineNumbers": false,
|
||||||
|
"readableLineLength": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "obsidian-minimal-settings",
|
||||||
|
"name": "Minimal Theme Settings",
|
||||||
|
"version": "8.2.2",
|
||||||
|
"minAppVersion": "1.11.1",
|
||||||
|
"description": "Change the colors, fonts and features of Minimal Theme.",
|
||||||
|
"author": "@kepano",
|
||||||
|
"authorUrl": "https://www.twitter.com/kepano",
|
||||||
|
"fundingUrl": "https://www.buymeacoffee.com/kepano",
|
||||||
|
"isDesktopOnly": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.setting-group > .setting-item-heading .setting-item-description {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"primary-theme@@alt-folder-icons": true,
|
||||||
|
"primary-theme@@colorful-folders_text": true,
|
||||||
|
"primary-theme@@colorful-folders_collapse-indicator": true,
|
||||||
|
"primary-theme@@colorful-folders_background": false,
|
||||||
|
"primary-theme@@colorful-folders_indentation-guide": true,
|
||||||
|
"primary-theme@@colorful-folders_inherit-color": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "obsidian-style-settings",
|
||||||
|
"name": "Style Settings",
|
||||||
|
"version": "1.0.9",
|
||||||
|
"minAppVersion": "0.11.5",
|
||||||
|
"description": "Offers controls for adjusting theme, plugin, and snippet CSS variables.",
|
||||||
|
"author": "mgmeyers",
|
||||||
|
"authorUrl": "https://github.com/mgmeyers/obsidian-style-settings",
|
||||||
|
"isDesktopOnly": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "Minimal",
|
||||||
|
"version": "8.1.7",
|
||||||
|
"minAppVersion": "1.9.0",
|
||||||
|
"author": "@kepano",
|
||||||
|
"authorUrl": "https://twitter.com/kepano",
|
||||||
|
"fundingUrl": "https://www.buymeacoffee.com/kepano"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "Primary",
|
||||||
|
"version": "2.10.0",
|
||||||
|
"minAppVersion": "1.4.0",
|
||||||
|
"author": "Cecilia May",
|
||||||
|
"fundingUrl": {
|
||||||
|
"Ko-fi": "https://ko-fi.com/ceciliamay"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
short:
|
||||||
|
readable:
|
||||||
|
takeable:
|
||||||
|
names:
|
||||||
|
---
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
views:
|
||||||
|
- type: table
|
||||||
|
name: Table
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
short:
|
||||||
|
readable:
|
||||||
|
---
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
formulas:
|
||||||
|
Room: |-
|
||||||
|
file.backlinks
|
||||||
|
.filter(value.asFile().inFolder("rooms"))
|
||||||
|
.map(value.asFile())
|
||||||
|
views:
|
||||||
|
- type: cards
|
||||||
|
name: Rooms - Exits
|
||||||
|
filters:
|
||||||
|
and:
|
||||||
|
- file.inFolder("rooms")
|
||||||
|
order:
|
||||||
|
- file.name
|
||||||
|
- exitN
|
||||||
|
- exitS
|
||||||
|
- exitE
|
||||||
|
- exitW
|
||||||
|
- exitU
|
||||||
|
- exitD
|
||||||
|
- items
|
||||||
|
- type: cards
|
||||||
|
name: Items
|
||||||
|
filters:
|
||||||
|
and:
|
||||||
|
- file.inFolder("items")
|
||||||
|
order:
|
||||||
|
- file.name
|
||||||
|
- short
|
||||||
|
- readable
|
||||||
|
- takeable
|
||||||
|
- names
|
||||||
|
- formula.Room
|
||||||
|
- type: cards
|
||||||
|
name: Items By Room
|
||||||
|
filters:
|
||||||
|
and:
|
||||||
|
- file.inFolder("items")
|
||||||
|
order:
|
||||||
|
- file.name
|
||||||
|
- formula.Room
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
- [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").
|
||||||
|
- [x] Add a tile for USE
|
||||||
|
- [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.*")
|
||||||
|
- [x] Fix mobile - scrolling issue (page grows as the terminal grows).
|
||||||
|
- [x] Fix mobile - ascii text art at beginning too big to render
|
||||||
|
- [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)"
|
||||||
|
- [x] Wire up favicon
|
||||||
|
- [x] Feature: Provide ability to change cursor type. Block, underline, and whatever we have now.
|
||||||
|
- [x] Feature: Move cursor selection and screen toggle to option menu. Put the option menu where the screen toggle currently is. Give it a gear icon and let it be a tile.
|
||||||
|
- [x] Bug: gear icon too small.
|
||||||
|
- [x] Bug: option for cursor should show cursor in terminal when option menu is open.
|
||||||
|
- [x] Bug: cursor toggle not affecting the cursor at all.
|
||||||
|
- [x] Bug: idle text appearing above the tiles instead of in the terminal line.
|
||||||
|
- [x] Feature: / brings focus to terminal
|
||||||
|
- [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.
|
||||||
|
- [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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -1,13 +1,124 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
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', () => {
|
describe('assembled world', () => {
|
||||||
it('contains all three rooms', () => {
|
it('assembles a minimal manifest-backed world', () => {
|
||||||
expect(Object.keys(world.rooms).sort()).toEqual(['cellar-stair', 'foyer', 'hallway'])
|
const result = build()
|
||||||
|
expect(result.startingRoom).toBe('foyer')
|
||||||
|
expect(result.startingInventory).toEqual(['letter'])
|
||||||
|
expect(result.endingPriority).toEqual(['true'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('contains all three items', () => {
|
it('rejects a game manifest with an unknown starting room', () => {
|
||||||
expect(Object.keys(world.items).sort()).toEqual(['lamp', 'letter', 'matches'])
|
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',
|
||||||
|
'foyer',
|
||||||
|
'hallway',
|
||||||
|
'cellar-stair',
|
||||||
|
'parlor',
|
||||||
|
'study',
|
||||||
|
'dining-room',
|
||||||
|
'conservatory',
|
||||||
|
'smoking-room',
|
||||||
|
'music-room',
|
||||||
|
'servants-passage',
|
||||||
|
'laundry',
|
||||||
|
'stair-up',
|
||||||
|
'bedroom',
|
||||||
|
'nursery',
|
||||||
|
'attic',
|
||||||
|
'chapel',
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('contains the authored opening, main-floor, and upper-floor items', () => {
|
||||||
|
expect(Object.keys(world.items)).toEqual(expect.arrayContaining([
|
||||||
|
'broken-cigarette',
|
||||||
|
'lamp',
|
||||||
|
'letter',
|
||||||
|
'matches',
|
||||||
|
'candlestick',
|
||||||
|
'pruning-shears',
|
||||||
|
'silver-lighter',
|
||||||
|
'music-box-key',
|
||||||
|
'damp-sheet',
|
||||||
|
'grandfather-clock',
|
||||||
|
'dinner-place-setting',
|
||||||
|
'covered-cage',
|
||||||
|
'childs-drawing',
|
||||||
|
'music-box',
|
||||||
|
'toy-dog',
|
||||||
|
'silver-vial',
|
||||||
|
]))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('all room exits resolve to known rooms', () => {
|
it('all room exits resolve to known rooms', () => {
|
||||||
@@ -18,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', () => {
|
it('all room item refs resolve to known items', () => {
|
||||||
for (const room of Object.values(world.rooms)) {
|
for (const room of Object.values(world.rooms)) {
|
||||||
for (const itemId of room.items) {
|
for (const itemId of room.items) {
|
||||||
@@ -35,16 +164,356 @@ describe('assembled world', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('startingRoom is a known room', () => {
|
it('startingRoom is a known room', () => {
|
||||||
|
expect(world.game?.startingRoom).toBe(world.startingRoom)
|
||||||
expect(world.rooms[world.startingRoom]).toBeDefined()
|
expect(world.rooms[world.startingRoom]).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('startingInventory items are known', () => {
|
it('startingInventory items are known', () => {
|
||||||
|
expect(world.game?.startingInventory).toEqual(world.startingInventory)
|
||||||
for (const itemId of world.startingInventory) {
|
for (const itemId of world.startingInventory) {
|
||||||
expect(world.items[itemId]).toBeDefined()
|
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', () => {
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,30 +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',
|
|
||||||
narration: narration('rat', 'attack-rat-resolved'),
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
verb: 'wait',
|
|
||||||
narration: narration('rat', 'wait-stays'),
|
|
||||||
to: 'lurking',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onResolved: { setFlags: { ratGone: true } },
|
|
||||||
defaultWrongVerbNarration: 'The rat watches.',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
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, 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 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.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
The covered cage rustles once. Then again, softer, as if whatever is inside has learned restraint.
|
||||||
|
|
||||||
|
## open-covered-cage-resolved
|
||||||
|
You lift the cloth.
|
||||||
|
|
||||||
|
The cage is empty. A few pale feathers cling to the wire, though no bird could have passed through it.
|
||||||
|
|
||||||
|
Somewhere far above you, wings beat once inside a wall.
|
||||||
|
|
||||||
|
## wrong-verb
|
||||||
|
The cloth trembles, then goes still again.
|
||||||
|
|
||||||
|
## failed
|
||||||
|
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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
The ivy has gathered itself into the suggestion of a person. Leaves cling where eyes should be.
|
||||||
|
|
||||||
|
## cut-ivy-figure-resolved
|
||||||
|
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. 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, with leaves clinging to your sleeves.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
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. The shape shifts when you look straight at it.
|
||||||
|
|
||||||
|
## wait-resolved
|
||||||
|
You wait.
|
||||||
|
|
||||||
|
The sheet stirs. Nothing stands behind it. Nothing had stood behind it.
|
||||||
|
|
||||||
|
## wrong-verb
|
||||||
|
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, with damp cloth brushing your face.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
The held piano key waits under your eye. A second note answers from a room you have not found.
|
||||||
|
|
||||||
|
## play-piano-echo-resolved
|
||||||
|
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. 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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -2,8 +2,25 @@
|
|||||||
id: rat
|
id: rat
|
||||||
startsIn: "[[cellar-stair]]"
|
startsIn: "[[cellar-stair]]"
|
||||||
initialPhase: lurking
|
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
|
||||||
---
|
---
|
||||||
|
|
||||||
## lurking
|
## lurking
|
||||||
A heavy rat watches you from the third step. Its eyes catch the light.
|
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
|
## wait-stays
|
||||||
The rat does not move. Neither do you.
|
The rat does not move. Neither do you.
|
||||||
|
|
||||||
|
## wrong-verb
|
||||||
|
The rat watches.
|
||||||
|
|||||||
@@ -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.
|
||||||