diff --git a/.woodpecker.yml b/.woodpecker.yml index c21ba0f..c76cfdb 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -9,23 +9,7 @@ steps: commands: - npm ci - npm run test - - PUBLIC_GLITCHTIP_RELEASE=$CI_PIPELINE_NUMBER npm run build - - - name: upload-sourcemaps - image: rust:1 - environment: - SENTRY_URL: - from_secret: glitchtip_url - SENTRY_AUTH_TOKEN: - from_secret: glitchtip_auth_token - SENTRY_ORG: - from_secret: glitchtip_org - SENTRY_PROJECT: - from_secret: glitchtip_project - commands: - - cargo install --locked --git https://gitlab.com/glitchtip/glitchtip-cli.git - - glitchtip-cli sourcemaps inject ./dist - - glitchtip-cli sourcemaps upload ./dist --release "$CI_PIPELINE_NUMBER" + - npm run build - name: deploy image: node:22 diff --git a/README.md b/README.md index 39014f6..c186ff7 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,6 @@ Played at [halfstreet.io](https://halfstreet.io). - TypeScript engine — pure (no DOM, `Date`, `Math.random`, or console) - World content authored in markdown (rooms, items, encounters, endings) under `src/world/` - [Vitest](https://vitest.dev) for tests -- Optional client error reporting via [GlitchTip](https://glitchtip.com) using `PUBLIC_GLITCHTIP_DSN` ## Development @@ -23,9 +22,6 @@ npm run dev # local dev server npm run build # type-check + production build ``` -To enable GlitchTip in the browser, set `PUBLIC_GLITCHTIP_DSN` in your environment before running or building the site. -If you want deploy-aware grouping and readable stack traces, also set `PUBLIC_GLITCHTIP_RELEASE` at build time and upload the generated source maps for that same release. - ## Layout - `src/engine/` — parser, dispatcher, encounter logic diff --git a/astro.config.mjs b/astro.config.mjs index 72231f0..697ad6d 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -6,9 +6,4 @@ export default defineConfig({ build: { inlineStylesheets: 'auto', }, - vite: { - build: { - sourcemap: true, - }, - }, }) diff --git a/package-lock.json b/package-lock.json index 4c0ac4b..43e263b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "0.0.1", "license": "GPL-3.0-or-later", "dependencies": { - "@sentry/browser": "^10.52.0", "astro": "^6.1.9", "yaml": "^2.8.4", "zod": "^4.4.3" @@ -1767,81 +1766,6 @@ "win32" ] }, - "node_modules/@sentry-internal/browser-utils": { - "version": "10.52.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.52.0.tgz", - "integrity": "sha512-x/yEPZdpH6NGQeoeQnV9tj8reAH8twNttiltGZl2o8Rk7sQeUfe7E8yuYP2XbJ2RqyZK5qRS3COrNyMPzf6KFA==", - "license": "MIT", - "dependencies": { - "@sentry/core": "10.52.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry-internal/feedback": { - "version": "10.52.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.52.0.tgz", - "integrity": "sha512-5kAn1W8ZvCuHtEHXpq6iRkUMdNCilwww+YxaN2yofVrCivAbB3Ha5JJUMqmWOPW0pC27zGYmoJMIDvG+PczUxA==", - "license": "MIT", - "dependencies": { - "@sentry/core": "10.52.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry-internal/replay": { - "version": "10.52.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.52.0.tgz", - "integrity": "sha512-diywyuc/H7VTUR+W5ryVmLF+0X4UP1OskMqb6V8RSAvJHcj2JmIm7uP+Fc6ACTno+b6AUShwT/L4xVXzO6X9Cw==", - "license": "MIT", - "dependencies": { - "@sentry-internal/browser-utils": "10.52.0", - "@sentry/core": "10.52.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry-internal/replay-canvas": { - "version": "10.52.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.52.0.tgz", - "integrity": "sha512-BI5ie4dxPuUJ344CXVSnAxY1xZCbghglPSCIlTOYODpR9so9yo5IZh+Mwspt0oWsUMaxWJiQSNYlbPWi7WDavg==", - "license": "MIT", - "dependencies": { - "@sentry-internal/replay": "10.52.0", - "@sentry/core": "10.52.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/browser": { - "version": "10.52.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.52.0.tgz", - "integrity": "sha512-ijL9jN86oXwXQWbwhPlEb70ODJSEmjxQEQdnZkC4gDWbjswcwvRsVJPYk+1xl2ir2iZixRIHipVxDcLwian35g==", - "license": "MIT", - "dependencies": { - "@sentry-internal/browser-utils": "10.52.0", - "@sentry-internal/feedback": "10.52.0", - "@sentry-internal/replay": "10.52.0", - "@sentry-internal/replay-canvas": "10.52.0", - "@sentry/core": "10.52.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/core": { - "version": "10.52.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.52.0.tgz", - "integrity": "sha512-VA/kAqLhkMnRWY2RXdBLyTemR9D4m7MVRy/gyapoq9yvllVPx9WXbvKgnMP2LQp7mFgT/oLFvw58aQKaYTGn3A==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/@shikijs/core": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz", diff --git a/package.json b/package.json index 6e39a3f..cdc75a3 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "test:watch": "vitest" }, "dependencies": { - "@sentry/browser": "^10.52.0", "astro": "^6.1.9", "yaml": "^2.8.4", "zod": "^4.4.3" diff --git a/src/engine/dispatcher.ts b/src/engine/dispatcher.ts index b88cf3a..cccf75e 100644 --- a/src/engine/dispatcher.ts +++ b/src/engine/dispatcher.ts @@ -178,6 +178,7 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World) if (command.verb === 'look') return withEndingCheck(handleLook(state, world), world) if (command.verb === 'inventory') return withEndingCheck(handleInventory(state, world), world) if (command.verb === 'wait') return withEndingCheck(handleWait(state, world), world) + if (command.verb === 'listen') return withEndingCheck(narrate(state, [{ kind: 'narration', text: 'You listen. The house listens back.' }]), world) } if (command.kind === 'verb-target') { diff --git a/src/engine/encounters.test.ts b/src/engine/encounters.test.ts index 7839864..574dd57 100644 --- a/src/engine/encounters.test.ts +++ b/src/engine/encounters.test.ts @@ -10,7 +10,7 @@ const world: World = { id: 'foyer', title: '[ Foyer ]', descriptions: { firstVisit: 'Foyer.', revisit: 'Foyer.', examined: 'Foyer.' }, - exits: { n: 'stair' }, + exits: { n: 'stair', e: 'chapel' }, items: [], safe: true, }, @@ -29,10 +29,19 @@ const world: World = { exits: { u: 'stair' }, items: [], }, + chapel: { + id: 'chapel', + title: '[ Chapel ]', + descriptions: { firstVisit: 'Chapel.', revisit: 'Chapel.', examined: 'Chapel.' }, + exits: { s: 'foyer' }, + items: ['vial'], + encounter: 'basilisk', + }, }, items: { mirror: { id: 'mirror', names: ['mirror', 'tarnished mirror'], short: 'a tarnished mirror', long: 'A small mirror, tarnished black.', initialState: {}, takeable: true }, sword: { id: 'sword', names: ['sword', 'cane sword'], short: 'a cane sword', long: 'A slim cane sword.', initialState: {}, takeable: true }, + vial: { id: 'vial', names: ['vial'], short: 'a vial', long: 'A small vial.', initialState: {}, takeable: true }, }, encounters: { revenant: { @@ -59,6 +68,22 @@ const world: World = { onFailed: { narration: 'You stagger back.', retreatTo: 'foyer' }, defaultWrongVerbNarration: 'The revenant does not seem to notice.', }, + basilisk: { + id: 'basilisk', + aliases: ['basilisk'], + startsIn: 'chapel', + initialPhase: 'sleeping', + phases: { + sleeping: { + description: 'An eye opens beneath the altar.', + transitions: [ + { verb: 'pour', target: 'vial', requires: { item: 'vial' }, narration: 'The eye closes.', to: 'resolved' }, + ], + }, + }, + onResolved: { setFlags: { basiliskSpared: true } }, + defaultWrongVerbNarration: 'The eye watches.', + }, }, endings: { true: { whenFlags: { _never: true }, narration: '' }, @@ -116,4 +141,27 @@ describe('encounters — phase advancement', () => { s = dispatch(s, { kind: 'go', direction: 's' }, world).state expect(s.resolveLevel).toBe('steady') }) + + 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) + }) }) diff --git a/src/engine/encounters.ts b/src/engine/encounters.ts index 2f2a09f..be196fb 100644 --- a/src/engine/encounters.ts +++ b/src/engine/encounters.ts @@ -96,7 +96,7 @@ export function applyVerbToEncounter( } } } - if (t.requires && instrumentId && t.requires.item !== instrumentId) return false + if (t.requires && instrumentId && t.requires.item !== instrumentId && t.requires.item !== targetId) return false return true }) diff --git a/src/engine/parser.test.ts b/src/engine/parser.test.ts index b5782ae..f0e42fe 100644 --- a/src/engine/parser.test.ts +++ b/src/engine/parser.test.ts @@ -16,6 +16,10 @@ describe('parser — verb-only commands', () => { expect(parse('look', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'look' }) }) + it('recognizes bare "listen"', () => { + expect(parse('listen', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'listen' }) + }) + it('recognizes bare "inventory" and short forms', () => { expect(parse('inventory', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'inventory' }) expect(parse('inv', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'inventory' }) @@ -100,6 +104,24 @@ describe('parser — verb + target', () => { }) }) + it('recognizes pour commands with an indirect target', () => { + const ctx: ParserContext = { + knownItems: ['silver-vial'], + knownEncounters: ['basilisk'], + visibleNouns: [{ id: 'basilisk', aliases: ['basilisk'] }], + inventoryItemIds: ['silver-vial'], + lastNoun: null, + awaitingDisambiguation: null, + } + expect(parse('pour silver-vial on basilisk', ctx)).toEqual({ + kind: 'verb-target-prep', + verb: 'pour', + target: { canonical: 'silver-vial', raw: 'silver-vial' }, + preposition: 'on', + indirect: { canonical: 'basilisk', raw: 'basilisk' }, + }) + }) + it('resolves a single visible noun', () => { const ctx: ParserContext = { knownItems: ['torch'], diff --git a/src/engine/parser.ts b/src/engine/parser.ts index de2cde0..a179e24 100644 --- a/src/engine/parser.ts +++ b/src/engine/parser.ts @@ -34,6 +34,8 @@ const VERB_SYNONYMS: Record = { pull: 'pull', cut: 'cut', trim: 'cut', play: 'play', + listen: 'listen', + pour: 'pour', uncover: 'open', wait: 'wait', z: 'wait', } @@ -57,7 +59,7 @@ const META_VERBS: Record = { } /** Verbs that legally take no target. */ -const VERB_ONLY_VERBS = new Set(['look', 'inventory', 'wait']) +const VERB_ONLY_VERBS = new Set(['look', 'inventory', 'wait', 'listen']) /** Two-word verb prefixes (e.g. "pick up X"). */ const TWO_WORD_VERBS = ['pick up'] @@ -157,7 +159,7 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand { if (rest.length === 0) { if (VERB_ONLY_VERBS.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' } } diff --git a/src/engine/playthrough.test.ts b/src/engine/playthrough.test.ts index 157383b..234b135 100644 --- a/src/engine/playthrough.test.ts +++ b/src/engine/playthrough.test.ts @@ -128,4 +128,98 @@ describe('playthrough — sample world', () => { expect(state.location).toBe('attic') expect(state.inventory.map((i) => i.id)).toContain('toy-dog') }) + + it('plays through the garden and grounds slice', () => { + const state = play([ + 'n', // gate → foyer + 'n', // foyer → hallway + 'u', // hallway → parlor + 'u', // parlor → upper stair + 'wait', + 'u', // upper stair → bedroom + 'e', // bedroom → nursery + 'take dog', + 'w', + 'd', // bedroom → upper stair + 'd', // upper stair → parlor + 'd', // parlor → hallway + 'n', // hallway → dining-room + 'close curtains', + 'e', // dining-room → kitchen + 'e', // kitchen → back-door + 'e', // back-door → garden + 'wait', + 'n', // garden → well + 'd', // well → well-shaft + 'hold dog', + ]) + + expect(state.flags['garden-procession.resolved']).toBe(true) + expect(state.flags['child-beneath-well.resolved']).toBe(true) + expect(state.flags['gardenQuiet']).toBe(true) + expect(state.flags['childPassedWell']).toBe(true) + expect(state.location).toBe('well-shaft') + }) + + it('plays through the lower-passages slice', () => { + const state = play([ + 'n', // gate → foyer + 'n', // foyer → hallway + 'n', // hallway → dining-room + 'close curtains', + 'n', // dining-room → conservatory + 'take shears', + 'cut vines with shears', + 's', // conservatory → dining-room + 'w', // dining-room → hallway + 'd', // hallway → music-room + 'play note', + 'n', // music-room → servants-passage + 'wait', + 'e', // servants-passage → laundry + 'wait', + 'take damp sheet', + 'w', // laundry → servants-passage + 's', // servants-passage → music-room + 'u', // music-room → hallway + 'n', // hallway → dining-room + 'e', // dining-room → kitchen + 'e', // kitchen → back-door + 'e', // back-door → garden + 'wait', + 'n', // garden → well + 'd', // well → well-shaft + 'wait', + 'd', // well-shaft → tunnel + 'n', // tunnel → ossuary + 'take ring', + 'leave ring', + 'e', // ossuary → flooded-passage + 'use water with sheet', + 'take boat', + 'n', // flooded-passage → root-chamber + 'listen', + 'e', // root-chamber → burial-gallery + 'examine portraits', + 'take register', + 'read register', + 'e', // burial-gallery → antechamber + 'e', // antechamber → vault + ]) + + expect(state.flags['bone-keeper.resolved']).toBe(true) + expect(state.flags['reflection.resolved']).toBe(true) + expect(state.flags['root-movement.resolved']).toBe(true) + expect(state.flags['portrait-woman.resolved']).toBe(true) + expect(state.flags['burialRingPlaced']).toBe(true) + expect(state.flags['reflectionObscured']).toBe(true) + expect(state.flags['rootsListenedTo']).toBe(true) + expect(state.flags['familyResemblanceSeen']).toBe(true) + expect(state.location).toBe('vault') + expect(state.inventory.map((i) => i.id)).toEqual(expect.arrayContaining([ + 'damp-sheet', + 'toy-boat', + 'family-register', + ])) + }) }) diff --git a/src/engine/types.ts b/src/engine/types.ts index e9447bf..78a37e0 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -9,7 +9,7 @@ export type Direction = 'n' | 's' | 'e' | 'w' | 'u' | 'd' export type Verb = | 'go' | 'look' | 'examine' | 'take' | 'drop' | 'use' | 'open' | 'close' | 'read' | 'light' | 'extinguish' | 'attack' | 'inventory' | 'wait' - | 'hold' | 'push' | 'pull' | 'cut' | 'play' + | 'hold' | 'push' | 'pull' | 'cut' | 'play' | 'listen' | 'pour' export type MetaVerb = 'restart' | 'undo' | 'hint' | 'save' | 'quit' | 'theme' @@ -21,7 +21,7 @@ export interface NounRef { } export type ParsedCommand = - | { kind: 'verb-only'; verb: Verb | 'look' | 'inventory' | 'wait' } + | { kind: 'verb-only'; verb: Verb | 'look' | 'inventory' | 'wait' | 'listen' } | { kind: 'verb-target'; verb: Verb; target: NounRef } | { kind: 'verb-target-prep'; verb: Verb; target: NounRef; preposition: string; indirect: NounRef } | { kind: 'ambiguous'; verb: Verb; rawNoun: string; candidates: string[] } diff --git a/src/pages/index.astro b/src/pages/index.astro index e538674..b15d57f 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -50,6 +50,8 @@ import '../ui/crt.css'
Game
+ +
@@ -90,11 +92,15 @@ import '../ui/crt.css' const storedCursor = (() => { try { return localStorage.getItem('halfstreet:cursor:v1') } catch { return null } })() + const storedChips = (() => { + try { return localStorage.getItem('halfstreet:chips:v1') } catch { return null } + })() document.documentElement.setAttribute('data-mystery-theme', stored === 'ansi' ? 'ansi' : 'amber') document.documentElement.setAttribute( 'data-mystery-cursor', storedCursor === 'block' || storedCursor === 'underscore' ? storedCursor : 'bar', ) + document.documentElement.setAttribute('data-mystery-chips-state', storedChips === 'off' ? 'off' : 'on')