Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions docs/architecture/adrs/adr-069-perception-event-filtering.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,3 +448,43 @@ Both operate on the event stream between action execution and final output. The
- ADR-052: Event Handlers for Custom Logic
- Cloak of Darkness story test logs
- Inform 6/7 end-of-turn room description model

## Amendment — Per-sense rendering selection, not degradation (2026-06-23)

**Context.** The accepted design (Option B) filters events by matching on `event.type` (e.g. `if.event.room.description`), and where a sense cannot perceive an event it was heading toward *transforming a primary visual event into a stripped-down one*. That bakes in an asymmetry — sight as canonical, other senses as lossy fallbacks ("degradation"). The senses are parallel channels, not tiers: in the dark you do not perceive a *worse* version of a visual fact, you perceive a *different* fact through a different sense. Type-matching also breaks for events whose type is author-overridable (a message ID), since the type is not known ahead of time. #159 (NPC movement announcements, override-able per-NPC via `NpcTrait.movementMessages` and globally via `extendLanguage`) forced the issue.

**Change.** A **witnessable event carries a sense-neutral fact plus a set of per-sense _renderings_** — each a `(messageId, params)` pair keyed by sense. `PerceptionService` *selects* the rendering for the perceiver's available sense. No rendering is derived from another; none is primary.

```
event.data.renderings = {
sight: { messageId: 'npc.leaves', params: { npcName, direction } },
hearing: { messageId: 'npc.heard_departs', params: {} },
}
```

**Contract (normative).**

1. **Shared wire-type.** The renderings shape is a single type both the emitter and `PerceptionService` import — never redeclared per side (root CLAUDE.md rule 8b). It reuses the `Sense` union already defined by this ADR (Phase 1: `'sight' | 'hearing' | 'smell' | 'touch'`):

```ts
interface Rendering { messageId: string; params: Record<string, unknown>; }
type PerSenseRenderings = Partial<Record<Sense, Rendering>>;
```

2. **Selection surface.** Selection folds into the existing `PerceptionService.filterEvents(events, actor, location, world)` — there is no separate `renderEvents` method; a private helper may exist but the public surface is unchanged. For each event:
- **`data.renderings` absent (`undefined`)** → not a witnessable fact; pass through unchanged (the existing type-matching path still applies).
- **`data.renderings` present** → select the rendering for the perceiver's highest-precedence available sense and replace the event with `{ ...event, type: rendering.messageId, data: rendering.params }`.
- **present but no listed sense is perceivable** (including an empty `{}` map) → imperceptible: `createPerceptionBlockedEvent`. An empty map is a deliberate "perceptible by nothing here," distinct from absent.

3. **Sense precedence (normative).** When more than one sense is available and has a rendering, selection follows a fixed precedence order — `sight` ▸ `hearing` ▸ `smell` ▸ `touch` — independent of map key order. A future sense added to `Sense` must declare its rank here.

4. **Lifecycle.** The witnessable event is transient — created by the emitter, replaced 1:1 during filtering, never persisted. The durable record (e.g. `npc.moved`) is a separate event and is not rendering-bearing.

**Consequences.**
- **No sense is canonical.** An event that omits a `hearing` rendering is simply inaudible; one that omits `sight` is invisible — by *absence of a rendering*, not by degrading a richer one. "Sam leaves to the east." and "You hear someone leave." are co-equal projections of one movement fact.
- **Override-friendly.** Authors override the `sight` rendering's `messageId` (per-NPC or global) when the emitter builds the renderings map; `PerceptionService` selects by sense and is agnostic to which IDs are present.
- **Reusable.** Future witnessable facts (combat, object sounds, smells) attach their own renderings map; `PerceptionService` gains no per-feature branches and no per-feature message IDs.
- **Resolves the `if-services` coupling** flagged in Tech Debt above: because the emitter (e.g. `NpcService`) puts both the `sight` and `hearing` message IDs into the event's renderings, `PerceptionService` holds *no* stdlib message-ID imports — sense selection is fully generic. The move to `@sharpee/if-services` gets *easier*, not harder.
- **Scope.** This supersedes the type-matching filter *for witnessable events*. The original type-based path (room-description darkness) remains for events that do not yet carry a renderings map; migrating those is follow-on work, not required by #159. If the sense set grows into a documented taxonomy, that taxonomy supersedes this amendment as its own ADR.

