feat(mystery): add opening and main-floor content

This commit is contained in:
2026-05-09 21:51:12 -05:00
parent e46b2359c0
commit 2a9b6155ef
65 changed files with 7555 additions and 72 deletions
+11 -5
View File
@@ -14,30 +14,36 @@ describe('computeChips — sample world', () => {
})
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)
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', () => {
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)
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', () => {
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: 'e' }, world).state
const chips = computeChips(s, world)
expect(chips.find((c) => c.kind === 'encounter' && c.command.includes('rat'))).toBeTruthy()
})
it('always includes LOOK and INV', () => {
it('always includes LOOK, INV, and HELP', () => {
const s = initialStateFor(world)
const chips = computeChips(s, world)
expect(chips.find((c) => c.command === 'look')).toBeTruthy()
expect(chips.find((c) => c.command === 'inventory')).toBeTruthy()
expect(chips.find((c) => c.command === 'help')).toBeTruthy()
})
})
+1
View File
@@ -68,6 +68,7 @@ export function computeChips(state: GameState, world: World): Chip[] {
// Persistent meta chips.
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: 'HELP', command: 'help', disabled: false })
return out
}
+38 -6
View File
@@ -46,6 +46,25 @@
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 {
color: var(--m-fg);
text-decoration: none;
}
.mystery-footer span {
margin: 0 0.5ch;
}
.mystery-bezel::before {
/* scanlines overlay */
content: '';
@@ -104,6 +123,14 @@
margin-top: 0.6em;
}
.mystery-transcript .help {
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 .player {
color: var(--m-accent-2);
}
@@ -140,13 +167,12 @@
}
.mystery-chips {
display: none;
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px 0 4px;
position: relative;
z-index: 2;
border-top: 1px var(--m-divider-style) var(--m-dim);
margin-top: 8px;
}
@@ -166,10 +192,6 @@
cursor: not-allowed;
}
@media (pointer: coarse) {
.mystery-chips { display: flex; }
}
.mystery-transcript .ending {
margin-top: 2em;
margin-bottom: 1em;
@@ -179,6 +201,16 @@
white-space: pre-wrap;
}
@media (max-width: 640px) {
.mystery-root {
padding: 8px;
}
.mystery-bezel {
padding: 18px 14px 12px;
}
}
[data-mystery-input].ended {
opacity: 0.55;
}
+47 -1
View File
@@ -11,12 +11,32 @@ import { renderChips } from './chip-render'
const transcriptEl = document.querySelector<HTMLDivElement>('[data-mystery-transcript]')
const inputEl = document.querySelector<HTMLInputElement>('[data-mystery-input]')
const HELP_TEXT = `You arrive at the address, but you do not remember what has happened. The road behind you is gone...
This is a text adventure. Type short commands to act in the house.
Common commands:
look describe the room again
n, s, e, w, u, d move by direction
take lamp pick something up
examine letter inspect something nearby or held
read letter read a readable object
inventory see what you carry
light lamp with matches use one thing with another
wait let the room continue
undo step back once
restart begin again
theme change the terminal colors
Most commands are verb first, then the thing: examine gate, take lamp, use key on door.`
if (!transcriptEl || !inputEl) {
console.error('[halfstreet] terminal mount points missing')
} else {
const restored = loadState()
let state: GameState = restored ?? initialStateFor(world)
let lastState: GameState | null = null // for one-step undo
let transientHelpEl: HTMLDivElement | null = null
if (!restored) {
// Fresh state already includes the opening narration in its transcript.
@@ -50,7 +70,11 @@ if (!transcriptEl || !inputEl) {
if (it) visibleNouns.push({ id, aliases: it.names })
}
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) {
@@ -78,6 +102,23 @@ if (!transcriptEl || !inputEl) {
transcriptEl.scrollTop = transcriptEl.scrollHeight
}
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'
el.textContent = HELP_TEXT
transcriptEl.appendChild(el)
transientHelpEl = el
transcriptEl.scrollTop = transcriptEl.scrollHeight
}
// For UI-originated lines (player input, restart/undo/quit messages, error
// notices). Pushes into state.transcript so they survive reload, then renders.
// Engine-originated lines (from dispatch) are already in state.transcript;
@@ -98,6 +139,7 @@ if (!transcriptEl || !inputEl) {
const raw = inputEl.value
inputEl.value = ''
if (!raw.trim()) return
clearTransientHelp()
appendLines([{ kind: 'player', text: raw }])
// Once the game has ended, only restart and undo are allowed.
@@ -126,6 +168,10 @@ if (!transcriptEl || !inputEl) {
syncEndedUI()
return
}
if (trimmed === 'help') {
renderTransientHelp()
return
}
if (trimmed === 'undo') {
if (lastState) {
state = lastState