Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d56c0c8363 | |||
| 83e4877852 |
@@ -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 |
@@ -1,5 +0,0 @@
|
||||
<?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="m937.31 531.24-50.766-13.922c-8.8125-2.3906-15.938-8.8594-19.172-17.391-2.5312-6.7031-5.5312-13.312-8.8594-20.297h-0.046875c-4.125-7.8281-4.125-17.203 0-25.078l25.781-44.297 0.046875 0.046875c5.3906-9.1406 4.7344-20.625-1.6875-29.062-20.062-25.453-43.453-48.141-69.469-67.453-8.4375-5.7656-19.359-6.4688-28.453-1.7812l-45.938 25.078h-0.046875c-8.4844 4.0781-18.375 4.0781-26.859 0-7.9219-3.4688-14.766-6-20.625-8.1562-8.7188-2.8125-15.562-9.6562-18.375-18.375l-14.156-48.609c-2.625-10.359-11.344-18.047-21.984-19.312-12.188-1.5-24.422-2.3438-36.703-2.625-12.047 0.46875-24.047 1.5469-36 3.2344-10.688 1.0781-19.594 8.6719-22.312 19.078l-14.156 48.703v0.046875c-2.8594 8.7188-9.75 15.609-18.469 18.469-5.8594 2.1562-12.703 4.6875-20.766 8.2969v-0.046875c-8.4844 4.0312-18.328 4.0312-26.766 0l-46.312-25.781c-9.2344-5.1094-20.531-4.4531-29.062 1.6875-26.016 19.266-49.312 41.906-69.375 67.312-6.4219 8.4375-7.0781 19.922-1.6406 29.062l25.781 44.297v-0.046875c4.5938 7.875 4.875 17.578 0.70312 25.688-3.4688 7.2188-6.4688 13.781-9 20.531-3.2344 8.5312-10.359 14.953-19.172 17.391l-50.766 13.922c-10.219 2.5312-17.859 10.969-19.453 21.375-4.5 31.406-4.5 63.328 0 94.781 1.2656 10.641 9.0938 19.312 19.547 21.609l50.391 13.781h0.046875c8.8594 2.4844 16.078 8.9531 19.406 17.531 2.5312 6.9375 5.4844 13.688 8.7656 20.297 3.8438 7.9219 3.6094 17.25-0.70312 24.938l-25.922 44.625c-5.3906 9.1875-4.5938 20.672 1.9219 29.062 20.062 25.219 43.359 47.672 69.234 66.844 8.5781 6.2812 20.062 6.8906 29.297 1.5469l45.703-24.844c8.3906-4.1719 18.234-4.4531 26.859-0.70312 6.8438 3.1406 13.781 5.9531 20.906 8.3906 8.625 3 15.375 9.8906 18.234 18.609l14.156 48c2.7188 10.688 11.906 18.516 22.922 19.453 11.953 1.6406 23.953 2.5781 36 2.8594 12.047-0.23438 24.047-1.2188 36-2.8594 10.641-1.125 19.5-8.7188 22.219-19.078l14.156-48.844c2.8125-8.6719 9.5156-15.562 18.094-18.609 7.4531-2.625 14.156-5.2969 21-8.3906 8.6719-3.8438 18.562-3.6562 27 0.5625l45.984 25.219c9.0938 5.4844 20.578 4.9219 29.156-1.3125 26.016-19.172 49.406-41.766 69.469-67.078 6.6094-8.3438 7.3594-19.969 1.7812-29.062l-25.641-45c-4.4062-7.5938-4.7344-16.922-0.84375-24.844 3.4688-7.0781 6.375-13.781 8.7656-20.156 3.2344-8.6719 10.453-15.281 19.406-17.766l49.453-13.641c10.641-1.9688 18.891-10.453 20.531-21.141 4.5-31.547 4.5-63.609 0-95.156-1.5-10.406-9.0938-18.891-19.219-21.609zm-3.8438 113.77c-0.60938 0.89062-1.5469 1.5-2.625 1.6875l-50.531 13.312c-15.984 4.4531-28.922 16.219-34.922 31.688-2.2969 6.2812-4.9688 12.469-7.9219 18.469-7.1719 14.812-6.6094 32.25 1.5938 46.547l25.688 44.391-0.046875 0.046875c0.70312 0.9375 0.70312 2.25 0 3.2344-18.844 23.719-40.828 44.812-65.25 62.766h-3.9375l-45.844-24.938-0.046875-0.046875c-14.766-7.5469-32.203-8.0625-47.391-1.3125-6.375 2.9062-12.891 5.5312-19.547 7.7812-15.422 5.6719-27.375 18.188-32.297 33.844l-14.391 49.453c-0.28125 1.2188-1.3594 2.1562-2.625 2.1562-11.016 1.5469-22.125 2.4375-33.234 2.625-11.391-0.1875-22.734-1.0312-33.984-2.625-1.3125-0.14062-2.3438-1.2188-2.5312-2.5312l-14.297-49.078h0.046875c-5.0156-15.703-17.109-28.219-32.672-33.703-6.7031-2.3906-12.844-4.9219-19.453-7.9219h0.046875c-7.0781-3-14.672-4.5469-22.359-4.5469-8.6719-0.046875-17.25 2.0625-24.938 6.0938l-45.938 24.938v0.046875c-1.2188 0.79688-2.7656 0.79688-3.9844 0-24.328-17.906-46.125-38.906-64.922-62.531-0.65625-0.98438-0.65625-2.25 0-3.2344l25.781-44.531c8.2031-14.297 8.7656-31.734 1.5938-46.547-3.1406-6.4688-5.7656-12-8.0625-18.609-5.8594-15.609-18.844-27.469-34.922-31.922l-50.766-13.453c-1.0312-0.1875-1.875-0.89062-2.25-1.9219-4.2188-29.578-4.2188-59.578 0-89.156 0.51562-0.9375 1.4531-1.6406 2.5312-1.9219l50.484-13.547c15.984-4.3594 28.922-16.078 34.828-31.547 2.4375-6.375 5.2031-12.656 8.25-18.75 7.125-14.906 6.4688-32.297-1.6875-46.641l-25.781-44.156v-0.046875c-0.51562-0.89062-0.51562-2.0625 0-3 18.75-23.766 40.5-44.953 64.781-63 1.4062-0.70312 3.0938-0.70312 4.4531 0l45.938 25.078h0.046875c14.719 7.7344 32.156 8.1562 47.25 1.2188 7.3125-3.2344 13.781-5.5312 19.312-7.5469 15.609-5.4844 27.703-17.953 32.672-33.75l14.391-49.219v0.046875c0.32812-1.2188 1.3594-2.1094 2.625-2.1562 12-1.6875 22.547-2.625 33-3.1406 10.453-0.46875 22.453 1.4531 34.453 3.1406 1.1719 0.32812 2.1094 1.3125 2.2969 2.5312l14.297 49.078h-0.046875c5.1094 15.609 17.156 27.984 32.672 33.469 5.5312 2.0625 12 4.3125 19.219 7.5469h-0.046875c15.141 6.9375 32.625 6.5156 47.391-1.2188l45.938-25.078h0.046875c1.3594-0.70312 2.9531-0.70312 4.3125 0 24.328 18.094 46.125 39.328 64.781 63.141 0.60938 0.89062 0.60938 2.1094 0 3l-25.875 43.781c-8.2031 14.438-8.7656 31.969-1.5938 46.922 3.0469 6.0469 5.7656 12.188 8.1562 18.516 5.8594 15.516 18.797 27.375 34.828 31.781l50.766 13.781c1.0312 0.28125 1.9688 0.98438 2.4844 1.9219 4.125 29.625 4.0781 59.719-0.23438 89.297z"/>
|
||||
<path d="m600 414.24c-49.266 0-96.516 19.594-131.34 54.422-34.828 34.828-54.422 82.078-54.422 131.34s19.594 96.516 54.422 131.34c34.828 34.828 82.078 54.422 131.34 54.422s96.516-19.594 131.34-54.422c34.828-34.828 54.422-82.078 54.422-131.34-0.046875-49.266-19.641-96.469-54.469-131.29s-82.031-54.422-131.29-54.469zm0 348c-65.812 0-125.11-39.656-150.24-100.45-25.172-60.797-11.203-130.78 35.391-177.24 46.594-46.5 116.58-60.328 177.32-35.016 60.75 25.266 100.27 84.656 100.12 150.47-0.14062 89.766-72.844 162.47-162.61 162.61z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.3 KiB |
@@ -202,6 +202,29 @@ describe('dispatcher — examine', () => {
|
||||
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)
|
||||
})
|
||||
|
||||
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', () => {
|
||||
@@ -369,6 +392,14 @@ describe('light/extinguish verbs (implicit lighter)', () => {
|
||||
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)
|
||||
@@ -459,6 +490,9 @@ describe('use verb routing', () => {
|
||||
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.' },
|
||||
},
|
||||
encounters: {},
|
||||
endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } },
|
||||
@@ -474,6 +508,52 @@ describe('use verb routing', () => {
|
||||
}, 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('ending detection', () => {
|
||||
|
||||
@@ -165,7 +165,13 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World)
|
||||
if (command.verb === 'read') return withEndingCheck(handleRead(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') return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }]), world)
|
||||
if (command.verb === 'use') {
|
||||
const target = world.items[command.target.canonical]
|
||||
if (target?.lighter && !target.lightable) {
|
||||
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: 'Use match with what?' }]), world)
|
||||
}
|
||||
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }]), world)
|
||||
}
|
||||
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]), world)
|
||||
}
|
||||
|
||||
@@ -180,6 +186,10 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World)
|
||||
return withEndingCheck(handleLight(stateWithNoun, command.target.canonical, command.indirect.canonical, world), world)
|
||||
}
|
||||
if (command.verb === 'use') {
|
||||
const burnResult = handleBurnLetter(stateWithNoun, command.target.canonical, command.indirect.canonical, world)
|
||||
if (burnResult) return withEndingCheck(burnResult, world)
|
||||
const lightResult = handleUseAsLight(stateWithNoun, command.target.canonical, command.indirect.canonical, world)
|
||||
if (lightResult) return withEndingCheck(lightResult, world)
|
||||
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }]), world)
|
||||
}
|
||||
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]), world)
|
||||
@@ -339,11 +349,25 @@ function handleDrop(state: GameState, itemId: string, _world: World): DispatchRe
|
||||
function handleExamine(state: GameState, itemId: string, world: World): DispatchResult {
|
||||
const item = world.items[itemId]
|
||||
if (!item) return narrate(state, [{ kind: 'narration', text: 'You don\'t see anything like that.' }])
|
||||
const inventoryInst = state.inventory.find((i) => i.id === itemId) ?? null
|
||||
const visible =
|
||||
state.inventory.find((i) => i.id === itemId) ||
|
||||
inventoryInst ||
|
||||
getItemsInRoom(state, world, state.location).includes(itemId)
|
||||
if (!visible) return narrate(state, [{ kind: 'narration', text: 'You don\'t see anything like that.' }])
|
||||
return narrate(state, [{ kind: 'narration', text: item.long }])
|
||||
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 {
|
||||
@@ -362,6 +386,7 @@ function handleRead(state: GameState, itemId: string, world: World): DispatchRes
|
||||
function handleLight(state: GameState, targetId: string, instrumentId: string | null, world: World): DispatchResult {
|
||||
const target = world.items[targetId]
|
||||
if (!target) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }])
|
||||
if (target.lighter && !target.lightable) return narrate(state, [{ kind: 'narration', text: 'Use match with what?' }])
|
||||
if (!target.lightable) return narrate(state, [{ kind: 'narration', text: "You can't light that." }])
|
||||
const targetInst = state.inventory.find((i) => i.id === targetId) ?? null
|
||||
const visibleInRoom = getItemsInRoom(state, world, state.location).includes(targetId)
|
||||
@@ -417,6 +442,60 @@ function handleLight(state: GameState, targetId: string, instrumentId: string |
|
||||
return narrate({ ...state, inventory: newInventory }, lines)
|
||||
}
|
||||
|
||||
function handleBurnLetter(state: GameState, firstId: string, secondId: string, world: World): DispatchResult | null {
|
||||
const ids = [firstId, secondId]
|
||||
if (!ids.includes('letter') || !ids.includes('matches')) return null
|
||||
|
||||
const matches = state.inventory.find((i) => i.id === 'matches')
|
||||
if (!matches) return narrate(state, [{ kind: 'narration', text: "You don't have a match." }])
|
||||
if (typeof matches.state['uses'] === 'number' && matches.state['uses'] <= 0) {
|
||||
return narrate(state, [{ kind: 'narration', text: 'The matchbook is empty.' }])
|
||||
}
|
||||
|
||||
const letterHeld = state.inventory.some((i) => i.id === 'letter')
|
||||
const letterInRoom = getItemsInRoom(state, world, state.location).includes('letter')
|
||||
if (!letterHeld && !letterInRoom) {
|
||||
return narrate(state, [{ kind: 'narration', text: "You don't see the letter here." }])
|
||||
}
|
||||
|
||||
const newMatchesUses = typeof matches.state['uses'] === 'number' ? matches.state['uses'] - 1 : null
|
||||
let next: GameState = {
|
||||
...state,
|
||||
inventory: state.inventory
|
||||
.filter((i) => i.id !== 'letter')
|
||||
.map((i) => i.id === 'matches' && newMatchesUses !== null ? { ...i, state: { ...i.state, uses: newMatchesUses } } : i),
|
||||
flags: { ...state.flags, letterBurned: true },
|
||||
}
|
||||
|
||||
if (letterInRoom) {
|
||||
const baseItems = world.rooms[state.location]?.items ?? []
|
||||
const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[]
|
||||
if (baseItems.includes('letter')) {
|
||||
const taken = (next.roomState[state.location]?.['takenItems'] ?? []) as string[]
|
||||
next = setRoomFlag(next, state.location, 'takenItems', [...new Set([...taken, 'letter'])])
|
||||
}
|
||||
if (dropped.includes('letter')) {
|
||||
next = setRoomFlag(next, state.location, 'droppedItems', dropped.filter((id) => id !== 'letter'))
|
||||
}
|
||||
}
|
||||
|
||||
const lines: TranscriptLine[] = [
|
||||
{ kind: 'narration', text: 'The letter catches at one corner. In a few breaths it is ash.' },
|
||||
]
|
||||
if (newMatchesUses === 0) {
|
||||
lines.push({ kind: 'narration', text: world.items['matches']?.lighterEmptyText ?? 'The matchbook is empty.' })
|
||||
}
|
||||
return narrate(next, lines)
|
||||
}
|
||||
|
||||
function handleUseAsLight(state: GameState, firstId: string, secondId: string, world: World): DispatchResult | 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 target = world.items[targetId]
|
||||
if (!target) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }])
|
||||
|
||||
@@ -328,7 +328,7 @@ describe('verb-target-prep with "with"', () => {
|
||||
knownEncounters: [],
|
||||
visibleNouns: [
|
||||
{ id: 'lamp', aliases: ['lamp'] },
|
||||
{ id: 'matches', aliases: ['matches', 'matchbook'] },
|
||||
{ id: 'matches', aliases: ['matches', 'match', 'matchbook'] },
|
||||
],
|
||||
inventoryItemIds: ['matches'],
|
||||
lastNoun: null,
|
||||
@@ -346,6 +346,17 @@ describe('verb-target-prep with "with"', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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'],
|
||||
|
||||
@@ -107,4 +107,25 @@ describe('playthrough — sample world', () => {
|
||||
'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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
import '../ui/crt.css'
|
||||
import gearIcon from '../assets/noun-gear-8323296.svg?url'
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
@@ -27,9 +26,15 @@ import gearIcon from '../assets/noun-gear-8323296.svg?url'
|
||||
data-options-toggle
|
||||
aria-label="Options"
|
||||
aria-expanded="false"
|
||||
style={`--gear-icon: url(${gearIcon})`}
|
||||
>
|
||||
<span class="mystery-options-icon" aria-hidden="true"></span>
|
||||
<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">
|
||||
|
||||
@@ -39,12 +39,13 @@ describe('computeChips — sample world', () => {
|
||||
expect(chips.find((c) => c.kind === 'encounter' && c.label === 'ATTACK RAT' && c.command === 'attack rat')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('always includes LOOK, INV, USE, and HELP', () => {
|
||||
it('always includes LOOK, INV, USE, WAIT, 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.label === 'USE' && c.command === 'use ')).toBeTruthy()
|
||||
expect(chips.find((c) => c.command === 'wait')).toBeTruthy()
|
||||
expect(chips.find((c) => c.command === 'help')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -69,6 +69,7 @@ export function computeChips(state: GameState, world: World): Chip[] {
|
||||
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: '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
|
||||
|
||||
+11
-5
@@ -137,10 +137,9 @@ body {
|
||||
.mystery-options-icon {
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
background: currentColor;
|
||||
display: block;
|
||||
mask: var(--gear-icon) center / contain no-repeat;
|
||||
-webkit-mask: var(--gear-icon) center / contain no-repeat;
|
||||
fill: currentColor;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.mystery-options-menu {
|
||||
@@ -191,6 +190,10 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mystery-options .mystery-options-toggle {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mystery-options button[aria-pressed='true'],
|
||||
.mystery-options-toggle[aria-expanded='true'] {
|
||||
color: var(--m-fg);
|
||||
@@ -202,7 +205,7 @@ body {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-color: var(--m-dim) transparent;
|
||||
scrollbar-color: var(--m-dim) var(--m-bg);
|
||||
scrollbar-width: thin;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
@@ -217,11 +220,13 @@ body {
|
||||
}
|
||||
|
||||
.mystery-transcript::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
background: var(--m-bg);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.mystery-transcript::-webkit-scrollbar-thumb {
|
||||
background: var(--m-dim);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.mystery-transcript .system {
|
||||
@@ -238,6 +243,7 @@ body {
|
||||
overflow-wrap: normal;
|
||||
font-size: clamp(9px, 2vw, 14px);
|
||||
line-height: 1.1;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.mystery-transcript .help {
|
||||
|
||||
@@ -26,3 +26,10 @@
|
||||
- [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.
|
||||
- [ ] Add a mechanic where after the player waits 3 times or moves six times the light goes out and needs to be relit.
|
||||
- [ ] Add attack options for most encounters. Rarely this will be a good idea though.
|
||||
- [ ] Add a failure condition for attacking at the wrong time. Make the reasons for the failure condition contextual, for example, when they attack the stair sleeper they might trip on the stair and get injured.
|
||||
- [x] If the user says "light match" or "light match" the response should be "use match with what?"
|
||||
- [x] If the user says "use match with letter" they should burn the letter.
|
||||
- [x] There should be a lighter in the smoking room that allows unlimited lighting.
|
||||
|
||||
@@ -16,10 +16,14 @@ describe('assembled world', () => {
|
||||
'music-room',
|
||||
'servants-passage',
|
||||
'laundry',
|
||||
'stair-up',
|
||||
'bedroom',
|
||||
'nursery',
|
||||
'attic',
|
||||
]))
|
||||
})
|
||||
|
||||
it('contains the authored opening and main-floor items', () => {
|
||||
it('contains the authored opening, main-floor, and upper-floor items', () => {
|
||||
expect(Object.keys(world.items)).toEqual(expect.arrayContaining([
|
||||
'broken-cigarette',
|
||||
'lamp',
|
||||
@@ -33,6 +37,9 @@ describe('assembled world', () => {
|
||||
'grandfather-clock',
|
||||
'dinner-place-setting',
|
||||
'covered-cage',
|
||||
'childs-drawing',
|
||||
'music-box',
|
||||
'toy-dog',
|
||||
]))
|
||||
})
|
||||
|
||||
|
||||
@@ -180,4 +180,26 @@ export const encounters: Record<string, EncounterDef> = {
|
||||
onFailed: { narration: narration('linen-shape', 'failed'), retreatTo: 'servants-passage' },
|
||||
defaultWrongVerbNarration: narration('linen-shape', 'wrong-verb'),
|
||||
},
|
||||
'stair-sleeper': {
|
||||
id: 'stair-sleeper',
|
||||
aliases: ['stair sleeper', 'sleeper', 'figure', 'person', 'body'],
|
||||
startsIn: 'stair-up',
|
||||
initialPhase: 'seated',
|
||||
phases: {
|
||||
seated: {
|
||||
description: narration('stair-sleeper', 'seated'),
|
||||
transitions: [
|
||||
{
|
||||
verb: 'wait',
|
||||
chipLabel: 'WAIT',
|
||||
narration: narration('stair-sleeper', 'wait-resolved'),
|
||||
to: 'resolved',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
onResolved: { setFlags: { hallwayShifted: true } },
|
||||
onFailed: { narration: narration('stair-sleeper', 'failed'), retreatTo: 'parlor' },
|
||||
defaultWrongVerbNarration: narration('stair-sleeper', 'wrong-verb'),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
id: stair-sleeper
|
||||
startsIn: "[[stair-up]]"
|
||||
initialPhase: seated
|
||||
---
|
||||
|
||||
## seated
|
||||
Someone sits halfway up the stair with their back to you. Their head rests against the banister. One hand lies open on the runner.
|
||||
|
||||
## wait-resolved
|
||||
You do not speak.
|
||||
|
||||
After a while, the figure leans aside, not waking. The stair above is not the same stair, but it is open.
|
||||
|
||||
## wrong-verb
|
||||
The seated figure draws one breath. The sound is too small for the body that made it.
|
||||
|
||||
## failed
|
||||
The sleeper turns before you see the face. The stair folds down under your feet, and the parlor receives you again.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: childs-drawing
|
||||
names: ["drawing", "child's drawing", "childs drawing", "crayon drawing", "childs-drawing"]
|
||||
short: "a child's drawing"
|
||||
takeable: false
|
||||
readable: true
|
||||
initialState: {}
|
||||
---
|
||||
|
||||
A child's drawing is pinned to the wall with two black tacks. It shows a hallway with too many doors and a small square room where the house has no room to keep one.
|
||||
|
||||
## read
|
||||
Beneath the drawing, in blunt blue crayon, someone has written:
|
||||
|
||||
NOT THIS HALL
|
||||
@@ -1,15 +1,20 @@
|
||||
---
|
||||
id: matches
|
||||
names: ["matches", "safety matches", "matchbook", "box"]
|
||||
short: "a matchbook"
|
||||
names:
|
||||
- matches
|
||||
- match
|
||||
- safety matches
|
||||
- matchbook
|
||||
- box
|
||||
short: a matchbook
|
||||
takeable: true
|
||||
lighter: true
|
||||
lighterUses: 4
|
||||
lighterUses: 5
|
||||
initialState:
|
||||
uses: 4
|
||||
uses: 5
|
||||
---
|
||||
|
||||
A damp matchbook with four matches left inside.
|
||||
A damp matchbook with five matches left inside.
|
||||
|
||||
## lighter-empty
|
||||
The last match flares, burns down, and goes out. The book is empty.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
id: music-box
|
||||
names: ["music box", "box", "nursery music box", "music-box"]
|
||||
short: "a bolted music box"
|
||||
takeable: false
|
||||
initialState:
|
||||
wound: false
|
||||
---
|
||||
|
||||
The music box is bolted to the nursery shelf. A tiny keyhole waits below a painted house with all its windows shut.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
id: toy-dog
|
||||
names: ["toy dog", "stuffed dog", "dog", "woof", "wolf", "toy-dog"]
|
||||
short: "a stuffed dog"
|
||||
takeable: true
|
||||
initialState: {}
|
||||
---
|
||||
|
||||
The toy dog is several shades darker than it used to be.
|
||||
His fur is matted down in areas and worn from years of handling.
|
||||
|
||||
A name is stitched into one paw in faded blue thread:
|
||||
|
||||
WOOF
|
||||
|
||||
Or perhaps:
|
||||
|
||||
WOLF
|
||||
|
||||
The stitching has begun to loosen.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
id: attic
|
||||
title: "[ Attic ]"
|
||||
exitN: null
|
||||
exitS: null
|
||||
exitE: null
|
||||
exitW: null
|
||||
exitU: null
|
||||
exitD: "[[bedroom]]"
|
||||
items: []
|
||||
encounter: null
|
||||
---
|
||||
|
||||
## first-visit
|
||||
The attic is lower than the roof outside promised. Trunks lean together under the beams. A piano note sounds once below you, then once above.
|
||||
|
||||
The bedroom is down.
|
||||
|
||||
## revisit
|
||||
The attic crouches under its own dust.
|
||||
|
||||
## examined
|
||||
Old sheets cover old furniture. The shapes beneath them are all too tall. In the far corner, a child's handprint has been pressed into the dust from underneath.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
id: bedroom
|
||||
title: "[ Bedroom ]"
|
||||
exitN: null
|
||||
exitS: null
|
||||
exitE: "[[nursery]]"
|
||||
exitW: null
|
||||
exitU: "[[attic]]"
|
||||
exitD: "[[stair-up]]"
|
||||
items: []
|
||||
encounter: null
|
||||
safe: true
|
||||
---
|
||||
|
||||
## first-visit
|
||||
The bedroom has been prepared for another sleeper. The counterpane is turned down on one side. The pillow holds a shallow dent.
|
||||
|
||||
A nursery opens east. A narrower stair climbs into the ceiling.
|
||||
|
||||
## revisit
|
||||
The bedroom keeps the shape of its absent sleeper.
|
||||
|
||||
## examined
|
||||
The wardrobe stands open a hand's width. A dress hangs inside, dark at the hem. On the bedside table, a dry glass has left a ring in the dust.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
id: nursery
|
||||
title: "[ Nursery ]"
|
||||
exitN: null
|
||||
exitS: null
|
||||
exitE: null
|
||||
exitW: "[[bedroom]]"
|
||||
exitU: null
|
||||
exitD: null
|
||||
items:
|
||||
- "[[childs-drawing]]"
|
||||
- "[[music-box]]"
|
||||
- "[[toy-dog]]"
|
||||
encounter: null
|
||||
safe: true
|
||||
---
|
||||
|
||||
## first-visit
|
||||
The nursery is awake. Toys have been arranged in a careful circle on the floorboards, all facing the door.
|
||||
|
||||
A music box is bolted to the shelf. A crayon drawing is pinned beside it.
|
||||
|
||||
## revisit
|
||||
The nursery keeps its circle.
|
||||
|
||||
## examined
|
||||
The small bed has not been slept in. The blanket is folded square enough to hurt the eye. The toys face you now, though none has moved.
|
||||
@@ -5,7 +5,7 @@ exitN: "[[study]]"
|
||||
exitS: null
|
||||
exitE: null
|
||||
exitW: null
|
||||
exitU: null
|
||||
exitU: "[[stair-up]]"
|
||||
exitD: "[[hallway]]"
|
||||
items:
|
||||
- "[[grandfather-clock]]"
|
||||
@@ -18,6 +18,8 @@ The parlor has been arranged for company. No chair faces another. Each one waits
|
||||
|
||||
A stopped clock stands against the north wall. A narrow study opens beyond it. The hallway is below.
|
||||
|
||||
A staircase climbs where the corner of the room should be.
|
||||
|
||||
## revisit
|
||||
The parlor waits with its empty chairs.
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
id: stair-up
|
||||
title: "[ Upper Stair ]"
|
||||
exitN: null
|
||||
exitS: null
|
||||
exitE: null
|
||||
exitW: null
|
||||
exitU: "[[bedroom]]"
|
||||
exitD: "[[parlor]]"
|
||||
items: []
|
||||
encounter: "[[stair-sleeper]]"
|
||||
---
|
||||
|
||||
## first-visit
|
||||
The stair rises from the parlor and turns before it should. The banister is polished by hands that knew the way in the dark.
|
||||
|
||||
Halfway up, someone sits with their back to you.
|
||||
|
||||
## revisit
|
||||
The upper stair waits in its narrow turn.
|
||||
|
||||
## examined
|
||||
The runner is worn thin along the center. Dust gathers at either side, untouched. The figure on the stair is still enough to be furniture, until the house settles and their head settles with it.
|
||||
Reference in New Issue
Block a user