**Session.** Branch `fix/platform-issues-book-qa`, issue #159.
17 changes: 17 additions & 0 deletions docs/architecture/adrs/adr-070-npc-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -508,3 +508,20 @@ Pure ECS with system that queries components.
- Inform 7 NPC system
- TADS Actor class
- Zork MDL source (actors.mud)

## Amendment — NPC action execution and event emission live in stdlib (2026-06-23)

**Context.** The original "stdlib vs Story Responsibility" table assigned *NPC action execution* (move/take/drop/attack/speak/emote) and *event emission for NPC actions* to **engine (Core Infrastructure)**. The implementation diverged: those responsibilities live in `NpcService` (`packages/stdlib/src/npc/npc-service.ts`), which executes NPC actions and emits the `npc.*` semantic events (`npc.moved`, `npc.spoke`, etc.). The engine's only role is routing — `GameEngine.processPluginEvents` enriches, perception-filters, appends to `turnEvents`, and re-emits; it does not execute NPC actions or originate their events. This divergence surfaced while planning #159 (opt-in NPC movement announcements), whose emission logic correctly extends `NpcService`, not the engine.

**Change.** The responsibility split is corrected as follows:

| Layer | Owns |
| ----- | ---- |
| **engine** | NPC turn-phase scheduling; routing NPC events through `processPluginEvents` (enrich → perception-filter → re-emit). Does **not** execute NPC actions or originate `npc.*` events. |
| **stdlib (`NpcService`)** | NPC action execution (move/take/drop/attack/speak/emote) **and** emission of `npc.*` semantic events. |
| **world-model** | `NpcTrait` definition (data only). |
| **lang-en-us** | Text for `npc.*` message IDs. |

**Consequences.** New NPC behaviors and event types are added in stdlib `NpcService`, not the engine. The engine stays generic and never gains NPC-specific emission logic. The witnessable movement event `NpcService` emits is **sense-neutral** — it carries a per-sense renderings map and defers sense selection to `PerceptionService` (see the ADR-069 amendment), rather than emitting a visual line that other senses strip down. A known gap remains (tracked, not resolved here): because `processPluginEvents` does not call `eventProcessor.processEvents()`, `world.registerEventHandler` handlers do **not** fire for `npc.*` events — the "deeper half" of #159.

**Session.** Branch `fix/platform-issues-book-qa`, issue #159.
2 changes: 1 addition & 1 deletion docs/book/backmatter/appendix-a-architecture-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ every **channel** "what do you have this turn?" and assembles a **packet**. The
The single idea that ties Volume VII back to everything before it: **every story-to-UI
signal travels as a channel** — prose, score, location, prompt, images, sound. There
is no special path for any of them. That data-only packet stream is what lets one
unchanged story run in a terminal, a browser, or a multi-user Zifmia room.
unchanged story run in a terminal or a browser.

## Where does this belong?

Expand Down
1 change: 0 additions & 1 deletion docs/book/book.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ input-files:
- parts/part-8/29-transcript-testing-and-walkthroughs.md # new (stub)
- parts/part-8/30-saving-and-restoring.md # new (stub)
- parts/part-8/31-building-and-publishing.md # new (stub)
- parts/part-8/32-multi-user-with-zifmia.md # new (stub)
# Back matter — appendices, then credits
- backmatter/appendix-a-architecture-map.md # A — the layer model
- backmatter/appendix-b-action-catalog.md # B — standard actions (generated)
Expand Down
14 changes: 9 additions & 5 deletions docs/book/parts/part-1/01-installing-sharpee.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,16 +98,20 @@ can pin different platform versions.

## Creating a story project

`sharpee init` scaffolds a new project:
`sharpee init` scaffolds a new project. On its own it walks you through a short
wizard — story title, package ID, author, description — each question defaulting
to a sensible value (the directory name, your username, and so on). Pass `-y` to
accept every default and scaffold in one shot, which is what we'll do here:

```bash
sharpee init my-zoo
sharpee init my-zoo -y
cd my-zoo
npm install
```

`init` writes a small, complete starting point; `npm install` pulls down the
platform it pins. After that you have:
(Drop the `-y` if you'd rather answer the prompts yourself; the `my-zoo` argument
just supplies the default for the first question.) `init` writes a small, complete
starting point; `npm install` pulls down the platform it pins. After that you have:

```
my-zoo/
Expand Down Expand Up @@ -166,7 +170,7 @@ author:

| Command | What it does |
|---|---|
| `sharpee init [dir]` | Scaffold a new story project |
| `sharpee init [dir] [-y]` | Scaffold a new story project (`-y` skips the prompts) |
| `sharpee init-browser` | Add a web client (`src/browser-entry.ts`) |
| `sharpee build` | Compile `src/` and emit the `.sharpee` bundle (and the web client, if present) |
| `sharpee build-browser` | Rebuild only the web client → `dist/web/` |
Expand Down
13 changes: 13 additions & 0 deletions docs/book/parts/part-1/02-your-first-room.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,19 @@ class FamilyZooStory implements Story {
The two methods below are members of this `FamilyZooStory` class — they go where
the comments are. We'll write each one, then assemble the whole file at the end.

**The scaffolded stub looks a little different — and that's fine.** The
`src/index.ts` that `sharpee init` generated isn't written exactly like the file we
build here, but both are valid. The stub imports `Story` and `StoryConfig` from
**`@sharpee/sharpee`** (a convenience barrel that re-exports the engine, world
model, and parser as one package), where the book imports them from
**`@sharpee/engine`** directly; the two names refer to the same types. The stub
also defines the story as a plain **object literal**
(`export const story: Story = { config, createPlayer, … }`) rather than a `class`.
An object literal and a class instance satisfy the `Story` interface identically —
we use the class form throughout the book because it gives the two methods a
natural home and reads well as the story grows. Either style works; pick one and
stay consistent.

## Creating the player

The engine calls `createPlayer` first. Inside the class, you build the player like
Expand Down
7 changes: 6 additions & 1 deletion docs/book/parts/part-5/17-extending-the-grammar.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,17 @@ narrow it with `.where`, giving the slot a scope rule:
```typescript
grammar
.define('feed :animal')
.where('animal', scope => scope.touchable())
.where('animal', (scope: any) => scope.touchable())
.mapsTo('zoo.action.feeding')
.withPriority(150)
.build();
```

The `(scope: any)` annotation on the callback is there to satisfy the strict
`tsconfig.json` that `sharpee init` generates: `.where` accepts more than one kind
of constraint, so TypeScript can't infer the parameter's type on its own and
`noImplicitAny` flags it. Annotating it keeps the build clean.

Keep these rules **permissive** — `touchable` rather than `visible` — for the
reason from Chapter 11: let the parser resolve the noun, and let the action's
`validate` phase make the strict call about whether sight (or anything else) is
Expand Down
4 changes: 2 additions & 2 deletions docs/book/parts/part-5/18-the-language-layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ the turn, the engine's prose pipeline takes that ID to the language layer and as
prints.

The standard library works exactly the same way. Every built-in verb emits IDs
like `if.action.taking.success`; the English language package maps each to its
like `if.action.taking.taken`; the English language package maps each to its
prose. Nothing in the engine, stdlib, or world model ever hardcodes a sentence — it
all flows through IDs.

Expand Down Expand Up @@ -64,7 +64,7 @@ capitalization — "the toucan," "a flashlight," "Some feed." That machinery is

IDs are just strings, but a consistent scheme keeps them legible. The convention:

- Built-in messages use the `if.*` namespace — `if.action.taking.success`.
- Built-in messages use the `if.*` namespace — `if.action.taking.taken`.
- Your story's messages take a story prefix — `zoo.feeding.fed_goats`,
`zoo.photo.no_camera`.

Expand Down
11 changes: 10 additions & 1 deletion docs/book/parts/part-6/22-timed-events-and-daemons.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,9 +238,18 @@ lines to it — a story has just one.)
> wait (repeat until "FEEDING TIME" is announced)
> wait The goats start bleating
> take feed Grab the feed
> feed goats The bleating stops
> feed goats Feed them — but the bleating runs on its own timer
> wait …a turn or two later the bleating stops on its own
```

The bleating ends when the daemon's three-turn countdown reaches zero — *not*
because you fed the goats. The feeding action (Chapter 14) records that the goats
were fed; it never touches `zoo.feeding_time_active`, which is the only state the
daemon watches. If you *wanted* feeding to silence them early, you'd add an event
handler on the feed action that clears that flag — a nice exercise, but the
scheduler's own countdown is doing the stopping here, exactly as the conditional
daemon above ("counting itself down and stopping") was built to do.

## Key takeaway

The `SchedulerPlugin` gives the world a clock: register it in `onEngineReady()`
Expand Down
25 changes: 25 additions & 0 deletions docs/book/parts/part-6/23-scoring-and-endgame.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,31 @@ world.chainEvent('if.event.read', (event: ISemanticEvent, w: IWorldModel) => {
(`this.entityIds.zooMap` and `.brochure` are recorded in `initializeWorld` when you
create those items, the same way Chapter 13 stored the feed and penny ids.)

That covers eight of the twelve awards (40 of the 75 points). The remaining four
ride the very same two patterns, so wire them up the same way. Feeding the goats or
rabbits and photographing an animal award inside their custom actions' `execute()`
(like petting, above); pressing a souvenir penny awards in the penny-press chain
from Chapter 13 (like collecting the map):

```typescript
// inside the feeding action's execute(), keyed on which animal was fed:
world.awardScore(ScoreIds.FEED_GOATS, ScorePoints[ScoreIds.FEED_GOATS], 'Fed the goats');
// …and ScoreIds.FEED_RABBITS the same way when the rabbits are fed.

// inside the photograph action's execute():
world.awardScore(ScoreIds.PHOTOGRAPH_ANIMAL,
ScorePoints[ScoreIds.PHOTOGRAPH_ANIMAL], 'Photographed an animal');

// in the penny-press chain (Chapter 13), the same shape as the map award:
w.awardScore(ScoreIds.COLLECT_PRESSED_PENNY,
ScorePoints[ScoreIds.COLLECT_PRESSED_PENNY], 'Pressed a souvenir penny');
```

With all twelve awards in place the scores sum to the full 75, so the victory
daemon below has a target it can actually reach. Leave any of these four out and
the game caps at 40 (or wherever you stopped) and the win never fires — a useful
reminder that the max score and the awarding code have to agree.

## The victory daemon

The win condition is checked by a daemon — exactly the scheduler pattern from the
Expand Down
15 changes: 12 additions & 3 deletions docs/book/parts/part-7/24-channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ own **`IOChannel`** in the `registerChannels` hook. A channel is an object with
computes its value for the turn:

```typescript
// A mood line per room — rooms not listed stay quiet.
// A mood line per room — rooms not listed clear the line.
const AMBIENCE_BY_ROOM: Record<string, string> = {
'Aviary': 'The air is alive with birdsong and the rustle of wings.',
'Nocturnal Animals Exhibit': 'Your eyes strain against the warm red dark.',
Expand All @@ -104,8 +104,8 @@ registerChannels(registry: IChannelRegistry): void {
produce: (ctx) => {
const world = ctx.world as WorldModel;
const room = world.getEntity(world.getLocation(world.getPlayer()!.id)!);
// a mood line for the current room, or undefined to stay quiet
return room ? AMBIENCE_BY_ROOM[room.name] ?? undefined : undefined;
// a mood line for the current room, or '' to clear the line
return room ? AMBIENCE_BY_ROOM[room.name] ?? '' : '';
},
});
}
Expand All @@ -117,6 +117,15 @@ stay silent. The `emit` policy decides idle turns: `sparse` emits only when the
value changes; `always` emits every turn. To *override* a standard channel, register
one with the same `id` — last write wins.

One subtlety to internalize, because it bites everyone once: on a `sparse`
`replace` channel, `undefined` means *"no change this turn"* — **not** *"clear the
line."* The channel doesn't re-emit, so whatever it last showed stays on screen. If
you returned `undefined` for "rooms without a mood," the previous room's line would
follow the player around. To actually blank the line you must emit a *different*
value — here, the empty string `''` — which is a real transition the renderer paints
as blank (and `sparse` then stays quiet until the mood changes again). Reach for
`undefined` only when you genuinely want the current value to persist untouched.

Crucially, a channel emits **data** — text, a number, JSON — never UI. The value
says *what*; the renderer (next chapter) decides *how* it looks. That data-only wire
is what keeps presentation in the client's hands, where an author can restyle or
Expand Down
2 changes: 1 addition & 1 deletion docs/book/parts/part-8/31-building-and-publishing.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ reproducible.

```bash
npm install -g @sharpee/devkit # one-time
sharpee init my-game # scaffold src/index.ts, package.json, tsconfig.json
sharpee init my-game -y # scaffold src/index.ts, package.json, tsconfig.json
cd my-game && npm install # pull the platform from npm
sharpee build # compile src/ → dist/, emit the .sharpee bundle
```
Expand Down
Loading
Loading