Code Snippets

Every code step from The Sharpee Author and Developer Manual (v2.0.0), by chapter. The step blocks are the deltas you add as you read; the combined block is the complete, runnable story so far, taken from the tested familyzoo tutorial source.

Chapter 1: Installing Sharpee: The CLI and Your First Project

Book source: docs/book/v2.0.0/parts/part-1/01-installing-sharpee.md · 13 step(s)

Working in a terminal

step 1 bash
node --version

Installing Node and npm

step 2 bash
node --version    # want v18.0.0 or newer
npm --version

Installing the CLI

step 3 bash
npm install -g @sharpee/devkit

Creating a story project

step 4 bash
sharpee init my-zoo -y
cd my-zoo
npm install

Building the story

step 5 bash
sharpee build

Playing it

step 6 bash
sharpee init-browser   # adds the browser entry and its support files
sharpee build          # now also emits a web client → dist/web/
step 7 bash
python3 -m http.server -d dist/web

A TypeScript primer

step 8 typescript
const title: string = 'Willowbrook Family Zoo';
let turns: number = 0;
step 9 typescript
const options = { isOpen: false, capacity: 10 };
// e.g. `capacity?: number` means capacity may be left out
step 10 typescript
const light = new IdentityTrait({ name: 'flashlight' });
step 11 typescript
class MyStory implements Story { /* … */ }
step 12 typescript
import { IdentityTrait } from '@sharpee/world-model';
export const story = new MyStory();
step 13 typescript
items.some(item => item.name === 'feed');

Combined runnable file

No cumulative story file yet (this chapter sets up tooling, not story code).

Chapter 2: Your First Room: Entities, Traits, and the World

Book source: docs/book/v2.0.0/parts/part-1/02-your-first-room.md · 7 step(s)

The Story interface

reference typescript
interface Story {
  config: StoryConfig;
  initializeWorld(world: WorldModel): void;
  createPlayer(world: WorldModel): IFEntity;
  // optional:
  getCustomActions?(): any[];
  getCustomVocabulary?(): CustomVocabulary;
  extendParser?(parser: Parser): void;
  isComplete?(): boolean;
}

The shape of the file

step 2 typescript
import { Story, StoryConfig } from '@sharpee/engine';
import {
  WorldModel,
  IFEntity,
  EntityType,
} from '@sharpee/world-model';
import {
  IdentityTrait,
  ActorTrait,
  ContainerTrait,
  RoomTrait,
  SceneryTrait,
} from '@sharpee/world-model';

const config: StoryConfig = {
  id: 'familyzoo',
  title: 'Family Zoo',
  author: 'Sharpee Tutorial',
  version: '0.1.0',
  description:
    'A small family zoo: Learn Sharpee one concept at ' +
    'a time.',
};

class FamilyZooStory implements Story {
  config = config;

  // createPlayer(world)     - fills in next
  // initializeWorld(world)  - and after that
}

Creating the player

step 3 typescript
createPlayer(world: WorldModel): IFEntity {
  const player = world.createEntity('yourself', EntityType.ACTOR);

  player.add(new IdentityTrait({
    name: 'yourself',
    description: 'Just an ordinary visitor to the zoo.',
    aliases: ['self', 'myself', 'me'],
    properName: true,
    article: '',
  }));

  player.add(new ActorTrait({ isPlayer: true }));

  player.add(new ContainerTrait({
    capacity: { maxItems: 10 },
  }));

  return player;
}
reference typescript
class ContainerTrait implements ITrait {
  constructor(data?: Partial<ContainerTrait>);
  capacity?: {
    maxWeight?: number;
    maxVolume?: number;
    maxItems?: number;
  };
  isTransparent: boolean;
  enterable: boolean;
}

Building the world

step 5 typescript
initializeWorld(world: WorldModel): void {
  const entrance = world
    .createEntity('Zoo Entrance', EntityType.ROOM);

  entrance.add(new RoomTrait({ exits: {}, isDark: false }));
  entrance.add(new IdentityTrait({
    name: 'Zoo Entrance',
    description:
      'You stand before the gates of the Willowbrook Family ' +
      'Zoo. A cheerful welcome sign arches over the entrance, ' +
      'and a small ticket booth sits to one side.',
    aliases: ['entrance', 'gates', 'gate'],
    article: 'the',
  }));

  const sign = world
    .createEntity('welcome sign', EntityType.SCENERY);
  sign.add(new IdentityTrait({
    name: 'welcome sign',
    description:
      'A brightly painted wooden sign welcomes you to ' +
      'the zoo.',
    aliases: ['sign', 'wooden sign'],
    article: 'a',
  }));

  const booth = world
    .createEntity('ticket booth', EntityType.SCENERY);
  booth.add(new IdentityTrait({
    name: 'ticket booth',
    description:
      'A small wooden booth with a sliding glass window. A ' +
      'sign in the window reads "Self-Guided Tours / No ' +
      'Ticket Needed Today!"',
    aliases: ['booth', 'ticket booth', 'window'],
    article: 'a',
  }));

  world.moveEntity(sign.id, entrance.id);
  world.moveEntity(booth.id, entrance.id);

  const player = world.getPlayer();
  if (player) world.moveEntity(player.id, entrance.id);
}

Exposing the story

step 6 typescript
export const story: Story = new FamilyZooStory();
export default story;

Prove it: your first transcript test

step 7 bash
npx sharpee build --test

Combined runnable file

The complete, runnable story at the end of this chapter (tested source: tutorials/familyzoo/v2.0.0/src/ch02-first-room.ts).

runnable ch02-first-room.ts typescript
/**
 * Family Zoo — Book-aligned tutorial, Chapter 2: Your First Room
 *
 * Cumulative snapshot matching the book's reading order (see
 * docs/book/parts/part-1/02-your-first-room.md). One room, a welcome sign, and a
 * ticket booth — the simplest playable Sharpee story.
 *
 * This set is a 1:1 companion to the book; the historical v01–v18 versions remain
 * the original tutorial. Not wired into index.ts.
 */

import { Story, StoryConfig } from '@sharpee/engine';
import { WorldModel, IFEntity, EntityType } from '@sharpee/world-model';
import {
  IdentityTrait,
  ActorTrait,
  ContainerTrait,
  RoomTrait,
  SceneryTrait,
} from '@sharpee/world-model';

const config: StoryConfig = {
  id: 'familyzoo',
  title: 'Family Zoo',
  author: 'Sharpee Tutorial',
  version: '0.1.0',
  description: 'A small family zoo — learn Sharpee one concept at a time.',
};

class FamilyZooStory implements Story {
  config = config;

  createPlayer(world: WorldModel): IFEntity {
    const player = world.createEntity('yourself', EntityType.ACTOR);

    player.add(new IdentityTrait({
      name: 'yourself',
      description: 'Just an ordinary visitor to the zoo.',
      aliases: ['self', 'myself', 'me'],
      properName: true,
      article: '',
    }));

    player.add(new ActorTrait({ isPlayer: true }));

    player.add(new ContainerTrait({
      capacity: { maxItems: 10 },
    }));

    return player;
  }

  initializeWorld(world: WorldModel): void {
    const entrance = world.createEntity('Zoo Entrance', EntityType.ROOM);

    entrance.add(new RoomTrait({ exits: {}, isDark: false }));
    entrance.add(new IdentityTrait({
      name: 'Zoo Entrance',
      description:
        'You stand before the gates of the Willowbrook Family Zoo. ' +
        'A cheerful welcome sign arches over the entrance, and a small ' +
        'ticket booth sits to one side.',
      aliases: ['entrance', 'gates', 'gate'],
      article: 'the',
    }));

    const sign = world.createEntity('welcome sign', EntityType.SCENERY);
    sign.add(new IdentityTrait({
      name: 'welcome sign',
      description: 'A brightly painted wooden sign welcomes you to the zoo.',
      aliases: ['sign', 'wooden sign'],
      article: 'a',
    }));

    const booth = world.createEntity('ticket booth', EntityType.SCENERY);
    booth.add(new IdentityTrait({
      name: 'ticket booth',
      description:
        'A small wooden booth with a sliding glass window. A sign in the ' +
        'window reads "Self-Guided Tours — No Ticket Needed Today!"',
      aliases: ['booth', 'ticket booth', 'window'],
      article: 'a',
    }));

    world.moveEntity(sign.id, entrance.id);
    world.moveEntity(booth.id, entrance.id);

    const player = world.getPlayer();
    if (player) world.moveEntity(player.id, entrance.id);
  }
}

export const story: Story = new FamilyZooStory();
export default story;

Chapter 3: The Play Loop: How a Turn Works

Book source: docs/book/v2.0.0/parts/part-1/03-the-play-loop.md · 1 step(s)

Events become text

step 1 typescript
context.event('if.event.taken', {
  messageId: 'if.action.taking.taken',
  params: { item: 'brass key' },
});

Combined runnable file

No story-code change this chapter; the runnable file is unchanged from the previous checkpoint (tutorials/familyzoo/v2.0.0/src/ch02-first-room.ts).

Chapter 4: Rooms & Navigation: Exits Wired in Pairs

Book source: docs/book/v2.0.0/parts/part-2/04-rooms-and-navigation.md · 6 step(s)

Exits live on the room

step 1 typescript
entranceRoom.exits = {
  [Direction.SOUTH]: { destination: mainPath.id },
};

Two rules that trip up everyone

step 2 typescript
entranceRoom.exits = {
  // entrance → path
  [Direction.SOUTH]: { destination: mainPath.id },
};
mainPathRoom.exits = {
  // path → entrance (the way back)
  [Direction.NORTH]: { destination: entrance.id },
};

Getting a trait back with `.get()`

step 3 typescript
const entranceRoom = entrance.get(RoomTrait)!;
entranceRoom.exits = { /* ... */ };
step 4 typescript
const roomTrait = entrance.get(RoomTrait);
if (roomTrait) {
  roomTrait.exits = { /* ... */ };
}

Putting it together

step 5 typescript
import {
  WorldModel, IFEntity, EntityType, Direction,
} from '@sharpee/world-model';
step 6 typescript
initializeWorld(world: WorldModel): void {
  // Step 1: create every room first, with empty exits.
  const entrance = world.createEntity(
    'Zoo Entrance',
    EntityType.ROOM,
  );
  entrance.add(new RoomTrait({ exits: {}, isDark: false }));
  entrance.add(new IdentityTrait({
    name: 'Zoo Entrance',
    description:
      'You stand before the gates of the Willowbrook Family ' +
      'Zoo. A cheerful welcome sign arches over the entrance, ' +
      'and a small ticket booth sits to one side. The main ' +
      'path leads south into the zoo grounds.',
    aliases: ['entrance', 'gates', 'gate'],
    article: 'the',
  }));

  const mainPath = world.createEntity(
    'Main Path',
    EntityType.ROOM,
  );
  mainPath.add(new RoomTrait({ exits: {}, isDark: false }));
  mainPath.add(new IdentityTrait({
    name: 'Main Path',
    description:
      'A wide gravel path winds through the heart of the zoo. ' +
      'Colorful direction signs point every which way. To the ' +
      'east, a white picket fence surrounds the petting zoo. ' +
      'To the west, a tall mesh enclosure rises above the ' +
      'treetops, the aviary. The entrance gates are back to ' +
      'the north.',
    aliases: ['path', 'main path', 'gravel path'],
    article: 'the',
  }));

  const pettingZoo = world.createEntity(
    'Petting Zoo',
    EntityType.ROOM,
  );
  pettingZoo.add(new RoomTrait({ exits: {}, isDark: false }));
  pettingZoo.add(new IdentityTrait({
    name: 'Petting Zoo',
    description:
      'A cheerful open-air enclosure filled with friendly ' +
      'animals. Pygmy goats trot around nibbling at visitors\' ' +
      'shoelaces, while a pair of fluffy rabbits hop near a ' +
      'hay bale. The main path is back to the west.',
    aliases: ['petting zoo', 'petting area', 'pen'],
    article: 'the',
  }));

  const aviary = world.createEntity('Aviary', EntityType.ROOM);
  aviary.add(new RoomTrait({ exits: {}, isDark: false }));
  aviary.add(new IdentityTrait({
    name: 'Aviary',
    description:
      'You step inside a soaring mesh dome. Brilliantly ' +
      'colored parrots chatter from rope perches, and a toucan ' +
      'eyes you curiously from a branch overhead. The exit ' +
      'back to the main path is to the east.',
    aliases: ['aviary', 'bird house', 'dome'],
    article: 'the',
  }));

  // Step 2: wire exits now that every room exists.
  entrance.get(RoomTrait)!.exits = {
    [Direction.SOUTH]: { destination: mainPath.id },
  };
  mainPath.get(RoomTrait)!.exits = {
    [Direction.NORTH]: { destination: entrance.id },
    [Direction.EAST]:  { destination: pettingZoo.id },
    [Direction.WEST]:  { destination: aviary.id },
  };
  pettingZoo.get(RoomTrait)!.exits = {
    [Direction.WEST]: { destination: mainPath.id },
  };
  aviary.get(RoomTrait)!.exits = {
    [Direction.EAST]: { destination: mainPath.id },
  };

  // Step 3: scenery. The welcome sign and ticket booth from
  // Chapter 2 stay in the entrance. The three new rooms get
  // scenery of their own. Each is the same pattern you already
  // know: an entity, an IdentityTrait, and a moveEntity to place
  // it. The EntityType.SCENERY type makes each one fixed.
  const sign = world.createEntity(
    'welcome sign',
    EntityType.SCENERY,
  );
  sign.add(new IdentityTrait({
    name: 'welcome sign',
    description:
      'A brightly painted wooden sign welcomes you to the zoo.',
    aliases: ['sign', 'welcome sign', 'wooden sign'],
    article: 'a',
  }));
  world.moveEntity(sign.id, entrance.id);

  const booth = world.createEntity(
    'ticket booth',
    EntityType.SCENERY,
  );
  booth.add(new IdentityTrait({
    name: 'ticket booth',
    description:
      'A small wooden booth with a sliding glass window ' +
      'reading "Self-Guided Tours / No Ticket Needed Today!"',
    aliases: ['booth', 'ticket booth', 'window'],
    article: 'a',
  }));
  world.moveEntity(booth.id, entrance.id);

  const directionSigns = world.createEntity(
    'direction signs',
    EntityType.SCENERY,
  );
  directionSigns.add(new IdentityTrait({
    name: 'direction signs',
    description:
      'A cluster of brightly colored arrow signs nailed to a ' +
      'wooden post. They point to: PETTING ZOO (east), AVIARY ' +
      '(west), REPTILE HOUSE (south -> coming soon!), and EXIT ' +
      '(north).',
    aliases: ['signs', 'direction signs', 'arrow signs', 'post'],
    article: 'some',
    grammaticalNumber: 'plural',
  }));
  world.moveEntity(directionSigns.id, mainPath.id);

  const goats = world.createEntity(
    'pygmy goats',
    EntityType.SCENERY,
  );
  goats.add(new IdentityTrait({
    name: 'pygmy goats',
    description:
      'Three pygmy goats with stubby legs and rectangular ' +
      'pupils, clearly hoping you have food.',
    aliases: ['goats', 'pygmy goats', 'goat'],
    article: 'some',
    grammaticalNumber: 'plural',
  }));
  world.moveEntity(goats.id, pettingZoo.id);

  const toucan = world.createEntity('toucan', EntityType.SCENERY);
  toucan.add(new IdentityTrait({
    name: 'toucan',
    description:
      'A Toco toucan with an enormous orange-and-black bill. ' +
      'It regards you with one intelligent eye.',
    aliases: ['toucan', 'bird', 'toco toucan'],
    article: 'a',
  }));
  world.moveEntity(toucan.id, aviary.id);

  // Step 4: place the player at the entrance, as in Chapter 2.
  const player = world.getPlayer();
  if (player) world.moveEntity(player.id, entrance.id);
}

Combined runnable file

The complete, runnable story at the end of this chapter (tested source: tutorials/familyzoo/v2.0.0/src/ch04-navigation.ts).

runnable ch04-navigation.ts typescript
/**
 * Family Zoo Tutorial — Version 2: Multiple Rooms & Navigation
 *
 * NEW IN THIS VERSION:
 *   - Creating multiple rooms
 *   - Connecting rooms with exits (Direction enum)
 *   - The player can walk between rooms using compass directions
 *
 * WHAT YOU'LL LEARN:
 *   - How exits work (each room declares where its exits lead)
 *   - How the Direction enum maps compass directions to exits
 *   - Exits must be wired in BOTH directions (south exit needs matching north)
 *   - The "going" action is built into Sharpee — no custom code needed
 *
 * TRY IT:
 *   > look                (see the Zoo Entrance)
 *   > south               (walk to the Main Path)
 *   > look                (see the Main Path)
 *   > east                (walk to the Petting Zoo)
 *   > west                (back to Main Path)
 *   > north               (back to Zoo Entrance)
 *   > south               (Main Path again)
 *   > west                (walk to the Aviary)
 *
 * BUILD & RUN:
 *   ./build.sh -s familyzoo
 *   node dist/cli/sharpee.js --story tutorials/familyzoo --play
 */

// ============================================================================
// IMPORTS — same as V1, see v01.ts for detailed explanations
// ============================================================================

import { Story, StoryConfig } from '@sharpee/engine';
import {
  WorldModel,
  IFEntity,
  EntityType,
  Direction,       // NEW: compass directions for room exits
} from '@sharpee/world-model';
import {
  IdentityTrait,
  ActorTrait,
  ContainerTrait,
  RoomTrait,
  SceneryTrait,
} from '@sharpee/world-model';


// ============================================================================
// STORY CONFIGURATION — same as V1
// ============================================================================

const config: StoryConfig = {
  id: 'familyzoo',
  title: 'Family Zoo',
  author: 'Sharpee Tutorial',
  version: '0.2.0',
  description: 'A small family zoo — learn Sharpee one concept at a time.',
};


// ============================================================================
// THE STORY CLASS
// ============================================================================

class FamilyZooStory implements Story {
  config = config;

  // createPlayer — same as V1, see v01.ts for detailed comments
  createPlayer(world: WorldModel): IFEntity {
    const player = world.createEntity('yourself', EntityType.ACTOR);
    player.add(new IdentityTrait({
      name: 'yourself',
      description: 'Just an ordinary visitor to the zoo.',
      aliases: ['self', 'myself', 'me'],
      properName: true,
      article: '',
    }));
    player.add(new ActorTrait({ isPlayer: true }));
    player.add(new ContainerTrait({ capacity: { maxItems: 10 } }));
    return player;
  }

  initializeWorld(world: WorldModel): void {

    // ========================================================================
    // STEP 1: Create ALL rooms first (without exits)
    // ========================================================================
    // We create all rooms before wiring exits. Why? Because an exit needs
    // the destination room's ID, and you can't reference a room that doesn't
    // exist yet. Create first, connect second.

    // --- Zoo Entrance (same as V1) ---
    const entrance = world.createEntity('Zoo Entrance', EntityType.ROOM);
    entrance.add(new RoomTrait({ exits: {}, isDark: false }));
    entrance.add(new IdentityTrait({
      name: 'Zoo Entrance',
      description:
        'You stand before the gates of the Willowbrook Family Zoo. ' +
        'A cheerful welcome sign arches over the entrance, and a small ' +
        'ticket booth sits to one side. The main path leads south into ' +
        'the zoo grounds.',
      aliases: ['entrance', 'gates', 'gate'],
      properName: false,
      article: 'the',
    }));

    // --- Main Path (NEW) ---
    const mainPath = world.createEntity('Main Path', EntityType.ROOM);
    mainPath.add(new RoomTrait({ exits: {}, isDark: false }));
    mainPath.add(new IdentityTrait({
      name: 'Main Path',
      description:
        'A wide gravel path winds through the heart of the zoo. Colorful ' +
        'direction signs point every which way. To the east, a white ' +
        'picket fence surrounds the petting zoo. To the west, a tall ' +
        'mesh enclosure rises above the treetops — the aviary. The ' +
        'entrance gates are back to the north.',
      aliases: ['path', 'main path', 'gravel path'],
      properName: false,
      article: 'the',
    }));

    // --- Petting Zoo (NEW) ---
    const pettingZoo = world.createEntity('Petting Zoo', EntityType.ROOM);
    pettingZoo.add(new RoomTrait({ exits: {}, isDark: false }));
    pettingZoo.add(new IdentityTrait({
      name: 'Petting Zoo',
      description:
        'A cheerful open-air enclosure filled with friendly animals. ' +
        'Pygmy goats trot around nibbling at visitors\' shoelaces, while ' +
        'a pair of fluffy rabbits hop lazily near a hay bale. A low ' +
        'wooden fence separates you from a muddy pig pen. The main ' +
        'path is back to the west.',
      aliases: ['petting zoo', 'petting area', 'pen'],
      properName: false,
      article: 'the',
    }));

    // --- Aviary (NEW) ---
    const aviary = world.createEntity('Aviary', EntityType.ROOM);
    aviary.add(new RoomTrait({ exits: {}, isDark: false }));
    aviary.add(new IdentityTrait({
      name: 'Aviary',
      description:
        'You step inside a soaring mesh dome that stretches high above ' +
        'the treetops. Brilliantly colored parrots chatter from rope ' +
        'perches, and a toucan eyes you curiously from a branch ' +
        'overhead. A small waterfall splashes into a stone basin where ' +
        'finches bathe. The exit back to the main path is to the east.',
      aliases: ['aviary', 'bird house', 'bird cage', 'dome'],
      properName: false,
      article: 'the',
    }));


    // ========================================================================
    // STEP 2: Wire up exits between rooms
    // ========================================================================
    // Exits use the Direction enum: NORTH, SOUTH, EAST, WEST, UP, DOWN, etc.
    // Each exit is an object with a "destination" property — the ID of the
    // room the player will arrive in when they go that direction.
    //
    // IMPORTANT: Exits are one-way! If the entrance has a SOUTH exit to
    // the main path, you ALSO need a NORTH exit on the main path back to
    // the entrance. Forgetting the return exit means the player gets stuck!

    // Get the RoomTrait from each room so we can set its exits.
    // The .get() method retrieves a trait by its class.
    const entranceRoom = entrance.get(RoomTrait)!;
    const mainPathRoom = mainPath.get(RoomTrait)!;
    const pettingZooRoom = pettingZoo.get(RoomTrait)!;
    const aviaryRoom = aviary.get(RoomTrait)!;

    // Entrance ←→ Main Path (north/south)
    entranceRoom.exits = {
      [Direction.SOUTH]: { destination: mainPath.id },
    };

    mainPathRoom.exits = {
      [Direction.NORTH]: { destination: entrance.id },
      [Direction.EAST]:  { destination: pettingZoo.id },
      [Direction.WEST]:  { destination: aviary.id },
    };

    // Petting Zoo → Main Path (back west)
    pettingZooRoom.exits = {
      [Direction.WEST]: { destination: mainPath.id },
    };

    // Aviary → Main Path (back east)
    aviaryRoom.exits = {
      [Direction.EAST]: { destination: mainPath.id },
    };


    // ========================================================================
    // STEP 3: Create scenery objects (same pattern as V1)
    // ========================================================================

    // --- Welcome Sign (Zoo Entrance) ---
    const sign = world.createEntity('welcome sign', EntityType.SCENERY);
    sign.add(new IdentityTrait({
      name: 'welcome sign',
      description:
        'A brightly painted wooden sign reads: "WELCOME TO WILLOWBROOK ' +
        'FAMILY ZOO — Home to over 50 amazing animals! Open daily, ' +
        'rain or shine." Cartoon animals dance along the border.',
      aliases: ['sign', 'welcome sign', 'wooden sign'],
      properName: false,
      article: 'a',
    }));
    world.moveEntity(sign.id, entrance.id);

    // --- Ticket Booth (Zoo Entrance) ---
    const booth = world.createEntity('ticket booth', EntityType.SCENERY);
    booth.add(new IdentityTrait({
      name: 'ticket booth',
      description:
        'A small wooden booth with a sliding glass window. A sign in the ' +
        'window reads "Self-Guided Tours — No Ticket Needed Today!" ' +
        'A friendly cartoon giraffe is painted on the side.',
      aliases: ['booth', 'ticket booth', 'window'],
      properName: false,
      article: 'a',
    }));
    world.moveEntity(booth.id, entrance.id);

    // --- Direction Signs (Main Path) ---
    const directionSigns = world.createEntity('direction signs', EntityType.SCENERY);
    directionSigns.add(new IdentityTrait({
      name: 'direction signs', grammaticalNumber: 'plural',
      description:
        'A cluster of brightly colored arrow signs nailed to a wooden ' +
        'post. They point to: PETTING ZOO (east), AVIARY (west), ' +
        'REPTILE HOUSE (south — coming soon!), and EXIT (north).',
      aliases: ['signs', 'direction signs', 'arrow signs', 'post'],
      properName: false,
      article: 'some',
    }));
    world.moveEntity(directionSigns.id, mainPath.id);

    // --- Goats (Petting Zoo) ---
    const goats = world.createEntity('pygmy goats', EntityType.SCENERY);
    goats.add(new IdentityTrait({
      name: 'pygmy goats', grammaticalNumber: 'plural',
      description:
        'Three pygmy goats with stubby legs and expressive faces. They ' +
        'stare at you with their weird rectangular pupils, clearly ' +
        'hoping you have food. One of them is chewing on a visitor\'s ' +
        'dropped map.',
      aliases: ['goats', 'pygmy goats', 'goat'],
      properName: false,
      article: 'some',
    }));
    world.moveEntity(goats.id, pettingZoo.id);

    // --- Toucan (Aviary) ---
    const toucan = world.createEntity('toucan', EntityType.SCENERY);
    toucan.add(new IdentityTrait({
      name: 'toucan',
      description:
        'A Toco toucan with an enormous orange-and-black bill. It tilts ' +
        'its head and regards you with one intelligent eye, as if deciding ' +
        'whether you\'re interesting enough to fly closer to.',
      aliases: ['toucan', 'bird', 'toco toucan'],
      properName: false,
      article: 'a',
    }));
    world.moveEntity(toucan.id, aviary.id);


    // ========================================================================
    // STEP 4: Place the player in the starting room
    // ========================================================================

    const player = world.getPlayer();
    if (player) {
      world.moveEntity(player.id, entrance.id);
    }
  }
}


// ============================================================================
// EXPORTS
// ============================================================================

export const story: Story = new FamilyZooStory();
export default story;

Chapter 5: Scenery & Portable Objects: Everything Is Portable by Default

Book source: docs/book/v2.0.0/parts/part-2/05-scenery-and-portable-objects.md · 4 step(s)

Everything is portable by default

step 1 typescript
const penny = world.createEntity(
  'souvenir penny',
  EntityType.ITEM,
);
penny.add(new IdentityTrait({
  name: 'souvenir penny',
  description:
    'A flattened copper penny stamped with a smiling elephant.',
  aliases: ['penny', 'coin', 'souvenir'],
}));
world.moveEntity(penny.id, mainPath.id);

EntityType.SCENERY makes a thing fixed

step 2 typescript
const fence = world.createEntity(
  'iron fence',
  EntityType.SCENERY,
);
fence.add(new IdentityTrait({
  name: 'iron fence',
  description:
    'A tall wrought-iron fence with animal silhouettes.',
  aliases: ['fence', 'iron fence', 'railing'],
}));
world.moveEntity(fence.id, entrance.id);

Aliases make objects findable

step 3 typescript
aliases: ['fence', 'iron fence', 'wrought-iron fence', 'railing'],

Putting it together

step 4 typescript
// Scenery: the SCENERY type fixes it in place, examinable,
// mentioned in room prose.
const fence = world.createEntity(
  'iron fence',
  EntityType.SCENERY,
);
fence.add(new IdentityTrait({
  name: 'iron fence',
  description:
    'A tall wrought-iron fence with animal silhouettes.',
  aliases: ['fence', 'iron fence', 'railing'],
}));
world.moveEntity(fence.id, entrance.id);

// More scenery: a pair of rabbits in the Petting Zoo, beside the
// goats.
const rabbits = world.createEntity('rabbits', EntityType.SCENERY);
rabbits.add(new IdentityTrait({
  name: 'rabbits',
  description:
    'A pair of Holland Lop rabbits with floppy ears and ' +
    'twitching noses, one pure white and the other brown and ' +
    'cream.',
  aliases: ['rabbits', 'rabbit', 'bunnies', 'bunny'],
  article: 'some',
  grammaticalNumber: 'plural',
}));
world.moveEntity(rabbits.id, pettingZoo.id);

// A takeable item: no SceneryTrait, so it's portable by default.
const zooMap = world.createEntity('zoo map', EntityType.ITEM);
zooMap.add(new IdentityTrait({
  name: 'zoo map',
  description:
    'A colorful folding map of the zoo, a heart drawn around ' +
    'the petting zoo in crayon.',
  aliases: ['map', 'zoo map', 'folding map'],
}));
world.moveEntity(zooMap.id, entrance.id);

// A second takeable item, in the Petting Zoo this time.
const animalFeed = world.createEntity(
  'bag of animal feed',
  EntityType.ITEM,
);
animalFeed.add(new IdentityTrait({
  name: 'bag of animal feed',
  description:
    'A small brown paper bag of dried corn and pellets. The ' +
    'label reads "ZOO SNACKS: Safe for goats, rabbits, and ' +
    'birds." It rustles invitingly.',
  aliases: [
    'feed', 'animal feed', 'bag of feed',
    'bag', 'corn', 'pellets',
  ],
}));
world.moveEntity(animalFeed.id, pettingZoo.id);

Combined runnable file

The complete, runnable story at the end of this chapter (tested source: tutorials/familyzoo/v2.0.0/src/ch05-scenery-items.ts).

runnable ch05-scenery-items.ts typescript
/**
 * Family Zoo Tutorial — Version 4: Portable Objects
 *
 * NEW IN THIS VERSION:
 *   - Items the player can pick up and carry
 *   - The taking, dropping, and inventory actions
 *   - How portable objects differ from scenery
 *   - EntityType.ITEM — the default portable object type
 *
 * WHAT YOU'LL LEARN:
 *   - Items are portable by default — no special trait needed
 *   - EntityType.ITEM creates a takeable object
 *   - "take", "drop", and "inventory" are built-in actions
 *   - Portable items show up in room descriptions and inventory
 *
 * TRY IT:
 *   > look                 (notice the zoo map on the ground)
 *   > take map             (pick it up)
 *   > inventory            (see what you're carrying)
 *   > south                (walk to Main Path — the map comes with you)
 *   > drop map             (leave it here)
 *   > look                 (the map is now on the ground in Main Path)
 *   > east                 (go to Petting Zoo)
 *   > take feed            (pick up the bag of animal feed)
 *   > inventory            (carrying the feed now)
 *
 * BUILD & RUN:
 *   ./build.sh -s familyzoo
 *   node dist/cli/sharpee.js --story tutorials/familyzoo --play
 */

// ============================================================================
// IMPORTS — same as V3, see v01.ts for detailed explanations
// ============================================================================

import { Story, StoryConfig } from '@sharpee/engine';
import {
  WorldModel,
  IFEntity,
  EntityType,    // NEW focus: EntityType.ITEM for portable objects
  Direction,
} from '@sharpee/world-model';
import {
  IdentityTrait,
  ActorTrait,
  ContainerTrait,
  RoomTrait,
  SceneryTrait,
} from '@sharpee/world-model';


// ============================================================================
// STORY CONFIGURATION
// ============================================================================

const config: StoryConfig = {
  id: 'familyzoo',
  title: 'Family Zoo',
  author: 'Sharpee Tutorial',
  version: '0.4.0',
  description: 'A small family zoo — learn Sharpee one concept at a time.',
};


// ============================================================================
// THE STORY CLASS
// ============================================================================

class FamilyZooStory implements Story {
  config = config;

  // createPlayer — same as V1, see v01.ts for detailed comments
  createPlayer(world: WorldModel): IFEntity {
    const player = world.createEntity('yourself', EntityType.ACTOR);
    player.add(new IdentityTrait({
      name: 'yourself',
      description: 'Just an ordinary visitor to the zoo.',
      aliases: ['self', 'myself', 'me'],
      properName: true,
      article: '',
    }));
    player.add(new ActorTrait({ isPlayer: true }));
    player.add(new ContainerTrait({ capacity: { maxItems: 10 } }));
    return player;
  }

  initializeWorld(world: WorldModel): void {

    // ========================================================================
    // ROOMS — same as V2, see v02.ts for detailed comments
    // ========================================================================

    const entrance = world.createEntity('Zoo Entrance', EntityType.ROOM);
    entrance.add(new RoomTrait({ exits: {}, isDark: false }));
    entrance.add(new IdentityTrait({
      name: 'Zoo Entrance',
      description:
        'You stand before the wrought-iron gates of the Willowbrook ' +
        'Family Zoo. A cheerful welcome sign arches over the entrance, ' +
        'and a small ticket booth sits to one side. A sturdy iron fence ' +
        'runs along either side of the gates. The main path leads south ' +
        'into the zoo grounds.',
      aliases: ['entrance', 'gates', 'gate'],
      properName: false,
      article: 'the',
    }));

    const mainPath = world.createEntity('Main Path', EntityType.ROOM);
    mainPath.add(new RoomTrait({ exits: {}, isDark: false }));
    mainPath.add(new IdentityTrait({
      name: 'Main Path',
      description:
        'A wide gravel path winds through the heart of the zoo. Colorful ' +
        'direction signs point every which way. Wooden benches line the ' +
        'path, and neat flower beds add splashes of color. To the east, ' +
        'a white picket fence surrounds the petting zoo. To the west, a ' +
        'tall mesh enclosure rises above the treetops — the aviary. The ' +
        'entrance gates are back to the north.',
      aliases: ['path', 'main path', 'gravel path'],
      properName: false,
      article: 'the',
    }));

    const pettingZoo = world.createEntity('Petting Zoo', EntityType.ROOM);
    pettingZoo.add(new RoomTrait({ exits: {}, isDark: false }));
    pettingZoo.add(new IdentityTrait({
      name: 'Petting Zoo',
      description:
        'A cheerful open-air enclosure filled with friendly animals. ' +
        'Pygmy goats trot around nibbling at visitors\' shoelaces, while ' +
        'a pair of fluffy rabbits hop lazily near a hay bale. A low ' +
        'wooden fence separates you from a muddy pig pen. The main ' +
        'path is back to the west.',
      aliases: ['petting zoo', 'petting area', 'pen'],
      properName: false,
      article: 'the',
    }));

    const aviary = world.createEntity('Aviary', EntityType.ROOM);
    aviary.add(new RoomTrait({ exits: {}, isDark: false }));
    aviary.add(new IdentityTrait({
      name: 'Aviary',
      description:
        'You step inside a soaring mesh dome that stretches high above ' +
        'the treetops. Brilliantly colored parrots chatter from rope ' +
        'perches, and a toucan eyes you curiously from a branch ' +
        'overhead. A small waterfall splashes into a stone basin where ' +
        'finches bathe. The exit back to the main path is to the east.',
      aliases: ['aviary', 'bird house', 'bird cage', 'dome'],
      properName: false,
      article: 'the',
    }));


    // ========================================================================
    // EXITS — same as V2
    // ========================================================================

    const entranceRoom = entrance.get(RoomTrait)!;
    const mainPathRoom = mainPath.get(RoomTrait)!;
    const pettingZooRoom = pettingZoo.get(RoomTrait)!;
    const aviaryRoom = aviary.get(RoomTrait)!;

    entranceRoom.exits = {
      [Direction.SOUTH]: { destination: mainPath.id },
    };
    mainPathRoom.exits = {
      [Direction.NORTH]: { destination: entrance.id },
      [Direction.EAST]:  { destination: pettingZoo.id },
      [Direction.WEST]:  { destination: aviary.id },
    };
    pettingZooRoom.exits = {
      [Direction.WEST]: { destination: mainPath.id },
    };
    aviaryRoom.exits = {
      [Direction.EAST]: { destination: mainPath.id },
    };


    // ========================================================================
    // SCENERY — same as V3, see v03.ts for detailed SceneryTrait explanation
    // ========================================================================

    // Zoo Entrance scenery
    const sign = world.createEntity('welcome sign', EntityType.SCENERY);
    sign.add(new IdentityTrait({
      name: 'welcome sign',
      description:
        'A brightly painted wooden sign reads: "WELCOME TO WILLOWBROOK ' +
        'FAMILY ZOO — Home to over 50 amazing animals! Open daily, ' +
        'rain or shine." Cartoon animals dance along the border.',
      aliases: ['sign', 'welcome sign', 'wooden sign'],
      properName: false, article: 'a',
    }));
    world.moveEntity(sign.id, entrance.id);

    const booth = world.createEntity('ticket booth', EntityType.SCENERY);
    booth.add(new IdentityTrait({
      name: 'ticket booth',
      description:
        'A small wooden booth with a sliding glass window. A sign in the ' +
        'window reads "Self-Guided Tours — No Ticket Needed Today!"',
      aliases: ['booth', 'ticket booth'],
      properName: false, article: 'a',
    }));
    world.moveEntity(booth.id, entrance.id);

    const fence = world.createEntity('iron fence', EntityType.SCENERY);
    fence.add(new IdentityTrait({
      name: 'iron fence',
      description:
        'A tall wrought-iron fence with decorative animal silhouettes ' +
        'cut into every other bar.',
      aliases: ['fence', 'iron fence', 'railing'],
      properName: false, article: 'an',
    }));
    world.moveEntity(fence.id, entrance.id);

    // Main Path scenery
    const directionSigns = world.createEntity('direction signs', EntityType.SCENERY);
    directionSigns.add(new IdentityTrait({
      name: 'direction signs', grammaticalNumber: 'plural',
      description:
        'A cluster of brightly colored arrow signs nailed to a wooden ' +
        'post. PETTING ZOO (east), AVIARY (west), EXIT (north).',
      aliases: ['signs', 'direction signs', 'arrow signs', 'post'],
      properName: false, article: 'some',
    }));
    world.moveEntity(directionSigns.id, mainPath.id);

    const benches = world.createEntity('wooden benches', EntityType.SCENERY);
    benches.add(new IdentityTrait({
      name: 'wooden benches', grammaticalNumber: 'plural',
      description:
        'Sturdy park benches painted forest green, each with a small ' +
        'brass plaque thanking a zoo donor.',
      aliases: ['benches', 'bench', 'wooden benches', 'seat'],
      properName: false, article: 'some',
    }));
    world.moveEntity(benches.id, mainPath.id);

    const flowerBeds = world.createEntity('flower beds', EntityType.SCENERY);
    flowerBeds.add(new IdentityTrait({
      name: 'flower beds', grammaticalNumber: 'plural',
      description:
        'Tidy beds of marigolds and petunias in alternating orange and ' +
        'purple stripes.',
      aliases: ['flowers', 'flower beds', 'marigolds', 'petunias'],
      properName: false, article: 'some',
    }));
    world.moveEntity(flowerBeds.id, mainPath.id);

    // Petting Zoo scenery
    const goats = world.createEntity('pygmy goats', EntityType.SCENERY);
    goats.add(new IdentityTrait({
      name: 'pygmy goats', grammaticalNumber: 'plural',
      description:
        'Three pygmy goats with stubby legs and expressive faces. They ' +
        'stare at you with their weird rectangular pupils, clearly ' +
        'hoping you have food.',
      aliases: ['goats', 'pygmy goats', 'goat'],
      properName: false, article: 'some',
    }));
    world.moveEntity(goats.id, pettingZoo.id);

    const hayBale = world.createEntity('hay bale', EntityType.SCENERY);
    hayBale.add(new IdentityTrait({
      name: 'hay bale',
      description:
        'A large round bale of golden hay, slightly nibbled around the ' +
        'edges by enthusiastic goats.',
      aliases: ['hay', 'hay bale', 'bale', 'straw'],
      properName: false, article: 'a',
    }));
    world.moveEntity(hayBale.id, pettingZoo.id);

    const rabbits = world.createEntity('rabbits', EntityType.SCENERY);
    rabbits.add(new IdentityTrait({
      name: 'rabbits', grammaticalNumber: 'plural',
      description:
        'A pair of Holland Lop rabbits with floppy ears and twitching ' +
        'noses. One is pure white, the other brown and cream.',
      aliases: ['rabbits', 'rabbit', 'bunnies', 'bunny'],
      properName: false, article: 'some',
    }));
    world.moveEntity(rabbits.id, pettingZoo.id);

    // Aviary scenery
    const toucan = world.createEntity('toucan', EntityType.SCENERY);
    toucan.add(new IdentityTrait({
      name: 'toucan',
      description:
        'A Toco toucan with an enormous orange-and-black bill.',
      aliases: ['toucan', 'toco toucan'],
      properName: false, article: 'a',
    }));
    world.moveEntity(toucan.id, aviary.id);

    const parrots = world.createEntity('parrots', EntityType.SCENERY);
    parrots.add(new IdentityTrait({
      name: 'parrots', grammaticalNumber: 'plural',
      description:
        'A raucous flock of scarlet macaws and grey African parrots.',
      aliases: ['parrots', 'parrot', 'macaws', 'macaw', 'birds'],
      properName: false, article: 'some',
    }));
    world.moveEntity(parrots.id, aviary.id);

    const waterfall = world.createEntity('waterfall', EntityType.SCENERY);
    waterfall.add(new IdentityTrait({
      name: 'waterfall',
      description:
        'A gentle artificial waterfall cascading over smooth rocks into ' +
        'a shallow stone basin.',
      aliases: ['waterfall', 'water', 'basin', 'fountain'],
      properName: false, article: 'a',
    }));
    world.moveEntity(waterfall.id, aviary.id);


    // ========================================================================
    // PORTABLE OBJECTS — NEW IN V4
    // ========================================================================
    //
    // Now for the fun part: items the player can actually pick up!
    //
    // In Sharpee, items are portable BY DEFAULT. You don't need a special
    // "PortableTrait" — just create an entity with EntityType.ITEM and an
    // IdentityTrait, and the player can take it, carry it, and drop it.
    //
    // The built-in actions that handle portable objects:
    //   "take <item>"      → Moves item from room to player's inventory
    //   "drop <item>"      → Moves item from inventory back to current room
    //   "inventory" / "i"  → Lists everything the player is carrying
    //
    // These actions are part of Sharpee's standard library (stdlib).
    // You don't need to write any code to make them work.
    //
    // HOW IT WORKS:
    //   1. Player types "take map"
    //   2. Parser finds the "map" entity in the current room
    //   3. The "taking" action checks if the entity has SceneryTrait
    //      - If yes: "The map is fixed in place." (blocked)
    //      - If no:  Moves entity to player's ContainerTrait (inventory)
    //   4. Player sees "Taken."
    //
    // That's it! The distinction between "takeable" and "not takeable"
    // is simply: does the entity have SceneryTrait?

    // --- Zoo Map (at the Zoo Entrance) ---
    // A simple portable item. No traits besides IdentityTrait needed.
    const zooMap = world.createEntity('zoo map', EntityType.ITEM);

    zooMap.add(new IdentityTrait({
      name: 'zoo map',
      description:
        'A colorful folding map of the Willowbrook Family Zoo. It shows ' +
        'the layout of all the exhibits, with cartoon animals marking ' +
        'each one. Someone has drawn a heart around the petting zoo ' +
        'in crayon.',
      aliases: ['map', 'zoo map', 'folding map'],
      properName: false,
      article: 'a',
    }));

    // Notice: NO SceneryTrait! That's what makes it portable.
    // Just place it in the entrance and the player can take it.
    world.moveEntity(zooMap.id, entrance.id);


    // --- Bag of Animal Feed (at the Petting Zoo) ---
    const animalFeed = world.createEntity('bag of animal feed', EntityType.ITEM);

    animalFeed.add(new IdentityTrait({
      name: 'bag of animal feed',
      description:
        'A small brown paper bag filled with dried corn and pellets. ' +
        'The label reads "ZOO SNACKS — Safe for goats, rabbits, and ' +
        'birds. Do NOT feed to reptiles." It rustles invitingly.',
      aliases: ['feed', 'animal feed', 'bag of feed', 'bag', 'corn', 'pellets'],
      properName: false,
      article: 'a',
    }));

    world.moveEntity(animalFeed.id, pettingZoo.id);


    // --- Souvenir Penny (on the Main Path) ---
    const penny = world.createEntity('souvenir penny', EntityType.ITEM);

    penny.add(new IdentityTrait({
      name: 'souvenir penny',
      description:
        'A shiny copper penny. It looks like it could fit in one of ' +
        'those souvenir penny press machines — if there were one around.',
      aliases: ['penny', 'souvenir penny', 'coin', 'copper penny'],
      properName: false,
      article: 'a',
    }));

    world.moveEntity(penny.id, mainPath.id);


    // ========================================================================
    // PLAYER STARTING LOCATION
    // ========================================================================

    const player = world.getPlayer();
    if (player) {
      world.moveEntity(player.id, entrance.id);
    }
  }
}


// ============================================================================
// EXPORTS
// ============================================================================

export const story: Story = new FamilyZooStory();
export default story;

Chapter 6: Containers & Supporters: What Holds What

Book source: docs/book/v2.0.0/parts/part-2/06-containers-and-supporters.md · 5 step(s)

ContainerTrait

step 1 typescript
const backpack = world.createEntity(
  'backpack',
  EntityType.CONTAINER,
);
backpack.add(new IdentityTrait({
  name: 'backpack',
  description: 'A small red canvas backpack.',
  aliases: ['backpack', 'bag', 'pack'],
}));
backpack.add(new ContainerTrait({
  capacity: { maxItems: 5 },   // holds up to 5 things
}));
world.moveEntity(backpack.id, entrance.id);

Portable vs fixed containers

step 2 typescript
const dispenser = world.createEntity(
  'feed dispenser',
  EntityType.CONTAINER,
);
dispenser.add(new IdentityTrait({
  name: 'feed dispenser',
  description:
    'A coin-operated feed dispenser mounted on a wooden post, ' +
    'its glass globe half full of pellets.',
  aliases: ['dispenser', 'feed dispenser', 'machine', 'globe'],
}));
dispenser.add(new ContainerTrait({ capacity: { maxItems: 3 } }));
// can't take the dispenser itself
dispenser.add(new SceneryTrait());
world.moveEntity(dispenser.id, pettingZoo.id);

SupporterTrait

step 3 typescript
import {
  IdentityTrait,
  ActorTrait,
  ContainerTrait,
  RoomTrait,
  SceneryTrait,
  SupporterTrait,   // new this chapter
} from '@sharpee/world-model';
step 4 typescript
const parkBench = world.createEntity(
  'park bench',
  EntityType.SUPPORTER,
);
parkBench.add(new IdentityTrait({
  name: 'park bench',
  description:
    'A weathered wooden bench worn smooth by decades of ' +
    'visitors.',
  aliases: ['bench', 'park bench', 'seat'],
}));
parkBench.add(new SupporterTrait({ capacity: { maxItems: 3 } }));
// can't take the bench itself
parkBench.add(new SceneryTrait());
world.moveEntity(parkBench.id, mainPath.id);

Capacity limits

step 5 typescript
capacity: { maxItems: 5 }

Combined runnable file

The complete, runnable story at the end of this chapter (tested source: tutorials/familyzoo/v2.0.0/src/ch06-containers.ts).

runnable ch06-containers.ts typescript
/**
 * Family Zoo Tutorial — Version 5: Containers & Supporters
 *
 * NEW IN THIS VERSION:
 *   - ContainerTrait — objects that hold other objects inside them
 *   - SupporterTrait — surfaces you can put things on top of
 *   - "put X in Y" and "put X on Y" actions
 *   - Portable containers (backpack you carry around)
 *   - Scenery containers (feed dispenser bolted to the wall)
 *
 * WHAT YOU'LL LEARN:
 *   - ContainerTrait lets entities hold other entities
 *   - SupporterTrait lets entities have things placed on them
 *   - Containers can be portable (backpack) or scenery (dispenser)
 *   - "put in" and "put on" are built-in stdlib actions
 *   - "look in" / "look on" shows container/supporter contents
 *   - Capacity limits control how much a container can hold
 *
 * TRY IT:
 *   > take backpack         (pick up the backpack)
 *   > take map              (pick up the zoo map)
 *   > put map in backpack   (store the map in the backpack)
 *   > look in backpack      (see what's inside)
 *   > south                 (go to Main Path)
 *   > take penny            (pick up the penny)
 *   > put penny on bench    (place penny on the park bench)
 *   > look                  (penny is visible on the bench)
 *
 * BUILD & RUN:
 *   ./build.sh -s familyzoo
 *   node dist/cli/sharpee.js --story tutorials/familyzoo --play
 */

// ============================================================================
// IMPORTS
// ============================================================================

import { Story, StoryConfig } from '@sharpee/engine';
import {
  WorldModel,
  IFEntity,
  EntityType,
  Direction,
} from '@sharpee/world-model';
import {
  IdentityTrait,
  ActorTrait,
  ContainerTrait,    // ← Used for containers (backpack, feed dispenser)
  SupporterTrait,    // ← NEW: surfaces you can put things ON
  RoomTrait,
  SceneryTrait,
} from '@sharpee/world-model';


// ============================================================================
// STORY CONFIGURATION
// ============================================================================

const config: StoryConfig = {
  id: 'familyzoo',
  title: 'Family Zoo',
  author: 'Sharpee Tutorial',
  version: '0.5.0',
  description: 'A small family zoo — learn Sharpee one concept at a time.',
};


// ============================================================================
// THE STORY CLASS
// ============================================================================

class FamilyZooStory implements Story {
  config = config;

  // createPlayer — same as V1, see v01.ts for detailed comments
  createPlayer(world: WorldModel): IFEntity {
    const player = world.createEntity('yourself', EntityType.ACTOR);
    player.add(new IdentityTrait({
      name: 'yourself',
      description: 'Just an ordinary visitor to the zoo.',
      aliases: ['self', 'myself', 'me'],
      properName: true,
      article: '',
    }));
    player.add(new ActorTrait({ isPlayer: true }));
    player.add(new ContainerTrait({ capacity: { maxItems: 10 } }));
    return player;
  }

  initializeWorld(world: WorldModel): void {

    // ========================================================================
    // ROOMS — same as V2, see v02.ts for detailed comments
    // ========================================================================

    const entrance = world.createEntity('Zoo Entrance', EntityType.ROOM);
    entrance.add(new RoomTrait({ exits: {}, isDark: false }));
    entrance.add(new IdentityTrait({
      name: 'Zoo Entrance',
      description:
        'You stand before the wrought-iron gates of the Willowbrook ' +
        'Family Zoo. A cheerful welcome sign arches over the entrance, ' +
        'and a small ticket booth sits to one side. A sturdy iron fence ' +
        'runs along either side of the gates. The main path leads south ' +
        'into the zoo grounds.',
      aliases: ['entrance', 'gates', 'gate'],
      properName: false,
      article: 'the',
    }));

    const mainPath = world.createEntity('Main Path', EntityType.ROOM);
    mainPath.add(new RoomTrait({ exits: {}, isDark: false }));
    mainPath.add(new IdentityTrait({
      name: 'Main Path',
      description:
        'A wide gravel path winds through the heart of the zoo. Colorful ' +
        'direction signs point every which way. A park bench sits beside ' +
        'the path, and neat flower beds add splashes of color. To the ' +
        'east, a white picket fence surrounds the petting zoo. To the ' +
        'west, a tall mesh enclosure rises above the treetops — the ' +
        'aviary. The entrance gates are back to the north.',
      aliases: ['path', 'main path', 'gravel path'],
      properName: false,
      article: 'the',
    }));

    const pettingZoo = world.createEntity('Petting Zoo', EntityType.ROOM);
    pettingZoo.add(new RoomTrait({ exits: {}, isDark: false }));
    pettingZoo.add(new IdentityTrait({
      name: 'Petting Zoo',
      description:
        'A cheerful open-air enclosure filled with friendly animals. ' +
        'Pygmy goats trot around nibbling at visitors\' shoelaces, while ' +
        'a pair of fluffy rabbits hop lazily near a hay bale. A feed ' +
        'dispenser is mounted on a post near the entrance. The main ' +
        'path is back to the west.',
      aliases: ['petting zoo', 'petting area', 'pen'],
      properName: false,
      article: 'the',
    }));

    const aviary = world.createEntity('Aviary', EntityType.ROOM);
    aviary.add(new RoomTrait({ exits: {}, isDark: false }));
    aviary.add(new IdentityTrait({
      name: 'Aviary',
      description:
        'You step inside a soaring mesh dome that stretches high above ' +
        'the treetops. Brilliantly colored parrots chatter from rope ' +
        'perches, and a toucan eyes you curiously from a branch ' +
        'overhead. A small waterfall splashes into a stone basin where ' +
        'finches bathe. The exit back to the main path is to the east.',
      aliases: ['aviary', 'bird house', 'bird cage', 'dome'],
      properName: false,
      article: 'the',
    }));


    // ========================================================================
    // EXITS — same as V2
    // ========================================================================

    const entranceRoom = entrance.get(RoomTrait)!;
    const mainPathRoom = mainPath.get(RoomTrait)!;
    const pettingZooRoom = pettingZoo.get(RoomTrait)!;
    const aviaryRoom = aviary.get(RoomTrait)!;

    entranceRoom.exits = {
      [Direction.SOUTH]: { destination: mainPath.id },
    };
    mainPathRoom.exits = {
      [Direction.NORTH]: { destination: entrance.id },
      [Direction.EAST]:  { destination: pettingZoo.id },
      [Direction.WEST]:  { destination: aviary.id },
    };
    pettingZooRoom.exits = {
      [Direction.WEST]: { destination: mainPath.id },
    };
    aviaryRoom.exits = {
      [Direction.EAST]: { destination: mainPath.id },
    };


    // ========================================================================
    // SCENERY — same as V3 (abbreviated), see v03.ts for detailed comments
    // ========================================================================

    // Zoo Entrance scenery
    const sign = world.createEntity('welcome sign', EntityType.SCENERY);
    sign.add(new IdentityTrait({ name: 'welcome sign', description: 'A brightly painted wooden sign reads: "WELCOME TO WILLOWBROOK FAMILY ZOO."', aliases: ['sign', 'welcome sign'], properName: false, article: 'a' }));
    world.moveEntity(sign.id, entrance.id);

    const booth = world.createEntity('ticket booth', EntityType.SCENERY);
    booth.add(new IdentityTrait({ name: 'ticket booth', description: 'A small wooden booth with a "Self-Guided Tours" sign.', aliases: ['booth', 'ticket booth'], properName: false, article: 'a' }));
    world.moveEntity(booth.id, entrance.id);

    const ironFence = world.createEntity('iron fence', EntityType.SCENERY);
    ironFence.add(new IdentityTrait({ name: 'iron fence', description: 'A tall wrought-iron fence with animal silhouettes.', aliases: ['fence', 'iron fence', 'railing'], properName: false, article: 'an' }));
    world.moveEntity(ironFence.id, entrance.id);

    // Main Path scenery
    const directionSigns = world.createEntity('direction signs', EntityType.SCENERY);
    directionSigns.add(new IdentityTrait({ name: 'direction signs', grammaticalNumber: 'plural', description: 'Arrow signs: PETTING ZOO (east), AVIARY (west), EXIT (north).', aliases: ['signs', 'direction signs', 'arrow signs'], properName: false, article: 'some' }));
    world.moveEntity(directionSigns.id, mainPath.id);

    const flowerBeds = world.createEntity('flower beds', EntityType.SCENERY);
    flowerBeds.add(new IdentityTrait({ name: 'flower beds', grammaticalNumber: 'plural', description: 'Tidy beds of marigolds and petunias.', aliases: ['flowers', 'flower beds'], properName: false, article: 'some' }));
    world.moveEntity(flowerBeds.id, mainPath.id);

    // Petting Zoo scenery
    const goats = world.createEntity('pygmy goats', EntityType.SCENERY);
    goats.add(new IdentityTrait({ name: 'pygmy goats', grammaticalNumber: 'plural', description: 'Three pygmy goats hoping you have food.', aliases: ['goats', 'pygmy goats', 'goat'], properName: false, article: 'some' }));
    world.moveEntity(goats.id, pettingZoo.id);

    const hayBale = world.createEntity('hay bale', EntityType.SCENERY);
    hayBale.add(new IdentityTrait({ name: 'hay bale', description: 'A large round bale of golden hay.', aliases: ['hay', 'hay bale', 'bale'], properName: false, article: 'a' }));
    world.moveEntity(hayBale.id, pettingZoo.id);

    const rabbits = world.createEntity('rabbits', EntityType.SCENERY);
    rabbits.add(new IdentityTrait({ name: 'rabbits', grammaticalNumber: 'plural', description: 'A pair of Holland Lop rabbits with floppy ears.', aliases: ['rabbits', 'rabbit', 'bunnies'], properName: false, article: 'some' }));
    world.moveEntity(rabbits.id, pettingZoo.id);

    // Aviary scenery
    const toucan = world.createEntity('toucan', EntityType.SCENERY);
    toucan.add(new IdentityTrait({ name: 'toucan', description: 'A Toco toucan with an enormous orange-and-black bill.', aliases: ['toucan', 'toco toucan'], properName: false, article: 'a' }));
    world.moveEntity(toucan.id, aviary.id);

    const parrots = world.createEntity('parrots', EntityType.SCENERY);
    parrots.add(new IdentityTrait({ name: 'parrots', grammaticalNumber: 'plural', description: 'A raucous flock of scarlet macaws and grey African parrots.', aliases: ['parrots', 'parrot', 'macaws', 'birds'], properName: false, article: 'some' }));
    world.moveEntity(parrots.id, aviary.id);

    const waterfall = world.createEntity('waterfall', EntityType.SCENERY);
    waterfall.add(new IdentityTrait({ name: 'waterfall', description: 'A gentle artificial waterfall cascading into a stone basin.', aliases: ['waterfall', 'water', 'basin'], properName: false, article: 'a' }));
    world.moveEntity(waterfall.id, aviary.id);

    const perches = world.createEntity('rope perches', EntityType.SCENERY);
    perches.add(new IdentityTrait({ name: 'rope perches', grammaticalNumber: 'plural', description: 'Thick sisal ropes strung between wooden posts — both furniture and snacks for the parrots.', aliases: ['perches', 'rope perches', 'ropes', 'rope'], properName: false, article: 'some' }));
    world.moveEntity(perches.id, aviary.id);


    // ========================================================================
    // PORTABLE OBJECTS — same as V4, see v04.ts for detailed comments
    // ========================================================================

    const zooMap = world.createEntity('zoo map', EntityType.ITEM);
    zooMap.add(new IdentityTrait({
      name: 'zoo map',
      description:
        'A colorful folding map of the Willowbrook Family Zoo. Someone ' +
        'has drawn a heart around the petting zoo in crayon.',
      aliases: ['map', 'zoo map', 'folding map'],
      properName: false,
      article: 'a',
    }));
    world.moveEntity(zooMap.id, entrance.id);

    const animalFeed = world.createEntity('bag of animal feed', EntityType.ITEM);
    animalFeed.add(new IdentityTrait({
      name: 'bag of animal feed',
      description:
        'A small brown paper bag filled with dried corn and pellets.',
      aliases: ['feed', 'animal feed', 'bag of feed', 'bag', 'corn'],
      properName: false,
      article: 'a',
    }));
    world.moveEntity(animalFeed.id, pettingZoo.id);

    const penny = world.createEntity('souvenir penny', EntityType.ITEM);
    penny.add(new IdentityTrait({
      name: 'souvenir penny',
      description:
        'A shiny copper penny that could fit in a souvenir press machine.',
      aliases: ['penny', 'souvenir penny', 'coin', 'copper penny'],
      properName: false,
      article: 'a',
    }));
    world.moveEntity(penny.id, mainPath.id);


    // ========================================================================
    // CONTAINERS & SUPPORTERS — NEW IN V5
    // ========================================================================
    //
    // So far we've seen ContainerTrait on the player (for inventory).
    // But containers aren't just for players — ANY entity can hold things.
    //
    // There are two kinds of "things that hold other things":
    //
    // 1. CONTAINERS — things go INSIDE them ("put map in backpack")
    //    - Uses ContainerTrait
    //    - EntityType.CONTAINER (or ITEM with ContainerTrait)
    //    - Examples: bags, boxes, drawers, lockers, pockets
    //
    // 2. SUPPORTERS — things go ON TOP of them ("put penny on bench")
    //    - Uses SupporterTrait
    //    - EntityType.SUPPORTER (or any type with SupporterTrait)
    //    - Examples: tables, shelves, benches, counters
    //
    // Both can be:
    //    - Portable: player can carry them (and their contents!)
    //    - Fixed: scenery that stays put (add SceneryTrait)
    //
    // Both have capacity limits to prevent infinite storage.


    // --- Backpack (portable container at the Zoo Entrance) ---
    //
    // This is a PORTABLE CONTAINER: the player can pick it up, carry it
    // around, and put things inside it. When the player takes the backpack,
    // anything already inside comes along for the ride.

    const backpack = world.createEntity('backpack', EntityType.CONTAINER);

    backpack.add(new IdentityTrait({
      name: 'backpack',
      description:
        'A small red canvas backpack with a cartoon monkey patch on the ' +
        'front pocket. It looks like someone left it behind. There\'s a ' +
        'tag inside that reads "Property of Willowbrook Zoo — Loaner."',
      aliases: ['backpack', 'bag', 'rucksack', 'pack', 'red backpack'],
      properName: false,
      article: 'a',
    }));

    // ContainerTrait makes this entity able to hold other entities INSIDE it.
    // capacity.maxItems limits how many things fit.
    // "put map in backpack" will work because of this trait.
    backpack.add(new ContainerTrait({
      capacity: { maxItems: 5 },  // Can hold up to 5 items
    }));

    // NO SceneryTrait — so the player can pick it up and carry it!
    world.moveEntity(backpack.id, entrance.id);


    // --- Feed Dispenser (scenery container at the Petting Zoo) ---
    //
    // This is a FIXED CONTAINER: it holds things inside, but the player
    // can't pick up the dispenser itself. They CAN take things out of it
    // and put things into it.

    const dispenser = world.createEntity('feed dispenser', EntityType.CONTAINER);

    dispenser.add(new IdentityTrait({
      name: 'feed dispenser',
      description:
        'A coin-operated feed dispenser mounted on a wooden post. It\'s ' +
        'a clear plastic globe filled with animal feed pellets, with a ' +
        'crank handle on the side. A sign reads "FREE — Just Turn!"',
      aliases: ['dispenser', 'feed dispenser', 'machine', 'globe'],
      properName: false,
      article: 'a',
    }));

    dispenser.add(new ContainerTrait({
      capacity: { maxItems: 3 },
    }));

    // SceneryTrait makes it fixed — player can't take the whole dispenser.
    // But they CAN interact with its contents (take things out, put things in).
    dispenser.add(new SceneryTrait());

    world.moveEntity(dispenser.id, pettingZoo.id);


    // --- Park Bench (supporter on the Main Path) ---
    //
    // A SUPPORTER is like a container, but things go ON TOP instead of inside.
    // "put penny on bench" places the penny on the bench's surface.
    // "look on bench" shows what's sitting on it.
    //
    // Supporters use SupporterTrait instead of ContainerTrait.
    // The parser handles "on" vs "in" automatically based on which trait
    // the target entity has.

    const parkBench = world.createEntity('park bench', EntityType.SUPPORTER);

    parkBench.add(new IdentityTrait({
      name: 'park bench',
      description:
        'A sturdy park bench painted forest green. A small brass plaque ' +
        'reads: "In memory of Mr. Whiskers, the world\'s friendliest ' +
        'capybara, 2019–2024."',
      aliases: ['bench', 'park bench', 'benches', 'seat'],
      properName: false,
      article: 'a',
    }));

    // SupporterTrait — things go ON this entity, not IN it.
    // capacity.maxItems limits how many things can sit on the surface.
    parkBench.add(new SupporterTrait({
      capacity: { maxItems: 3 },
    }));

    // SceneryTrait makes the bench itself immovable.
    // But objects ON the bench can still be taken.
    parkBench.add(new SceneryTrait());

    world.moveEntity(parkBench.id, mainPath.id);


    // ========================================================================
    // PLAYER STARTING LOCATION
    // ========================================================================

    const player = world.getPlayer();
    if (player) {
      world.moveEntity(player.id, entrance.id);
    }
  }
}


// ============================================================================
// EXPORTS
// ============================================================================

export const story: Story = new FamilyZooStory();
export default story;

Chapter 7: Openable Things, Locked Doors & Keys: Gating the Way Through

Book source: docs/book/v2.0.0/parts/part-2/07-openable-locked-doors-and-keys.md · 7 step(s)

OpenableTrait

step 1 typescript
lunchbox.add(new OpenableTrait({
  isOpen: false,          // starts closed
  canClose: true,         // the player can close it again
  revealsContents: true,  // opening announces what's inside
}));

Stocking a container that starts closed

step 2 typescript
lunchbox.get(OpenableTrait)!.isOpen = true;   // temporarily open
// place the item inside
world.moveEntity(juice.id, lunchbox.id);
lunchbox.get(OpenableTrait)!.isOpen = false;   // close it again

LockableTrait

step 3 typescript
staffGate.add(new LockableTrait({
  isLocked: true,      // starts locked
  keyId: keycard.id,   // THIS key unlocks THIS lock
}));

DoorTrait and the exit `via` property

step 4 typescript
staffGate.add(new DoorTrait({
  room1: mainPath.id,      // one side
  room2: supplyRoom.id,    // the other side
  bidirectional: true,     // passable both ways
}));
step 5 typescript
mainPath.get(RoomTrait)!.exits = {
  [Direction.SOUTH]: {
    destination: supplyRoom.id,
    via: staffGate.id,          // must pass through this entity
  },
};

Wiring it all together

step 6 typescript
import {
  OpenableTrait, LockableTrait, DoorTrait,
} from '@sharpee/world-model';
step 7 typescript
// A new room, behind the gate.
const supplyRoom = world.createEntity(
  'Supply Room',
  EntityType.ROOM,
);
supplyRoom.add(new RoomTrait({ exits: {}, isDark: false }));
supplyRoom.add(new IdentityTrait({
  name: 'Supply Room',
  description:
    'A cluttered storage room behind the staff gate. Metal ' +
    'shelves line the walls, stacked with feed sacks, coiled ' +
    'hoses, and cleaning supplies.',
  aliases: ['supply room', 'storage room', 'store room'],
  article: 'the',
}));

// The metal shelves: scenery, so "examine shelves" has something
// to find.
const shelves = world.createEntity(
  'metal shelves',
  EntityType.SCENERY,
);
shelves.add(new IdentityTrait({
  name: 'metal shelves',
  description:
    'Industrial steel shelving stacked with feed sacks and ' +
    'supplies.',
  aliases: ['shelves', 'metal shelves', 'shelf', 'shelving'],
}));
world.moveEntity(shelves.id, supplyRoom.id);

// The key: an ordinary item, placed at the entrance for the
// player to find.
const keycard = world.createEntity(
  'staff keycard',
  EntityType.ITEM,
);
keycard.add(new IdentityTrait({
  name: 'staff keycard',
  description:
    'A white plastic keycard reading "WILLOWBROOK ZOO / STAFF ' +
    'ONLY," with a faded photo of a smiling zookeeper on the ' +
    'back.',
  aliases: [
    'keycard', 'key card', 'card', 'key', 'staff keycard',
  ],
  article: 'a',
}));
world.moveEntity(keycard.id, entrance.id);

// The gate: type DOOR, wearing all five traits, placed on the
// Main Path.
const staffGate = world.createEntity(
  'staff gate',
  EntityType.DOOR,
);
staffGate.add(new IdentityTrait({
  name: 'staff gate',
  description:
    'A sturdy metal gate marked STAFF ONLY, with a card reader ' +
    'beside it.',
  aliases: ['gate', 'staff gate', 'metal gate', 'staff door'],
  article: 'a',
}));
staffGate.add(new DoorTrait({
  room1: mainPath.id,
  room2: supplyRoom.id,
  bidirectional: true,
}));
staffGate.add(new OpenableTrait({ isOpen: false }));
staffGate.add(new LockableTrait({
  isLocked: true,
  keyId: keycard.id,
}));
staffGate.add(new SceneryTrait());
world.moveEntity(staffGate.id, mainPath.id);

// Wire the passage on BOTH sides, each routing through the gate
// with `via`. This replaces the Main Path exits from Chapter 4,
// adding the south passage.
mainPath.get(RoomTrait)!.exits = {
  [Direction.NORTH]: { destination: entrance.id },
  [Direction.EAST]:  { destination: pettingZoo.id },
  [Direction.WEST]:  { destination: aviary.id },
  [Direction.SOUTH]: {
    destination: supplyRoom.id,
    via: staffGate.id,
  },
};
supplyRoom.get(RoomTrait)!.exits = {
  [Direction.NORTH]: {
    destination: mainPath.id,
    via: staffGate.id,
  },
};

Combined runnable file

The complete, runnable story at the end of this chapter (tested source: tutorials/familyzoo/v2.0.0/src/ch07-doors-keys.ts).

runnable ch07-doors-keys.ts typescript
/**
 * Family Zoo Tutorial — Version 7: Locked Doors & Keys
 *
 * NEW IN THIS VERSION:
 *   - LockableTrait — entities that can be locked and unlocked
 *   - A locked staff gate blocking access to the supply room
 *   - A keycard that unlocks the gate
 *   - The "unlock X with Y" action (built into stdlib)
 *   - Door entities with DoorTrait connecting rooms
 *   - Exit `via` property — gates/doors that block passage
 *
 * WHAT YOU'LL LEARN:
 *   - LockableTrait adds lock/unlock state to any entity
 *   - LockableTrait.keyId wires a specific key to a specific lock
 *   - Exits can reference a door/gate via the `via` property
 *   - The going action checks if the `via` entity is open before allowing passage
 *   - Combining OpenableTrait + LockableTrait = locked door
 *
 * TRY IT:
 *   > take keycard           (pick up the keycard at the entrance)
 *   > south                  (go to Main Path)
 *   > south                  (blocked — the staff gate is locked!)
 *   > unlock gate with keycard  (unlock it)
 *   > open gate              (now open the gate)
 *   > south                  (walk through to the Supply Room)
 *
 * BUILD & RUN:
 *   ./build.sh -s familyzoo
 *   node dist/cli/sharpee.js --story tutorials/familyzoo --play
 */

// ============================================================================
// IMPORTS
// ============================================================================

import { Story, StoryConfig } from '@sharpee/engine';
import {
  WorldModel,
  IFEntity,
  EntityType,
  Direction,
} from '@sharpee/world-model';
import {
  IdentityTrait,
  ActorTrait,
  ContainerTrait,
  SupporterTrait,
  RoomTrait,
  SceneryTrait,
  OpenableTrait,
  LockableTrait,     // ← NEW: makes entities lockable/unlockable
  DoorTrait,         // ← NEW: marks an entity as a door between rooms
} from '@sharpee/world-model';


// ============================================================================
// STORY CONFIGURATION
// ============================================================================

const config: StoryConfig = {
  id: 'familyzoo',
  title: 'Family Zoo',
  author: 'Sharpee Tutorial',
  version: '0.7.0',
  description: 'A small family zoo — learn Sharpee one concept at a time.',
};


// ============================================================================
// THE STORY CLASS
// ============================================================================

class FamilyZooStory implements Story {
  config = config;

  // createPlayer — same as V1, see v01.ts
  createPlayer(world: WorldModel): IFEntity {
    const player = world.createEntity('yourself', EntityType.ACTOR);
    player.add(new IdentityTrait({ name: 'yourself', description: 'Just an ordinary visitor to the zoo.', aliases: ['self', 'myself', 'me'], properName: true, article: '' }));
    player.add(new ActorTrait({ isPlayer: true }));
    player.add(new ContainerTrait({ capacity: { maxItems: 10 } }));
    return player;
  }

  initializeWorld(world: WorldModel): void {

    // ========================================================================
    // ROOMS
    // ========================================================================

    const entrance = world.createEntity('Zoo Entrance', EntityType.ROOM);
    entrance.add(new RoomTrait({ exits: {}, isDark: false }));
    entrance.add(new IdentityTrait({ name: 'Zoo Entrance', description: 'You stand before the wrought-iron gates of the Willowbrook Family Zoo. A cheerful welcome sign arches over the entrance, and a small ticket booth sits to one side. A sturdy iron fence runs along either side of the gates. The main path leads south into the zoo grounds.', aliases: ['entrance', 'gates', 'gate'], properName: false, article: 'the' }));

    const mainPath = world.createEntity('Main Path', EntityType.ROOM);
    mainPath.add(new RoomTrait({ exits: {}, isDark: false }));
    mainPath.add(new IdentityTrait({ name: 'Main Path', description: 'A wide gravel path winds through the heart of the zoo. Colorful direction signs point every which way. A park bench sits beside the path. To the east, the petting zoo. To the west, the aviary. A staff gate blocks the path to the south. The entrance is back to the north.', aliases: ['path', 'main path', 'gravel path'], properName: false, article: 'the' }));

    const pettingZoo = world.createEntity('Petting Zoo', EntityType.ROOM);
    pettingZoo.add(new RoomTrait({ exits: {}, isDark: false }));
    pettingZoo.add(new IdentityTrait({ name: 'Petting Zoo', description: 'A cheerful open-air enclosure filled with friendly animals. Pygmy goats trot around nibbling at visitors\' shoelaces, while a pair of fluffy rabbits hop lazily near a hay bale. A feed dispenser is mounted on a post near the entrance. The main path is back to the west.', aliases: ['petting zoo', 'petting area', 'pen'], properName: false, article: 'the' }));

    const aviary = world.createEntity('Aviary', EntityType.ROOM);
    aviary.add(new RoomTrait({ exits: {}, isDark: false }));
    aviary.add(new IdentityTrait({ name: 'Aviary', description: 'You step inside a soaring mesh dome. Brilliantly colored parrots chatter from rope perches, and a toucan eyes you curiously from a branch overhead. A small waterfall splashes into a stone basin. The exit back to the main path is to the east.', aliases: ['aviary', 'bird house', 'dome'], properName: false, article: 'the' }));

    // --- Supply Room (NEW — behind the locked staff gate) ---
    const supplyRoom = world.createEntity('Supply Room', EntityType.ROOM);
    supplyRoom.add(new RoomTrait({ exits: {}, isDark: false }));
    supplyRoom.add(new IdentityTrait({
      name: 'Supply Room',
      description:
        'A cluttered storage room behind the staff gate. Metal shelves ' +
        'line the walls, stacked with bags of feed, cleaning supplies, ' +
        'and spare parts for the exhibits. A cork board on the wall is ' +
        'covered with staff schedules and animal care notes. The staff ' +
        'gate leads back north to the main path.',
      aliases: ['supply room', 'storage room', 'staff room', 'storeroom'],
      properName: false,
      article: 'the',
    }));


    // ========================================================================
    // THE STAFF GATE — NEW IN V7
    // ========================================================================
    //
    // A locked gate is an entity that combines three traits:
    //   1. OpenableTrait — it can be opened and closed
    //   2. LockableTrait — it can be locked and unlocked
    //   3. DoorTrait — it connects two rooms
    //
    // The KEY INSIGHT is how these traits interact:
    //   - LockableTrait PREVENTS opening while locked
    //   - The player must "unlock gate with keycard" FIRST
    //   - Then "open gate" to actually open it
    //   - Only then can they walk through
    //
    // LockableTrait has a `keyId` property that specifies which entity
    // acts as the key. The engine checks: does the player have an entity
    // whose ID matches `keyId`? If yes, the unlock succeeds.

    // First, create the keycard (the key) so we have its ID
    const keycard = world.createEntity('staff keycard', EntityType.ITEM);
    keycard.add(new IdentityTrait({
      name: 'staff keycard',
      description:
        'A white plastic keycard with "WILLOWBROOK ZOO — STAFF ONLY" ' +
        'printed in blue. There\'s a faded photo of a smiling zookeeper ' +
        'on the back.',
      aliases: ['keycard', 'key card', 'card', 'key', 'staff keycard'],
      properName: false,
      article: 'a',
    }));
    world.moveEntity(keycard.id, entrance.id);  // Found near the entrance

    // Now create the staff gate
    const staffGate = world.createEntity('staff gate', EntityType.DOOR);

    staffGate.add(new IdentityTrait({
      name: 'staff gate',
      description:
        'A sturdy metal gate with a "STAFF ONLY — AUTHORIZED PERSONNEL" ' +
        'sign. There\'s a card reader mounted on the post beside it.',
      aliases: ['gate', 'staff gate', 'metal gate', 'staff door'],
      properName: false,
      article: 'a',
    }));

    // DoorTrait marks this entity as a connection between two rooms.
    // room1 and room2 are the IDs of the rooms it connects.
    staffGate.add(new DoorTrait({
      room1: mainPath.id,
      room2: supplyRoom.id,
      bidirectional: true,   // Can go through in both directions
    }));

    // OpenableTrait — the gate can be opened and closed.
    // Starts closed (isOpen: false).
    staffGate.add(new OpenableTrait({
      isOpen: false,
    }));

    // LockableTrait — the gate is locked!
    // keyId points to the keycard entity. Only that specific entity
    // can unlock this gate. The player must have it in their inventory.
    staffGate.add(new LockableTrait({
      isLocked: true,        // Starts locked
      keyId: keycard.id,     // THIS is how you wire a key to a lock
    }));

    // SceneryTrait — can't take the gate itself
    staffGate.add(new SceneryTrait());

    // Place the gate in the main path (it's visible from there)
    world.moveEntity(staffGate.id, mainPath.id);


    // ========================================================================
    // EXITS — including the locked gate passage
    // ========================================================================
    //
    // The `via` property on an exit tells the going action: "before letting
    // the player through, check this door/gate entity." If the `via` entity
    // is closed (or locked), the player is blocked.
    //
    // This is how you create doors that the player must open to pass through.

    entrance.get(RoomTrait)!.exits = {
      [Direction.SOUTH]: { destination: mainPath.id },
    };

    mainPath.get(RoomTrait)!.exits = {
      [Direction.NORTH]: { destination: entrance.id },
      [Direction.EAST]:  { destination: pettingZoo.id },
      [Direction.WEST]:  { destination: aviary.id },
      // The via property references the staff gate entity.
      // The going action will check: is staffGate open?
      //   - If closed/locked → "The staff gate is closed."
      //   - If open → player walks through to the supply room
      [Direction.SOUTH]: { destination: supplyRoom.id, via: staffGate.id },
    };

    pettingZoo.get(RoomTrait)!.exits = {
      [Direction.WEST]: { destination: mainPath.id },
    };

    aviary.get(RoomTrait)!.exits = {
      [Direction.EAST]: { destination: mainPath.id },
    };

    // Return exit from supply room also goes via the gate
    supplyRoom.get(RoomTrait)!.exits = {
      [Direction.NORTH]: { destination: mainPath.id, via: staffGate.id },
    };


    // ========================================================================
    // SCENERY — abbreviated, see v03.ts
    // ========================================================================

    const sign = world.createEntity('welcome sign', EntityType.SCENERY);
    sign.add(new IdentityTrait({ name: 'welcome sign', description: 'A brightly painted wooden sign reads: "WELCOME TO WILLOWBROOK FAMILY ZOO."', aliases: ['sign', 'welcome sign'], properName: false, article: 'a' }));
    world.moveEntity(sign.id, entrance.id);

    const booth = world.createEntity('ticket booth', EntityType.SCENERY);
    booth.add(new IdentityTrait({ name: 'ticket booth', description: 'A small wooden booth with a "Self-Guided Tours" sign.', aliases: ['booth', 'ticket booth'], properName: false, article: 'a' }));
    world.moveEntity(booth.id, entrance.id);

    const ironFence = world.createEntity('iron fence', EntityType.SCENERY);
    ironFence.add(new IdentityTrait({ name: 'iron fence', description: 'A tall wrought-iron fence with animal silhouettes.', aliases: ['fence', 'iron fence', 'railing'], properName: false, article: 'an' }));
    world.moveEntity(ironFence.id, entrance.id);

    const directionSigns = world.createEntity('direction signs', EntityType.SCENERY);
    directionSigns.add(new IdentityTrait({ name: 'direction signs', grammaticalNumber: 'plural', description: 'Arrow signs: PETTING ZOO (east), AVIARY (west), EXIT (north).', aliases: ['signs', 'direction signs', 'arrow signs'], properName: false, article: 'some' }));
    world.moveEntity(directionSigns.id, mainPath.id);

    const flowerBeds = world.createEntity('flower beds', EntityType.SCENERY);
    flowerBeds.add(new IdentityTrait({ name: 'flower beds', grammaticalNumber: 'plural', description: 'Tidy beds of marigolds and petunias.', aliases: ['flowers', 'flower beds'], properName: false, article: 'some' }));
    world.moveEntity(flowerBeds.id, mainPath.id);

    const goats = world.createEntity('pygmy goats', EntityType.SCENERY);
    goats.add(new IdentityTrait({ name: 'pygmy goats', grammaticalNumber: 'plural', description: 'Three pygmy goats hoping you have food.', aliases: ['goats', 'pygmy goats', 'goat'], properName: false, article: 'some' }));
    world.moveEntity(goats.id, pettingZoo.id);

    const hayBale = world.createEntity('hay bale', EntityType.SCENERY);
    hayBale.add(new IdentityTrait({ name: 'hay bale', description: 'A large round bale of golden hay.', aliases: ['hay', 'hay bale', 'bale'], properName: false, article: 'a' }));
    world.moveEntity(hayBale.id, pettingZoo.id);

    const rabbits = world.createEntity('rabbits', EntityType.SCENERY);
    rabbits.add(new IdentityTrait({ name: 'rabbits', grammaticalNumber: 'plural', description: 'A pair of Holland Lop rabbits with floppy ears.', aliases: ['rabbits', 'rabbit', 'bunnies'], properName: false, article: 'some' }));
    world.moveEntity(rabbits.id, pettingZoo.id);

    const toucan = world.createEntity('toucan', EntityType.SCENERY);
    toucan.add(new IdentityTrait({ name: 'toucan', description: 'A Toco toucan with an enormous orange-and-black bill.', aliases: ['toucan', 'toco toucan'], properName: false, article: 'a' }));
    world.moveEntity(toucan.id, aviary.id);

    const parrots = world.createEntity('parrots', EntityType.SCENERY);
    parrots.add(new IdentityTrait({ name: 'parrots', grammaticalNumber: 'plural', description: 'A raucous flock of scarlet macaws and grey African parrots.', aliases: ['parrots', 'parrot', 'macaws', 'birds'], properName: false, article: 'some' }));
    world.moveEntity(parrots.id, aviary.id);

    const waterfall = world.createEntity('waterfall', EntityType.SCENERY);
    waterfall.add(new IdentityTrait({ name: 'waterfall', description: 'A gentle artificial waterfall cascading into a stone basin.', aliases: ['waterfall', 'water', 'basin'], properName: false, article: 'a' }));
    world.moveEntity(waterfall.id, aviary.id);

    const perches = world.createEntity('rope perches', EntityType.SCENERY);
    perches.add(new IdentityTrait({ name: 'rope perches', grammaticalNumber: 'plural', description: 'Thick sisal ropes strung between wooden posts — both furniture and snacks for the parrots.', aliases: ['perches', 'rope perches', 'ropes', 'rope'], properName: false, article: 'some' }));
    world.moveEntity(perches.id, aviary.id);

    // Supply Room scenery
    const shelves = world.createEntity('metal shelves', EntityType.SCENERY);
    shelves.add(new IdentityTrait({
      name: 'metal shelves', grammaticalNumber: 'plural',
      description:
        'Industrial metal shelving units stacked with supplies: bags of ' +
        'feed, bottles of disinfectant, spare lightbulbs, and rolls of ' +
        'chicken wire. Everything is neatly labeled.',
      aliases: ['shelves', 'metal shelves', 'shelf', 'shelving'],
      properName: false,
      article: 'some',
    }));
    world.moveEntity(shelves.id, supplyRoom.id);

    const corkBoard = world.createEntity('cork board', EntityType.SCENERY);
    corkBoard.add(new IdentityTrait({
      name: 'cork board',
      description:
        'A cork board covered with pinned notices: feeding schedules, ' +
        'veterinary check dates, and a photo of the staff holiday party. ' +
        'Someone has written "DON\'T FORGET: nocturnal exhibit lights ' +
        'need new batteries!" in red marker.',
      aliases: ['cork board', 'board', 'notices', 'bulletin board'],
      properName: false,
      article: 'a',
    }));
    world.moveEntity(corkBoard.id, supplyRoom.id);


    // ========================================================================
    // PORTABLE OBJECTS
    // ========================================================================

    const zooMap = world.createEntity('zoo map', EntityType.ITEM);
    zooMap.add(new IdentityTrait({ name: 'zoo map', description: 'A colorful folding map of the Willowbrook Family Zoo.', aliases: ['map', 'zoo map', 'folding map'], properName: false, article: 'a' }));
    world.moveEntity(zooMap.id, entrance.id);

    const animalFeed = world.createEntity('bag of animal feed', EntityType.ITEM);
    animalFeed.add(new IdentityTrait({ name: 'bag of animal feed', description: 'A small brown paper bag filled with dried corn and pellets.', aliases: ['feed', 'animal feed', 'bag of feed', 'corn'], properName: false, article: 'a' }));
    world.moveEntity(animalFeed.id, pettingZoo.id);

    const penny = world.createEntity('souvenir penny', EntityType.ITEM);
    penny.add(new IdentityTrait({ name: 'souvenir penny', description: 'A shiny copper penny that could fit in a souvenir press machine.', aliases: ['penny', 'souvenir penny', 'coin'], properName: false, article: 'a' }));
    world.moveEntity(penny.id, mainPath.id);


    // ========================================================================
    // CONTAINERS — from V5/V6
    // ========================================================================

    const backpack = world.createEntity('backpack', EntityType.CONTAINER);
    backpack.add(new IdentityTrait({ name: 'backpack', description: 'A small red canvas backpack with a cartoon monkey patch.', aliases: ['backpack', 'rucksack', 'pack'], properName: false, article: 'a' }));
    backpack.add(new ContainerTrait({ capacity: { maxItems: 5 } }));
    world.moveEntity(backpack.id, entrance.id);

    const parkBench = world.createEntity('park bench', EntityType.SUPPORTER);
    parkBench.add(new IdentityTrait({ name: 'park bench', description: 'A sturdy park bench painted forest green. Plaque: "In memory of Mr. Whiskers."', aliases: ['bench', 'park bench', 'benches', 'seat'], properName: false, article: 'a' }));
    parkBench.add(new SupporterTrait({ capacity: { maxItems: 3 } }));
    parkBench.add(new SceneryTrait());
    world.moveEntity(parkBench.id, mainPath.id);

    const lunchbox = world.createEntity('lunchbox', EntityType.CONTAINER);
    lunchbox.add(new IdentityTrait({ name: 'lunchbox', description: 'A dented metal lunchbox decorated with cartoon zoo animals.', aliases: ['lunchbox', 'lunch box', 'box'], properName: false, article: 'a' }));
    lunchbox.add(new ContainerTrait({ capacity: { maxItems: 3 } }));
    lunchbox.add(new OpenableTrait({ isOpen: false }));
    world.moveEntity(lunchbox.id, mainPath.id);

    lunchbox.get(OpenableTrait)!.isOpen = true;
    const juice = world.createEntity('juice box', EntityType.ITEM);
    juice.add(new IdentityTrait({ name: 'juice box', description: 'A small juice box with a picture of a happy elephant.', aliases: ['juice', 'juice box', 'drink'], properName: false, article: 'a' }));
    world.moveEntity(juice.id, lunchbox.id);
    lunchbox.get(OpenableTrait)!.isOpen = false;

    const dispenser = world.createEntity('feed dispenser', EntityType.CONTAINER);
    dispenser.add(new IdentityTrait({ name: 'feed dispenser', description: 'A coin-operated feed dispenser mounted on a wooden post. Sign: "FREE — Just Turn!"', aliases: ['dispenser', 'feed dispenser', 'machine'], properName: false, article: 'a' }));
    dispenser.add(new ContainerTrait({ capacity: { maxItems: 3 } }));
    dispenser.add(new OpenableTrait({ isOpen: false }));
    dispenser.add(new SceneryTrait());
    world.moveEntity(dispenser.id, pettingZoo.id);


    // ========================================================================
    // PLAYER STARTING LOCATION
    // ========================================================================

    const player = world.getPlayer();
    if (player) {
      world.moveEntity(player.id, entrance.id);
    }
  }
}


// ============================================================================
// EXPORTS
// ============================================================================

export const story: Story = new FamilyZooStory();
export default story;

Chapter 8: Light & Dark: What the Player Can See

Book source: docs/book/v2.0.0/parts/part-2/08-light-and-dark.md · 9 step(s)

Dark rooms

step 1 typescript
nocturnalExhibit.add(new RoomTrait({
  exits: {},
  isDark: true,     // this room is pitch black
}));

LightSourceTrait

step 2 typescript
flashlight.add(new LightSourceTrait({
  brightness: 8,    // how powerful, 1–10
  isLit: false,     // starts unlit
}));

SwitchableTrait

step 3 typescript
flashlight.add(new SwitchableTrait({
  isOn: false,      // starts off
}));

The flashlight pattern

step 4 typescript
const flashlight = world.createEntity(
  'flashlight',
  EntityType.ITEM,
);
flashlight.add(new IdentityTrait({
  name: 'flashlight',
  description:
    'A heavy rubberized flashlight with a bright halogen bulb.',
  aliases: ['flashlight', 'torch', 'light'],
}));
flashlight.add(new SwitchableTrait({ isOn: false }));
flashlight.add(new LightSourceTrait({
  brightness: 8,
  isLit: false,
}));
world.moveEntity(flashlight.id, supplyRoom.id);

Other light-source patterns

step 5 typescript
gem.add(new LightSourceTrait({ isLit: true, brightness: 5 }));
step 6 typescript
candle.add(new LightSourceTrait({
  isLit: false,
  brightness: 3,
  fuelRemaining: 50,        // burns for 50 turns
  fuelConsumptionRate: 1,   // one fuel per turn
}));
step 7 typescript
lantern.add(new LightSourceTrait({ brightness: 10 }));

Wiring it into the zoo

step 8 typescript
import {
  LightSourceTrait, SwitchableTrait,
} from '@sharpee/world-model';
step 9 typescript
const nocturnalExhibit = world.createEntity(
  'Nocturnal Animals Exhibit',
  EntityType.ROOM,
);
nocturnalExhibit.add(new RoomTrait({ exits: {}, isDark: true }));
nocturnalExhibit.add(new IdentityTrait({
  name: 'Nocturnal Animals Exhibit',
  description:
    'A hushed, cavern-like hall lit by faint blue moonlight ' +
    'panels. Sugar gliders leap between branches, wide-eyed ' +
    'bush babies cling to a rope, and an enormous barn owl ' +
    'perches motionless on a stump.',
  aliases: [
    'nocturnal exhibit', 'nocturnal animals',
    'dark exhibit', 'exhibit',
  ],
  article: 'the',
}));

// Connect it south of the Supply Room, with the way back north.
// This adds the south passage to the Supply Room exits from
// Chapter 7.
supplyRoom.get(RoomTrait)!.exits = {
  [Direction.NORTH]: {
    destination: mainPath.id,
    via: staffGate.id,
  },
  [Direction.SOUTH]: { destination: nocturnalExhibit.id },
};
nocturnalExhibit.get(RoomTrait)!.exits = {
  [Direction.NORTH]: { destination: supplyRoom.id },
};

// The animals: scenery, examinable only once the room is lit.
const sugarGliders = world.createEntity(
  'sugar gliders',
  EntityType.SCENERY,
);
sugarGliders.add(new IdentityTrait({
  name: 'sugar gliders',
  description:
    'A family of tiny sugar gliders with enormous dark eyes, ' +
    'gliding between branches.',
  aliases: ['sugar gliders', 'gliders', 'sugar glider'],
  article: 'some',
}));
world.moveEntity(sugarGliders.id, nocturnalExhibit.id);

const bushBabies = world.createEntity(
  'bush babies',
  EntityType.SCENERY,
);
bushBabies.add(new IdentityTrait({
  name: 'bush babies',
  description:
    'Two bush babies with impossibly large round eyes, ' +
    'clinging to a rope.',
  aliases: ['bush babies', 'bush baby', 'galagos'],
  article: 'some',
}));
world.moveEntity(bushBabies.id, nocturnalExhibit.id);

const barnOwl = world.createEntity(
  'barn owl',
  EntityType.SCENERY,
);
barnOwl.add(new IdentityTrait({
  name: 'barn owl',
  description:
    'An enormous barn owl with a heart-shaped white face, ' +
    'watching you without blinking.',
  aliases: ['barn owl', 'owl'],
  article: 'a',
}));
world.moveEntity(barnOwl.id, nocturnalExhibit.id);

Combined runnable file

The complete, runnable story at the end of this chapter (tested source: tutorials/familyzoo/v2.0.0/src/ch08-light-dark.ts).

runnable ch08-light-dark.ts typescript
/**
 * Family Zoo Tutorial — Version 8: Light & Dark
 *
 * NEW IN THIS VERSION:
 *   - Dark rooms (isDark: true on RoomTrait)
 *   - LightSourceTrait — entities that provide illumination
 *   - SwitchableTrait — entities with on/off state (the flashlight)
 *   - A Nocturnal Animals exhibit that's dark without a light source
 *   - A flashlight in the supply room
 *
 * WHAT YOU'LL LEARN:
 *   - Setting isDark: true on a room makes it pitch black
 *   - Dark rooms block examine, take, and most other actions
 *   - LightSourceTrait + SwitchableTrait = a switchable light source
 *   - Carrying a lit light source illuminates dark rooms
 *
 * TRY IT:
 *   > take keycard                (at entrance)
 *   > south                      (Main Path)
 *   > unlock gate with keycard   (unlock the staff gate)
 *   > open gate                  (open it)
 *   > south                      (Supply Room)
 *   > take flashlight            (get the flashlight)
 *   > north                      (back to Main Path)
 *   > south; south               (through to Nocturnal Exhibit — dark!)
 *   > switch on flashlight       (let there be light!)
 *   > look                       (now you can see)
 *
 * BUILD & RUN:
 *   ./build.sh -s familyzoo
 *   node dist/cli/sharpee.js --story tutorials/familyzoo --play
 */

// ============================================================================
// IMPORTS
// ============================================================================

import { Story, StoryConfig } from '@sharpee/engine';
import {
  WorldModel,
  IFEntity,
  EntityType,
  Direction,
} from '@sharpee/world-model';
import {
  IdentityTrait,
  ActorTrait,
  ContainerTrait,
  SupporterTrait,
  RoomTrait,
  SceneryTrait,
  OpenableTrait,
  LockableTrait,
  DoorTrait,
  SwitchableTrait,     // ← NEW: on/off state for devices
  LightSourceTrait,    // ← NEW: provides illumination in dark rooms
} from '@sharpee/world-model';


// ============================================================================
// STORY CONFIGURATION
// ============================================================================

const config: StoryConfig = {
  id: 'familyzoo',
  title: 'Family Zoo',
  author: 'Sharpee Tutorial',
  version: '0.8.0',
  description: 'A small family zoo — learn Sharpee one concept at a time.',
};


// ============================================================================
// THE STORY CLASS
// ============================================================================

class FamilyZooStory implements Story {
  config = config;

  // createPlayer — same as V1, see v01.ts
  createPlayer(world: WorldModel): IFEntity {
    const player = world.createEntity('yourself', EntityType.ACTOR);
    player.add(new IdentityTrait({ name: 'yourself', description: 'Just an ordinary visitor to the zoo.', aliases: ['self', 'myself', 'me'], properName: true, article: '' }));
    player.add(new ActorTrait({ isPlayer: true }));
    player.add(new ContainerTrait({ capacity: { maxItems: 10 } }));
    return player;
  }

  initializeWorld(world: WorldModel): void {

    // ========================================================================
    // ROOMS
    // ========================================================================

    const entrance = world.createEntity('Zoo Entrance', EntityType.ROOM);
    entrance.add(new RoomTrait({ exits: {}, isDark: false }));
    entrance.add(new IdentityTrait({ name: 'Zoo Entrance', description: 'You stand before the wrought-iron gates of the Willowbrook Family Zoo. A cheerful welcome sign arches over the entrance, and a small ticket booth sits to one side. A sturdy iron fence runs along either side of the gates. The main path leads south into the zoo grounds.', aliases: ['entrance', 'gates', 'gate'], properName: false, article: 'the' }));

    const mainPath = world.createEntity('Main Path', EntityType.ROOM);
    mainPath.add(new RoomTrait({ exits: {}, isDark: false }));
    mainPath.add(new IdentityTrait({ name: 'Main Path', description: 'A wide gravel path winds through the heart of the zoo. Colorful direction signs point every which way. A park bench sits beside the path. To the east, the petting zoo. To the west, the aviary. A staff gate blocks the path to the south. The entrance is back to the north.', aliases: ['path', 'main path', 'gravel path'], properName: false, article: 'the' }));

    const pettingZoo = world.createEntity('Petting Zoo', EntityType.ROOM);
    pettingZoo.add(new RoomTrait({ exits: {}, isDark: false }));
    pettingZoo.add(new IdentityTrait({ name: 'Petting Zoo', description: 'A cheerful open-air enclosure filled with friendly animals. Pygmy goats trot around nibbling at visitors\' shoelaces, while a pair of fluffy rabbits hop lazily near a hay bale. A feed dispenser is mounted on a post near the entrance. The main path is back to the west.', aliases: ['petting zoo', 'petting area', 'pen'], properName: false, article: 'the' }));

    const aviary = world.createEntity('Aviary', EntityType.ROOM);
    aviary.add(new RoomTrait({ exits: {}, isDark: false }));
    aviary.add(new IdentityTrait({ name: 'Aviary', description: 'You step inside a soaring mesh dome. Brilliantly colored parrots chatter from rope perches, and a toucan eyes you curiously from a branch overhead. A small waterfall splashes into a stone basin. The exit back to the main path is to the east.', aliases: ['aviary', 'bird house', 'dome'], properName: false, article: 'the' }));

    const supplyRoom = world.createEntity('Supply Room', EntityType.ROOM);
    supplyRoom.add(new RoomTrait({ exits: {}, isDark: false }));
    supplyRoom.add(new IdentityTrait({ name: 'Supply Room', description: 'A cluttered storage room behind the staff gate. Metal shelves line the walls, stacked with bags of feed, cleaning supplies, and spare parts for the exhibits. A cork board on the wall is covered with staff schedules. A heavy door to the south leads to the nocturnal exhibit. The staff gate leads back north.', aliases: ['supply room', 'storage room', 'storeroom'], properName: false, article: 'the' }));

    // --- Nocturnal Animals Exhibit (NEW — dark room!) ---
    const nocturnalExhibit = world.createEntity('Nocturnal Animals Exhibit', EntityType.ROOM);

    nocturnalExhibit.add(new RoomTrait({
      exits: {},
      // isDark: true — THIS IS THE KEY!
      // When isDark is true, the player can't see anything in the room.
      // They'll get a "It is pitch dark" message instead of the room
      // description. They can't examine things, take things, or interact
      // with anything in the room.
      //
      // The ONLY way to see in a dark room is to carry a lit light source
      // (an entity with LightSourceTrait that has isLit: true).
      isDark: true,
    }));

    nocturnalExhibit.add(new IdentityTrait({
      name: 'Nocturnal Animals Exhibit',
      description:
        'A cool, dimly lit cavern designed to simulate nighttime. Glass ' +
        'enclosures line both walls, each with a soft red light inside. ' +
        'You can see a family of sugar gliders leaping between branches, ' +
        'a pair of wide-eyed bush babies clinging to a rope, and an ' +
        'enormous barn owl perched motionless on a fake tree stump. ' +
        'The exit leads back north to the supply room.',
      aliases: ['nocturnal exhibit', 'nocturnal animals', 'dark exhibit', 'exhibit'],
      properName: false,
      article: 'the',
    }));


    // ========================================================================
    // THE STAFF GATE — same as V7, see v07.ts for detailed comments
    // ========================================================================

    const keycard = world.createEntity('staff keycard', EntityType.ITEM);
    keycard.add(new IdentityTrait({ name: 'staff keycard', description: 'A white plastic keycard with "WILLOWBROOK ZOO — STAFF ONLY" printed in blue.', aliases: ['keycard', 'key card', 'card', 'key', 'staff keycard'], properName: false, article: 'a' }));
    world.moveEntity(keycard.id, entrance.id);

    const staffGate = world.createEntity('staff gate', EntityType.DOOR);
    staffGate.add(new IdentityTrait({ name: 'staff gate', description: 'A sturdy metal gate with a "STAFF ONLY" sign. There\'s a card reader beside it.', aliases: ['gate', 'staff gate', 'metal gate', 'staff door'], properName: false, article: 'a' }));
    staffGate.add(new DoorTrait({ room1: mainPath.id, room2: supplyRoom.id, bidirectional: true }));
    staffGate.add(new OpenableTrait({ isOpen: false }));
    staffGate.add(new LockableTrait({ isLocked: true, keyId: keycard.id }));
    staffGate.add(new SceneryTrait());
    world.moveEntity(staffGate.id, mainPath.id);


    // ========================================================================
    // EXITS
    // ========================================================================

    entrance.get(RoomTrait)!.exits = { [Direction.SOUTH]: { destination: mainPath.id } };
    mainPath.get(RoomTrait)!.exits = {
      [Direction.NORTH]: { destination: entrance.id },
      [Direction.EAST]:  { destination: pettingZoo.id },
      [Direction.WEST]:  { destination: aviary.id },
      [Direction.SOUTH]: { destination: supplyRoom.id, via: staffGate.id },
    };
    pettingZoo.get(RoomTrait)!.exits = { [Direction.WEST]: { destination: mainPath.id } };
    aviary.get(RoomTrait)!.exits = { [Direction.EAST]: { destination: mainPath.id } };
    supplyRoom.get(RoomTrait)!.exits = {
      [Direction.NORTH]: { destination: mainPath.id, via: staffGate.id },
      [Direction.SOUTH]: { destination: nocturnalExhibit.id },
    };
    nocturnalExhibit.get(RoomTrait)!.exits = {
      [Direction.NORTH]: { destination: supplyRoom.id },
    };


    // ========================================================================
    // SCENERY — abbreviated, see v03.ts
    // ========================================================================

    const sign = world.createEntity('welcome sign', EntityType.SCENERY);
    sign.add(new IdentityTrait({ name: 'welcome sign', description: 'A brightly painted wooden sign reads: "WELCOME TO WILLOWBROOK FAMILY ZOO."', aliases: ['sign', 'welcome sign'], properName: false, article: 'a' }));
    world.moveEntity(sign.id, entrance.id);

    const booth = world.createEntity('ticket booth', EntityType.SCENERY);
    booth.add(new IdentityTrait({ name: 'ticket booth', description: 'A small wooden booth with a "Self-Guided Tours" sign.', aliases: ['booth', 'ticket booth'], properName: false, article: 'a' }));
    world.moveEntity(booth.id, entrance.id);

    const ironFence = world.createEntity('iron fence', EntityType.SCENERY);
    ironFence.add(new IdentityTrait({ name: 'iron fence', description: 'A tall wrought-iron fence with animal silhouettes.', aliases: ['fence', 'iron fence', 'railing'], properName: false, article: 'an' }));
    world.moveEntity(ironFence.id, entrance.id);

    const directionSigns = world.createEntity('direction signs', EntityType.SCENERY);
    directionSigns.add(new IdentityTrait({ name: 'direction signs', grammaticalNumber: 'plural', description: 'Arrow signs: PETTING ZOO (east), AVIARY (west), EXIT (north).', aliases: ['signs', 'direction signs', 'arrow signs'], properName: false, article: 'some' }));
    world.moveEntity(directionSigns.id, mainPath.id);

    const flowerBeds = world.createEntity('flower beds', EntityType.SCENERY);
    flowerBeds.add(new IdentityTrait({ name: 'flower beds', grammaticalNumber: 'plural', description: 'Tidy beds of marigolds and petunias.', aliases: ['flowers', 'flower beds'], properName: false, article: 'some' }));
    world.moveEntity(flowerBeds.id, mainPath.id);

    const goats = world.createEntity('pygmy goats', EntityType.SCENERY);
    goats.add(new IdentityTrait({ name: 'pygmy goats', grammaticalNumber: 'plural', description: 'Three pygmy goats hoping you have food.', aliases: ['goats', 'pygmy goats', 'goat'], properName: false, article: 'some' }));
    world.moveEntity(goats.id, pettingZoo.id);

    const hayBale = world.createEntity('hay bale', EntityType.SCENERY);
    hayBale.add(new IdentityTrait({ name: 'hay bale', description: 'A large round bale of golden hay.', aliases: ['hay', 'hay bale', 'bale'], properName: false, article: 'a' }));
    world.moveEntity(hayBale.id, pettingZoo.id);

    const rabbits = world.createEntity('rabbits', EntityType.SCENERY);
    rabbits.add(new IdentityTrait({ name: 'rabbits', grammaticalNumber: 'plural', description: 'A pair of Holland Lop rabbits with floppy ears.', aliases: ['rabbits', 'rabbit', 'bunnies'], properName: false, article: 'some' }));
    world.moveEntity(rabbits.id, pettingZoo.id);

    const toucan = world.createEntity('toucan', EntityType.SCENERY);
    toucan.add(new IdentityTrait({ name: 'toucan', description: 'A Toco toucan with an enormous orange-and-black bill.', aliases: ['toucan', 'toco toucan'], properName: false, article: 'a' }));
    world.moveEntity(toucan.id, aviary.id);

    const parrots = world.createEntity('parrots', EntityType.SCENERY);
    parrots.add(new IdentityTrait({ name: 'parrots', grammaticalNumber: 'plural', description: 'A raucous flock of scarlet macaws and grey African parrots.', aliases: ['parrots', 'parrot', 'macaws', 'birds'], properName: false, article: 'some' }));
    world.moveEntity(parrots.id, aviary.id);

    const waterfall = world.createEntity('waterfall', EntityType.SCENERY);
    waterfall.add(new IdentityTrait({ name: 'waterfall', description: 'A gentle artificial waterfall cascading into a stone basin.', aliases: ['waterfall', 'water', 'basin'], properName: false, article: 'a' }));
    world.moveEntity(waterfall.id, aviary.id);

    const perches = world.createEntity('rope perches', EntityType.SCENERY);
    perches.add(new IdentityTrait({ name: 'rope perches', grammaticalNumber: 'plural', description: 'Thick sisal ropes strung between wooden posts — both furniture and snacks for the parrots.', aliases: ['perches', 'rope perches', 'ropes', 'rope'], properName: false, article: 'some' }));
    world.moveEntity(perches.id, aviary.id);

    const shelves = world.createEntity('metal shelves', EntityType.SCENERY);
    shelves.add(new IdentityTrait({ name: 'metal shelves', grammaticalNumber: 'plural', description: 'Industrial metal shelving units stacked with supplies.', aliases: ['shelves', 'metal shelves', 'shelf'], properName: false, article: 'some' }));
    world.moveEntity(shelves.id, supplyRoom.id);

    const corkBoard = world.createEntity('cork board', EntityType.SCENERY);
    corkBoard.add(new IdentityTrait({ name: 'cork board', description: 'A cork board with staff schedules. A note in red marker: "DON\'T FORGET: nocturnal exhibit lights need new batteries!"', aliases: ['cork board', 'board', 'notices'], properName: false, article: 'a' }));
    world.moveEntity(corkBoard.id, supplyRoom.id);

    // Nocturnal exhibit scenery (only visible when the room is lit)
    const sugarGliders = world.createEntity('sugar gliders', EntityType.SCENERY);
    sugarGliders.add(new IdentityTrait({
      name: 'sugar gliders', grammaticalNumber: 'plural',
      description:
        'A family of tiny sugar gliders with enormous dark eyes. They ' +
        'leap from branch to branch with their patagium spread wide, ' +
        'gliding silently through the red-lit enclosure.',
      aliases: ['sugar gliders', 'gliders', 'sugar glider'],
      properName: false,
      article: 'some',
    }));
    world.moveEntity(sugarGliders.id, nocturnalExhibit.id);

    const bushBabies = world.createEntity('bush babies', EntityType.SCENERY);
    bushBabies.add(new IdentityTrait({
      name: 'bush babies', grammaticalNumber: 'plural',
      description:
        'Two bush babies with impossibly large round eyes, clinging to ' +
        'a rope with tiny hands. They freeze and stare at you with an ' +
        'expression of perpetual surprise.',
      aliases: ['bush babies', 'bush baby', 'galagos'],
      properName: false,
      article: 'some',
    }));
    world.moveEntity(bushBabies.id, nocturnalExhibit.id);

    const barnOwl = world.createEntity('barn owl', EntityType.SCENERY);
    barnOwl.add(new IdentityTrait({
      name: 'barn owl',
      description:
        'An enormous barn owl with a heart-shaped white face, perched ' +
        'motionless on a fake tree stump. It swivels its head to follow ' +
        'your movement with unsettling precision.',
      aliases: ['barn owl', 'owl'],
      properName: false,
      article: 'a',
    }));
    world.moveEntity(barnOwl.id, nocturnalExhibit.id);


    // ========================================================================
    // THE FLASHLIGHT — NEW IN V8
    // ========================================================================
    //
    // A flashlight is an entity with THREE traits:
    //   1. SwitchableTrait — it can be switched on and off
    //   2. LightSourceTrait — it provides illumination when lit
    //   3. IdentityTrait — it has a name and description (as always)
    //
    // HOW LIGHT WORKS IN SHARPEE:
    //
    // When the player enters a dark room (isDark: true), the engine checks:
    //   "Is the player carrying any entity with LightSourceTrait where
    //    isLit is true?"
    //
    //   - If yes: the room is illuminated and the player can see normally
    //   - If no:  "It is pitch dark. You can't see a thing."
    //
    // For a flashlight, isLit is tied to SwitchableTrait's isOn state.
    // When the player types "switch on flashlight":
    //   1. SwitchableTrait.isOn becomes true
    //   2. LightSourceTrait.isLit becomes true (they're linked)
    //   3. The room is now illuminated
    //
    // OTHER LIGHT SOURCES:
    //   - A candle might have LightSourceTrait with fuelRemaining (burns out)
    //   - A glowing gem might always be lit (isLit: true, no SwitchableTrait)
    //   - A lantern might have both SwitchableTrait and fuel mechanics

    const flashlight = world.createEntity('flashlight', EntityType.ITEM);

    flashlight.add(new IdentityTrait({
      name: 'flashlight',
      description:
        'A heavy-duty yellow flashlight with "PROPERTY OF WILLOWBROOK ' +
        'ZOO" stenciled on the side. It looks well-used but functional.',
      aliases: ['flashlight', 'torch', 'light', 'lamp'],
      properName: false,
      article: 'a',
    }));

    // SwitchableTrait — the flashlight can be switched on and off.
    // isOn: false means it starts switched off.
    flashlight.add(new SwitchableTrait({
      isOn: false,
    }));

    // LightSourceTrait — when lit, it illuminates dark rooms.
    // brightness controls how powerful the light is (1-10).
    // isLit starts as false — it will become true when switched on.
    flashlight.add(new LightSourceTrait({
      brightness: 8,
      isLit: false,
    }));

    // Place the flashlight in the supply room.
    // The player needs to unlock the staff gate (V7) to reach it.
    world.moveEntity(flashlight.id, supplyRoom.id);


    // ========================================================================
    // OTHER PORTABLE OBJECTS
    // ========================================================================

    const zooMap = world.createEntity('zoo map', EntityType.ITEM);
    zooMap.add(new IdentityTrait({ name: 'zoo map', description: 'A colorful folding map of the Willowbrook Family Zoo.', aliases: ['map', 'zoo map', 'folding map'], properName: false, article: 'a' }));
    world.moveEntity(zooMap.id, entrance.id);

    const animalFeed = world.createEntity('bag of animal feed', EntityType.ITEM);
    animalFeed.add(new IdentityTrait({ name: 'bag of animal feed', description: 'A small brown paper bag filled with dried corn and pellets.', aliases: ['feed', 'animal feed', 'bag of feed', 'corn'], properName: false, article: 'a' }));
    world.moveEntity(animalFeed.id, pettingZoo.id);

    const penny = world.createEntity('souvenir penny', EntityType.ITEM);
    penny.add(new IdentityTrait({ name: 'souvenir penny', description: 'A shiny copper penny that could fit in a souvenir press machine.', aliases: ['penny', 'souvenir penny', 'coin'], properName: false, article: 'a' }));
    world.moveEntity(penny.id, mainPath.id);


    // ========================================================================
    // CONTAINERS — from V5/V6
    // ========================================================================

    const backpack = world.createEntity('backpack', EntityType.CONTAINER);
    backpack.add(new IdentityTrait({ name: 'backpack', description: 'A small red canvas backpack with a cartoon monkey patch.', aliases: ['backpack', 'rucksack', 'pack'], properName: false, article: 'a' }));
    backpack.add(new ContainerTrait({ capacity: { maxItems: 5 } }));
    world.moveEntity(backpack.id, entrance.id);

    const parkBench = world.createEntity('park bench', EntityType.SUPPORTER);
    parkBench.add(new IdentityTrait({ name: 'park bench', description: 'A sturdy park bench painted forest green. Plaque: "In memory of Mr. Whiskers."', aliases: ['bench', 'park bench', 'benches', 'seat'], properName: false, article: 'a' }));
    parkBench.add(new SupporterTrait({ capacity: { maxItems: 3 } }));
    parkBench.add(new SceneryTrait());
    world.moveEntity(parkBench.id, mainPath.id);

    const lunchbox = world.createEntity('lunchbox', EntityType.CONTAINER);
    lunchbox.add(new IdentityTrait({ name: 'lunchbox', description: 'A dented metal lunchbox decorated with cartoon zoo animals.', aliases: ['lunchbox', 'lunch box', 'box'], properName: false, article: 'a' }));
    lunchbox.add(new ContainerTrait({ capacity: { maxItems: 3 } }));
    lunchbox.add(new OpenableTrait({ isOpen: false }));
    world.moveEntity(lunchbox.id, mainPath.id);

    lunchbox.get(OpenableTrait)!.isOpen = true;
    const juice = world.createEntity('juice box', EntityType.ITEM);
    juice.add(new IdentityTrait({ name: 'juice box', description: 'A small juice box with a picture of a happy elephant.', aliases: ['juice', 'juice box', 'drink'], properName: false, article: 'a' }));
    world.moveEntity(juice.id, lunchbox.id);
    lunchbox.get(OpenableTrait)!.isOpen = false;

    const dispenser = world.createEntity('feed dispenser', EntityType.CONTAINER);
    dispenser.add(new IdentityTrait({ name: 'feed dispenser', description: 'A coin-operated feed dispenser mounted on a wooden post. Sign: "FREE — Just Turn!"', aliases: ['dispenser', 'feed dispenser', 'machine'], properName: false, article: 'a' }));
    dispenser.add(new ContainerTrait({ capacity: { maxItems: 3 } }));
    dispenser.add(new OpenableTrait({ isOpen: false }));
    dispenser.add(new SceneryTrait());
    world.moveEntity(dispenser.id, pettingZoo.id);


    // ========================================================================
    // PLAYER STARTING LOCATION
    // ========================================================================

    const player = world.getPlayer();
    if (player) {
      world.moveEntity(player.id, entrance.id);
    }
  }
}


// ============================================================================
// EXPORTS
// ============================================================================

export const story: Story = new FamilyZooStory();
export default story;

Chapter 9: The Map & Regions: Grouping Rooms

Book source: docs/book/v2.0.0/parts/part-2/09-the-map-and-regions.md · 4 step(s)

Regions: grouping rooms

step 1 typescript
world.createRegion('reg-public', {
  name: 'Public Zoo',
});

world.createRegion('reg-staff', {
  name: 'Staff Area',
  ambientSmell: 'disinfectant and animal feed',
});
step 2 typescript
world.assignRoom(entrance.id, 'reg-public');
world.assignRoom(mainPath.id, 'reg-public');
world.assignRoom(pettingZoo.id, 'reg-public');
world.assignRoom(aviary.id, 'reg-public');

world.assignRoom(supplyRoom.id, 'reg-staff');
world.assignRoom(nocturnalExhibit.id, 'reg-staff');

Crossing the boundary

step 3 typescript
world.registerEventHandler(
  'if.event.region_entered',
  (event, world) => {
    const data = event.data as { regionId?: string } | undefined;
    if (data?.regionId === 'reg-staff') {
      // The visitor just slipped into the staff area: flavor, a
      // warning, a scoring hook, whatever the moment calls for.
    }
  },
);

Nesting and querying

step 4 typescript
world.createRegion('reg-underground', {
  name: 'The Underground',
  defaultDark: true,
});
world.createRegion('reg-mine', {
  name: 'Coal Mine',
  parentRegionId: 'reg-underground',
});
// a room in reg-mine answers true for reg-underground as well

Combined runnable file

No story-code change this chapter; the runnable file is unchanged from the previous checkpoint (tutorials/familyzoo/v2.0.0/src/ch08-light-dark.ts).

Chapter 10: The Standard Actions: The Four-Phase Model

Book source: docs/book/v2.0.0/parts/part-3/10-standard-actions-and-the-four-phase-model.md · 1 step(s)

One shape for every action

step 1 typescript
const someAction: Action = {
  id: 'if.action.taking',

  validate(context): ValidationResult { /* can this happen? */ },
  execute(context): void           { /* change the world */ },
  report(context): ISemanticEvent[] {
    /* record what happened */
  },
  blocked(context, result): ISemanticEvent[] {
    /* explain the refusal */
  },
};

Combined runnable file

No story-code change this chapter; the runnable file is unchanged from the previous checkpoint (tutorials/familyzoo/v2.0.0/src/ch08-light-dark.ts).

Chapter 11: Scope & Visibility: What the Player Can Reach

Book source: docs/book/v2.0.0/parts/part-3/11-scope-and-visibility.md · 0 step(s)

No code snippets in this chapter.

Combined runnable file

No story-code change this chapter; the runnable file is unchanged from the previous checkpoint (tutorials/familyzoo/v2.0.0/src/ch08-light-dark.ts).

Chapter 12: Readable Objects & Switchable Devices: Things That Carry State

Book source: docs/book/v2.0.0/parts/part-3/12-readable-objects-and-switchable-devices.md · 5 step(s)

Readable Objects & Switchable Devices: Things That Carry State

step 1 typescript
import { ReadableTrait } from '@sharpee/world-model';

ReadableTrait: what an object says

step 2 typescript
plaque.add(new ReadableTrait({
  text:
    'PYGMY GOATS: These Nigerian Dwarf goats are gentle, ' +
    'curious, and always hungry.',
}));

Readable scenery: the info plaque

step 3 typescript
const pettingPlaque = world
  .createEntity('info plaque', EntityType.SCENERY);
pettingPlaque.add(new IdentityTrait({
  name: 'info plaque',
  description:
    'A brass plaque mounted on a wooden post near the petting ' +
    'zoo gate. It has text etched into the metal.',
  aliases: ['plaque', 'info plaque', 'brass plaque'],
  properName: false,
  article: 'an',
}));
pettingPlaque.add(new ReadableTrait({
  text:
    'PYGMY GOATS: These Nigerian Dwarf goats are gentle, ' +
    'curious, and always hungry. They can eat up to 3% of ' +
    'their body weight daily. Please use only zoo-approved ' +
    'feed from the dispensers.\n\nHOLLAND LOP RABBITS: Known ' +
    'for their floppy ears and calm temperament. Our pair, ' +
    'Biscuit and Marmalade, were born right here at ' +
    'Willowbrook in 2023.',
}));
world.moveEntity(pettingPlaque.id, pettingZoo.id);

Readable items: the brochure

step 4 typescript
const brochure = world
  .createEntity('zoo brochure', EntityType.ITEM);
brochure.add(new IdentityTrait({
  name: 'zoo brochure',
  description:
    'A glossy tri-fold brochure with "WILLOWBROOK FAMILY ' +
    'ZOO" on the cover in cheerful yellow letters.',
  aliases: ['brochure', 'zoo brochure', 'pamphlet', 'leaflet'],
  properName: false,
  article: 'a',
}));
brochure.add(new ReadableTrait({
  text:
    'WILLOWBROOK FAMILY ZOO: Your Guide\n\n' +
    'EXHIBITS:\n' +
    '  Petting Zoo ............ East from Main Path\n' +
    '  Aviary ................. West from Main Path\n' +
    '  Nocturnal Animals ...... Staff Area (ask a keeper!)\n\n' +
    '"Where every visit is a wild adventure!"',
}));
world.moveEntity(brochure.id, entrance.id);

SwitchableTrait: a device with on/off state

step 5 typescript
const radio = world.createEntity('radio', EntityType.ITEM);
radio.add(new IdentityTrait({
  name: 'radio',
  description:
    'A battered portable radio held together with duct ' +
    'tape. A faded sticker on the side reads "ZOO FM | All ' +
    'Animals, All The Time."',
  aliases: ['radio', 'portable radio'],
  properName: false,
  article: 'a',
}));
radio.add(new SwitchableTrait({ isOn: false }));   // starts off
// bolted to the shelf
radio.add(new SceneryTrait());
world.moveEntity(radio.id, supplyRoom.id);

Combined runnable file

The complete, runnable story at the end of this chapter (tested source: tutorials/familyzoo/v2.0.0/src/ch12-readables.ts).

runnable ch12-readables.ts typescript
/**
 * Family Zoo Tutorial — Version 10: Switchable Devices
 *
 * NEW IN THIS VERSION:
 *   - SwitchableTrait in detail — on/off state for any device
 *   - A radio in the supply room (switch on/off produces messages)
 *   - SwitchableTrait standalone (without LightSourceTrait)
 *   - How SwitchableTrait differs from OpenableTrait
 *
 * WHAT YOU'LL LEARN:
 *   - SwitchableTrait gives any entity an on/off toggle
 *   - "switch on" / "switch off" / "turn on" / "turn off" all work
 *   - SwitchableTrait can be used alone (radio) or with LightSourceTrait (flashlight)
 *   - SwitchableTrait vs OpenableTrait — switches vs lids
 *
 * TRY IT:
 *   > take keycard / south / unlock gate with keycard / open gate / south
 *   > examine radio          (see the radio)
 *   > switch on radio        (turn it on)
 *   > switch off radio       (turn it off)
 *
 * BUILD & RUN:
 *   ./build.sh -s familyzoo
 *   node dist/cli/sharpee.js --story tutorials/familyzoo --play
 */

// ============================================================================
// IMPORTS
// ============================================================================

import { Story, StoryConfig } from '@sharpee/engine';
import {
  WorldModel,
  IFEntity,
  EntityType,
  Direction,
} from '@sharpee/world-model';
import {
  IdentityTrait,
  ActorTrait,
  ContainerTrait,
  SupporterTrait,
  RoomTrait,
  SceneryTrait,
  OpenableTrait,
  LockableTrait,
  DoorTrait,
  SwitchableTrait,     // ← This version's focus (detailed comments below)
  LightSourceTrait,
  ReadableTrait,
} from '@sharpee/world-model';


// ============================================================================
// STORY CONFIGURATION
// ============================================================================

const config: StoryConfig = {
  id: 'familyzoo',
  title: 'Family Zoo',
  author: 'Sharpee Tutorial',
  version: '0.10.0',
  description: 'A small family zoo — learn Sharpee one concept at a time.',
};


// ============================================================================
// THE STORY CLASS
// ============================================================================

class FamilyZooStory implements Story {
  config = config;

  createPlayer(world: WorldModel): IFEntity {
    const player = world.createEntity('yourself', EntityType.ACTOR);
    player.add(new IdentityTrait({ name: 'yourself', description: 'Just an ordinary visitor to the zoo.', aliases: ['self', 'myself', 'me'], properName: true, article: '' }));
    player.add(new ActorTrait({ isPlayer: true }));
    player.add(new ContainerTrait({ capacity: { maxItems: 10 } }));
    return player;
  }

  initializeWorld(world: WorldModel): void {

    // ========================================================================
    // ROOMS — same as V9
    // ========================================================================

    const entrance = world.createEntity('Zoo Entrance', EntityType.ROOM);
    entrance.add(new RoomTrait({ exits: {}, isDark: false }));
    entrance.add(new IdentityTrait({ name: 'Zoo Entrance', description: 'You stand before the wrought-iron gates of the Willowbrook Family Zoo. A cheerful welcome sign arches over the entrance, and a small ticket booth sits to one side. A sturdy iron fence runs along either side of the gates. The main path leads south into the zoo grounds.', aliases: ['entrance', 'gates', 'gate'], properName: false, article: 'the' }));

    const mainPath = world.createEntity('Main Path', EntityType.ROOM);
    mainPath.add(new RoomTrait({ exits: {}, isDark: false }));
    mainPath.add(new IdentityTrait({ name: 'Main Path', description: 'A wide gravel path winds through the heart of the zoo. Colorful direction signs point every which way. A park bench sits beside the path. To the east, the petting zoo. To the west, the aviary. A staff gate blocks the path to the south. The entrance is back to the north.', aliases: ['path', 'main path', 'gravel path'], properName: false, article: 'the' }));

    const pettingZoo = world.createEntity('Petting Zoo', EntityType.ROOM);
    pettingZoo.add(new RoomTrait({ exits: {}, isDark: false }));
    pettingZoo.add(new IdentityTrait({ name: 'Petting Zoo', description: 'A cheerful open-air enclosure filled with friendly animals. Pygmy goats trot around nibbling at visitors\' shoelaces, while a pair of fluffy rabbits hop lazily near a hay bale. A feed dispenser is mounted on a post. An info plaque is posted by the gate. The main path is back to the west.', aliases: ['petting zoo', 'petting area', 'pen'], properName: false, article: 'the' }));

    const aviary = world.createEntity('Aviary', EntityType.ROOM);
    aviary.add(new RoomTrait({ exits: {}, isDark: false }));
    aviary.add(new IdentityTrait({ name: 'Aviary', description: 'You step inside a soaring mesh dome. Brilliantly colored parrots chatter from rope perches, and a toucan eyes you curiously from a branch overhead. A small waterfall splashes into a stone basin. An info plaque hangs near the entrance. The exit back to the main path is to the east.', aliases: ['aviary', 'bird house', 'dome'], properName: false, article: 'the' }));

    const supplyRoom = world.createEntity('Supply Room', EntityType.ROOM);
    supplyRoom.add(new RoomTrait({ exits: {}, isDark: false }));
    supplyRoom.add(new IdentityTrait({ name: 'Supply Room', description: 'A cluttered storage room behind the staff gate. Metal shelves line the walls. A cork board on the wall is covered with staff schedules. A battered radio sits on one of the shelves. The staff gate leads back north.', aliases: ['supply room', 'storage room', 'storeroom'], properName: false, article: 'the' }));

    const nocturnalExhibit = world.createEntity('Nocturnal Animals Exhibit', EntityType.ROOM);
    nocturnalExhibit.add(new RoomTrait({ exits: {}, isDark: true }));
    nocturnalExhibit.add(new IdentityTrait({ name: 'Nocturnal Animals Exhibit', description: 'A cool, dimly lit cavern designed to simulate nighttime. Glass enclosures line both walls with soft red lights. You can see sugar gliders, bush babies, and a barn owl. A warning sign is posted near the entrance. The exit leads back north to the supply room.', aliases: ['nocturnal exhibit', 'nocturnal animals', 'exhibit'], properName: false, article: 'the' }));


    // ========================================================================
    // STAFF GATE — same as V7
    // ========================================================================

    const keycard = world.createEntity('staff keycard', EntityType.ITEM);
    keycard.add(new IdentityTrait({ name: 'staff keycard', description: 'A white plastic keycard with "WILLOWBROOK ZOO — STAFF ONLY" printed in blue.', aliases: ['keycard', 'key card', 'card', 'key', 'staff keycard'], properName: false, article: 'a' }));
    world.moveEntity(keycard.id, entrance.id);

    const staffGate = world.createEntity('staff gate', EntityType.DOOR);
    staffGate.add(new IdentityTrait({ name: 'staff gate', description: 'A sturdy metal gate with a "STAFF ONLY" sign. There\'s a card reader beside it.', aliases: ['gate', 'staff gate', 'metal gate', 'staff door'], properName: false, article: 'a' }));
    staffGate.add(new DoorTrait({ room1: mainPath.id, room2: supplyRoom.id, bidirectional: true }));
    staffGate.add(new OpenableTrait({ isOpen: false }));
    staffGate.add(new LockableTrait({ isLocked: true, keyId: keycard.id }));
    staffGate.add(new SceneryTrait());
    world.moveEntity(staffGate.id, mainPath.id);


    // ========================================================================
    // EXITS — same as V8
    // ========================================================================

    entrance.get(RoomTrait)!.exits = { [Direction.SOUTH]: { destination: mainPath.id } };
    mainPath.get(RoomTrait)!.exits = { [Direction.NORTH]: { destination: entrance.id }, [Direction.EAST]: { destination: pettingZoo.id }, [Direction.WEST]: { destination: aviary.id }, [Direction.SOUTH]: { destination: supplyRoom.id, via: staffGate.id } };
    pettingZoo.get(RoomTrait)!.exits = { [Direction.WEST]: { destination: mainPath.id } };
    aviary.get(RoomTrait)!.exits = { [Direction.EAST]: { destination: mainPath.id } };
    supplyRoom.get(RoomTrait)!.exits = { [Direction.NORTH]: { destination: mainPath.id, via: staffGate.id }, [Direction.SOUTH]: { destination: nocturnalExhibit.id } };
    nocturnalExhibit.get(RoomTrait)!.exits = { [Direction.NORTH]: { destination: supplyRoom.id } };


    // ========================================================================
    // SCENERY — abbreviated (same as V9)
    // ========================================================================

    const sign = world.createEntity('welcome sign', EntityType.SCENERY);
    sign.add(new IdentityTrait({ name: 'welcome sign', description: 'A brightly painted wooden sign reads: "WELCOME TO WILLOWBROOK FAMILY ZOO."', aliases: ['sign', 'welcome sign'], properName: false, article: 'a' }));
    world.moveEntity(sign.id, entrance.id);

    const booth = world.createEntity('ticket booth', EntityType.SCENERY);
    booth.add(new IdentityTrait({ name: 'ticket booth', description: 'A small wooden booth with a "Self-Guided Tours" sign.', aliases: ['booth', 'ticket booth'], properName: false, article: 'a' }));
    world.moveEntity(booth.id, entrance.id);

    const ironFence = world.createEntity('iron fence', EntityType.SCENERY);
    ironFence.add(new IdentityTrait({ name: 'iron fence', description: 'A tall wrought-iron fence with animal silhouettes.', aliases: ['fence', 'iron fence', 'railing'], properName: false, article: 'an' }));
    world.moveEntity(ironFence.id, entrance.id);

    const directionSigns = world.createEntity('direction signs', EntityType.SCENERY);
    directionSigns.add(new IdentityTrait({ name: 'direction signs', grammaticalNumber: 'plural', description: 'Arrow signs: PETTING ZOO (east), AVIARY (west), EXIT (north).', aliases: ['signs', 'direction signs', 'arrow signs'], properName: false, article: 'some' }));
    world.moveEntity(directionSigns.id, mainPath.id);

    const flowerBeds = world.createEntity('flower beds', EntityType.SCENERY);
    flowerBeds.add(new IdentityTrait({ name: 'flower beds', grammaticalNumber: 'plural', description: 'Tidy beds of marigolds and petunias.', aliases: ['flowers', 'flower beds'], properName: false, article: 'some' }));
    world.moveEntity(flowerBeds.id, mainPath.id);

    const goats = world.createEntity('pygmy goats', EntityType.SCENERY);
    goats.add(new IdentityTrait({ name: 'pygmy goats', grammaticalNumber: 'plural', description: 'Three pygmy goats hoping you have food.', aliases: ['goats', 'pygmy goats', 'goat'], properName: false, article: 'some' }));
    world.moveEntity(goats.id, pettingZoo.id);

    const hayBale = world.createEntity('hay bale', EntityType.SCENERY);
    hayBale.add(new IdentityTrait({ name: 'hay bale', description: 'A large round bale of golden hay.', aliases: ['hay', 'hay bale', 'bale'], properName: false, article: 'a' }));
    world.moveEntity(hayBale.id, pettingZoo.id);

    const rabbits = world.createEntity('rabbits', EntityType.SCENERY);
    rabbits.add(new IdentityTrait({ name: 'rabbits', grammaticalNumber: 'plural', description: 'A pair of Holland Lop rabbits with floppy ears.', aliases: ['rabbits', 'rabbit', 'bunnies'], properName: false, article: 'some' }));
    world.moveEntity(rabbits.id, pettingZoo.id);

    const toucan = world.createEntity('toucan', EntityType.SCENERY);
    toucan.add(new IdentityTrait({ name: 'toucan', description: 'A Toco toucan with an enormous orange-and-black bill.', aliases: ['toucan', 'toco toucan'], properName: false, article: 'a' }));
    world.moveEntity(toucan.id, aviary.id);

    const parrots = world.createEntity('parrots', EntityType.SCENERY);
    parrots.add(new IdentityTrait({ name: 'parrots', grammaticalNumber: 'plural', description: 'A raucous flock of scarlet macaws and grey African parrots.', aliases: ['parrots', 'parrot', 'macaws', 'birds'], properName: false, article: 'some' }));
    world.moveEntity(parrots.id, aviary.id);

    const waterfall = world.createEntity('waterfall', EntityType.SCENERY);
    waterfall.add(new IdentityTrait({ name: 'waterfall', description: 'A gentle artificial waterfall cascading into a stone basin.', aliases: ['waterfall', 'water', 'basin'], properName: false, article: 'a' }));
    world.moveEntity(waterfall.id, aviary.id);

    const perches = world.createEntity('rope perches', EntityType.SCENERY);
    perches.add(new IdentityTrait({ name: 'rope perches', grammaticalNumber: 'plural', description: 'Thick sisal ropes strung between wooden posts — both furniture and snacks for the parrots.', aliases: ['perches', 'rope perches', 'ropes', 'rope'], properName: false, article: 'some' }));
    world.moveEntity(perches.id, aviary.id);

    const shelves = world.createEntity('metal shelves', EntityType.SCENERY);
    shelves.add(new IdentityTrait({ name: 'metal shelves', grammaticalNumber: 'plural', description: 'Industrial metal shelving units stacked with supplies.', aliases: ['shelves', 'metal shelves', 'shelf'], properName: false, article: 'some' }));
    world.moveEntity(shelves.id, supplyRoom.id);

    const corkBoard = world.createEntity('cork board', EntityType.SCENERY);
    corkBoard.add(new IdentityTrait({ name: 'cork board', description: 'A cork board with staff schedules. A note in red marker: "DON\'T FORGET: nocturnal exhibit lights need new batteries!"', aliases: ['cork board', 'board', 'notices'], properName: false, article: 'a' }));
    world.moveEntity(corkBoard.id, supplyRoom.id);

    const sugarGliders = world.createEntity('sugar gliders', EntityType.SCENERY);
    sugarGliders.add(new IdentityTrait({ name: 'sugar gliders', grammaticalNumber: 'plural', description: 'A family of tiny sugar gliders with enormous dark eyes, leaping between branches.', aliases: ['sugar gliders', 'gliders'], properName: false, article: 'some' }));
    world.moveEntity(sugarGliders.id, nocturnalExhibit.id);

    const bushBabies = world.createEntity('bush babies', EntityType.SCENERY);
    bushBabies.add(new IdentityTrait({ name: 'bush babies', grammaticalNumber: 'plural', description: 'Two bush babies with impossibly large round eyes, clinging to a rope with tiny hands.', aliases: ['bush babies', 'galagos'], properName: false, article: 'some' }));
    world.moveEntity(bushBabies.id, nocturnalExhibit.id);

    const barnOwl = world.createEntity('barn owl', EntityType.SCENERY);
    barnOwl.add(new IdentityTrait({ name: 'barn owl', description: 'An enormous barn owl with a heart-shaped white face on a fake tree stump.', aliases: ['barn owl', 'owl'], properName: false, article: 'a' }));
    world.moveEntity(barnOwl.id, nocturnalExhibit.id);


    // ========================================================================
    // READABLE OBJECTS — from V9
    // ========================================================================

    const pettingPlaque = world.createEntity('info plaque', EntityType.SCENERY);
    pettingPlaque.add(new IdentityTrait({ name: 'info plaque', description: 'A brass plaque mounted on a wooden post near the petting zoo gate.', aliases: ['plaque', 'info plaque', 'brass plaque'], properName: false, article: 'an' }));
    pettingPlaque.add(new ReadableTrait({ text: 'PYGMY GOATS — These Nigerian Dwarf goats are gentle, curious, and always hungry.\n\nHOLLAND LOP RABBITS — Known for their floppy ears. Our pair, Biscuit and Marmalade, were born here in 2023.' }));
    world.moveEntity(pettingPlaque.id, pettingZoo.id);

    const aviaryPlaque = world.createEntity('aviary plaque', EntityType.SCENERY);
    aviaryPlaque.add(new IdentityTrait({ name: 'aviary plaque', description: 'A colorful information board near the aviary entrance.', aliases: ['plaque', 'aviary plaque', 'information board'], properName: false, article: 'an' }));
    aviaryPlaque.add(new ReadableTrait({ text: 'WELCOME TO THE AVIARY — Home to over 30 species!\n\nTOCO TOUCAN — Its bill weighs less than a smartphone.\n\nSCARLET MACAW — Can live over 75 years. Our oldest, Captain, is 42.' }));
    world.moveEntity(aviaryPlaque.id, aviary.id);

    const warningSign = world.createEntity('warning sign', EntityType.SCENERY);
    warningSign.add(new IdentityTrait({ name: 'warning sign', description: 'A yellow warning sign near the nocturnal exhibit entrance.', aliases: ['warning', 'warning sign', 'yellow sign'], properName: false, article: 'a' }));
    warningSign.add(new ReadableTrait({ text: 'CAUTION: The Nocturnal Animals Exhibit is kept dark. Please use a flashlight. Do NOT use camera flash. (We don\'t talk about the Great Owl Incident of 2022.)' }));
    world.moveEntity(warningSign.id, supplyRoom.id);

    const brochure = world.createEntity('zoo brochure', EntityType.ITEM);
    brochure.add(new IdentityTrait({ name: 'zoo brochure', description: 'A glossy tri-fold brochure with "WILLOWBROOK FAMILY ZOO" on the cover.', aliases: ['brochure', 'zoo brochure', 'pamphlet', 'leaflet'], properName: false, article: 'a' }));
    brochure.add(new ReadableTrait({ text: 'WILLOWBROOK FAMILY ZOO — Your Guide\n\nEXHIBITS:\n  Petting Zoo — East from Main Path\n  Aviary — West from Main Path\n  Nocturnal Animals — Staff Area\n\n"Where every visit is a wild adventure!"' }));
    world.moveEntity(brochure.id, entrance.id);


    // ========================================================================
    // SWITCHABLE DEVICES — NEW IN V10
    // ========================================================================
    //
    // In V8, we used SwitchableTrait with LightSourceTrait for the flashlight.
    // But SwitchableTrait works on its own too — for any device with an
    // on/off state.
    //
    // SwitchableTrait vs OpenableTrait — they seem similar but serve
    // different purposes:
    //
    //   SwitchableTrait:
    //     - Models DEVICES with on/off state
    //     - Player uses "switch on" / "switch off" / "turn on" / "turn off"
    //     - Examples: flashlights, radios, alarms, machines
    //     - State: isOn (boolean)
    //
    //   OpenableTrait:
    //     - Models PHYSICAL objects with open/closed state
    //     - Player uses "open" / "close"
    //     - Examples: doors, containers, lids, books
    //     - State: isOpen (boolean)
    //
    // You'd never "open" a radio or "switch on" a lunchbox — the verbs
    // naturally map to different kinds of objects.

    // --- Radio (switchable scenery in Supply Room) ---
    //
    // A radio that the player can switch on and off. It's fixed to the
    // shelf (SceneryTrait) so it can't be taken, but it can be toggled.

    const radio = world.createEntity('radio', EntityType.ITEM);

    radio.add(new IdentityTrait({
      name: 'radio',
      description:
        'A battered portable radio held together with duct tape. The ' +
        'antenna is bent at a jaunty angle. A faded sticker on the side ' +
        'reads "ZOO FM — All Animals, All The Time."',
      aliases: ['radio', 'portable radio'],
      properName: false,
      article: 'a',
    }));

    // SwitchableTrait — the radio can be switched on and off.
    // isOn: false means it starts switched off.
    // Unlike the flashlight, the radio has NO LightSourceTrait —
    // switching it on doesn't illuminate anything.
    radio.add(new SwitchableTrait({
      isOn: false,
    }));

    // SceneryTrait — bolted to the shelf, can't take it
    radio.add(new SceneryTrait());

    world.moveEntity(radio.id, supplyRoom.id);


    // ========================================================================
    // PORTABLE OBJECTS
    // ========================================================================

    const zooMap = world.createEntity('zoo map', EntityType.ITEM);
    zooMap.add(new IdentityTrait({ name: 'zoo map', description: 'A colorful folding map of the Willowbrook Family Zoo.', aliases: ['map', 'zoo map', 'folding map'], properName: false, article: 'a' }));
    world.moveEntity(zooMap.id, entrance.id);

    const animalFeed = world.createEntity('bag of animal feed', EntityType.ITEM);
    animalFeed.add(new IdentityTrait({ name: 'bag of animal feed', description: 'A small brown paper bag filled with dried corn and pellets.', aliases: ['feed', 'animal feed', 'bag of feed', 'corn'], properName: false, article: 'a' }));
    world.moveEntity(animalFeed.id, pettingZoo.id);

    const penny = world.createEntity('souvenir penny', EntityType.ITEM);
    penny.add(new IdentityTrait({ name: 'souvenir penny', description: 'A shiny copper penny that could fit in a souvenir press machine.', aliases: ['penny', 'souvenir penny', 'coin'], properName: false, article: 'a' }));
    world.moveEntity(penny.id, mainPath.id);

    const flashlight = world.createEntity('flashlight', EntityType.ITEM);
    flashlight.add(new IdentityTrait({ name: 'flashlight', description: 'A heavy-duty yellow flashlight with "PROPERTY OF WILLOWBROOK ZOO" stenciled on the side.', aliases: ['flashlight', 'torch', 'light', 'lamp'], properName: false, article: 'a' }));
    flashlight.add(new SwitchableTrait({ isOn: false }));
    flashlight.add(new LightSourceTrait({ brightness: 8, isLit: false }));
    world.moveEntity(flashlight.id, supplyRoom.id);


    // ========================================================================
    // CONTAINERS
    // ========================================================================

    const backpack = world.createEntity('backpack', EntityType.CONTAINER);
    backpack.add(new IdentityTrait({ name: 'backpack', description: 'A small red canvas backpack with a cartoon monkey patch.', aliases: ['backpack', 'rucksack', 'pack'], properName: false, article: 'a' }));
    backpack.add(new ContainerTrait({ capacity: { maxItems: 5 } }));
    world.moveEntity(backpack.id, entrance.id);

    const parkBench = world.createEntity('park bench', EntityType.SUPPORTER);
    parkBench.add(new IdentityTrait({ name: 'park bench', description: 'A sturdy park bench painted forest green. Plaque: "In memory of Mr. Whiskers."', aliases: ['bench', 'park bench', 'benches', 'seat'], properName: false, article: 'a' }));
    parkBench.add(new SupporterTrait({ capacity: { maxItems: 3 } }));
    parkBench.add(new SceneryTrait());
    world.moveEntity(parkBench.id, mainPath.id);

    const lunchbox = world.createEntity('lunchbox', EntityType.CONTAINER);
    lunchbox.add(new IdentityTrait({ name: 'lunchbox', description: 'A dented metal lunchbox decorated with cartoon zoo animals.', aliases: ['lunchbox', 'lunch box', 'box'], properName: false, article: 'a' }));
    lunchbox.add(new ContainerTrait({ capacity: { maxItems: 3 } }));
    lunchbox.add(new OpenableTrait({ isOpen: false }));
    world.moveEntity(lunchbox.id, mainPath.id);

    lunchbox.get(OpenableTrait)!.isOpen = true;
    const juice = world.createEntity('juice box', EntityType.ITEM);
    juice.add(new IdentityTrait({ name: 'juice box', description: 'A small juice box with a picture of a happy elephant.', aliases: ['juice', 'juice box', 'drink'], properName: false, article: 'a' }));
    world.moveEntity(juice.id, lunchbox.id);
    lunchbox.get(OpenableTrait)!.isOpen = false;

    const dispenser = world.createEntity('feed dispenser', EntityType.CONTAINER);
    dispenser.add(new IdentityTrait({ name: 'feed dispenser', description: 'A coin-operated feed dispenser mounted on a wooden post. Sign: "FREE — Just Turn!"', aliases: ['dispenser', 'feed dispenser', 'machine'], properName: false, article: 'a' }));
    dispenser.add(new ContainerTrait({ capacity: { maxItems: 3 } }));
    dispenser.add(new OpenableTrait({ isOpen: false }));
    dispenser.add(new SceneryTrait());
    world.moveEntity(dispenser.id, pettingZoo.id);


    // ========================================================================
    // PLAYER STARTING LOCATION
    // ========================================================================

    const player = world.getPlayer();
    if (player) {
      world.moveEntity(player.id, entrance.id);
    }
  }
}


// ============================================================================
// EXPORTS
// ============================================================================

export const story: Story = new FamilyZooStory();
export default story;

Chapter 13: Event Handlers: Reacting to What Happens

Book source: docs/book/v2.0.0/parts/part-4/13-event-handlers.md · 8 step(s)

Two kinds of handler

step 1 typescript
world.registerEventHandler('if.event.dropped', (event, world) => {
  // Set a flag, move an item, change state; but no visible text
  world.setStateValue('item-was-dropped', true);
});
step 2 typescript
world.chainEvent(
  'if.event.dropped',
  (event, w) => {
    const data = event.data as Record<string, any>;
    // not our item, ignore
    if (data.itemId !== feedId) return null;
    return {
      id: `goats-react-${Date.now()}`,
      type: 'zoo.event.goats_react',
      timestamp: Date.now(),
      entities: {},
      data: { text: 'The goats rush over and devour the feed!' },
    };
  },
  { key: 'zoo.chain.goats-eat-feed' },
);

Reading the event data

step 3 typescript
// if.event.dropped
{ item: string, itemId: EntityId, toLocation: EntityId }

// if.event.put_in
{ itemId: EntityId, targetId: EntityId, preposition: 'in' }

Setting up: the gift shop, the press, and remembering IDs

step 4 typescript
import { GameEngine } from '@sharpee/engine';
import { ISemanticEvent } from '@sharpee/core';
import { IWorldModel } from '@sharpee/world-model';

class FamilyZooStory implements Story {
  config = config;

  private roomIds: { giftShop: string; pettingZoo: string } =
    { giftShop: '', pettingZoo: '' };
  private entityIds: {
    animalFeed: string;
    penny: string;
    souvenirPress: string;
  } = { animalFeed: '', penny: '', souvenirPress: '' };

  // createPlayer / initializeWorld / onEngineReady …
}
step 5 typescript
const giftShop = world.createEntity('Gift Shop', EntityType.ROOM);
giftShop.add(new RoomTrait({ exits: {}, isDark: false }));
giftShop.add(new IdentityTrait({
  name: 'Gift Shop',
  description:
    'A small zoo gift shop crammed with stuffed animals and ' +
    'postcards. A large souvenir penny press stands near the ' +
    'door. The aviary is back to the east.',
  aliases: ['gift shop', 'shop', 'store'],
  article: 'the',
}));
// Connect it west of the Aviary (and back east). This replaces
// the Aviary exits from Chapter 4, adding the west passage.
aviary.get(RoomTrait)!.exits = {
  [Direction.EAST]: { destination: mainPath.id },
  [Direction.WEST]: { destination: giftShop.id },
};
giftShop.get(RoomTrait)!.exits = {
  [Direction.EAST]: { destination: aviary.id },
};

const souvenirPress = world.createEntity(
  'souvenir press',
  EntityType.CONTAINER,
);
souvenirPress.add(new IdentityTrait({
  name: 'souvenir press',
  description:
    'A heavy cast-iron machine with a crank handle and a slot ' +
    'that accepts pennies. A sign reads: "INSERT PENNY, TURN ' +
    'HANDLE, KEEP FOREVER!"',
  aliases: ['press', 'souvenir press', 'penny press', 'machine'],
  article: 'a',
}));
souvenirPress.add(new ContainerTrait({
  capacity: { maxItems: 1 },
}));
souvenirPress.add(new SceneryTrait());
world.moveEntity(souvenirPress.id, giftShop.id);

// Remember the IDs the event handlers will match against.
this.roomIds.giftShop = giftShop.id;
this.roomIds.pettingZoo = pettingZoo.id;
this.entityIds.animalFeed = animalFeed.id;
this.entityIds.penny = penny.id;
this.entityIds.souvenirPress = souvenirPress.id;
step 6 typescript
onEngineReady(engine: GameEngine): void {
  const world = engine.getWorld();
  // the chainEvent registrations below go here
}

Reaction pattern: the goats eat the feed

step 7 typescript
const feedId = this.entityIds.animalFeed;
const pettingZooId = this.roomIds.pettingZoo;

world.chainEvent(
  'if.event.dropped',
  (
    event: ISemanticEvent,
    w: IWorldModel,
  ): ISemanticEvent | null => {
    const data = event.data as Record<string, any>;

    // Is it the feed, dropped in the petting zoo?
    if (data.itemId !== feedId ||
        data.toLocation !== pettingZooId) {
      return null;
    }

    // Only react once
    if (w.getStateValue('goats-fed')) return null;
    w.setStateValue('goats-fed', true);

    return {
      id: `zoo-goats-eat-${Date.now()}`,
      type: 'zoo.event.goats_react',
      timestamp: Date.now(),
      entities: {},
      data: {
        text:
          'The pygmy goats spot the bag of feed and rush over! ' +
          'They crowd around, bleating excitedly, and devour ' +
          'the corn and pellets in seconds. The smallest goat ' +
          'looks up at you with big grateful eyes.',
      },
    };
  },
  { key: 'zoo.chain.goats-eat-feed' },
);

Transformation pattern: put A in, get B out

step 8 typescript
const pennyId = this.entityIds.penny;
const pressId = this.entityIds.souvenirPress;

world.chainEvent(
  'if.event.put_in',
  (
    event: ISemanticEvent,
    w: IWorldModel,
  ): ISemanticEvent | null => {
    const data = event.data as Record<string, any>;
    if (data.itemId !== pennyId ||
        data.targetId !== pressId) return null;

    // 1. Destroy the input
    w.removeEntity(pennyId);

    // 2. Create the output
    const pressedPenny = w.createEntity(
      'pressed penny',
      EntityType.ITEM,
    );
    pressedPenny.add(new IdentityTrait({
      name: 'pressed penny',
      description:
        'A flattened oval of copper with an embossed toucan.',
      aliases: ['pressed penny', 'pressed coin', 'souvenir'],
      properName: false,
      article: 'a',
    }));

    // 3. Hand it to the player
    const player = w.getPlayer();
    if (player) w.moveEntity(pressedPenny.id, player.id);

    // 4. Tell them what happened
    return {
      id: `zoo-press-penny-${Date.now()}`,
      type: 'zoo.event.penny_pressed',
      timestamp: Date.now(),
      entities: {},
      data: {
        text:
          'CLUNK! CRUNCH! WHIRRR! The souvenir press swallows ' +
          'the penny and spits out a beautiful pressed penny ' +
          'with an embossed toucan design. You pocket it ' +
          'proudly.',
      },
    };
  },
  { key: 'zoo.chain.penny-press' },
);

Combined runnable file

The complete, runnable story at the end of this chapter (tested source: tutorials/familyzoo/v2.0.0/src/ch13-event-handlers.ts).

runnable ch13-event-handlers.ts typescript
/**
 * Family Zoo Tutorial — Version 12: Event Handlers
 *
 * NEW IN THIS VERSION:
 *   - world.registerEventHandler() — react to game events with custom logic
 *   - if.event.dropped — fires after an item is successfully dropped
 *   - if.event.put_in — fires after an item is put into a container
 *   - Item transformation — destroy one item and create another in response
 *   - A new Gift Shop room with a souvenir press machine
 *
 * WHAT YOU'LL LEARN:
 *   - Every stdlib action emits an event when it succeeds (dropped, taken, etc.)
 *   - world.registerEventHandler(eventType, handler) lets you react to these
 *   - Handlers receive the event data (which item, where, etc.)
 *   - Handlers can mutate the world — move items, create new ones, etc.
 *   - This is how you build puzzles without writing custom actions
 *
 * TRY IT:
 *   > south / east                    (go to the petting zoo)
 *   > take feed                       (pick up the bag of feed)
 *   > drop feed                       (the goats rush to eat!)
 *   > west / west                     (go to the gift shop)
 *   > look                            (see the souvenir press)
 *   > west / take penny / east / east (get the penny from main path)
 *   > put penny in press              (get a pressed penny!)
 *   > inventory                       (see the pressed penny)
 *
 * BUILD & RUN:
 *   ./build.sh -s familyzoo
 *   node dist/cli/sharpee.js --story tutorials/familyzoo --play
 */

// ============================================================================
// IMPORTS
// ============================================================================

import { Story, StoryConfig, GameEngine } from '@sharpee/engine';
import {
  WorldModel,
  IFEntity,
  EntityType,
  Direction,
} from '@sharpee/world-model';
import {
  IdentityTrait,
  ActorTrait,
  ContainerTrait,
  SupporterTrait,
  RoomTrait,
  SceneryTrait,
  OpenableTrait,
  LockableTrait,
  DoorTrait,
  SwitchableTrait,
  LightSourceTrait,
  ReadableTrait,
} from '@sharpee/world-model';

// ISemanticEvent is the type for events flowing through the system.
// We need it to type-annotate our event handler parameters and return values.
import { ISemanticEvent } from '@sharpee/core';
// IWorldModel is the interface for the world model passed to chain handlers.
import { IWorldModel } from '@sharpee/world-model';
// NPCs (the zookeeper and the parrot's behavior) arrive in Chapter 20; this
// snapshot has none yet.


// ============================================================================
// STORY CONFIGURATION
// ============================================================================

const config: StoryConfig = {
  id: 'familyzoo',
  title: 'Family Zoo',
  author: 'Sharpee Tutorial',
  version: '0.12.0',
  description: 'A small family zoo — learn Sharpee one concept at a time.',
};


// ============================================================================
// PARROT BEHAVIOR — same as V11
// ============================================================================

// ============================================================================
// THE STORY CLASS
// ============================================================================

class FamilyZooStory implements Story {
  config = config;

  private roomIds: {
    entrance: string;
    mainPath: string;
    pettingZoo: string;
    aviary: string;
    supplyRoom: string;
    nocturnalExhibit: string;
    giftShop: string;     // ← NEW room in V12
  } = { entrance: '', mainPath: '', pettingZoo: '', aviary: '', supplyRoom: '', nocturnalExhibit: '', giftShop: '' };

  // We also need to store entity IDs for the event handlers.
  // The handlers need to know which item was dropped and where.
  private entityIds: {
    animalFeed: string;
    penny: string;
    souvenirPress: string;
  } = { animalFeed: '', penny: '', souvenirPress: '' };

  createPlayer(world: WorldModel): IFEntity {
    const player = world.createEntity('yourself', EntityType.ACTOR);
    player.add(new IdentityTrait({ name: 'yourself', description: 'Just an ordinary visitor to the zoo.', aliases: ['self', 'myself', 'me'], properName: true, article: '' }));
    player.add(new ActorTrait({ isPlayer: true }));
    player.add(new ContainerTrait({ capacity: { maxItems: 10 } }));
    return player;
  }

  initializeWorld(world: WorldModel): void {

    // ========================================================================
    // ROOMS — V10 rooms + Gift Shop (new in V12)
    // ========================================================================

    const entrance = world.createEntity('Zoo Entrance', EntityType.ROOM);
    entrance.add(new RoomTrait({ exits: {}, isDark: false }));
    entrance.add(new IdentityTrait({ name: 'Zoo Entrance', description: 'You stand before the wrought-iron gates of the Willowbrook Family Zoo. A cheerful welcome sign arches over the entrance, and a small ticket booth sits to one side. A sturdy iron fence runs along either side of the gates. The main path leads south into the zoo grounds.', aliases: ['entrance', 'gates', 'gate'], properName: false, article: 'the' }));

    const mainPath = world.createEntity('Main Path', EntityType.ROOM);
    mainPath.add(new RoomTrait({ exits: {}, isDark: false }));
    mainPath.add(new IdentityTrait({ name: 'Main Path', description: 'A wide gravel path winds through the heart of the zoo. Colorful direction signs point every which way. A park bench sits beside the path. To the east, the petting zoo. To the west, the aviary. A staff gate blocks the path to the south. The gift shop is also to the west, past the aviary. The entrance is back to the north.', aliases: ['path', 'main path', 'gravel path'], properName: false, article: 'the' }));

    const pettingZoo = world.createEntity('Petting Zoo', EntityType.ROOM);
    pettingZoo.add(new RoomTrait({ exits: {}, isDark: false }));
    pettingZoo.add(new IdentityTrait({ name: 'Petting Zoo', description: 'A cheerful open-air enclosure filled with friendly animals. Pygmy goats trot around nibbling at visitors\' shoelaces, while a pair of fluffy rabbits hop lazily near a hay bale. A feed dispenser is mounted on a post. An info plaque is posted by the gate. The main path is back to the west.', aliases: ['petting zoo', 'petting area', 'pen'], properName: false, article: 'the' }));

    const aviary = world.createEntity('Aviary', EntityType.ROOM);
    aviary.add(new RoomTrait({ exits: {}, isDark: false }));
    aviary.add(new IdentityTrait({ name: 'Aviary', description: 'You step inside a soaring mesh dome. Brilliantly colored parrots chatter from rope perches, and a toucan eyes you curiously from a branch overhead. A small waterfall splashes into a stone basin. An info plaque hangs near the entrance. The gift shop is to the west. The main path is back to the east.', aliases: ['aviary', 'bird house', 'dome'], properName: false, article: 'the' }));

    const supplyRoom = world.createEntity('Supply Room', EntityType.ROOM);
    supplyRoom.add(new RoomTrait({ exits: {}, isDark: false }));
    supplyRoom.add(new IdentityTrait({ name: 'Supply Room', description: 'A cluttered storage room behind the staff gate. Metal shelves line the walls. A cork board on the wall is covered with staff schedules. A battered radio sits on one of the shelves. The staff gate leads back north.', aliases: ['supply room', 'storage room', 'storeroom'], properName: false, article: 'the' }));

    const nocturnalExhibit = world.createEntity('Nocturnal Animals Exhibit', EntityType.ROOM);
    nocturnalExhibit.add(new RoomTrait({ exits: {}, isDark: true }));
    nocturnalExhibit.add(new IdentityTrait({ name: 'Nocturnal Animals Exhibit', description: 'A cool, dimly lit cavern designed to simulate nighttime. Glass enclosures line both walls with soft red lights. You can see sugar gliders, bush babies, and a barn owl. A warning sign is posted near the entrance. The exit leads back north to the supply room.', aliases: ['nocturnal exhibit', 'nocturnal animals', 'exhibit'], properName: false, article: 'the' }));

    // --- Gift Shop (NEW in V12) ---
    //
    // The gift shop houses the souvenir press machine. It's connected
    // to the aviary to the west, giving the player a reason to explore
    // past the aviary.
    const giftShop = world.createEntity('Gift Shop', EntityType.ROOM);
    giftShop.add(new RoomTrait({ exits: {}, isDark: false }));
    giftShop.add(new IdentityTrait({
      name: 'Gift Shop',
      description:
        'A small zoo gift shop crammed with stuffed animals and postcards. ' +
        'A large souvenir penny press machine stands near the door, its ' +
        'handle gleaming invitingly. A sign above it reads: "INSERT PENNY, ' +
        'TURN HANDLE, KEEP FOREVER!" The aviary is back to the east.',
      aliases: ['gift shop', 'shop', 'store'],
      properName: false,
      article: 'the',
    }));

    this.roomIds = {
      entrance: entrance.id,
      mainPath: mainPath.id,
      pettingZoo: pettingZoo.id,
      aviary: aviary.id,
      supplyRoom: supplyRoom.id,
      nocturnalExhibit: nocturnalExhibit.id,
      giftShop: giftShop.id,
    };


    // ========================================================================
    // STAFF GATE — same as V7
    // ========================================================================

    const keycard = world.createEntity('staff keycard', EntityType.ITEM);
    keycard.add(new IdentityTrait({ name: 'staff keycard', description: 'A white plastic keycard with "WILLOWBROOK ZOO — STAFF ONLY" printed in blue.', aliases: ['keycard', 'key card', 'card', 'key', 'staff keycard'], properName: false, article: 'a' }));
    world.moveEntity(keycard.id, entrance.id);

    const staffGate = world.createEntity('staff gate', EntityType.DOOR);
    staffGate.add(new IdentityTrait({ name: 'staff gate', description: 'A sturdy metal gate with a "STAFF ONLY" sign. There\'s a card reader beside it.', aliases: ['gate', 'staff gate', 'metal gate', 'staff door'], properName: false, article: 'a' }));
    staffGate.add(new DoorTrait({ room1: mainPath.id, room2: supplyRoom.id, bidirectional: true }));
    staffGate.add(new OpenableTrait({ isOpen: false }));
    staffGate.add(new LockableTrait({ isLocked: true, keyId: keycard.id }));
    staffGate.add(new SceneryTrait());
    world.moveEntity(staffGate.id, mainPath.id);


    // ========================================================================
    // EXITS — V10 exits + Gift Shop connections
    // ========================================================================

    entrance.get(RoomTrait)!.exits = { [Direction.SOUTH]: { destination: mainPath.id } };
    mainPath.get(RoomTrait)!.exits = {
      [Direction.NORTH]: { destination: entrance.id },
      [Direction.EAST]: { destination: pettingZoo.id },
      [Direction.WEST]: { destination: aviary.id },
      [Direction.SOUTH]: { destination: supplyRoom.id, via: staffGate.id },
    };
    pettingZoo.get(RoomTrait)!.exits = { [Direction.WEST]: { destination: mainPath.id } };
    aviary.get(RoomTrait)!.exits = {
      [Direction.EAST]: { destination: mainPath.id },
      [Direction.WEST]: { destination: giftShop.id },   // ← NEW exit to gift shop
    };
    supplyRoom.get(RoomTrait)!.exits = { [Direction.NORTH]: { destination: mainPath.id, via: staffGate.id }, [Direction.SOUTH]: { destination: nocturnalExhibit.id } };
    nocturnalExhibit.get(RoomTrait)!.exits = { [Direction.NORTH]: { destination: supplyRoom.id } };
    giftShop.get(RoomTrait)!.exits = {
      [Direction.EAST]: { destination: aviary.id },      // ← back to aviary
    };


    // ========================================================================
    // SCENERY — abbreviated (same as V10)
    // ========================================================================

    const sign = world.createEntity('welcome sign', EntityType.SCENERY);
    sign.add(new IdentityTrait({ name: 'welcome sign', description: 'A brightly painted wooden sign reads: "WELCOME TO WILLOWBROOK FAMILY ZOO."', aliases: ['sign', 'welcome sign'], properName: false, article: 'a' }));
    world.moveEntity(sign.id, entrance.id);

    const booth = world.createEntity('ticket booth', EntityType.SCENERY);
    booth.add(new IdentityTrait({ name: 'ticket booth', description: 'A small wooden booth with a "Self-Guided Tours" sign.', aliases: ['booth', 'ticket booth'], properName: false, article: 'a' }));
    world.moveEntity(booth.id, entrance.id);

    const ironFence = world.createEntity('iron fence', EntityType.SCENERY);
    ironFence.add(new IdentityTrait({ name: 'iron fence', description: 'A tall wrought-iron fence with animal silhouettes.', aliases: ['fence', 'iron fence', 'railing'], properName: false, article: 'an' }));
    world.moveEntity(ironFence.id, entrance.id);

    const directionSigns = world.createEntity('direction signs', EntityType.SCENERY);
    directionSigns.add(new IdentityTrait({ name: 'direction signs', grammaticalNumber: 'plural', description: 'Arrow signs: PETTING ZOO (east), AVIARY (west), EXIT (north).', aliases: ['signs', 'direction signs', 'arrow signs'], properName: false, article: 'some' }));
    world.moveEntity(directionSigns.id, mainPath.id);

    const flowerBeds = world.createEntity('flower beds', EntityType.SCENERY);
    flowerBeds.add(new IdentityTrait({ name: 'flower beds', grammaticalNumber: 'plural', description: 'Tidy beds of marigolds and petunias.', aliases: ['flowers', 'flower beds'], properName: false, article: 'some' }));
    world.moveEntity(flowerBeds.id, mainPath.id);

    const goats = world.createEntity('pygmy goats', EntityType.SCENERY);
    goats.add(new IdentityTrait({ name: 'pygmy goats', grammaticalNumber: 'plural', description: 'Three pygmy goats hoping you have food.', aliases: ['goats', 'pygmy goats', 'goat'], properName: false, article: 'some' }));
    world.moveEntity(goats.id, pettingZoo.id);

    const hayBale = world.createEntity('hay bale', EntityType.SCENERY);
    hayBale.add(new IdentityTrait({ name: 'hay bale', description: 'A large round bale of golden hay.', aliases: ['hay', 'hay bale', 'bale'], properName: false, article: 'a' }));
    world.moveEntity(hayBale.id, pettingZoo.id);

    const rabbits = world.createEntity('rabbits', EntityType.SCENERY);
    rabbits.add(new IdentityTrait({ name: 'rabbits', grammaticalNumber: 'plural', description: 'A pair of Holland Lop rabbits with floppy ears.', aliases: ['rabbits', 'rabbit', 'bunnies'], properName: false, article: 'some' }));
    world.moveEntity(rabbits.id, pettingZoo.id);

    const toucan = world.createEntity('toucan', EntityType.SCENERY);
    toucan.add(new IdentityTrait({ name: 'toucan', description: 'A Toco toucan with an enormous orange-and-black bill.', aliases: ['toucan', 'toco toucan'], properName: false, article: 'a' }));
    world.moveEntity(toucan.id, aviary.id);

    const parrots = world.createEntity('parrots', EntityType.SCENERY);
    parrots.add(new IdentityTrait({ name: 'parrots', grammaticalNumber: 'plural', description: 'A raucous flock of scarlet macaws and grey African parrots.', aliases: ['parrots', 'macaws', 'birds'], properName: false, article: 'some' }));
    world.moveEntity(parrots.id, aviary.id);

    const waterfall = world.createEntity('waterfall', EntityType.SCENERY);
    waterfall.add(new IdentityTrait({ name: 'waterfall', description: 'A gentle artificial waterfall cascading into a stone basin.', aliases: ['waterfall', 'water', 'basin'], properName: false, article: 'a' }));
    world.moveEntity(waterfall.id, aviary.id);

    const perches = world.createEntity('rope perches', EntityType.SCENERY);
    perches.add(new IdentityTrait({ name: 'rope perches', grammaticalNumber: 'plural', description: 'Thick sisal ropes strung between wooden posts — both furniture and snacks for the parrots.', aliases: ['perches', 'rope perches', 'ropes', 'rope'], properName: false, article: 'some' }));
    world.moveEntity(perches.id, aviary.id);

    const shelves = world.createEntity('metal shelves', EntityType.SCENERY);
    shelves.add(new IdentityTrait({ name: 'metal shelves', grammaticalNumber: 'plural', description: 'Industrial metal shelving units stacked with supplies.', aliases: ['shelves', 'metal shelves', 'shelf'], properName: false, article: 'some' }));
    world.moveEntity(shelves.id, supplyRoom.id);

    const corkBoard = world.createEntity('cork board', EntityType.SCENERY);
    corkBoard.add(new IdentityTrait({ name: 'cork board', description: 'A cork board with staff schedules. A note in red marker: "DON\'T FORGET: nocturnal exhibit lights need new batteries!"', aliases: ['cork board', 'board', 'notices'], properName: false, article: 'a' }));
    world.moveEntity(corkBoard.id, supplyRoom.id);

    const sugarGliders = world.createEntity('sugar gliders', EntityType.SCENERY);
    sugarGliders.add(new IdentityTrait({ name: 'sugar gliders', grammaticalNumber: 'plural', description: 'A family of tiny sugar gliders with enormous dark eyes, leaping between branches.', aliases: ['sugar gliders', 'gliders'], properName: false, article: 'some' }));
    world.moveEntity(sugarGliders.id, nocturnalExhibit.id);

    const bushBabies = world.createEntity('bush babies', EntityType.SCENERY);
    bushBabies.add(new IdentityTrait({ name: 'bush babies', grammaticalNumber: 'plural', description: 'Two bush babies with impossibly large round eyes, clinging to a rope with tiny hands.', aliases: ['bush babies', 'galagos'], properName: false, article: 'some' }));
    world.moveEntity(bushBabies.id, nocturnalExhibit.id);

    const barnOwl = world.createEntity('barn owl', EntityType.SCENERY);
    barnOwl.add(new IdentityTrait({ name: 'barn owl', description: 'An enormous barn owl with a heart-shaped white face on a fake tree stump.', aliases: ['barn owl', 'owl'], properName: false, article: 'a' }));
    world.moveEntity(barnOwl.id, nocturnalExhibit.id);

    // --- Gift Shop scenery ---

    const stuffedAnimals = world.createEntity('stuffed animals', EntityType.SCENERY);
    stuffedAnimals.add(new IdentityTrait({ name: 'stuffed animals', grammaticalNumber: 'plural', description: 'Shelves of plush tigers, pandas, and penguins in various sizes.', aliases: ['stuffed animals', 'plush', 'toys'], properName: false, article: 'some' }));
    world.moveEntity(stuffedAnimals.id, giftShop.id);

    const postcards = world.createEntity('postcards', EntityType.SCENERY);
    postcards.add(new IdentityTrait({ name: 'postcards', grammaticalNumber: 'plural', description: 'A spinning rack of postcards showing the zoo\'s greatest hits.', aliases: ['postcards', 'cards', 'postcard rack'], properName: false, article: 'some' }));
    world.moveEntity(postcards.id, giftShop.id);


    // ========================================================================
    // READABLE OBJECTS — from V9
    // ========================================================================

    const pettingPlaque = world.createEntity('info plaque', EntityType.SCENERY);
    pettingPlaque.add(new IdentityTrait({ name: 'info plaque', description: 'A brass plaque mounted on a wooden post near the petting zoo gate.', aliases: ['plaque', 'info plaque', 'brass plaque'], properName: false, article: 'an' }));
    pettingPlaque.add(new ReadableTrait({ text: 'PYGMY GOATS — These Nigerian Dwarf goats are gentle, curious, and always hungry.\n\nHOLLAND LOP RABBITS — Known for their floppy ears. Our pair, Biscuit and Marmalade, were born here in 2023.' }));
    world.moveEntity(pettingPlaque.id, pettingZoo.id);

    const aviaryPlaque = world.createEntity('aviary plaque', EntityType.SCENERY);
    aviaryPlaque.add(new IdentityTrait({ name: 'aviary plaque', description: 'A colorful information board near the aviary entrance.', aliases: ['plaque', 'aviary plaque', 'information board'], properName: false, article: 'an' }));
    aviaryPlaque.add(new ReadableTrait({ text: 'WELCOME TO THE AVIARY — Home to over 30 species!\n\nTOCO TOUCAN — Its bill weighs less than a smartphone.\n\nSCARLET MACAW — Can live over 75 years. Our oldest, Captain, is 42.' }));
    world.moveEntity(aviaryPlaque.id, aviary.id);

    const warningSign = world.createEntity('warning sign', EntityType.SCENERY);
    warningSign.add(new IdentityTrait({ name: 'warning sign', description: 'A yellow warning sign near the nocturnal exhibit entrance.', aliases: ['warning', 'warning sign', 'yellow sign'], properName: false, article: 'a' }));
    warningSign.add(new ReadableTrait({ text: 'CAUTION: The Nocturnal Animals Exhibit is kept dark. Please use a flashlight. Do NOT use camera flash. (We don\'t talk about the Great Owl Incident of 2022.)' }));
    world.moveEntity(warningSign.id, supplyRoom.id);

    const brochure = world.createEntity('zoo brochure', EntityType.ITEM);
    brochure.add(new IdentityTrait({ name: 'zoo brochure', description: 'A glossy tri-fold brochure with "WILLOWBROOK FAMILY ZOO" on the cover.', aliases: ['brochure', 'zoo brochure', 'pamphlet', 'leaflet'], properName: false, article: 'a' }));
    brochure.add(new ReadableTrait({ text: 'WILLOWBROOK FAMILY ZOO — Your Guide\n\nEXHIBITS:\n  Petting Zoo — East from Main Path\n  Aviary — West from Main Path\n  Gift Shop — West from Aviary\n  Nocturnal Animals — Staff Area\n\n"Where every visit is a wild adventure!"' }));
    world.moveEntity(brochure.id, entrance.id);


    // ========================================================================
    // SWITCHABLE DEVICES — from V10
    // ========================================================================

    const radio = world.createEntity('radio', EntityType.ITEM);
    radio.add(new IdentityTrait({ name: 'radio', description: 'A battered portable radio held together with duct tape. The antenna is bent at a jaunty angle. A faded sticker on the side reads "ZOO FM — All Animals, All The Time."', aliases: ['radio', 'portable radio'], properName: false, article: 'a' }));
    radio.add(new SwitchableTrait({ isOn: false }));
    radio.add(new SceneryTrait());
    world.moveEntity(radio.id, supplyRoom.id);


    // ========================================================================
    // PORTABLE OBJECTS
    // ========================================================================

    const zooMap = world.createEntity('zoo map', EntityType.ITEM);
    zooMap.add(new IdentityTrait({ name: 'zoo map', description: 'A colorful folding map of the Willowbrook Family Zoo.', aliases: ['map', 'zoo map', 'folding map'], properName: false, article: 'a' }));
    world.moveEntity(zooMap.id, entrance.id);

    const animalFeed = world.createEntity('bag of animal feed', EntityType.ITEM);
    animalFeed.add(new IdentityTrait({ name: 'bag of animal feed', description: 'A small brown paper bag filled with dried corn and pellets.', aliases: ['feed', 'animal feed', 'bag of feed', 'corn'], properName: false, article: 'a' }));
    world.moveEntity(animalFeed.id, pettingZoo.id);

    const penny = world.createEntity('souvenir penny', EntityType.ITEM);
    penny.add(new IdentityTrait({ name: 'souvenir penny', description: 'A shiny copper penny that could fit in a souvenir press machine.', aliases: ['penny', 'souvenir penny', 'coin'], properName: false, article: 'a' }));
    world.moveEntity(penny.id, mainPath.id);

    const flashlight = world.createEntity('flashlight', EntityType.ITEM);
    flashlight.add(new IdentityTrait({ name: 'flashlight', description: 'A heavy-duty yellow flashlight with "PROPERTY OF WILLOWBROOK ZOO" stenciled on the side.', aliases: ['flashlight', 'torch', 'light', 'lamp'], properName: false, article: 'a' }));
    flashlight.add(new SwitchableTrait({ isOn: false }));
    flashlight.add(new LightSourceTrait({ brightness: 8, isLit: false }));
    world.moveEntity(flashlight.id, supplyRoom.id);

    // Save entity IDs for event handlers (used in onEngineReady)
    this.entityIds = {
      animalFeed: animalFeed.id,
      penny: penny.id,
      souvenirPress: '',  // Set below after creating the press
    };


    // ========================================================================
    // CONTAINERS
    // ========================================================================

    const backpack = world.createEntity('backpack', EntityType.CONTAINER);
    backpack.add(new IdentityTrait({ name: 'backpack', description: 'A small red canvas backpack with a cartoon monkey patch.', aliases: ['backpack', 'rucksack', 'pack'], properName: false, article: 'a' }));
    backpack.add(new ContainerTrait({ capacity: { maxItems: 5 } }));
    world.moveEntity(backpack.id, entrance.id);

    const parkBench = world.createEntity('park bench', EntityType.SUPPORTER);
    parkBench.add(new IdentityTrait({ name: 'park bench', description: 'A sturdy park bench painted forest green. Plaque: "In memory of Mr. Whiskers."', aliases: ['bench', 'park bench', 'benches', 'seat'], properName: false, article: 'a' }));
    parkBench.add(new SupporterTrait({ capacity: { maxItems: 3 } }));
    parkBench.add(new SceneryTrait());
    world.moveEntity(parkBench.id, mainPath.id);

    const lunchbox = world.createEntity('lunchbox', EntityType.CONTAINER);
    lunchbox.add(new IdentityTrait({ name: 'lunchbox', description: 'A dented metal lunchbox decorated with cartoon zoo animals.', aliases: ['lunchbox', 'lunch box', 'box'], properName: false, article: 'a' }));
    lunchbox.add(new ContainerTrait({ capacity: { maxItems: 3 } }));
    lunchbox.add(new OpenableTrait({ isOpen: false }));
    world.moveEntity(lunchbox.id, mainPath.id);

    lunchbox.get(OpenableTrait)!.isOpen = true;
    const juice = world.createEntity('juice box', EntityType.ITEM);
    juice.add(new IdentityTrait({ name: 'juice box', description: 'A small juice box with a picture of a happy elephant.', aliases: ['juice', 'juice box', 'drink'], properName: false, article: 'a' }));
    world.moveEntity(juice.id, lunchbox.id);
    lunchbox.get(OpenableTrait)!.isOpen = false;

    const dispenser = world.createEntity('feed dispenser', EntityType.CONTAINER);
    dispenser.add(new IdentityTrait({ name: 'feed dispenser', description: 'A coin-operated feed dispenser mounted on a wooden post. Sign: "FREE — Just Turn!"', aliases: ['dispenser', 'feed dispenser'], properName: false, article: 'a' }));
    dispenser.add(new ContainerTrait({ capacity: { maxItems: 3 } }));
    dispenser.add(new OpenableTrait({ isOpen: false }));
    dispenser.add(new SceneryTrait());
    world.moveEntity(dispenser.id, pettingZoo.id);


    // ========================================================================
    // SOUVENIR PRESS — NEW IN V12
    // ========================================================================
    //
    // The souvenir press is a container that accepts pennies. When a penny
    // is put in, an event handler (registered in onEngineReady) transforms
    // the penny into a pressed penny with a zoo animal design.
    //
    // We use ContainerTrait so the player can "put penny in press".
    // The event handler reacts to the if.event.put_in event.

    const souvenirPress = world.createEntity('souvenir press', EntityType.CONTAINER);
    souvenirPress.add(new IdentityTrait({
      name: 'souvenir press',
      description:
        'A heavy cast-iron machine with a big crank handle. A slot on top ' +
        'accepts pennies, and the mechanism stamps them with a zoo animal ' +
        'design. A sign reads: "INSERT PENNY, TURN HANDLE, KEEP FOREVER!"',
      aliases: ['press', 'souvenir press', 'penny press', 'machine'],
      properName: false,
      article: 'a',
    }));
    souvenirPress.add(new ContainerTrait({ capacity: { maxItems: 1 } }));
    souvenirPress.add(new SceneryTrait());
    world.moveEntity(souvenirPress.id, giftShop.id);

    this.entityIds.souvenirPress = souvenirPress.id;


    // (NPCs — the zookeeper and the parrot — arrive in Chapter 20.)


    // ========================================================================
    // PLAYER STARTING LOCATION
    // ========================================================================

    const player = world.getPlayer();
    if (player) {
      world.moveEntity(player.id, entrance.id);
    }
  }


  // ==========================================================================
  // onEngineReady — Event Handlers (NPC registration arrives in Chapter 20)
  // ==========================================================================

  onEngineReady(engine: GameEngine): void {
    const world = engine.getWorld();

    // ========================================================================
    // EVENT CHAIN HANDLERS — NEW IN V12
    // ========================================================================
    //
    // Event handlers let you react to things that happen in the game.
    // Every stdlib action emits an event when it succeeds:
    //
    //   if.event.taken    — player took an item
    //   if.event.dropped  — player dropped an item
    //   if.event.put_in   — player put an item in a container
    //   if.event.put_on   — player put an item on a supporter
    //   if.event.opened   — player opened something
    //   if.event.closed   — player closed something
    //   if.event.locked   — player locked something
    //   if.event.unlocked — player unlocked something
    //   ...and many more
    //
    // There are two kinds of handlers:
    //
    //   world.registerEventHandler(eventType, fn)
    //     - fn returns void — can only mutate world state silently
    //     - Good for bookkeeping (setting flags, tracking state)
    //
    //   world.chainEvent(eventType, fn, options)
    //     - fn returns an ISemanticEvent | null
    //     - The returned event gets dispatched and rendered as text
    //     - This is what you use when you want the player to SEE something
    //
    // For this tutorial, we use chainEvent() because we want to show
    // the player custom text when they drop feed or press a penny.


    // --- Handler 1: Goats react to dropped feed ---
    //
    // When the player drops the bag of animal feed in the Petting Zoo,
    // the goats rush over and eat it. This is a "react to drop" pattern.
    //
    // The chain handler checks:
    //   1. Was it the animal feed that was dropped? (by item ID)
    //   2. Was it dropped in the Petting Zoo? (by location ID)
    // If yes, it returns a game.message event with custom text.

    const feedId = this.entityIds.animalFeed;
    const pettingZooId = this.roomIds.pettingZoo;

    world.chainEvent(
      'if.event.dropped',
      (event: ISemanticEvent, w: IWorldModel): ISemanticEvent | null => {
        // The event's data contains information about what was dropped.
        // DroppedEventData has:
        //   item     — the item's NAME (string, e.g., "bag of animal feed")
        //   itemId   — the item's entity ID (what we compare against)
        //   toLocation — the entity ID of where it was dropped
        const data = event.data as Record<string, any>;

        // Check if it's the feed being dropped in the petting zoo
        if (data.itemId !== feedId || data.toLocation !== pettingZooId) {
          return null; // Not our event — ignore it
        }

        // Prevent repeated reactions with a state flag
        if (w.getStateValue('goats-fed')) return null;
        w.setStateValue('goats-fed', true);

        // Return a custom event — the text service's generic handler will
        // render any event type that has a 'text' field in its data.
        //
        // IMPORTANT: Don't use type 'game.message' here! The event processor
        // treats game.message reactions as overrides of the original event's
        // text, which is not what we want. Use a custom event type instead.
        return {
          id: `zoo-goats-eat-${Date.now()}`,
          type: 'zoo.event.goats_react',
          timestamp: Date.now(),
          entities: {},
          data: {
            text: 'The pygmy goats spot the bag of feed and rush over! They crowd around, bleating excitedly, and devour the corn and pellets in seconds. The smallest goat looks up at you with big grateful eyes.',
          },
        };
      },
      { key: 'zoo.chain.goats-eat-feed' },
    );


    // --- Handler 2: Souvenir press transforms penny ---
    //
    // When the player puts the souvenir penny into the souvenir press,
    // the penny is consumed and a pressed penny appears in the player's
    // inventory. This is an "item transformation" pattern.
    //
    // The chain handler checks:
    //   1. Was it the penny that was put in? (by item ID)
    //   2. Was it put in the souvenir press? (by target ID)

    const pennyId = this.entityIds.penny;
    const pressId = this.entityIds.souvenirPress;

    world.chainEvent(
      'if.event.put_in',
      (event: ISemanticEvent, w: IWorldModel): ISemanticEvent | null => {
        const data = event.data as Record<string, any>;

        // Check: was the penny put into the press?
        if (data.itemId !== pennyId || data.targetId !== pressId) {
          return null; // Not our event
        }

        // Step 1: Remove the penny from the press (destroy it).
        w.removeEntity(pennyId);

        // Step 2: Create the pressed penny — a brand new item.
        const pressedPenny = w.createEntity('pressed penny', EntityType.ITEM);
        pressedPenny.add(new IdentityTrait({
          name: 'pressed penny',
          description:
            'A flattened oval of copper with an embossed image of a toucan. ' +
            'The text reads: "WILLOWBROOK FAMILY ZOO — I WAS HERE!"',
          aliases: ['pressed penny', 'pressed coin', 'souvenir'],
          properName: false,
          article: 'a',
        }));

        // Step 3: Place the pressed penny in the player's inventory.
        const player = w.getPlayer();
        if (player) {
          w.moveEntity(pressedPenny.id, player.id);
        }

        // Step 4: Return a custom event — the player sees the text.
        // Use a custom type (not 'game.message') so the event processor
        // doesn't consume it as an override of the put_in message.
        return {
          id: `zoo-press-penny-${Date.now()}`,
          type: 'zoo.event.penny_pressed',
          timestamp: Date.now(),
          entities: {},
          data: {
            text: 'CLUNK! CRUNCH! WHIRRR! The souvenir press swallows the penny and spits out a beautiful pressed penny with an embossed toucan design. You pocket it proudly.',
          },
        };
      },
      { key: 'zoo.chain.penny-press' },
    );
  }
}


// ============================================================================
// EXPORTS
// ============================================================================

export const story: Story = new FamilyZooStory();
export default story;

Chapter 14: Custom Actions: Teaching the Parser New Verbs

Book source: docs/book/v2.0.0/parts/part-4/14-custom-actions.md · 8 step(s)

Custom Actions: Teaching the Parser New Verbs

step 1 typescript
import {
  Action, ActionContext, ValidationResult,
} from '@sharpee/stdlib';
import type { Parser } from '@sharpee/parser-en-us';
import type { LanguageProvider } from '@sharpee/lang-en-us';
import { ISemanticEvent } from '@sharpee/core';
import {
  IdentityTrait, IFEntity, EntityType,
} from '@sharpee/world-model';

The four-phase action

step 2 typescript
const feedAction: Action = {
  id: 'zoo.action.feeding',
  group: 'interaction',

  // Phase 1: can the action proceed?
  validate(context: ActionContext): ValidationResult {
    if (!hasRequiredItem) {
      return { valid: false, error: 'no_feed' };
    }
    return { valid: true };
  },

  // Phase 2: mutate the world (only runs if valid)
  execute(context: ActionContext): void {
    context.world.setStateValue('item-used', true);
  },

  // Phase 3: success events (text output)
  report(context: ActionContext): ISemanticEvent[] {
    return [context.event('zoo.event.fed', {
      messageId: 'fed_goats',
    })];
  },

  // Phase 4: failure events (runs instead of
  // execute/report if invalid)
  blocked(
    context: ActionContext,
    result: ValidationResult,
  ): ISemanticEvent[] {
    return [context.event('zoo.event.feeding_blocked', {
      messageId: result.error,
    })];
  },
};

A complete custom action: feeding

step 3 typescript
const FEED_ACTION_ID = 'zoo.action.feeding';

const FeedMessages = {
  NO_FEED: 'zoo.feeding.no_feed',
  NOT_AN_ANIMAL: 'zoo.feeding.not_animal',
  ALREADY_FED: 'zoo.feeding.already_fed',
  FED_GOATS: 'zoo.feeding.fed_goats',
  FED_RABBITS: 'zoo.feeding.fed_rabbits',
  FED_GENERIC: 'zoo.feeding.fed_generic',
} as const;

const feedAction: Action = {
  id: FEED_ACTION_ID,
  group: 'interaction',

  validate(context: ActionContext): ValidationResult {
    const target = context.command.directObject?.entity;

    // Player must be carrying the feed
    const inventory = context.world
      .getContents(context.player.id);
    const hasFeed = inventory.some(item =>
      item.get(IdentityTrait)?.aliases?.includes('feed'));
    if (!hasFeed) {
      return { valid: false, error: FeedMessages.NO_FEED };
    }

    // There must be a target, and it must be feedable
    if (!target) {
      return { valid: false, error: FeedMessages.NOT_AN_ANIMAL };
    }
    const name =
      target.get(IdentityTrait)?.name?.toLowerCase() || '';
    const feedable = ['pygmy goats', 'rabbits']
      .some(a => name.includes(a));
    if (!feedable) {
      return { valid: false, error: FeedMessages.NOT_AN_ANIMAL };
    }

    // Not already fed
    if (context.world.getStateValue(`fed-${target.id}`)) {
      return { valid: false, error: FeedMessages.ALREADY_FED };
    }

    // Hand the target to the later phases
    context.sharedData.feedTarget = target;
    return { valid: true };
  },

  execute(context: ActionContext): void {
    const target = context.sharedData.feedTarget as IFEntity;
    if (target) {
      context.world.setStateValue(`fed-${target.id}`, true);
    }
  },

  report(context: ActionContext): ISemanticEvent[] {
    const target = context.sharedData.feedTarget as IFEntity;
    const name =
      target?.get(IdentityTrait)?.name?.toLowerCase() || '';

    let messageId: string = FeedMessages.FED_GENERIC;
    if (name.includes('goats')) {
      messageId = FeedMessages.FED_GOATS;
    } else if (name.includes('rabbits')) {
      messageId = FeedMessages.FED_RABBITS;
    }

    return [context.event('zoo.event.fed', {
      messageId,
      params: {
        animal: target?.get(IdentityTrait)?.name || 'animal',
      },
    })];
  },

  blocked(
    context: ActionContext,
    result: ValidationResult,
  ): ISemanticEvent[] {
    return [context.event('zoo.event.feeding_blocked', {
      messageId: result.error || FeedMessages.NOT_AN_ANIMAL,
    })];
  },
};

A second action: photographing

step 4 typescript
const PHOTOGRAPH_ACTION_ID = 'zoo.action.photographing';

const PhotoMessages = {
  NO_CAMERA: 'zoo.photo.no_camera',
  TOOK_PHOTO: 'zoo.photo.took_photo',
} as const;

const photographAction: Action = {
  id: PHOTOGRAPH_ACTION_ID,
  group: 'interaction',

  validate(context: ActionContext): ValidationResult {
    const inventory = context.world
      .getContents(context.player.id);
    const hasCamera = inventory.some(item =>
      item.get(IdentityTrait)?.aliases?.includes('camera'));
    if (!hasCamera) {
      return { valid: false, error: PhotoMessages.NO_CAMERA };
    }

    const target = context.command.directObject?.entity;
    if (target) context.sharedData.photoTarget = target;
    return { valid: true };
  },

  execute(_context: ActionContext): void {
    // Photographs are cosmetic; nothing in the world changes.
  },

  report(context: ActionContext): ISemanticEvent[] {
    const target =
      context.sharedData.photoTarget as IFEntity | undefined;
    const name =
      target?.get(IdentityTrait)?.name || 'the scenery';
    return [context.event('zoo.event.photographed', {
      messageId: PhotoMessages.TOOK_PHOTO,
      params: { target: name },
    })];
  },

  blocked(
    context: ActionContext,
    result: ValidationResult,
  ): ISemanticEvent[] {
    return [context.event('zoo.event.photographing_blocked', {
      messageId: result.error || PhotoMessages.NO_CAMERA,
    })];
  },
};
step 5 typescript
const camera = world.createEntity(
  'disposable camera',
  EntityType.ITEM,
);
camera.add(new IdentityTrait({
  name: 'disposable camera',
  description:
    'A cheap yellow disposable camera with "ZOO MEMORIES" ' +
    'on the side.',
  aliases: ['camera', 'disposable camera'],
  article: 'a',
}));
world.moveEntity(camera.id, giftShop.id);

Telling the engine: getCustomActions

step 6 typescript
getCustomActions(): any[] {
  return [feedAction, photographAction];
}

Teaching the parser: extendParser

step 7 typescript
extendParser(parser: Parser): void {
  const grammar = parser.getStoryGrammar();

  grammar.define('feed :thing')
    .mapsTo(FEED_ACTION_ID).withPriority(150).build();

  grammar.define('photograph :thing')
    .mapsTo(PHOTOGRAPH_ACTION_ID).withPriority(150).build();
  grammar.define('photo :thing')
    .mapsTo(PHOTOGRAPH_ACTION_ID).withPriority(150).build();
  grammar.define('snap :thing')
    .mapsTo(PHOTOGRAPH_ACTION_ID).withPriority(150).build();
}

Supplying the text: extendLanguage

step 8 typescript
extendLanguage(language: LanguageProvider): void {
  // Feed action: every FeedMessages id needs text, or the
  // player sees raw ids.
  language.addMessage(FeedMessages.NO_FEED,
    "You don't have any animal feed.");
  language.addMessage(FeedMessages.NOT_AN_ANIMAL,
    "That's not something you can feed.");
  language.addMessage(FeedMessages.ALREADY_FED,
    "You've already fed them. They look contentedly full.");
  language.addMessage(FeedMessages.FED_GOATS,
    'You scatter some feed on the ground. The pygmy goats ' +
    'rush over, bleating excitedly, and devour the corn and ' +
    'pellets in seconds.');
  language.addMessage(FeedMessages.FED_RABBITS,
    'You sprinkle some pellets near the rabbits. They hop ' +
    'over cautiously, then munch away happily, their little ' +
    'noses twitching.');
  language.addMessage(FeedMessages.FED_GENERIC,
    'You offer some feed. The animal eats it gratefully.');

  // Photograph action.
  language.addMessage(PhotoMessages.NO_CAMERA,
    "You don't have a camera. There's one in the gift shop.");
  language.addMessage(PhotoMessages.TOOK_PHOTO,
    "Click! You snap a photo of {the target}. That one's " +
    "going on the fridge.");
}

Combined runnable file

The complete, runnable story at the end of this chapter (tested source: tutorials/familyzoo/v2.0.0/src/ch14-custom-actions.ts).

runnable ch14-custom-actions.ts typescript
/**
 * Family Zoo Tutorial — Version 13: Custom Actions
 *
 * NEW IN THIS VERSION:
 *   - Story-specific actions — new verbs that don't exist in stdlib
 *   - The Action interface — validate / execute / report / blocked
 *   - Grammar extension via extendParser() — teaching the parser new verbs
 *   - Language extension via extendLanguage() — registering message text
 *   - getCustomActions() — telling the engine about your actions
 *   - A disposable camera item in the gift shop
 *
 * WHAT YOU'LL LEARN:
 *   - Custom actions let you add any verb to your game
 *   - Actions follow the four-phase pattern: validate → execute → report → blocked
 *   - Grammar patterns teach the parser to recognize your new verbs
 *   - Message IDs let you define the text the player sees
 *
 * TRY IT:
 *   > south / east                   (go to petting zoo)
 *   > take feed                      (pick up the feed)
 *   > feed goats                     (feed the goats!)
 *   > feed goats                     (already fed — blocked)
 *   > west / west / west             (go to gift shop)
 *   > take camera                    (pick up the camera)
 *   > photograph postcards           (take a photo)
 *   > east                           (go to aviary)
 *   > photograph toucan              (take another photo)
 *
 * BUILD & RUN:
 *   ./build.sh -s familyzoo
 *   node dist/cli/sharpee.js --story tutorials/familyzoo --play
 */

// ============================================================================
// IMPORTS
// ============================================================================

import { Story, StoryConfig, GameEngine } from '@sharpee/engine';
import {
  WorldModel,
  IFEntity,
  EntityType,
  Direction,
  IWorldModel,
} from '@sharpee/world-model';
import {
  IdentityTrait,
  ActorTrait,
  ContainerTrait,
  SupporterTrait,
  RoomTrait,
  SceneryTrait,
  OpenableTrait,
  LockableTrait,
  DoorTrait,
  SwitchableTrait,
  LightSourceTrait,
  ReadableTrait,
} from '@sharpee/world-model';
import { ISemanticEvent } from '@sharpee/core';
import {
  // --- Custom Action types (NEW in V13) ---
  Action,           // The interface your action must implement
  ActionContext,    // Context passed to each action phase
  ValidationResult, // What validate() returns
} from '@sharpee/stdlib';
// NPCs (zookeeper, parrot behavior) arrive in Chapter 20; none in this snapshot.

// Parser type — needed for extendParser()
import type { Parser } from '@sharpee/parser-en-us';

// LanguageProvider type — needed for extendLanguage()
import type { LanguageProvider } from '@sharpee/lang-en-us';


// ============================================================================
// STORY CONFIGURATION
// ============================================================================

const config: StoryConfig = {
  id: 'familyzoo',
  title: 'Family Zoo',
  author: 'Sharpee Tutorial',
  version: '0.13.0',
  description: 'A small family zoo — learn Sharpee one concept at a time.',
};


// ============================================================================
// CUSTOM ACTION: FEED — NEW IN V13
// ============================================================================
//
// A custom action is any new verb the player can type.
// It must implement the Action interface with four phases:
//
//   validate(context) — can the action proceed? Returns { valid, error }
//   execute(context)  — do the mutation (change world state)
//   report(context)   — return events describing what happened (for text output)
//   blocked(context)  — return events explaining why it failed
//
// The engine calls these in order:
//   1. validate() — if invalid, skip to blocked()
//   2. execute()  — mutate the world
//   3. report()   — generate success events
//   (or)
//   1. validate() — if invalid...
//   4. blocked()  — generate failure events

// Action ID — this must match the grammar's mapsTo() call.
const FEED_ACTION_ID = 'zoo.action.feeding';

// Message IDs — these are looked up in the language provider.
const FeedMessages = {
  NO_FEED: 'zoo.feeding.no_feed',
  NOT_AN_ANIMAL: 'zoo.feeding.not_animal',
  ALREADY_FED: 'zoo.feeding.already_fed',
  FED_GOATS: 'zoo.feeding.fed_goats',
  FED_RABBITS: 'zoo.feeding.fed_rabbits',
  FED_GENERIC: 'zoo.feeding.fed_generic',
} as const;

/**
 * The feed action — "feed goats", "feed rabbits", etc.
 *
 * Validates that:
 *   - The player is carrying animal feed
 *   - The target is a scenery animal (goats, rabbits)
 *   - The animal hasn't been fed already
 *
 * Executes:
 *   - Sets a "fed" flag on the target entity
 *
 * Reports:
 *   - A custom message based on which animal was fed
 */
const feedAction: Action = {
  id: FEED_ACTION_ID,
  group: 'interaction',

  validate(context: ActionContext): ValidationResult {
    const target = context.command.directObject?.entity;

    // Check the player is carrying the feed
    const player = context.player;
    const inventory = context.world.getContents(player.id);
    const hasFeed = inventory.some(item => {
      const identity = item.get(IdentityTrait);
      return identity?.aliases?.includes('feed');
    });

    if (!hasFeed) {
      return { valid: false, error: FeedMessages.NO_FEED };
    }

    // Check we have a target animal
    if (!target) {
      return { valid: false, error: FeedMessages.NOT_AN_ANIMAL };
    }

    // Check target is a known feedable animal
    const identity = target.get(IdentityTrait);
    const name = identity?.name?.toLowerCase() || '';
    const feedable = ['pygmy goats', 'rabbits'].some(a => name.includes(a));
    if (!feedable) {
      return { valid: false, error: FeedMessages.NOT_AN_ANIMAL };
    }

    // Check not already fed
    if (context.world.getStateValue(`fed-${target.id}`)) {
      return { valid: false, error: FeedMessages.ALREADY_FED };
    }

    // Store the target for execute/report
    context.sharedData.feedTarget = target;
    return { valid: true };
  },

  execute(context: ActionContext): void {
    const target = context.sharedData.feedTarget as IFEntity;
    if (target) {
      // Mark this animal as fed
      context.world.setStateValue(`fed-${target.id}`, true);
    }
  },

  report(context: ActionContext): ISemanticEvent[] {
    const target = context.sharedData.feedTarget as IFEntity;
    const identity = target?.get(IdentityTrait);
    const name = identity?.name?.toLowerCase() || '';

    // Pick a message based on the animal
    let messageId: string = FeedMessages.FED_GENERIC;
    if (name.includes('goats')) messageId = FeedMessages.FED_GOATS;
    else if (name.includes('rabbits')) messageId = FeedMessages.FED_RABBITS;

    return [
      context.event('zoo.event.fed', {
        messageId,
        params: { animal: identity?.name || 'animal' },
      }),
    ];
  },

  blocked(context: ActionContext, result: ValidationResult): ISemanticEvent[] {
    return [
      context.event('zoo.event.feeding_blocked', {
        messageId: result.error || FeedMessages.NOT_AN_ANIMAL,
      }),
    ];
  },
};


// ============================================================================
// CUSTOM ACTION: PHOTOGRAPH — NEW IN V13
// ============================================================================

const PHOTOGRAPH_ACTION_ID = 'zoo.action.photographing';

const PhotoMessages = {
  NO_CAMERA: 'zoo.photo.no_camera',
  TOOK_PHOTO: 'zoo.photo.took_photo',
} as const;

/**
 * The photograph action — "photograph toucan", "photograph sign", etc.
 *
 * Validates that the player is carrying a camera.
 * On success, reports a fun message about the photo.
 */
const photographAction: Action = {
  id: PHOTOGRAPH_ACTION_ID,
  group: 'interaction',

  validate(context: ActionContext): ValidationResult {
    // Check the player has a camera
    const player = context.player;
    const inventory = context.world.getContents(player.id);
    const hasCamera = inventory.some(item => {
      const identity = item.get(IdentityTrait);
      return identity?.aliases?.includes('camera');
    });

    if (!hasCamera) {
      return { valid: false, error: PhotoMessages.NO_CAMERA };
    }

    // Any target is valid — you can photograph anything
    const target = context.command.directObject?.entity;
    if (target) {
      context.sharedData.photoTarget = target;
    }
    return { valid: true };
  },

  execute(_context: ActionContext): void {
    // No world mutation needed — photographs are cosmetic
  },

  report(context: ActionContext): ISemanticEvent[] {
    const target = context.sharedData.photoTarget as IFEntity | undefined;
    const name = target?.get(IdentityTrait)?.name || 'the scenery';

    return [
      context.event('zoo.event.photographed', {
        messageId: PhotoMessages.TOOK_PHOTO,
        params: { target: name },
      }),
    ];
  },

  blocked(context: ActionContext, result: ValidationResult): ISemanticEvent[] {
    return [
      context.event('zoo.event.photographing_blocked', {
        messageId: result.error || PhotoMessages.NO_CAMERA,
      }),
    ];
  },
};


// ============================================================================
// PARROT BEHAVIOR — same as V11
// ============================================================================

// ============================================================================
// THE STORY CLASS
// ============================================================================

class FamilyZooStory implements Story {
  config = config;

  private roomIds: {
    entrance: string; mainPath: string; pettingZoo: string;
    aviary: string; supplyRoom: string; nocturnalExhibit: string; giftShop: string;
  } = { entrance: '', mainPath: '', pettingZoo: '', aviary: '', supplyRoom: '', nocturnalExhibit: '', giftShop: '' };

  private entityIds: {
    animalFeed: string; penny: string; souvenirPress: string;
  } = { animalFeed: '', penny: '', souvenirPress: '' };

  createPlayer(world: WorldModel): IFEntity {
    const player = world.createEntity('yourself', EntityType.ACTOR);
    player.add(new IdentityTrait({ name: 'yourself', description: 'Just an ordinary visitor to the zoo.', aliases: ['self', 'myself', 'me'], properName: true, article: '' }));
    player.add(new ActorTrait({ isPlayer: true }));
    player.add(new ContainerTrait({ capacity: { maxItems: 10 } }));
    return player;
  }

  initializeWorld(world: WorldModel): void {

    // ========================================================================
    // ROOMS — same as V12
    // ========================================================================

    const entrance = world.createEntity('Zoo Entrance', EntityType.ROOM);
    entrance.add(new RoomTrait({ exits: {}, isDark: false }));
    entrance.add(new IdentityTrait({ name: 'Zoo Entrance', description: 'You stand before the wrought-iron gates of the Willowbrook Family Zoo. A cheerful welcome sign arches over the entrance, and a small ticket booth sits to one side. A sturdy iron fence runs along either side of the gates. The main path leads south into the zoo grounds.', aliases: ['entrance', 'gates', 'gate'], properName: false, article: 'the' }));

    const mainPath = world.createEntity('Main Path', EntityType.ROOM);
    mainPath.add(new RoomTrait({ exits: {}, isDark: false }));
    mainPath.add(new IdentityTrait({ name: 'Main Path', description: 'A wide gravel path winds through the heart of the zoo. Colorful direction signs point every which way. A park bench sits beside the path. To the east, the petting zoo. To the west, the aviary. A staff gate blocks the path to the south. The gift shop is also to the west, past the aviary. The entrance is back to the north.', aliases: ['path', 'main path', 'gravel path'], properName: false, article: 'the' }));

    const pettingZoo = world.createEntity('Petting Zoo', EntityType.ROOM);
    pettingZoo.add(new RoomTrait({ exits: {}, isDark: false }));
    pettingZoo.add(new IdentityTrait({ name: 'Petting Zoo', description: 'A cheerful open-air enclosure filled with friendly animals. Pygmy goats trot around nibbling at visitors\' shoelaces, while a pair of fluffy rabbits hop lazily near a hay bale. A feed dispenser is mounted on a post. An info plaque is posted by the gate. The main path is back to the west.', aliases: ['petting zoo', 'petting area', 'pen'], properName: false, article: 'the' }));

    const aviary = world.createEntity('Aviary', EntityType.ROOM);
    aviary.add(new RoomTrait({ exits: {}, isDark: false }));
    aviary.add(new IdentityTrait({ name: 'Aviary', description: 'You step inside a soaring mesh dome. Brilliantly colored parrots chatter from rope perches, and a toucan eyes you curiously from a branch overhead. A small waterfall splashes into a stone basin. An info plaque hangs near the entrance. The gift shop is to the west. The main path is back to the east.', aliases: ['aviary', 'bird house', 'dome'], properName: false, article: 'the' }));

    const supplyRoom = world.createEntity('Supply Room', EntityType.ROOM);
    supplyRoom.add(new RoomTrait({ exits: {}, isDark: false }));
    supplyRoom.add(new IdentityTrait({ name: 'Supply Room', description: 'A cluttered storage room behind the staff gate. Metal shelves line the walls. A cork board on the wall is covered with staff schedules. A battered radio sits on one of the shelves. The staff gate leads back north.', aliases: ['supply room', 'storage room', 'storeroom'], properName: false, article: 'the' }));

    const nocturnalExhibit = world.createEntity('Nocturnal Animals Exhibit', EntityType.ROOM);
    nocturnalExhibit.add(new RoomTrait({ exits: {}, isDark: true }));
    nocturnalExhibit.add(new IdentityTrait({ name: 'Nocturnal Animals Exhibit', description: 'A cool, dimly lit cavern designed to simulate nighttime. Glass enclosures line both walls with soft red lights. You can see sugar gliders, bush babies, and a barn owl. A warning sign is posted near the entrance. The exit leads back north to the supply room.', aliases: ['nocturnal exhibit', 'nocturnal animals', 'exhibit'], properName: false, article: 'the' }));

    const giftShop = world.createEntity('Gift Shop', EntityType.ROOM);
    giftShop.add(new RoomTrait({ exits: {}, isDark: false }));
    giftShop.add(new IdentityTrait({ name: 'Gift Shop', description: 'A small zoo gift shop crammed with stuffed animals and postcards. A large souvenir penny press machine stands near the door. A disposable camera sits on the counter. The aviary is back to the east.', aliases: ['gift shop', 'shop', 'store'], properName: false, article: 'the' }));

    this.roomIds = {
      entrance: entrance.id, mainPath: mainPath.id, pettingZoo: pettingZoo.id,
      aviary: aviary.id, supplyRoom: supplyRoom.id,
      nocturnalExhibit: nocturnalExhibit.id, giftShop: giftShop.id,
    };

    // ========================================================================
    // STAFF GATE
    // ========================================================================

    const keycard = world.createEntity('staff keycard', EntityType.ITEM);
    keycard.add(new IdentityTrait({ name: 'staff keycard', description: 'A white plastic keycard with "WILLOWBROOK ZOO — STAFF ONLY" printed in blue.', aliases: ['keycard', 'key card', 'card', 'key', 'staff keycard'], properName: false, article: 'a' }));
    world.moveEntity(keycard.id, entrance.id);

    const staffGate = world.createEntity('staff gate', EntityType.DOOR);
    staffGate.add(new IdentityTrait({ name: 'staff gate', description: 'A sturdy metal gate with a "STAFF ONLY" sign. There\'s a card reader beside it.', aliases: ['gate', 'staff gate', 'metal gate', 'staff door'], properName: false, article: 'a' }));
    staffGate.add(new DoorTrait({ room1: mainPath.id, room2: supplyRoom.id, bidirectional: true }));
    staffGate.add(new OpenableTrait({ isOpen: false }));
    staffGate.add(new LockableTrait({ isLocked: true, keyId: keycard.id }));
    staffGate.add(new SceneryTrait());
    world.moveEntity(staffGate.id, mainPath.id);

    // ========================================================================
    // EXITS — same as V12
    // ========================================================================

    entrance.get(RoomTrait)!.exits = { [Direction.SOUTH]: { destination: mainPath.id } };
    mainPath.get(RoomTrait)!.exits = { [Direction.NORTH]: { destination: entrance.id }, [Direction.EAST]: { destination: pettingZoo.id }, [Direction.WEST]: { destination: aviary.id }, [Direction.SOUTH]: { destination: supplyRoom.id, via: staffGate.id } };
    pettingZoo.get(RoomTrait)!.exits = { [Direction.WEST]: { destination: mainPath.id } };
    aviary.get(RoomTrait)!.exits = { [Direction.EAST]: { destination: mainPath.id }, [Direction.WEST]: { destination: giftShop.id } };
    supplyRoom.get(RoomTrait)!.exits = { [Direction.NORTH]: { destination: mainPath.id, via: staffGate.id }, [Direction.SOUTH]: { destination: nocturnalExhibit.id } };
    nocturnalExhibit.get(RoomTrait)!.exits = { [Direction.NORTH]: { destination: supplyRoom.id } };
    giftShop.get(RoomTrait)!.exits = { [Direction.EAST]: { destination: aviary.id } };

    // ========================================================================
    // SCENERY (abbreviated)
    // ========================================================================

    const sign = world.createEntity('welcome sign', EntityType.SCENERY);
    sign.add(new IdentityTrait({ name: 'welcome sign', description: 'A brightly painted wooden sign reads: "WELCOME TO WILLOWBROOK FAMILY ZOO."', aliases: ['sign', 'welcome sign'], properName: false, article: 'a' }));
    world.moveEntity(sign.id, entrance.id);

    const booth = world.createEntity('ticket booth', EntityType.SCENERY);
    booth.add(new IdentityTrait({ name: 'ticket booth', description: 'A small wooden booth with a "Self-Guided Tours" sign.', aliases: ['booth', 'ticket booth'], properName: false, article: 'a' }));
    world.moveEntity(booth.id, entrance.id);

    const ironFence = world.createEntity('iron fence', EntityType.SCENERY);
    ironFence.add(new IdentityTrait({ name: 'iron fence', description: 'A tall wrought-iron fence with animal silhouettes.', aliases: ['fence', 'iron fence', 'railing'], properName: false, article: 'an' }));
    world.moveEntity(ironFence.id, entrance.id);

    const directionSigns = world.createEntity('direction signs', EntityType.SCENERY);
    directionSigns.add(new IdentityTrait({ name: 'direction signs', grammaticalNumber: 'plural', description: 'Arrow signs: PETTING ZOO (east), AVIARY (west), EXIT (north).', aliases: ['signs', 'direction signs', 'arrow signs'], properName: false, article: 'some' }));
    world.moveEntity(directionSigns.id, mainPath.id);

    const flowerBeds = world.createEntity('flower beds', EntityType.SCENERY);
    flowerBeds.add(new IdentityTrait({ name: 'flower beds', grammaticalNumber: 'plural', description: 'Tidy beds of marigolds and petunias.', aliases: ['flowers', 'flower beds'], properName: false, article: 'some' }));
    world.moveEntity(flowerBeds.id, mainPath.id);

    const goats = world.createEntity('pygmy goats', EntityType.SCENERY);
    goats.add(new IdentityTrait({ name: 'pygmy goats', grammaticalNumber: 'plural', description: 'Three pygmy goats hoping you have food.', aliases: ['goats', 'pygmy goats', 'goat'], properName: false, article: 'some' }));
    world.moveEntity(goats.id, pettingZoo.id);

    const hayBale = world.createEntity('hay bale', EntityType.SCENERY);
    hayBale.add(new IdentityTrait({ name: 'hay bale', description: 'A large round bale of golden hay.', aliases: ['hay', 'hay bale', 'bale'], properName: false, article: 'a' }));
    world.moveEntity(hayBale.id, pettingZoo.id);

    const rabbits = world.createEntity('rabbits', EntityType.SCENERY);
    rabbits.add(new IdentityTrait({ name: 'rabbits', grammaticalNumber: 'plural', description: 'A pair of Holland Lop rabbits with floppy ears.', aliases: ['rabbits', 'rabbit', 'bunnies'], properName: false, article: 'some' }));
    world.moveEntity(rabbits.id, pettingZoo.id);

    const toucan = world.createEntity('toucan', EntityType.SCENERY);
    toucan.add(new IdentityTrait({ name: 'toucan', description: 'A Toco toucan with an enormous orange-and-black bill.', aliases: ['toucan', 'toco toucan'], properName: false, article: 'a' }));
    world.moveEntity(toucan.id, aviary.id);

    const parrots = world.createEntity('parrots', EntityType.SCENERY);
    parrots.add(new IdentityTrait({ name: 'parrots', grammaticalNumber: 'plural', description: 'A raucous flock of scarlet macaws and grey African parrots.', aliases: ['parrots', 'macaws', 'birds'], properName: false, article: 'some' }));
    world.moveEntity(parrots.id, aviary.id);

    const waterfall = world.createEntity('waterfall', EntityType.SCENERY);
    waterfall.add(new IdentityTrait({ name: 'waterfall', description: 'A gentle artificial waterfall cascading into a stone basin.', aliases: ['waterfall', 'water', 'basin'], properName: false, article: 'a' }));
    world.moveEntity(waterfall.id, aviary.id);

    const perches = world.createEntity('rope perches', EntityType.SCENERY);
    perches.add(new IdentityTrait({ name: 'rope perches', grammaticalNumber: 'plural', description: 'Thick sisal ropes strung between wooden posts — both furniture and snacks for the parrots.', aliases: ['perches', 'rope perches', 'ropes', 'rope'], properName: false, article: 'some' }));
    world.moveEntity(perches.id, aviary.id);

    const shelves = world.createEntity('metal shelves', EntityType.SCENERY);
    shelves.add(new IdentityTrait({ name: 'metal shelves', grammaticalNumber: 'plural', description: 'Industrial metal shelving units stacked with supplies.', aliases: ['shelves', 'metal shelves', 'shelf'], properName: false, article: 'some' }));
    world.moveEntity(shelves.id, supplyRoom.id);

    const corkBoard = world.createEntity('cork board', EntityType.SCENERY);
    corkBoard.add(new IdentityTrait({ name: 'cork board', description: 'A cork board with staff schedules. A note in red marker: "DON\'T FORGET: nocturnal exhibit lights need new batteries!"', aliases: ['cork board', 'board', 'notices'], properName: false, article: 'a' }));
    world.moveEntity(corkBoard.id, supplyRoom.id);

    const sugarGliders = world.createEntity('sugar gliders', EntityType.SCENERY);
    sugarGliders.add(new IdentityTrait({ name: 'sugar gliders', grammaticalNumber: 'plural', description: 'A family of tiny sugar gliders with enormous dark eyes, leaping between branches.', aliases: ['sugar gliders', 'gliders'], properName: false, article: 'some' }));
    world.moveEntity(sugarGliders.id, nocturnalExhibit.id);

    const bushBabies = world.createEntity('bush babies', EntityType.SCENERY);
    bushBabies.add(new IdentityTrait({ name: 'bush babies', grammaticalNumber: 'plural', description: 'Two bush babies with impossibly large round eyes, clinging to a rope with tiny hands.', aliases: ['bush babies', 'galagos'], properName: false, article: 'some' }));
    world.moveEntity(bushBabies.id, nocturnalExhibit.id);

    const barnOwl = world.createEntity('barn owl', EntityType.SCENERY);
    barnOwl.add(new IdentityTrait({ name: 'barn owl', description: 'An enormous barn owl with a heart-shaped white face on a fake tree stump.', aliases: ['barn owl', 'owl'], properName: false, article: 'a' }));
    world.moveEntity(barnOwl.id, nocturnalExhibit.id);

    const stuffedAnimals = world.createEntity('stuffed animals', EntityType.SCENERY);
    stuffedAnimals.add(new IdentityTrait({ name: 'stuffed animals', grammaticalNumber: 'plural', description: 'Shelves of plush tigers, pandas, and penguins in various sizes.', aliases: ['stuffed animals', 'plush', 'toys'], properName: false, article: 'some' }));
    world.moveEntity(stuffedAnimals.id, giftShop.id);

    const postcards = world.createEntity('postcards', EntityType.SCENERY);
    postcards.add(new IdentityTrait({ name: 'postcards', grammaticalNumber: 'plural', description: 'A spinning rack of postcards showing the zoo\'s greatest hits.', aliases: ['postcards', 'cards', 'postcard rack'], properName: false, article: 'some' }));
    world.moveEntity(postcards.id, giftShop.id);

    // ========================================================================
    // READABLE OBJECTS
    // ========================================================================

    const pettingPlaque = world.createEntity('info plaque', EntityType.SCENERY);
    pettingPlaque.add(new IdentityTrait({ name: 'info plaque', description: 'A brass plaque mounted on a wooden post near the petting zoo gate.', aliases: ['plaque', 'info plaque', 'brass plaque'], properName: false, article: 'an' }));
    pettingPlaque.add(new ReadableTrait({ text: 'PYGMY GOATS — These Nigerian Dwarf goats are gentle, curious, and always hungry.\n\nHOLLAND LOP RABBITS — Known for their floppy ears. Our pair, Biscuit and Marmalade, were born here in 2023.' }));
    world.moveEntity(pettingPlaque.id, pettingZoo.id);

    const aviaryPlaque = world.createEntity('aviary plaque', EntityType.SCENERY);
    aviaryPlaque.add(new IdentityTrait({ name: 'aviary plaque', description: 'A colorful information board near the aviary entrance.', aliases: ['plaque', 'aviary plaque', 'information board'], properName: false, article: 'an' }));
    aviaryPlaque.add(new ReadableTrait({ text: 'WELCOME TO THE AVIARY — Home to over 30 species!\n\nTOCO TOUCAN — Its bill weighs less than a smartphone.\n\nSCARLET MACAW — Can live over 75 years. Our oldest, Captain, is 42.' }));
    world.moveEntity(aviaryPlaque.id, aviary.id);

    const warningSign = world.createEntity('warning sign', EntityType.SCENERY);
    warningSign.add(new IdentityTrait({ name: 'warning sign', description: 'A yellow warning sign near the nocturnal exhibit entrance.', aliases: ['warning', 'warning sign', 'yellow sign'], properName: false, article: 'a' }));
    warningSign.add(new ReadableTrait({ text: 'CAUTION: The Nocturnal Animals Exhibit is kept dark. Please use a flashlight. Do NOT use camera flash. (We don\'t talk about the Great Owl Incident of 2022.)' }));
    world.moveEntity(warningSign.id, supplyRoom.id);

    const brochure = world.createEntity('zoo brochure', EntityType.ITEM);
    brochure.add(new IdentityTrait({ name: 'zoo brochure', description: 'A glossy tri-fold brochure with "WILLOWBROOK FAMILY ZOO" on the cover.', aliases: ['brochure', 'zoo brochure', 'pamphlet', 'leaflet'], properName: false, article: 'a' }));
    brochure.add(new ReadableTrait({ text: 'WILLOWBROOK FAMILY ZOO — Your Guide\n\nEXHIBITS:\n  Petting Zoo — East from Main Path\n  Aviary — West from Main Path\n  Gift Shop — West from Aviary\n  Nocturnal Animals — Staff Area\n\n"Where every visit is a wild adventure!"' }));
    world.moveEntity(brochure.id, entrance.id);

    // ========================================================================
    // SWITCHABLE DEVICES
    // ========================================================================

    const radio = world.createEntity('radio', EntityType.ITEM);
    radio.add(new IdentityTrait({ name: 'radio', description: 'A battered portable radio held together with duct tape.', aliases: ['radio', 'portable radio'], properName: false, article: 'a' }));
    radio.add(new SwitchableTrait({ isOn: false }));
    radio.add(new SceneryTrait());
    world.moveEntity(radio.id, supplyRoom.id);

    // ========================================================================
    // PORTABLE OBJECTS
    // ========================================================================

    const zooMap = world.createEntity('zoo map', EntityType.ITEM);
    zooMap.add(new IdentityTrait({ name: 'zoo map', description: 'A colorful folding map of the Willowbrook Family Zoo.', aliases: ['map', 'zoo map', 'folding map'], properName: false, article: 'a' }));
    world.moveEntity(zooMap.id, entrance.id);

    const animalFeed = world.createEntity('bag of animal feed', EntityType.ITEM);
    animalFeed.add(new IdentityTrait({ name: 'bag of animal feed', description: 'A small brown paper bag filled with dried corn and pellets.', aliases: ['feed', 'animal feed', 'bag of feed', 'corn'], properName: false, article: 'a' }));
    world.moveEntity(animalFeed.id, pettingZoo.id);

    const penny = world.createEntity('souvenir penny', EntityType.ITEM);
    penny.add(new IdentityTrait({ name: 'souvenir penny', description: 'A shiny copper penny that could fit in a souvenir press machine.', aliases: ['penny', 'souvenir penny', 'coin'], properName: false, article: 'a' }));
    world.moveEntity(penny.id, mainPath.id);

    const flashlight = world.createEntity('flashlight', EntityType.ITEM);
    flashlight.add(new IdentityTrait({ name: 'flashlight', description: 'A heavy-duty yellow flashlight with "PROPERTY OF WILLOWBROOK ZOO" stenciled on the side.', aliases: ['flashlight', 'torch', 'light', 'lamp'], properName: false, article: 'a' }));
    flashlight.add(new SwitchableTrait({ isOn: false }));
    flashlight.add(new LightSourceTrait({ brightness: 8, isLit: false }));
    world.moveEntity(flashlight.id, supplyRoom.id);

    // --- Disposable Camera (NEW in V13) ---
    //
    // The camera is needed for the "photograph" action.
    // Without it, "photograph X" is blocked.
    const camera = world.createEntity('disposable camera', EntityType.ITEM);
    camera.add(new IdentityTrait({
      name: 'disposable camera',
      description: 'A cheap yellow disposable camera with "ZOO MEMORIES" printed on the side. It has a few exposures left.',
      aliases: ['camera', 'disposable camera'],
      properName: false,
      article: 'a',
    }));
    world.moveEntity(camera.id, giftShop.id);

    this.entityIds = {
      animalFeed: animalFeed.id,
      penny: penny.id,
      souvenirPress: '',
    };

    // ========================================================================
    // CONTAINERS
    // ========================================================================

    const backpack = world.createEntity('backpack', EntityType.CONTAINER);
    backpack.add(new IdentityTrait({ name: 'backpack', description: 'A small red canvas backpack with a cartoon monkey patch.', aliases: ['backpack', 'rucksack', 'pack'], properName: false, article: 'a' }));
    backpack.add(new ContainerTrait({ capacity: { maxItems: 5 } }));
    world.moveEntity(backpack.id, entrance.id);

    const parkBench = world.createEntity('park bench', EntityType.SUPPORTER);
    parkBench.add(new IdentityTrait({ name: 'park bench', description: 'A sturdy park bench painted forest green. Plaque: "In memory of Mr. Whiskers."', aliases: ['bench', 'park bench', 'benches', 'seat'], properName: false, article: 'a' }));
    parkBench.add(new SupporterTrait({ capacity: { maxItems: 3 } }));
    parkBench.add(new SceneryTrait());
    world.moveEntity(parkBench.id, mainPath.id);

    const lunchbox = world.createEntity('lunchbox', EntityType.CONTAINER);
    lunchbox.add(new IdentityTrait({ name: 'lunchbox', description: 'A dented metal lunchbox decorated with cartoon zoo animals.', aliases: ['lunchbox', 'lunch box', 'box'], properName: false, article: 'a' }));
    lunchbox.add(new ContainerTrait({ capacity: { maxItems: 3 } }));
    lunchbox.add(new OpenableTrait({ isOpen: false }));
    world.moveEntity(lunchbox.id, mainPath.id);

    lunchbox.get(OpenableTrait)!.isOpen = true;
    const juice = world.createEntity('juice box', EntityType.ITEM);
    juice.add(new IdentityTrait({ name: 'juice box', description: 'A small juice box with a picture of a happy elephant.', aliases: ['juice', 'juice box', 'drink'], properName: false, article: 'a' }));
    world.moveEntity(juice.id, lunchbox.id);
    lunchbox.get(OpenableTrait)!.isOpen = false;

    const dispenser = world.createEntity('feed dispenser', EntityType.CONTAINER);
    dispenser.add(new IdentityTrait({ name: 'feed dispenser', description: 'A coin-operated feed dispenser mounted on a wooden post. Sign: "FREE — Just Turn!"', aliases: ['dispenser', 'feed dispenser'], properName: false, article: 'a' }));
    dispenser.add(new ContainerTrait({ capacity: { maxItems: 3 } }));
    dispenser.add(new OpenableTrait({ isOpen: false }));
    dispenser.add(new SceneryTrait());
    world.moveEntity(dispenser.id, pettingZoo.id);

    const souvenirPress = world.createEntity('souvenir press', EntityType.CONTAINER);
    souvenirPress.add(new IdentityTrait({ name: 'souvenir press', description: 'A heavy cast-iron machine with a big crank handle. A slot on top accepts pennies.', aliases: ['press', 'souvenir press', 'penny press', 'machine'], properName: false, article: 'a' }));
    souvenirPress.add(new ContainerTrait({ capacity: { maxItems: 1 } }));
    souvenirPress.add(new SceneryTrait());
    world.moveEntity(souvenirPress.id, giftShop.id);
    this.entityIds.souvenirPress = souvenirPress.id;

    // (NPCs — the zookeeper and the parrot — arrive in Chapter 20.)

    // ========================================================================
    // PLAYER STARTING LOCATION
    // ========================================================================

    const player = world.getPlayer();
    if (player) {
      world.moveEntity(player.id, entrance.id);
    }
  }


  // ==========================================================================
  // getCustomActions — NEW IN V13
  // ==========================================================================
  //
  // The engine calls this to discover your story's custom actions.
  // Return an array of Action objects. Each one becomes a verb the
  // player can use.

  getCustomActions(): any[] {
    return [feedAction, photographAction];
  }


  // ==========================================================================
  // extendParser — NEW IN V13
  // ==========================================================================
  //
  // This is where you teach the parser to recognize your new verbs.
  // The grammar builder lets you define patterns like "feed :thing"
  // that map to your action IDs.
  //
  // Pattern syntax:
  //   'verb'           — a bare verb (no arguments)
  //   'verb :slot'     — verb + one argument (entity resolved by parser)
  //   'verb :slot1 prep :slot2' — verb + preposition + two arguments
  //
  // Use withPriority(150+) for story-specific patterns so they
  // take precedence over stdlib defaults.

  extendParser(parser: Parser): void {
    const grammar = parser.getStoryGrammar();

    // "feed <thing>" → zoo.action.feeding
    grammar
      .define('feed :thing')
      .mapsTo(FEED_ACTION_ID)
      .withPriority(150)
      .build();

    // "photograph <thing>" and "photo <thing>" → zoo.action.photographing
    grammar
      .define('photograph :thing')
      .mapsTo(PHOTOGRAPH_ACTION_ID)
      .withPriority(150)
      .build();

    grammar
      .define('photo :thing')
      .mapsTo(PHOTOGRAPH_ACTION_ID)
      .withPriority(150)
      .build();

    // "snap :thing" as a fun alias
    grammar
      .define('snap :thing')
      .mapsTo(PHOTOGRAPH_ACTION_ID)
      .withPriority(150)
      .build();
  }


  // ==========================================================================
  // extendLanguage — NEW IN V13
  // ==========================================================================
  //
  // Register the text for your custom message IDs.
  // When an action returns context.event('zoo.event.fed', { messageId: '...' }),
  // the text service looks up the messageId in the language provider.
  //
  // language.addMessage(messageId, text) registers the text.
  // Use {param} for template parameters.

  extendLanguage(language: LanguageProvider): void {
    // Feed action messages
    language.addMessage(FeedMessages.NO_FEED,
      "You don't have any animal feed.");
    language.addMessage(FeedMessages.NOT_AN_ANIMAL,
      "That's not something you can feed.");
    language.addMessage(FeedMessages.ALREADY_FED,
      "You've already fed them. They look contentedly full.");
    language.addMessage(FeedMessages.FED_GOATS,
      'You scatter some feed on the ground. The pygmy goats rush over, bleating excitedly, and devour the corn and pellets in seconds. The smallest goat looks up at you with big grateful eyes.');
    language.addMessage(FeedMessages.FED_RABBITS,
      'You sprinkle some pellets near the rabbits. Biscuit and Marmalade hop over cautiously, then munch away happily, their little noses twitching.');
    language.addMessage(FeedMessages.FED_GENERIC,
      'You offer some feed. The animal eats it gratefully.');

    // Photograph action messages
    language.addMessage(PhotoMessages.NO_CAMERA,
      "You don't have a camera. There's one in the gift shop.");
    language.addMessage(PhotoMessages.TOOK_PHOTO,
      'Click! You snap a photo of {target}. That one\'s going on the fridge.');
  }


  // ==========================================================================
  // onEngineReady — Event Handlers (NPC registration arrives in Chapter 20)
  // ==========================================================================

  onEngineReady(engine: GameEngine): void {
    const world = engine.getWorld();

    // Event chain handlers (from V12)
    const feedId = this.entityIds.animalFeed;
    const pettingZooId = this.roomIds.pettingZoo;

    world.chainEvent('if.event.dropped', (event: ISemanticEvent, w: IWorldModel): ISemanticEvent | null => {
      const data = event.data as Record<string, any>;
      if (data.itemId !== feedId || data.toLocation !== pettingZooId) return null;
      if (w.getStateValue('goats-fed')) return null;
      w.setStateValue('goats-fed', true);
      return {
        id: `zoo-goats-eat-${Date.now()}`, type: 'zoo.event.goats_react',
        timestamp: Date.now(), entities: {},
        data: { text: 'The pygmy goats spot the bag of feed and rush over! They crowd around, bleating excitedly, and devour the corn and pellets in seconds. The smallest goat looks up at you with big grateful eyes.' },
      };
    }, { key: 'zoo.chain.goats-eat-feed' });

    const pennyId = this.entityIds.penny;
    const pressId = this.entityIds.souvenirPress;

    world.chainEvent('if.event.put_in', (event: ISemanticEvent, w: IWorldModel): ISemanticEvent | null => {
      const data = event.data as Record<string, any>;
      if (data.itemId !== pennyId || data.targetId !== pressId) return null;
      w.removeEntity(pennyId);
      const pressedPenny = w.createEntity('pressed penny', EntityType.ITEM);
      pressedPenny.add(new IdentityTrait({ name: 'pressed penny', description: 'A flattened oval of copper with an embossed toucan.', aliases: ['pressed penny', 'pressed coin', 'souvenir'], properName: false, article: 'a' }));
      const player = w.getPlayer();
      if (player) w.moveEntity(pressedPenny.id, player.id);
      return {
        id: `zoo-press-${Date.now()}`, type: 'zoo.event.penny_pressed',
        timestamp: Date.now(), entities: {},
        data: { text: 'CLUNK! CRUNCH! WHIRRR! The souvenir press swallows the penny and spits out a beautiful pressed penny with an embossed toucan design. You pocket it proudly.' },
      };
    }, { key: 'zoo.chain.penny-press' });
  }
}


// ============================================================================
// EXPORTS
// ============================================================================

export const story: Story = new FamilyZooStory();
export default story;

Chapter 15: Capability Dispatch: One Verb, Many Rules

Book source: docs/book/v2.0.0/parts/part-4/15-capability-dispatch.md · 9 step(s)

Capability Dispatch: One Verb, Many Rules

step 1 typescript
import {
  ITrait, IFEntity,
  CapabilityBehavior, CapabilityValidationResult,
  CapabilitySharedData,
  CapabilityEffect, createEffect,
  findTraitWithCapability,
} from '@sharpee/world-model';
import {
  Action, ActionContext, ValidationResult,
} from '@sharpee/stdlib';
import { ISemanticEvent } from '@sharpee/core';

1. A trait that declares a capability

step 2 typescript
class PettableTrait implements ITrait {
  static readonly type = 'zoo.trait.pettable' as const;
  static readonly capabilities = ['zoo.action.petting'] as const;
  readonly type = PettableTrait.type;

  readonly animalKind: 'goats' | 'rabbits' | 'parrot' | 'snake';
  constructor(kind: 'goats' | 'rabbits' | 'parrot' | 'snake') {
    this.animalKind = kind;
  }
}

2. A behavior that implements the capability

step 3 typescript
const PetMessages = {
  PET_GOATS: 'zoo.petting.goats',
  PET_RABBITS: 'zoo.petting.rabbits',
  PET_PARROT: 'zoo.petting.parrot',
  CANT_PET: 'zoo.petting.cant_pet',
} as const;

const pettingBehavior: CapabilityBehavior = {
  validate(
    _entity, _world, _actorId, _shared,
  ): CapabilityValidationResult {
    // every pettable animal accepts a pet
    return { valid: true };
  },

  execute(_entity, _world, _actorId, _shared): void {
    // no world mutation; petting is cosmetic
  },

  report(entity, _world, _actorId, _shared): CapabilityEffect[] {
    const pettable = entity.get(PettableTrait);
    let messageId: string = PetMessages.CANT_PET;
    switch (pettable?.animalKind) {
      case 'goats':   messageId = PetMessages.PET_GOATS; break;
      case 'rabbits': messageId = PetMessages.PET_RABBITS; break;
      case 'parrot':  messageId = PetMessages.PET_PARROT; break;
    }
    return [createEffect('zoo.event.petted', {
      messageId,
      params: { target: entity.name },
    })];
  },

  blocked(
    entity, _world, _actorId, error, _shared,
  ): CapabilityEffect[] {
    return [createEffect('zoo.event.petting_blocked', {
      messageId: error,
      params: { target: entity.name },
    })];
  },
};

3. Registration that links trait to behavior

step 4 typescript
world.registerCapabilityBehavior(
  PettableTrait.type,     // which trait
  PETTING_ACTION_ID,      // which capability
  pettingBehavior,        // which behavior
);

The dispatch action

step 5 typescript
const PETTING_ACTION_ID = 'zoo.action.petting';

const pettingAction: Action = {
  id: PETTING_ACTION_ID,
  group: 'interaction',

  validate(context: ActionContext): ValidationResult {
    const entity = context.command.directObject?.entity;
    if (!entity) {
      return { valid: false, error: PetMessages.CANT_PET };
    }

    // Find the trait on the target that claims this capability
    const trait =
      findTraitWithCapability(entity, PETTING_ACTION_ID);
    if (!trait) {
      return { valid: false, error: PetMessages.CANT_PET };
    }

    // Look up the behavior registered on this world for that
    // trait + capability
    const behavior = context.world
      .getBehaviorForCapability(trait, PETTING_ACTION_ID);
    if (!behavior) {
      return { valid: false, error: PetMessages.CANT_PET };
    }

    // Delegate validation to the behavior
    const sharedData: CapabilitySharedData = {};
    const result = behavior.validate(
      entity, context.world, context.player.id, sharedData,
    );
    if (!result.valid) {
      return { valid: false, error: result.error };
    }

    // Carry the resolved behavior into the later phases
    context.sharedData.capEntity = entity;
    context.sharedData.capBehavior = behavior;
    context.sharedData.capSharedData = sharedData;
    return { valid: true };
  },

  execute(context: ActionContext): void {
    const entity = context.sharedData.capEntity as IFEntity;
    const behavior =
      context.sharedData.capBehavior as CapabilityBehavior;
    const shared =
      context.sharedData.capSharedData as CapabilitySharedData;
    if (entity && behavior) {
      behavior.execute(
        entity, context.world, context.player.id, shared,
      );
    }
  },

  report(context: ActionContext): ISemanticEvent[] {
    const entity = context.sharedData.capEntity as IFEntity;
    const behavior =
      context.sharedData.capBehavior as CapabilityBehavior;
    const shared =
      context.sharedData.capSharedData as CapabilitySharedData;
    if (!entity || !behavior) return [];
    const effects = behavior.report(
      entity, context.world, context.player.id, shared,
    );
    return effects.map(effect =>
      context.event(effect.type, effect.payload));
  },

  blocked(
    context: ActionContext,
    result: ValidationResult,
  ): ISemanticEvent[] {
    return [context.event('zoo.event.petting_blocked', {
      messageId: result.error || PetMessages.CANT_PET,
    })];
  },
};
step 6 typescript
getCustomActions(): any[] {
  return [feedAction, photographAction, pettingAction];
}
step 7 typescript
grammar.define('pet :thing')
  .mapsTo(PETTING_ACTION_ID).withPriority(150).build();
grammar.define('stroke :thing')
  .mapsTo(PETTING_ACTION_ID).withPriority(150).build();
step 8 typescript
language.addMessage(PetMessages.PET_GOATS,
  'You pet the nearest goat. It leans into your hand and ' +
  'bleats happily; the others crowd around demanding equal ' +
  'attention.');
language.addMessage(PetMessages.PET_RABBITS,
  'You gently stroke one of the rabbits. Its fur is ' +
  'incredibly soft, and it twitches its nose at you ' +
  'contentedly.');
language.addMessage(PetMessages.PET_PARROT,
  'You reach toward the parrot. CHOMP! It nips your ' +
  'finger. "NO TOUCHING!" it squawks indignantly.');
language.addMessage(PetMessages.CANT_PET,
  "You can't pet that.");

Making the zoo's animals pettable

step 9 typescript
// The petting-zoo animals, already in the world, now pettable.
goats.add(new PettableTrait('goats'));
rabbits.add(new PettableTrait('rabbits'));

// A new resident of the Aviary: a scarlet macaw with a temper.
const parrot = world.createEntity('parrot', EntityType.ACTOR);
parrot.add(new IdentityTrait({
  name: 'parrot',
  description:
    'A magnificent scarlet macaw perched on a rope, watching ' +
    'you with one bright, calculating eye.',
  aliases: ['parrot', 'macaw', 'scarlet macaw'],
  article: 'a',
}));
parrot.add(new ActorTrait({ isPlayer: false }));
parrot.add(new PettableTrait('parrot'));
world.moveEntity(parrot.id, aviary.id);

Combined runnable file

The complete, runnable story at the end of this chapter (tested source: tutorials/familyzoo/v2.0.0/src/ch15-capability-dispatch.ts).

runnable ch15-capability-dispatch.ts typescript
/**
 * Family Zoo Tutorial — Version 14: Capability Dispatch
 *
 * NEW IN THIS VERSION:
 *   - Capability dispatch (ADR-090) — one verb, different behavior per entity
 *   - Custom traits with capabilities[] — declaring what an entity can do
 *   - world.registerCapabilityBehavior() — registering what happens when you do it
 *   - CapabilityBehavior interface — validate/execute/report/blocked
 *
 * WHAT YOU'LL LEARN:
 *   - Some verbs mean different things for different entities
 *   - "pet goats" (affectionate) vs "pet parrot" (bites!) vs "pet snake" (glass blocks)
 *   - The entity decides how to respond, not the action
 *   - This is the most powerful pattern for entity-specific behavior
 *
 * TRY IT:
 *   > south / east                  (go to petting zoo)
 *   > pet goats                     (they love it)
 *   > pet rabbits                   (they're fuzzy)
 *   > west / west                   (go to aviary)
 *   > pet parrot                    (ouch!)
 *
 * BUILD & RUN:
 *   ./build.sh -s familyzoo
 *   node dist/cli/sharpee.js --story tutorials/familyzoo --play
 */

// ============================================================================
// IMPORTS
// ============================================================================

import { Story, StoryConfig, GameEngine } from '@sharpee/engine';
import {
  WorldModel,
  IFEntity,
  EntityType,
  Direction,
  IWorldModel,
  // --- Capability Dispatch types (NEW in V14) ---
  CapabilityBehavior,             // Interface for capability behaviors
  CapabilityValidationResult,     // What validate() returns
  CapabilitySharedData,           // Shared state between phases
  CapabilityEffect,               // What report/blocked return
  createEffect,                   // Helper to build effects
  findTraitWithCapability,        // Find trait on entity claiming a capability
  ITrait,                         // Base trait interface
} from '@sharpee/world-model';
import {
  IdentityTrait,
  ActorTrait,
  ContainerTrait,
  SupporterTrait,
  RoomTrait,
  SceneryTrait,
  OpenableTrait,
  LockableTrait,
  DoorTrait,
  SwitchableTrait,
  LightSourceTrait,
  ReadableTrait,
} from '@sharpee/world-model';
import { ISemanticEvent } from '@sharpee/core';
import {
  Action, ActionContext, ValidationResult,
} from '@sharpee/stdlib';
// The parrot is introduced here as a static, pettable actor; it becomes a full
// NPC (with the zookeeper and behaviors) in Chapter 20.
import type { Parser } from '@sharpee/parser-en-us';
import type { LanguageProvider } from '@sharpee/lang-en-us';


// ============================================================================
// STORY CONFIGURATION
// ============================================================================

const config: StoryConfig = {
  id: 'familyzoo',
  title: 'Family Zoo',
  author: 'Sharpee Tutorial',
  version: '0.14.0',
  description: 'A small family zoo — learn Sharpee one concept at a time.',
};


// ============================================================================
// CUSTOM TRAIT: PettableTrait — NEW IN V14
// ============================================================================
//
// A custom trait declares that an entity supports certain capabilities.
// The 'capabilities' array lists which action IDs this trait responds to.
//
// When the action fires, the engine:
//   1. Finds the trait on the target entity that claims the capability
//   2. Looks up the registered behavior for that trait + capability
//   3. Calls the behavior's validate/execute/report/blocked phases
//
// This way, different entities can have different PettableTrait instances
// with different registered behaviors, all responding to the same "pet" verb.

/**
 * PettableTrait — marks an entity as pettable.
 *
 * Each entity with this trait gets a behavior registered for it.
 * The behavior defines what happens when you pet that specific entity.
 */
class PettableTrait implements ITrait {
  // The type string must be unique across all traits in the game.
  static readonly type = 'zoo.trait.pettable' as const;

  // Capabilities: which action IDs this trait responds to.
  // 'zoo.action.petting' is our custom action created below.
  static readonly capabilities = ['zoo.action.petting'] as const;

  // Every trait instance must have a runtime 'type' property.
  readonly type = PettableTrait.type;

  // Custom data — what kind of animal this is (for choosing response)
  readonly animalKind: 'goats' | 'rabbits' | 'parrot' | 'snake';

  constructor(kind: 'goats' | 'rabbits' | 'parrot' | 'snake') {
    this.animalKind = kind;
  }
}


// ============================================================================
// CAPABILITY BEHAVIORS — NEW IN V14
// ============================================================================
//
// A CapabilityBehavior implements the four-phase pattern for a specific
// trait + capability combination. Each phase receives:
//   entity     — the target entity being acted upon
//   world      — the world model (for mutations)
//   actorId    — who is performing the action
//   sharedData — object for passing data between phases
//
// The report() and blocked() methods return CapabilityEffect[] —
// arrays of { type, payload } objects that become semantic events.

// Message IDs for petting responses
const PetMessages = {
  PET_GOATS: 'zoo.petting.goats',
  PET_RABBITS: 'zoo.petting.rabbits',
  PET_PARROT: 'zoo.petting.parrot',
  PET_SNAKE: 'zoo.petting.snake_glass',
  CANT_PET: 'zoo.petting.cant_pet',
} as const;

/**
 * Unified petting behavior — dispatches by animalKind.
 *
 * The capability registry only allows ONE behavior per trait type + capability.
 * So we write ONE behavior that checks PettableTrait.animalKind to decide
 * what message to show. This is the standard pattern when the same trait
 * type appears on multiple entities with different semantics.
 */
const pettingBehavior: CapabilityBehavior = {
  validate(
    _entity: IFEntity,
    _world: WorldModel,
    _actorId: string,
    _sharedData: CapabilitySharedData,
  ): CapabilityValidationResult {
    // All pettable animals accept being petted (validation always passes)
    return { valid: true };
  },

  execute(
    _entity: IFEntity,
    _world: WorldModel,
    _actorId: string,
    _sharedData: CapabilitySharedData,
  ): void {
    // No world mutation — petting is cosmetic
  },

  report(
    entity: IFEntity,
    _world: WorldModel,
    _actorId: string,
    _sharedData: CapabilitySharedData,
  ): CapabilityEffect[] {
    // Dispatch by animalKind — each animal gets a different message
    const pettable = entity.get(PettableTrait);
    let messageId: string = PetMessages.CANT_PET;

    switch (pettable?.animalKind) {
      case 'goats':   messageId = PetMessages.PET_GOATS; break;
      case 'rabbits': messageId = PetMessages.PET_RABBITS; break;
      case 'parrot':  messageId = PetMessages.PET_PARROT; break;
    }

    return [
      createEffect('zoo.event.petted', {
        messageId,
        params: { target: entity.name },
      }),
    ];
  },

  blocked(
    entity: IFEntity,
    _world: WorldModel,
    _actorId: string,
    error: string,
    _sharedData: CapabilitySharedData,
  ): CapabilityEffect[] {
    return [
      createEffect('zoo.event.petting_blocked', {
        messageId: error,
        params: { target: entity.name },
      }),
    ];
  },
};


// ============================================================================
// PETTING ACTION — manual dispatch action
// ============================================================================
//
// The petting action uses capability dispatch:
//   1. Find the PettableTrait on the target entity
//   2. Look up the registered behavior for that trait + capability
//   3. Delegate to the behavior's phases
//
// This is a manual version of what createCapabilityDispatchAction() does
// in the stdlib. We write it out so you can see exactly how it works.

const PETTING_ACTION_ID = 'zoo.action.petting';

const pettingAction: Action = {
  id: PETTING_ACTION_ID,
  group: 'interaction',

  validate(context: ActionContext): ValidationResult {
    const entity = context.command.directObject?.entity;

    // Must have a target
    if (!entity) {
      return { valid: false, error: PetMessages.CANT_PET };
    }

    // Find the trait that claims this capability
    const trait = findTraitWithCapability(entity, PETTING_ACTION_ID);
    if (!trait) {
      // Target doesn't have PettableTrait — can't pet it
      return { valid: false, error: PetMessages.CANT_PET };
    }

    // Get the behavior registered on this world (ADR-207: the binding map
    // belongs to the running game's world, not a process-wide registry)
    const behavior = context.world.getBehaviorForCapability(trait, PETTING_ACTION_ID);
    if (!behavior) {
      return { valid: false, error: PetMessages.CANT_PET };
    }

    // Create shared data for passing between phases
    const sharedData: CapabilitySharedData = {};

    // Delegate validation to the behavior
    const behaviorResult = behavior.validate(entity, context.world, context.player.id, sharedData);
    if (!behaviorResult.valid) {
      return { valid: false, error: behaviorResult.error };
    }

    // Store everything for execute/report
    context.sharedData.capEntity = entity;
    context.sharedData.capBehavior = behavior;
    context.sharedData.capSharedData = sharedData;
    return { valid: true };
  },

  execute(context: ActionContext): void {
    const entity = context.sharedData.capEntity as IFEntity;
    const behavior = context.sharedData.capBehavior as CapabilityBehavior;
    const sharedData = context.sharedData.capSharedData as CapabilitySharedData;
    if (entity && behavior) {
      behavior.execute(entity, context.world, context.player.id, sharedData);
    }
  },

  report(context: ActionContext): ISemanticEvent[] {
    const entity = context.sharedData.capEntity as IFEntity;
    const behavior = context.sharedData.capBehavior as CapabilityBehavior;
    const sharedData = context.sharedData.capSharedData as CapabilitySharedData;
    if (!entity || !behavior) return [];

    // Get effects from the behavior
    const effects = behavior.report(entity, context.world, context.player.id, sharedData);
    // Convert CapabilityEffect[] to ISemanticEvent[]
    return effects.map(effect => context.event(effect.type, effect.payload));
  },

  blocked(context: ActionContext, result: ValidationResult): ISemanticEvent[] {
    return [
      context.event('zoo.event.petting_blocked', {
        messageId: result.error || PetMessages.CANT_PET,
      }),
    ];
  },
};


// ============================================================================
// CUSTOM ACTIONS FROM V13 (carried forward)
// ============================================================================

const FEED_ACTION_ID = 'zoo.action.feeding';
const FeedMessages = {
  NO_FEED: 'zoo.feeding.no_feed', NOT_AN_ANIMAL: 'zoo.feeding.not_animal',
  ALREADY_FED: 'zoo.feeding.already_fed', FED_GOATS: 'zoo.feeding.fed_goats',
  FED_RABBITS: 'zoo.feeding.fed_rabbits', FED_GENERIC: 'zoo.feeding.fed_generic',
} as const;

const feedAction: Action = {
  id: FEED_ACTION_ID, group: 'interaction',
  validate(context: ActionContext): ValidationResult {
    const target = context.command.directObject?.entity;
    const inventory = context.world.getContents(context.player.id);
    const hasFeed = inventory.some(i => i.get(IdentityTrait)?.aliases?.includes('feed'));
    if (!hasFeed) return { valid: false, error: FeedMessages.NO_FEED };
    if (!target) return { valid: false, error: FeedMessages.NOT_AN_ANIMAL };
    const name = target.get(IdentityTrait)?.name?.toLowerCase() || '';
    if (!['pygmy goats', 'rabbits'].some(a => name.includes(a))) return { valid: false, error: FeedMessages.NOT_AN_ANIMAL };
    if (context.world.getStateValue(`fed-${target.id}`)) return { valid: false, error: FeedMessages.ALREADY_FED };
    context.sharedData.feedTarget = target;
    return { valid: true };
  },
  execute(context: ActionContext): void {
    const target = context.sharedData.feedTarget as IFEntity;
    if (target) context.world.setStateValue(`fed-${target.id}`, true);
  },
  report(context: ActionContext): ISemanticEvent[] {
    const target = context.sharedData.feedTarget as IFEntity;
    const name = target?.get(IdentityTrait)?.name?.toLowerCase() || '';
    let messageId: string = FeedMessages.FED_GENERIC;
    if (name.includes('goats')) messageId = FeedMessages.FED_GOATS;
    else if (name.includes('rabbits')) messageId = FeedMessages.FED_RABBITS;
    return [context.event('zoo.event.fed', { messageId, params: { animal: target?.get(IdentityTrait)?.name } })];
  },
  blocked(_context: ActionContext, result: ValidationResult): ISemanticEvent[] {
    return [_context.event('zoo.event.feeding_blocked', { messageId: result.error || FeedMessages.NOT_AN_ANIMAL })];
  },
};

const PHOTOGRAPH_ACTION_ID = 'zoo.action.photographing';
const PhotoMessages = { NO_CAMERA: 'zoo.photo.no_camera', TOOK_PHOTO: 'zoo.photo.took_photo' } as const;

const photographAction: Action = {
  id: PHOTOGRAPH_ACTION_ID, group: 'interaction',
  validate(context: ActionContext): ValidationResult {
    const inventory = context.world.getContents(context.player.id);
    const hasCamera = inventory.some(i => i.get(IdentityTrait)?.aliases?.includes('camera'));
    if (!hasCamera) return { valid: false, error: PhotoMessages.NO_CAMERA };
    const target = context.command.directObject?.entity;
    if (target) context.sharedData.photoTarget = target;
    return { valid: true };
  },
  execute(): void {},
  report(context: ActionContext): ISemanticEvent[] {
    const target = context.sharedData.photoTarget as IFEntity | undefined;
    const name = target?.get(IdentityTrait)?.name || 'the scenery';
    return [context.event('zoo.event.photographed', { messageId: PhotoMessages.TOOK_PHOTO, params: { target: name } })];
  },
  blocked(context: ActionContext, result: ValidationResult): ISemanticEvent[] {
    return [context.event('zoo.event.photographing_blocked', { messageId: result.error || PhotoMessages.NO_CAMERA })];
  },
};


// (The parrot's NPC behavior is added in Chapter 20.)


// ============================================================================
// THE STORY CLASS
// ============================================================================

class FamilyZooStory implements Story {
  config = config;
  private roomIds = { entrance: '', mainPath: '', pettingZoo: '', aviary: '', supplyRoom: '', nocturnalExhibit: '', giftShop: '' };
  private entityIds = { animalFeed: '', penny: '', souvenirPress: '' };

  createPlayer(world: WorldModel): IFEntity {
    const player = world.createEntity('yourself', EntityType.ACTOR);
    player.add(new IdentityTrait({ name: 'yourself', description: 'Just an ordinary visitor to the zoo.', aliases: ['self', 'myself', 'me'], properName: true, article: '' }));
    player.add(new ActorTrait({ isPlayer: true }));
    player.add(new ContainerTrait({ capacity: { maxItems: 10 } }));
    return player;
  }

  initializeWorld(world: WorldModel): void {

    // ROOMS
    const entrance = world.createEntity('Zoo Entrance', EntityType.ROOM);
    entrance.add(new RoomTrait({ exits: {}, isDark: false }));
    entrance.add(new IdentityTrait({ name: 'Zoo Entrance', description: 'You stand before the wrought-iron gates of the Willowbrook Family Zoo. A cheerful welcome sign arches over the entrance, and a small ticket booth sits to one side. A sturdy iron fence runs along either side of the gates. The main path leads south into the zoo grounds.', aliases: ['entrance', 'gates', 'gate'], properName: false, article: 'the' }));

    const mainPath = world.createEntity('Main Path', EntityType.ROOM);
    mainPath.add(new RoomTrait({ exits: {}, isDark: false }));
    mainPath.add(new IdentityTrait({ name: 'Main Path', description: 'A wide gravel path winds through the heart of the zoo. Colorful direction signs point every which way. A park bench sits beside the path. To the east, the petting zoo. To the west, the aviary. A staff gate blocks the path to the south. The entrance is back to the north.', aliases: ['path', 'main path', 'gravel path'], properName: false, article: 'the' }));

    const pettingZoo = world.createEntity('Petting Zoo', EntityType.ROOM);
    pettingZoo.add(new RoomTrait({ exits: {}, isDark: false }));
    pettingZoo.add(new IdentityTrait({ name: 'Petting Zoo', description: 'A cheerful open-air enclosure filled with friendly animals. Pygmy goats trot around nibbling at visitors\' shoelaces, while a pair of fluffy rabbits hop lazily near a hay bale. A feed dispenser is mounted on a post. An info plaque is posted by the gate. The main path is back to the west.', aliases: ['petting zoo', 'petting area', 'pen'], properName: false, article: 'the' }));

    const aviary = world.createEntity('Aviary', EntityType.ROOM);
    aviary.add(new RoomTrait({ exits: {}, isDark: false }));
    aviary.add(new IdentityTrait({ name: 'Aviary', description: 'You step inside a soaring mesh dome. Brilliantly colored parrots chatter from rope perches, and a toucan eyes you curiously from a branch overhead. A small waterfall splashes into a stone basin. An info plaque hangs near the entrance. The gift shop is to the west. The main path is back to the east.', aliases: ['aviary', 'bird house', 'dome'], properName: false, article: 'the' }));

    const supplyRoom = world.createEntity('Supply Room', EntityType.ROOM);
    supplyRoom.add(new RoomTrait({ exits: {}, isDark: false }));
    supplyRoom.add(new IdentityTrait({ name: 'Supply Room', description: 'A cluttered storage room behind the staff gate. Metal shelves line the walls. A cork board on the wall is covered with staff schedules. A battered radio sits on one of the shelves. The staff gate leads back north.', aliases: ['supply room', 'storage room', 'storeroom'], properName: false, article: 'the' }));

    const nocturnalExhibit = world.createEntity('Nocturnal Animals Exhibit', EntityType.ROOM);
    nocturnalExhibit.add(new RoomTrait({ exits: {}, isDark: true }));
    nocturnalExhibit.add(new IdentityTrait({ name: 'Nocturnal Animals Exhibit', description: 'A cool, dimly lit cavern designed to simulate nighttime. Glass enclosures line both walls with soft red lights. You can see sugar gliders, bush babies, and a barn owl. A warning sign is posted near the entrance. The exit leads back north to the supply room.', aliases: ['nocturnal exhibit', 'nocturnal animals', 'exhibit'], properName: false, article: 'the' }));

    const giftShop = world.createEntity('Gift Shop', EntityType.ROOM);
    giftShop.add(new RoomTrait({ exits: {}, isDark: false }));
    giftShop.add(new IdentityTrait({ name: 'Gift Shop', description: 'A small zoo gift shop crammed with stuffed animals and postcards. A large souvenir penny press machine stands near the door. A disposable camera sits on the counter. The aviary is back to the east.', aliases: ['gift shop', 'shop', 'store'], properName: false, article: 'the' }));

    this.roomIds = { entrance: entrance.id, mainPath: mainPath.id, pettingZoo: pettingZoo.id, aviary: aviary.id, supplyRoom: supplyRoom.id, nocturnalExhibit: nocturnalExhibit.id, giftShop: giftShop.id };

    // STAFF GATE
    const keycard = world.createEntity('staff keycard', EntityType.ITEM);
    keycard.add(new IdentityTrait({ name: 'staff keycard', description: 'A white plastic keycard with "WILLOWBROOK ZOO — STAFF ONLY" printed in blue.', aliases: ['keycard', 'key card', 'card', 'key', 'staff keycard'], properName: false, article: 'a' }));
    world.moveEntity(keycard.id, entrance.id);

    const staffGate = world.createEntity('staff gate', EntityType.DOOR);
    staffGate.add(new IdentityTrait({ name: 'staff gate', description: 'A sturdy metal gate with a "STAFF ONLY" sign.', aliases: ['gate', 'staff gate', 'metal gate', 'staff door'], properName: false, article: 'a' }));
    staffGate.add(new DoorTrait({ room1: mainPath.id, room2: supplyRoom.id, bidirectional: true }));
    staffGate.add(new OpenableTrait({ isOpen: false }));
    staffGate.add(new LockableTrait({ isLocked: true, keyId: keycard.id }));
    staffGate.add(new SceneryTrait());
    world.moveEntity(staffGate.id, mainPath.id);

    // EXITS
    entrance.get(RoomTrait)!.exits = { [Direction.SOUTH]: { destination: mainPath.id } };
    mainPath.get(RoomTrait)!.exits = { [Direction.NORTH]: { destination: entrance.id }, [Direction.EAST]: { destination: pettingZoo.id }, [Direction.WEST]: { destination: aviary.id }, [Direction.SOUTH]: { destination: supplyRoom.id, via: staffGate.id } };
    pettingZoo.get(RoomTrait)!.exits = { [Direction.WEST]: { destination: mainPath.id } };
    aviary.get(RoomTrait)!.exits = { [Direction.EAST]: { destination: mainPath.id }, [Direction.WEST]: { destination: giftShop.id } };
    supplyRoom.get(RoomTrait)!.exits = { [Direction.NORTH]: { destination: mainPath.id, via: staffGate.id }, [Direction.SOUTH]: { destination: nocturnalExhibit.id } };
    nocturnalExhibit.get(RoomTrait)!.exits = { [Direction.NORTH]: { destination: supplyRoom.id } };
    giftShop.get(RoomTrait)!.exits = { [Direction.EAST]: { destination: aviary.id } };

    // SCENERY — abbreviated
    for (const [name, desc, aliases, room] of [
      ['welcome sign', 'A brightly painted wooden sign reads: "WELCOME TO WILLOWBROOK FAMILY ZOO."', ['sign', 'welcome sign'], entrance],
      ['ticket booth', 'A small wooden booth with a "Self-Guided Tours" sign.', ['booth', 'ticket booth'], entrance],
      ['iron fence', 'A tall wrought-iron fence with animal silhouettes.', ['fence', 'iron fence', 'railing'], entrance],
      ['direction signs', 'Arrow signs: PETTING ZOO (east), AVIARY (west), EXIT (north).', ['signs', 'direction signs', 'arrow signs'], mainPath],
      ['flower beds', 'Tidy beds of marigolds and petunias.', ['flowers', 'flower beds'], mainPath],
      ['hay bale', 'A large round bale of golden hay.', ['hay', 'hay bale', 'bale'], pettingZoo],
      ['toucan', 'A Toco toucan with an enormous orange-and-black bill.', ['toucan', 'toco toucan'], aviary],
      ['waterfall', 'A gentle artificial waterfall cascading into a stone basin.', ['waterfall', 'water', 'basin'], aviary],
      ['rope perches', 'Thick sisal ropes strung between wooden posts.', ['perches', 'rope perches', 'ropes'], aviary],
      ['metal shelves', 'Industrial metal shelving units stacked with supplies.', ['shelves', 'metal shelves', 'shelf'], supplyRoom],
      ['sugar gliders', 'A family of tiny sugar gliders with enormous dark eyes.', ['sugar gliders', 'gliders'], nocturnalExhibit],
      ['bush babies', 'Two bush babies with impossibly large round eyes.', ['bush babies', 'galagos'], nocturnalExhibit],
      ['barn owl', 'An enormous barn owl with a heart-shaped white face.', ['barn owl', 'owl'], nocturnalExhibit],
      ['stuffed animals', 'Shelves of plush tigers, pandas, and penguins.', ['stuffed animals', 'plush', 'toys'], giftShop],
      ['postcards', 'A spinning rack of postcards showing the zoo\'s greatest hits.', ['postcards', 'cards', 'postcard rack'], giftShop],
    ] as [string, string, string[], IFEntity][]) {
      const e = world.createEntity(name, EntityType.SCENERY);
      e.add(new IdentityTrait({ name, description: desc, aliases, properName: false, article: 'a' }));
      world.moveEntity(e.id, room.id);
    }

    // Additional scenery with special traits (readable, switchable)
    const corkBoard = world.createEntity('cork board', EntityType.SCENERY);
    corkBoard.add(new IdentityTrait({ name: 'cork board', description: 'A cork board with staff schedules. A note in red marker: "DON\'T FORGET: nocturnal exhibit lights need new batteries!"', aliases: ['cork board', 'board', 'notices'], properName: false, article: 'a' }));
    world.moveEntity(corkBoard.id, supplyRoom.id);

    const radio = world.createEntity('radio', EntityType.ITEM);
    radio.add(new IdentityTrait({ name: 'radio', description: 'A battered portable radio held together with duct tape. The antenna is bent at a jaunty angle. A faded sticker on the side reads "ZOO FM — All Animals, All The Time."', aliases: ['radio', 'portable radio'], properName: false, article: 'a' }));
    radio.add(new SwitchableTrait({ isOn: false }));
    radio.add(new SceneryTrait());
    world.moveEntity(radio.id, supplyRoom.id);

    const pettingPlaque = world.createEntity('info plaque', EntityType.SCENERY);
    pettingPlaque.add(new IdentityTrait({ name: 'info plaque', description: 'A brass plaque mounted on a wooden post near the petting zoo gate.', aliases: ['plaque', 'info plaque', 'brass plaque'], properName: false, article: 'an' }));
    pettingPlaque.add(new ReadableTrait({ text: 'PYGMY GOATS — These Nigerian Dwarf goats are gentle, curious, and always hungry.\n\nHOLLAND LOP RABBITS — Known for their floppy ears. Our pair, Biscuit and Marmalade, were born here in 2023.' }));
    world.moveEntity(pettingPlaque.id, pettingZoo.id);

    const aviaryPlaque = world.createEntity('aviary plaque', EntityType.SCENERY);
    aviaryPlaque.add(new IdentityTrait({ name: 'aviary plaque', description: 'A colorful information board near the aviary entrance.', aliases: ['plaque', 'aviary plaque', 'information board'], properName: false, article: 'an' }));
    aviaryPlaque.add(new ReadableTrait({ text: 'WELCOME TO THE AVIARY — Home to over 30 species!\n\nTOCO TOUCAN — Its bill weighs less than a smartphone.\n\nSCARLET MACAW — Can live over 75 years. Our oldest, Captain, is 42.' }));
    world.moveEntity(aviaryPlaque.id, aviary.id);

    const warningSign = world.createEntity('warning sign', EntityType.SCENERY);
    warningSign.add(new IdentityTrait({ name: 'warning sign', description: 'A yellow warning sign near the nocturnal exhibit entrance.', aliases: ['warning', 'warning sign', 'yellow sign'], properName: false, article: 'a' }));
    warningSign.add(new ReadableTrait({ text: 'CAUTION: The Nocturnal Animals Exhibit is kept dark. Please use a flashlight. Do NOT use camera flash. (We don\'t talk about the Great Owl Incident of 2022.)' }));
    world.moveEntity(warningSign.id, supplyRoom.id);

    const brochure = world.createEntity('zoo brochure', EntityType.ITEM);
    brochure.add(new IdentityTrait({ name: 'zoo brochure', description: 'A glossy tri-fold brochure with "WILLOWBROOK FAMILY ZOO" on the cover.', aliases: ['brochure', 'zoo brochure', 'pamphlet', 'leaflet'], properName: false, article: 'a' }));
    brochure.add(new ReadableTrait({ text: 'WILLOWBROOK FAMILY ZOO — Your Guide\n\nEXHIBITS:\n  Petting Zoo — East from Main Path\n  Aviary — West from Main Path\n  Gift Shop — West from Aviary\n  Nocturnal Animals — Staff Area\n\n"Where every visit is a wild adventure!"' }));
    world.moveEntity(brochure.id, entrance.id);

    // PETTABLE ANIMALS — NEW IN V14
    //
    // These scenery entities also get PettableTrait, which declares
    // that they respond to the 'zoo.action.petting' capability.
    // Each gets a different behavior registered below.

    const goats = world.createEntity('pygmy goats', EntityType.SCENERY);
    goats.add(new IdentityTrait({ name: 'pygmy goats', grammaticalNumber: 'plural', description: 'Three pygmy goats hoping you have food.', aliases: ['goats', 'pygmy goats', 'goat'], properName: false, article: 'some' }));
    goats.add(new PettableTrait('goats'));  // ← PettableTrait with 'goats' kind
    world.moveEntity(goats.id, pettingZoo.id);

    const rabbits = world.createEntity('rabbits', EntityType.SCENERY);
    rabbits.add(new IdentityTrait({ name: 'rabbits', grammaticalNumber: 'plural', description: 'A pair of Holland Lop rabbits with floppy ears.', aliases: ['rabbits', 'rabbit', 'bunnies'], properName: false, article: 'some' }));
    rabbits.add(new PettableTrait('rabbits'));  // ← PettableTrait with 'rabbits' kind
    world.moveEntity(rabbits.id, pettingZoo.id);

    const parrots = world.createEntity('parrots', EntityType.SCENERY);
    parrots.add(new IdentityTrait({ name: 'parrots', grammaticalNumber: 'plural', description: 'A raucous flock of scarlet macaws and grey African parrots.', aliases: ['parrots', 'macaws', 'birds'], properName: false, article: 'some' }));
    world.moveEntity(parrots.id, aviary.id);

    // PORTABLE OBJECTS
    const zooMap = world.createEntity('zoo map', EntityType.ITEM);
    zooMap.add(new IdentityTrait({ name: 'zoo map', description: 'A colorful folding map of the Willowbrook Family Zoo.', aliases: ['map', 'zoo map', 'folding map'], properName: false, article: 'a' }));
    world.moveEntity(zooMap.id, entrance.id);

    const animalFeed = world.createEntity('bag of animal feed', EntityType.ITEM);
    animalFeed.add(new IdentityTrait({ name: 'bag of animal feed', description: 'A small brown paper bag filled with dried corn and pellets.', aliases: ['feed', 'animal feed', 'bag of feed', 'corn'], properName: false, article: 'a' }));
    world.moveEntity(animalFeed.id, pettingZoo.id);

    const penny = world.createEntity('souvenir penny', EntityType.ITEM);
    penny.add(new IdentityTrait({ name: 'souvenir penny', description: 'A shiny copper penny.', aliases: ['penny', 'souvenir penny', 'coin'], properName: false, article: 'a' }));
    world.moveEntity(penny.id, mainPath.id);

    const flashlight = world.createEntity('flashlight', EntityType.ITEM);
    flashlight.add(new IdentityTrait({ name: 'flashlight', description: 'A heavy-duty yellow flashlight.', aliases: ['flashlight', 'torch', 'light', 'lamp'], properName: false, article: 'a' }));
    flashlight.add(new SwitchableTrait({ isOn: false }));
    flashlight.add(new LightSourceTrait({ brightness: 8, isLit: false }));
    world.moveEntity(flashlight.id, supplyRoom.id);

    const camera = world.createEntity('disposable camera', EntityType.ITEM);
    camera.add(new IdentityTrait({ name: 'disposable camera', description: 'A cheap yellow disposable camera with "ZOO MEMORIES" printed on the side.', aliases: ['camera', 'disposable camera'], properName: false, article: 'a' }));
    world.moveEntity(camera.id, giftShop.id);

    this.entityIds = { animalFeed: animalFeed.id, penny: penny.id, souvenirPress: '' };

    // CONTAINERS
    const backpack = world.createEntity('backpack', EntityType.CONTAINER);
    backpack.add(new IdentityTrait({ name: 'backpack', description: 'A small red canvas backpack.', aliases: ['backpack', 'rucksack', 'pack'], properName: false, article: 'a' }));
    backpack.add(new ContainerTrait({ capacity: { maxItems: 5 } }));
    world.moveEntity(backpack.id, entrance.id);

    const parkBench = world.createEntity('park bench', EntityType.SUPPORTER);
    parkBench.add(new IdentityTrait({ name: 'park bench', description: 'A sturdy park bench painted forest green.', aliases: ['bench', 'park bench', 'benches', 'seat'], properName: false, article: 'a' }));
    parkBench.add(new SupporterTrait({ capacity: { maxItems: 3 } }));
    parkBench.add(new SceneryTrait());
    world.moveEntity(parkBench.id, mainPath.id);

    const lunchbox = world.createEntity('lunchbox', EntityType.CONTAINER);
    lunchbox.add(new IdentityTrait({ name: 'lunchbox', description: 'A dented metal lunchbox decorated with cartoon zoo animals.', aliases: ['lunchbox', 'lunch box', 'box'], properName: false, article: 'a' }));
    lunchbox.add(new ContainerTrait({ capacity: { maxItems: 3 } }));
    lunchbox.add(new OpenableTrait({ isOpen: false }));
    world.moveEntity(lunchbox.id, mainPath.id);

    lunchbox.get(OpenableTrait)!.isOpen = true;
    const juice = world.createEntity('juice box', EntityType.ITEM);
    juice.add(new IdentityTrait({ name: 'juice box', description: 'A small juice box with a picture of a happy elephant.', aliases: ['juice', 'juice box', 'drink'], properName: false, article: 'a' }));
    world.moveEntity(juice.id, lunchbox.id);
    lunchbox.get(OpenableTrait)!.isOpen = false;

    const dispenser = world.createEntity('feed dispenser', EntityType.CONTAINER);
    dispenser.add(new IdentityTrait({ name: 'feed dispenser', description: 'A coin-operated feed dispenser mounted on a wooden post. Sign: "FREE — Just Turn!"', aliases: ['dispenser', 'feed dispenser'], properName: false, article: 'a' }));
    dispenser.add(new ContainerTrait({ capacity: { maxItems: 3 } }));
    dispenser.add(new OpenableTrait({ isOpen: false }));
    dispenser.add(new SceneryTrait());
    world.moveEntity(dispenser.id, pettingZoo.id);

    const souvenirPress = world.createEntity('souvenir press', EntityType.CONTAINER);
    souvenirPress.add(new IdentityTrait({ name: 'souvenir press', description: 'A heavy cast-iron machine with a big crank handle. A slot on top accepts pennies, and the mechanism stamps them with a zoo animal design. A sign reads: "INSERT PENNY, TURN HANDLE, KEEP FOREVER!"', aliases: ['press', 'souvenir press', 'penny press', 'machine'], properName: false, article: 'a' }));
    souvenirPress.add(new ContainerTrait({ capacity: { maxItems: 1 } }));
    souvenirPress.add(new SceneryTrait());
    world.moveEntity(souvenirPress.id, giftShop.id);
    this.entityIds.souvenirPress = souvenirPress.id;

    // The parrot — introduced here as a static, pettable actor so "pet parrot"
    // works via capability dispatch. It becomes a full NPC in Chapter 20.
    // (The zookeeper NPC also arrives in Chapter 20.)
    const parrot = world.createEntity('parrot', EntityType.ACTOR);
    parrot.add(new IdentityTrait({ name: 'parrot', description: 'A magnificent scarlet macaw perched on a rope. It tilts its head and watches you with one bright eye.', aliases: ['parrot', 'macaw', 'scarlet macaw'], properName: false, article: 'a' }));
    parrot.add(new ActorTrait({ isPlayer: false }));
    parrot.add(new PettableTrait('parrot'));
    world.moveEntity(parrot.id, aviary.id);

    // ========================================================================
    // REGISTER CAPABILITY BEHAVIOR — NEW IN V14
    // ========================================================================
    //
    // world.registerCapabilityBehavior(traitType, actionId, behavior) tells
    // the engine: "when someone does actionId on an entity with this trait,
    // use this behavior."
    //
    // The binding map belongs to THIS world (ADR-207): every game registers
    // its own behaviors in initializeWorld(), and registration is idempotent
    // (re-registering a key just overwrites it), so there is no need to
    // check whether it is already registered.
    // Our unified pettingBehavior dispatches internally by animalKind.

    world.registerCapabilityBehavior(
      PettableTrait.type,
      PETTING_ACTION_ID,
      pettingBehavior,
    );

    // PLAYER STARTING LOCATION
    const player = world.getPlayer();
    if (player) world.moveEntity(player.id, entrance.id);
  }


  getCustomActions(): any[] {
    // Return all custom actions — both from V13 and V14
    return [feedAction, photographAction, pettingAction];
  }


  extendParser(parser: Parser): void {
    const grammar = parser.getStoryGrammar();

    // V13 grammar
    grammar.define('feed :thing').mapsTo(FEED_ACTION_ID).withPriority(150).build();
    grammar.define('photograph :thing').mapsTo(PHOTOGRAPH_ACTION_ID).withPriority(150).build();
    grammar.define('photo :thing').mapsTo(PHOTOGRAPH_ACTION_ID).withPriority(150).build();
    grammar.define('snap :thing').mapsTo(PHOTOGRAPH_ACTION_ID).withPriority(150).build();

    // V14 grammar — "pet" maps to the petting action
    grammar.define('pet :thing').mapsTo(PETTING_ACTION_ID).withPriority(150).build();
    grammar.define('stroke :thing').mapsTo(PETTING_ACTION_ID).withPriority(150).build();
  }


  extendLanguage(language: LanguageProvider): void {
    // V13 messages
    language.addMessage(FeedMessages.NO_FEED, "You don't have any animal feed.");
    language.addMessage(FeedMessages.NOT_AN_ANIMAL, "That's not something you can feed.");
    language.addMessage(FeedMessages.ALREADY_FED, "You've already fed them. They look contentedly full.");
    language.addMessage(FeedMessages.FED_GOATS, 'You scatter some feed on the ground. The pygmy goats rush over, bleating excitedly, and devour the corn and pellets in seconds. The smallest goat looks up at you with big grateful eyes.');
    language.addMessage(FeedMessages.FED_RABBITS, 'You sprinkle some pellets near the rabbits. Biscuit and Marmalade hop over cautiously, then munch away happily.');
    language.addMessage(FeedMessages.FED_GENERIC, 'You offer some feed. The animal eats it gratefully.');
    language.addMessage(PhotoMessages.NO_CAMERA, "You don't have a camera. There's one in the gift shop.");
    language.addMessage(PhotoMessages.TOOK_PHOTO, 'Click! You snap a photo of {target}. That one\'s going on the fridge.');

    // V14 messages — petting responses
    language.addMessage(PetMessages.PET_GOATS,
      'You reach down and pet the nearest goat. It leans into your hand and bleats happily. The others crowd around, demanding equal attention.');
    language.addMessage(PetMessages.PET_RABBITS,
      'You gently stroke one of the rabbits. Its fur is incredibly soft. It twitches its nose at you contentedly.');
    language.addMessage(PetMessages.PET_PARROT,
      'You reach toward the parrot. CHOMP! It nips your finger with its beak. "NO TOUCHING!" it squawks indignantly.');
    language.addMessage(PetMessages.CANT_PET,
      "You can't pet that.");
  }


  onEngineReady(engine: GameEngine): void {
    const world = engine.getWorld();

    // (The NPC plugin + behaviors are registered in Chapter 20.)

    // Event chain handlers (from V12)
    const feedId = this.entityIds.animalFeed;
    const pettingZooId = this.roomIds.pettingZoo;
    world.chainEvent('if.event.dropped', (event: ISemanticEvent, w: IWorldModel): ISemanticEvent | null => {
      const data = event.data as Record<string, any>;
      if (data.itemId !== feedId || data.toLocation !== pettingZooId) return null;
      if (w.getStateValue('goats-fed')) return null;
      w.setStateValue('goats-fed', true);
      return { id: `zoo-goats-${Date.now()}`, type: 'zoo.event.goats_react', timestamp: Date.now(), entities: {}, data: { text: 'The pygmy goats spot the bag of feed and rush over! They crowd around, bleating excitedly, and devour the corn and pellets in seconds. The smallest goat looks up at you with big grateful eyes.' } };
    }, { key: 'zoo.chain.goats-eat-feed' });

    const pennyId = this.entityIds.penny;
    const pressId = this.entityIds.souvenirPress;
    world.chainEvent('if.event.put_in', (event: ISemanticEvent, w: IWorldModel): ISemanticEvent | null => {
      const data = event.data as Record<string, any>;
      if (data.itemId !== pennyId || data.targetId !== pressId) return null;
      w.removeEntity(pennyId);
      const pp = w.createEntity('pressed penny', EntityType.ITEM);
      pp.add(new IdentityTrait({ name: 'pressed penny', description: 'A flattened oval of copper with an embossed toucan.', aliases: ['pressed penny', 'pressed coin', 'souvenir'], properName: false, article: 'a' }));
      const player = w.getPlayer();
      if (player) w.moveEntity(pp.id, player.id);
      return { id: `zoo-press-${Date.now()}`, type: 'zoo.event.penny_pressed', timestamp: Date.now(), entities: {}, data: { text: 'CLUNK! CRUNCH! WHIRRR! The souvenir press produces a beautiful pressed penny with an embossed toucan.' } };
    }, { key: 'zoo.chain.penny-press' });
  }
}

export const story: Story = new FamilyZooStory();
export default story;

Chapter 16: Custom Traits & Behaviors: Data and Logic, Kept Apart

Book source: docs/book/v2.0.0/parts/part-4/16-custom-traits-and-behaviors.md · 4 step(s)

Defining a custom trait

step 1 typescript
import { ITrait } from '@sharpee/world-model';

export class DispenserTrait implements ITrait {
  static readonly type = 'zoo.trait.dispenser';
  readonly type = DispenserTrait.type;

  /** How many servings remain. */
  chargesRemaining = 3;

  constructor(data?: Partial<DispenserTrait>) {
    if (data) Object.assign(this, data);
  }
}
step 2 typescript
dispenser.add(new DispenserTrait({ chargesRemaining: 5 }));

Defining the behavior

step 3 typescript
import { IFEntity } from '@sharpee/world-model';
import { DispenserTrait } from './dispenser-trait.js';

export class DispenserBehavior {
  /**
   * Spend one charge. Returns false if the dispenser is
   * already empty.
   */
  static dispense(dispenser: IFEntity): boolean {
    const trait = dispenser.get(DispenserTrait);
    if (!trait || trait.chargesRemaining <= 0) return false;
    trait.chargesRemaining -= 1;
    return true;
  }

  static isEmpty(dispenser: IFEntity): boolean {
    const trait = dispenser.get(DispenserTrait);
    return !trait || trait.chargesRemaining <= 0;
  }
}

Putting the pair to work

step 4 typescript
// inside a custom "operate dispenser" action's execute phase,
// or an event handler:
if (DispenserBehavior.dispense(dispenser)) {
  // success: hand out a serving of feed
} else {
  // empty: report that it's out
}

Combined runnable file

No story-code change this chapter; the runnable file is unchanged from the previous checkpoint (tutorials/familyzoo/v2.0.0/src/ch15-capability-dispatch.ts).

Chapter 17: Extending the Grammar: Teaching New Sentence Shapes

Book source: docs/book/v2.0.0/parts/part-5/17-extending-the-grammar.md · 4 step(s)

Where patterns go

step 1 typescript
extendParser(parser: Parser): void {
  const grammar = parser.getStoryGrammar();

  grammar
    .define('feed :thing')
    .mapsTo('zoo.action.feeding')
    .withPriority(150)
    .build();
}

Aliases: many patterns, one action

step 2 typescript
grammar.define('photograph :thing')
  .mapsTo('zoo.action.photographing').withPriority(150).build();
grammar.define('photo :thing')
  .mapsTo('zoo.action.photographing').withPriority(150).build();
grammar.define('snap :thing')
  .mapsTo('zoo.action.photographing').withPriority(150).build();

Prepositions and two slots

step 3 typescript
grammar
  .define('feed :food to :animal')
  .mapsTo('zoo.action.feeding')
  .withPriority(150)
  .build();

Constraining a slot

step 4 typescript
grammar
  .define('feed :animal')
  .where('animal', (scope: any) => scope.touchable())
  .mapsTo('zoo.action.feeding')
  .withPriority(150)
  .build();

Combined runnable file

No story-code change this chapter; the runnable file is unchanged from the previous checkpoint (tutorials/familyzoo/v2.0.0/src/ch15-capability-dispatch.ts).

Chapter 18: The Language Layer: Messages & Message IDs

Book source: docs/book/v2.0.0/parts/part-5/18-the-language-layer.md · 2 step(s)

Intent here, words there

step 1 typescript
context.event('zoo.event.photographed', {
  messageId: 'zoo.photo.took_photo',
  params: { target: name },
});

Registering your text

step 2 typescript
extendLanguage(language: LanguageProvider): void {
  language.addMessage('zoo.feeding.fed_goats',
    'You scatter some feed on the ground. The pygmy goats ' +
    'rush over, bleating excitedly, and devour the corn and ' +
    'pellets in seconds.');

  language.addMessage('zoo.photo.took_photo',
    "Click! You snap a photo of {the target}. That one's " +
    "going on the fridge.");
}

Combined runnable file

No story-code change this chapter; the runnable file is unchanged from the previous checkpoint (tutorials/familyzoo/v2.0.0/src/ch15-capability-dispatch.ts).

Chapter 19: The Phrase Algebra: Grammar in the Template, Not the Text

Book source: docs/book/v2.0.0/parts/part-5/19-the-phrase-algebra.md · 5 step(s)

Pass a noun phrase, not a name

step 1 typescript
import { nounPhraseFor } from '@sharpee/stdlib';

context.event('game.message', {
  messageId: ZooMessages.ADMIRED,
  params: { animal: nounPhraseFor(animal) },
});

Lists

step 2 typescript
params: {
  items: {
    kind: 'list' as const,
    conj: 'and' as const,
    items: visible.map(e => nounPhraseFor(e)),
  },
}

Branching stays in code

step 3 typescript
import type {
  Choice,
  Literal,
  Optional,
} from '@sharpee/if-domain';
import { OpenableBehavior } from '@sharpee/world-model';

const lit = (text: string): Literal =>
  ({ kind: 'literal', text });

const openClause: Optional = {
  kind: 'optional',
  child: lit(', standing wide open'),
  present: OpenableBehavior.isOpen(gate),
};

context.event('game.message', {
  messageId: ZooMessages.GATE_STATUS,
  params: { openClause },
});
step 4 typescript
const parrotLine: Choice = {
  kind: 'choice',
  selector: 'cycling',
  alternatives: [
    lit('The parrot whistles a jaunty tune.'),
    lit('The parrot rasps, "Pretty bird! Pretty bird!"'),
    lit('The parrot preens one wing, ignoring you.'),
  ],
  entityId: parrot.id,
  messageKey: 'parrot-flavor',
};

Where the parameters go: nest them under `params`

step 5 typescript
// CORRECT: render params nested under params
context.event('game.message', {
  messageId: ZooMessages.PHOTO,
  params: { target: nounPhraseFor(target) },
});

// WRONG: target at the top level; the template can't see it
context.event('game.message', {
  messageId: ZooMessages.PHOTO,
  target: nounPhraseFor(target),
});

Combined runnable file

No story-code change this chapter; the runnable file is unchanged from the previous checkpoint (tutorials/familyzoo/v2.0.0/src/ch15-capability-dispatch.ts).

Chapter 20: Non-Player Characters: Actors That Take Turns

Book source: docs/book/v2.0.0/parts/part-6/20-non-player-characters.md · 7 step(s)

Non-Player Characters: Actors That Take Turns

step 1 typescript
import { GameEngine } from '@sharpee/engine';
import { NpcTrait } from '@sharpee/world-model';
import { NpcPlugin } from '@sharpee/plugin-npc';
import {
  NpcBehavior, NpcContext, NpcAction, createPatrolBehavior,
} from '@sharpee/stdlib';

Creating an NPC entity

step 2 typescript
const zookeeper = world.createEntity(
  'zookeeper',
  EntityType.ACTOR,
);

zookeeper.add(new IdentityTrait({
  name: 'zookeeper',
  description:
    'A friendly zookeeper in khaki overalls and a ' +
    'wide-brimmed hat, carrying a bucket of mixed ' +
    'animal feed. A name tag reads "Sam."',
  aliases: ['keeper', 'zookeeper', 'sam'],
  properName: false,
  article: 'a',
}));

zookeeper.add(new ActorTrait({ isPlayer: false }));

zookeeper.add(new NpcTrait({
  // must match the behavior's id
  behaviorId: 'zoo-keeper-patrol',
  canMove: true,                    // allowed to change rooms
  // "The zookeeper leaves to the east."
  announcesMovement: true,
  isAlive: true,
  isConscious: true,
}));

world.moveEntity(zookeeper.id, mainPath.id);

The parrot becomes an NPC

step 3 typescript
// `parrot` is the entity from Chapter 15 (Aviary, already
// an ACTOR).
parrot.add(new NpcTrait({
  behaviorId: 'zoo-parrot',   // matches parrotBehavior.id, below
  canMove: false,             // it stays on its perch
  isAlive: true,
  isConscious: true,
}));

Writing a custom behavior

step 4 typescript
const PARROT_PHRASES = [
  'Polly wants a cracker!',
  'SQUAWK! Pretty bird! Pretty bird!',
  'Pieces of eight! Pieces of eight!',
  "Who's a good bird? WHO'S A GOOD BIRD?",
  'BAWK! Welcome to the zoo!',
];

const parrotBehavior: NpcBehavior = {
  id: 'zoo-parrot',
  name: 'Parrot Behavior',

  // Called every turn, whether or not the player is here.
  onTurn(context: NpcContext): NpcAction[] {
    // no audience, stay quiet
    if (!context.playerVisible) return [];

    // 50% chance to squawk
    if (context.random.chance(0.5)) {
      const phrase = context.random.pick(PARROT_PHRASES);
      return [{
        type: 'speak',
        messageId: 'npc.speech',
        data: { text: phrase },
      }];
    }
    return [];
  },

  // Called once when the player walks into the parrot's room.
  onPlayerEnters(context: NpcContext): NpcAction[] {
    return [{
      type: 'emote',
      messageId: 'npc.emote',
      data: {
        text:
          'The parrot ruffles its feathers and eyes you ' +
          'with interest.',
      },
    }];
  },
};

Registering the plugin and behaviors

step 5 typescript
private roomIds: {
  giftShop: string;
  pettingZoo: string;
  mainPath: string;
  aviary: string;
} = { giftShop: '', pettingZoo: '', mainPath: '', aviary: '' };
step 6 typescript
this.roomIds.mainPath = mainPath.id;
this.roomIds.aviary = aviary.id;
step 7 typescript
onEngineReady(engine: GameEngine): void {
  // 1. Create and register the plugin: gives NPCs a turn phase
  const npcPlugin = new NpcPlugin();
  engine.getPluginRegistry().register(npcPlugin);

  // 2. Get the NPC service from the plugin
  const npcService = npcPlugin.getNpcService();

  // 3. Build the zookeeper's patrol from a route of room IDs
  const keeperPatrol = createPatrolBehavior({
    route: [
      this.roomIds.mainPath,
      this.roomIds.pettingZoo,
      this.roomIds.aviary,
    ],
    // Main Path → Petting Zoo → Aviary → Main Path → …
    loop: true,
    waitTurns: 1,    // pause one turn at each stop
  });

  // The factory's default id is 'patrol'; override it to
  // match NpcTrait.behaviorId
  keeperPatrol.id = 'zoo-keeper-patrol';
  npcService.registerBehavior(keeperPatrol);

  // 4. Register the parrot's custom behavior
  // (its id already matches)
  npcService.registerBehavior(parrotBehavior);
}

Combined runnable file

The complete, runnable story at the end of this chapter (tested source: tutorials/familyzoo/v2.0.0/src/ch20-npcs.ts).

runnable ch20-npcs.ts typescript
/**
 * Family Zoo Tutorial — Version 14: Capability Dispatch
 *
 * NEW IN THIS VERSION:
 *   - Capability dispatch (ADR-090) — one verb, different behavior per entity
 *   - Custom traits with capabilities[] — declaring what an entity can do
 *   - world.registerCapabilityBehavior() — registering what happens when you do it
 *   - CapabilityBehavior interface — validate/execute/report/blocked
 *
 * WHAT YOU'LL LEARN:
 *   - Some verbs mean different things for different entities
 *   - "pet goats" (affectionate) vs "pet parrot" (bites!) vs "pet snake" (glass blocks)
 *   - The entity decides how to respond, not the action
 *   - This is the most powerful pattern for entity-specific behavior
 *
 * TRY IT:
 *   > south / east                  (go to petting zoo)
 *   > pet goats                     (they love it)
 *   > pet rabbits                   (they're fuzzy)
 *   > west / west                   (go to aviary)
 *   > pet parrot                    (ouch!)
 *
 * BUILD & RUN:
 *   ./build.sh -s familyzoo
 *   node dist/cli/sharpee.js --story tutorials/familyzoo --play
 */

// ============================================================================
// IMPORTS
// ============================================================================

import { Story, StoryConfig, GameEngine } from '@sharpee/engine';
import {
  WorldModel,
  IFEntity,
  EntityType,
  Direction,
  NpcTrait,
  IWorldModel,
  // --- Capability Dispatch types (NEW in V14) ---
  CapabilityBehavior,             // Interface for capability behaviors
  CapabilityValidationResult,     // What validate() returns
  CapabilitySharedData,           // Shared state between phases
  CapabilityEffect,               // What report/blocked return
  createEffect,                   // Helper to build effects
  findTraitWithCapability,        // Find trait on entity claiming a capability
  ITrait,                         // Base trait interface
} from '@sharpee/world-model';
import {
  IdentityTrait,
  ActorTrait,
  ContainerTrait,
  SupporterTrait,
  RoomTrait,
  SceneryTrait,
  OpenableTrait,
  LockableTrait,
  DoorTrait,
  SwitchableTrait,
  LightSourceTrait,
  ReadableTrait,
} from '@sharpee/world-model';
import { ISemanticEvent } from '@sharpee/core';
import { NpcPlugin } from '@sharpee/plugin-npc';
import {
  NpcBehavior, NpcContext, NpcAction, createPatrolBehavior,
  Action, ActionContext, ValidationResult,
} from '@sharpee/stdlib';
import type { Parser } from '@sharpee/parser-en-us';
import type { LanguageProvider } from '@sharpee/lang-en-us';


// ============================================================================
// STORY CONFIGURATION
// ============================================================================

const config: StoryConfig = {
  id: 'familyzoo',
  title: 'Family Zoo',
  author: 'Sharpee Tutorial',
  version: '0.14.0',
  description: 'A small family zoo — learn Sharpee one concept at a time.',
};


// ============================================================================
// CUSTOM TRAIT: PettableTrait — NEW IN V14
// ============================================================================
//
// A custom trait declares that an entity supports certain capabilities.
// The 'capabilities' array lists which action IDs this trait responds to.
//
// When the action fires, the engine:
//   1. Finds the trait on the target entity that claims the capability
//   2. Looks up the registered behavior for that trait + capability
//   3. Calls the behavior's validate/execute/report/blocked phases
//
// This way, different entities can have different PettableTrait instances
// with different registered behaviors, all responding to the same "pet" verb.

/**
 * PettableTrait — marks an entity as pettable.
 *
 * Each entity with this trait gets a behavior registered for it.
 * The behavior defines what happens when you pet that specific entity.
 */
class PettableTrait implements ITrait {
  // The type string must be unique across all traits in the game.
  static readonly type = 'zoo.trait.pettable' as const;

  // Capabilities: which action IDs this trait responds to.
  // 'zoo.action.petting' is our custom action created below.
  static readonly capabilities = ['zoo.action.petting'] as const;

  // Every trait instance must have a runtime 'type' property.
  readonly type = PettableTrait.type;

  // Custom data — what kind of animal this is (for choosing response)
  readonly animalKind: 'goats' | 'rabbits' | 'parrot' | 'snake';

  constructor(kind: 'goats' | 'rabbits' | 'parrot' | 'snake') {
    this.animalKind = kind;
  }
}


// ============================================================================
// CAPABILITY BEHAVIORS — NEW IN V14
// ============================================================================
//
// A CapabilityBehavior implements the four-phase pattern for a specific
// trait + capability combination. Each phase receives:
//   entity     — the target entity being acted upon
//   world      — the world model (for mutations)
//   actorId    — who is performing the action
//   sharedData — object for passing data between phases
//
// The report() and blocked() methods return CapabilityEffect[] —
// arrays of { type, payload } objects that become semantic events.

// Message IDs for petting responses
const PetMessages = {
  PET_GOATS: 'zoo.petting.goats',
  PET_RABBITS: 'zoo.petting.rabbits',
  PET_PARROT: 'zoo.petting.parrot',
  PET_SNAKE: 'zoo.petting.snake_glass',
  CANT_PET: 'zoo.petting.cant_pet',
} as const;

/**
 * Unified petting behavior — dispatches by animalKind.
 *
 * The capability registry only allows ONE behavior per trait type + capability.
 * So we write ONE behavior that checks PettableTrait.animalKind to decide
 * what message to show. This is the standard pattern when the same trait
 * type appears on multiple entities with different semantics.
 */
const pettingBehavior: CapabilityBehavior = {
  validate(
    _entity: IFEntity,
    _world: WorldModel,
    _actorId: string,
    _sharedData: CapabilitySharedData,
  ): CapabilityValidationResult {
    // All pettable animals accept being petted (validation always passes)
    return { valid: true };
  },

  execute(
    _entity: IFEntity,
    _world: WorldModel,
    _actorId: string,
    _sharedData: CapabilitySharedData,
  ): void {
    // No world mutation — petting is cosmetic
  },

  report(
    entity: IFEntity,
    _world: WorldModel,
    _actorId: string,
    _sharedData: CapabilitySharedData,
  ): CapabilityEffect[] {
    // Dispatch by animalKind — each animal gets a different message
    const pettable = entity.get(PettableTrait);
    let messageId: string = PetMessages.CANT_PET;

    switch (pettable?.animalKind) {
      case 'goats':   messageId = PetMessages.PET_GOATS; break;
      case 'rabbits': messageId = PetMessages.PET_RABBITS; break;
      case 'parrot':  messageId = PetMessages.PET_PARROT; break;
    }

    return [
      createEffect('zoo.event.petted', {
        messageId,
        params: { target: entity.name },
      }),
    ];
  },

  blocked(
    entity: IFEntity,
    _world: WorldModel,
    _actorId: string,
    error: string,
    _sharedData: CapabilitySharedData,
  ): CapabilityEffect[] {
    return [
      createEffect('zoo.event.petting_blocked', {
        messageId: error,
        params: { target: entity.name },
      }),
    ];
  },
};


// ============================================================================
// PETTING ACTION — manual dispatch action
// ============================================================================
//
// The petting action uses capability dispatch:
//   1. Find the PettableTrait on the target entity
//   2. Look up the registered behavior for that trait + capability
//   3. Delegate to the behavior's phases
//
// This is a manual version of what createCapabilityDispatchAction() does
// in the stdlib. We write it out so you can see exactly how it works.

const PETTING_ACTION_ID = 'zoo.action.petting';

const pettingAction: Action = {
  id: PETTING_ACTION_ID,
  group: 'interaction',

  validate(context: ActionContext): ValidationResult {
    const entity = context.command.directObject?.entity;

    // Must have a target
    if (!entity) {
      return { valid: false, error: PetMessages.CANT_PET };
    }

    // Find the trait that claims this capability
    const trait = findTraitWithCapability(entity, PETTING_ACTION_ID);
    if (!trait) {
      // Target doesn't have PettableTrait — can't pet it
      return { valid: false, error: PetMessages.CANT_PET };
    }

    // Get the behavior registered on this world (ADR-207: the binding map
    // belongs to the running game's world, not a process-wide registry)
    const behavior = context.world.getBehaviorForCapability(trait, PETTING_ACTION_ID);
    if (!behavior) {
      return { valid: false, error: PetMessages.CANT_PET };
    }

    // Create shared data for passing between phases
    const sharedData: CapabilitySharedData = {};

    // Delegate validation to the behavior
    const behaviorResult = behavior.validate(entity, context.world, context.player.id, sharedData);
    if (!behaviorResult.valid) {
      return { valid: false, error: behaviorResult.error };
    }

    // Store everything for execute/report
    context.sharedData.capEntity = entity;
    context.sharedData.capBehavior = behavior;
    context.sharedData.capSharedData = sharedData;
    return { valid: true };
  },

  execute(context: ActionContext): void {
    const entity = context.sharedData.capEntity as IFEntity;
    const behavior = context.sharedData.capBehavior as CapabilityBehavior;
    const sharedData = context.sharedData.capSharedData as CapabilitySharedData;
    if (entity && behavior) {
      behavior.execute(entity, context.world, context.player.id, sharedData);
    }
  },

  report(context: ActionContext): ISemanticEvent[] {
    const entity = context.sharedData.capEntity as IFEntity;
    const behavior = context.sharedData.capBehavior as CapabilityBehavior;
    const sharedData = context.sharedData.capSharedData as CapabilitySharedData;
    if (!entity || !behavior) return [];

    // Get effects from the behavior
    const effects = behavior.report(entity, context.world, context.player.id, sharedData);
    // Convert CapabilityEffect[] to ISemanticEvent[]
    return effects.map(effect => context.event(effect.type, effect.payload));
  },

  blocked(context: ActionContext, result: ValidationResult): ISemanticEvent[] {
    return [
      context.event('zoo.event.petting_blocked', {
        messageId: result.error || PetMessages.CANT_PET,
      }),
    ];
  },
};


// ============================================================================
// CUSTOM ACTIONS FROM V13 (carried forward)
// ============================================================================

const FEED_ACTION_ID = 'zoo.action.feeding';
const FeedMessages = {
  NO_FEED: 'zoo.feeding.no_feed', NOT_AN_ANIMAL: 'zoo.feeding.not_animal',
  ALREADY_FED: 'zoo.feeding.already_fed', FED_GOATS: 'zoo.feeding.fed_goats',
  FED_RABBITS: 'zoo.feeding.fed_rabbits', FED_GENERIC: 'zoo.feeding.fed_generic',
} as const;

const feedAction: Action = {
  id: FEED_ACTION_ID, group: 'interaction',
  validate(context: ActionContext): ValidationResult {
    const target = context.command.directObject?.entity;
    const inventory = context.world.getContents(context.player.id);
    const hasFeed = inventory.some(i => i.get(IdentityTrait)?.aliases?.includes('feed'));
    if (!hasFeed) return { valid: false, error: FeedMessages.NO_FEED };
    if (!target) return { valid: false, error: FeedMessages.NOT_AN_ANIMAL };
    const name = target.get(IdentityTrait)?.name?.toLowerCase() || '';
    if (!['pygmy goats', 'rabbits'].some(a => name.includes(a))) return { valid: false, error: FeedMessages.NOT_AN_ANIMAL };
    if (context.world.getStateValue(`fed-${target.id}`)) return { valid: false, error: FeedMessages.ALREADY_FED };
    context.sharedData.feedTarget = target;
    return { valid: true };
  },
  execute(context: ActionContext): void {
    const target = context.sharedData.feedTarget as IFEntity;
    if (target) context.world.setStateValue(`fed-${target.id}`, true);
  },
  report(context: ActionContext): ISemanticEvent[] {
    const target = context.sharedData.feedTarget as IFEntity;
    const name = target?.get(IdentityTrait)?.name?.toLowerCase() || '';
    let messageId: string = FeedMessages.FED_GENERIC;
    if (name.includes('goats')) messageId = FeedMessages.FED_GOATS;
    else if (name.includes('rabbits')) messageId = FeedMessages.FED_RABBITS;
    return [context.event('zoo.event.fed', { messageId, params: { animal: target?.get(IdentityTrait)?.name } })];
  },
  blocked(_context: ActionContext, result: ValidationResult): ISemanticEvent[] {
    return [_context.event('zoo.event.feeding_blocked', { messageId: result.error || FeedMessages.NOT_AN_ANIMAL })];
  },
};

const PHOTOGRAPH_ACTION_ID = 'zoo.action.photographing';
const PhotoMessages = { NO_CAMERA: 'zoo.photo.no_camera', TOOK_PHOTO: 'zoo.photo.took_photo' } as const;

const photographAction: Action = {
  id: PHOTOGRAPH_ACTION_ID, group: 'interaction',
  validate(context: ActionContext): ValidationResult {
    const inventory = context.world.getContents(context.player.id);
    const hasCamera = inventory.some(i => i.get(IdentityTrait)?.aliases?.includes('camera'));
    if (!hasCamera) return { valid: false, error: PhotoMessages.NO_CAMERA };
    const target = context.command.directObject?.entity;
    if (target) context.sharedData.photoTarget = target;
    return { valid: true };
  },
  execute(): void {},
  report(context: ActionContext): ISemanticEvent[] {
    const target = context.sharedData.photoTarget as IFEntity | undefined;
    const name = target?.get(IdentityTrait)?.name || 'the scenery';
    return [context.event('zoo.event.photographed', { messageId: PhotoMessages.TOOK_PHOTO, params: { target: name } })];
  },
  blocked(context: ActionContext, result: ValidationResult): ISemanticEvent[] {
    return [context.event('zoo.event.photographing_blocked', { messageId: result.error || PhotoMessages.NO_CAMERA })];
  },
};


// ============================================================================
// PARROT BEHAVIOR (from V11)
// ============================================================================

const PARROT_PHRASES = ['Polly wants a cracker!', 'SQUAWK! Pretty bird! Pretty bird!', 'Pieces of eight! Pieces of eight!', 'Who\'s a good bird? WHO\'S A GOOD BIRD?', 'BAWK! Welcome to the zoo!'];

const parrotBehavior: NpcBehavior = {
  id: 'zoo-parrot', name: 'Parrot Behavior',
  onTurn(context: NpcContext): NpcAction[] {
    if (!context.playerVisible) return [];
    if (context.random.chance(0.5)) return [{ type: 'speak', messageId: 'npc.speech', data: { text: context.random.pick(PARROT_PHRASES) } }];
    return [];
  },
  onPlayerEnters(): NpcAction[] {
    return [{ type: 'emote', messageId: 'npc.emote', data: { text: 'The parrot ruffles its feathers and eyes you with interest.' } }];
  },
};


// ============================================================================
// THE STORY CLASS
// ============================================================================

class FamilyZooStory implements Story {
  config = config;
  private roomIds = { entrance: '', mainPath: '', pettingZoo: '', aviary: '', supplyRoom: '', nocturnalExhibit: '', giftShop: '' };
  private entityIds = { animalFeed: '', penny: '', souvenirPress: '' };

  createPlayer(world: WorldModel): IFEntity {
    const player = world.createEntity('yourself', EntityType.ACTOR);
    player.add(new IdentityTrait({ name: 'yourself', description: 'Just an ordinary visitor to the zoo.', aliases: ['self', 'myself', 'me'], properName: true, article: '' }));
    player.add(new ActorTrait({ isPlayer: true }));
    player.add(new ContainerTrait({ capacity: { maxItems: 10 } }));
    return player;
  }

  initializeWorld(world: WorldModel): void {

    // ROOMS
    const entrance = world.createEntity('Zoo Entrance', EntityType.ROOM);
    entrance.add(new RoomTrait({ exits: {}, isDark: false }));
    entrance.add(new IdentityTrait({ name: 'Zoo Entrance', description: 'You stand before the wrought-iron gates of the Willowbrook Family Zoo. A cheerful welcome sign arches over the entrance, and a small ticket booth sits to one side. A sturdy iron fence runs along either side of the gates. The main path leads south into the zoo grounds.', aliases: ['entrance', 'gates', 'gate'], properName: false, article: 'the' }));

    const mainPath = world.createEntity('Main Path', EntityType.ROOM);
    mainPath.add(new RoomTrait({ exits: {}, isDark: false }));
    mainPath.add(new IdentityTrait({ name: 'Main Path', description: 'A wide gravel path winds through the heart of the zoo. Colorful direction signs point every which way. A park bench sits beside the path. To the east, the petting zoo. To the west, the aviary. A staff gate blocks the path to the south. The entrance is back to the north.', aliases: ['path', 'main path', 'gravel path'], properName: false, article: 'the' }));

    const pettingZoo = world.createEntity('Petting Zoo', EntityType.ROOM);
    pettingZoo.add(new RoomTrait({ exits: {}, isDark: false }));
    pettingZoo.add(new IdentityTrait({ name: 'Petting Zoo', description: 'A cheerful open-air enclosure filled with friendly animals. Pygmy goats trot around nibbling at visitors\' shoelaces, while a pair of fluffy rabbits hop lazily near a hay bale. A feed dispenser is mounted on a post. An info plaque is posted by the gate. The main path is back to the west.', aliases: ['petting zoo', 'petting area', 'pen'], properName: false, article: 'the' }));

    const aviary = world.createEntity('Aviary', EntityType.ROOM);
    aviary.add(new RoomTrait({ exits: {}, isDark: false }));
    aviary.add(new IdentityTrait({ name: 'Aviary', description: 'You step inside a soaring mesh dome. Brilliantly colored parrots chatter from rope perches, and a toucan eyes you curiously from a branch overhead. A small waterfall splashes into a stone basin. An info plaque hangs near the entrance. The gift shop is to the west. The main path is back to the east.', aliases: ['aviary', 'bird house', 'dome'], properName: false, article: 'the' }));

    const supplyRoom = world.createEntity('Supply Room', EntityType.ROOM);
    supplyRoom.add(new RoomTrait({ exits: {}, isDark: false }));
    supplyRoom.add(new IdentityTrait({ name: 'Supply Room', description: 'A cluttered storage room behind the staff gate. Metal shelves line the walls. A cork board on the wall is covered with staff schedules. A battered radio sits on one of the shelves. The staff gate leads back north.', aliases: ['supply room', 'storage room', 'storeroom'], properName: false, article: 'the' }));

    const nocturnalExhibit = world.createEntity('Nocturnal Animals Exhibit', EntityType.ROOM);
    nocturnalExhibit.add(new RoomTrait({ exits: {}, isDark: true }));
    nocturnalExhibit.add(new IdentityTrait({ name: 'Nocturnal Animals Exhibit', description: 'A cool, dimly lit cavern designed to simulate nighttime. Glass enclosures line both walls with soft red lights. You can see sugar gliders, bush babies, and a barn owl. A warning sign is posted near the entrance. The exit leads back north to the supply room.', aliases: ['nocturnal exhibit', 'nocturnal animals', 'exhibit'], properName: false, article: 'the' }));

    const giftShop = world.createEntity('Gift Shop', EntityType.ROOM);
    giftShop.add(new RoomTrait({ exits: {}, isDark: false }));
    giftShop.add(new IdentityTrait({ name: 'Gift Shop', description: 'A small zoo gift shop crammed with stuffed animals and postcards. A large souvenir penny press machine stands near the door. A disposable camera sits on the counter. The aviary is back to the east.', aliases: ['gift shop', 'shop', 'store'], properName: false, article: 'the' }));

    this.roomIds = { entrance: entrance.id, mainPath: mainPath.id, pettingZoo: pettingZoo.id, aviary: aviary.id, supplyRoom: supplyRoom.id, nocturnalExhibit: nocturnalExhibit.id, giftShop: giftShop.id };

    // STAFF GATE
    const keycard = world.createEntity('staff keycard', EntityType.ITEM);
    keycard.add(new IdentityTrait({ name: 'staff keycard', description: 'A white plastic keycard with "WILLOWBROOK ZOO — STAFF ONLY" printed in blue.', aliases: ['keycard', 'key card', 'card', 'key', 'staff keycard'], properName: false, article: 'a' }));
    world.moveEntity(keycard.id, entrance.id);

    const staffGate = world.createEntity('staff gate', EntityType.DOOR);
    staffGate.add(new IdentityTrait({ name: 'staff gate', description: 'A sturdy metal gate with a "STAFF ONLY" sign.', aliases: ['gate', 'staff gate', 'metal gate', 'staff door'], properName: false, article: 'a' }));
    staffGate.add(new DoorTrait({ room1: mainPath.id, room2: supplyRoom.id, bidirectional: true }));
    staffGate.add(new OpenableTrait({ isOpen: false }));
    staffGate.add(new LockableTrait({ isLocked: true, keyId: keycard.id }));
    staffGate.add(new SceneryTrait());
    world.moveEntity(staffGate.id, mainPath.id);

    // EXITS
    entrance.get(RoomTrait)!.exits = { [Direction.SOUTH]: { destination: mainPath.id } };
    mainPath.get(RoomTrait)!.exits = { [Direction.NORTH]: { destination: entrance.id }, [Direction.EAST]: { destination: pettingZoo.id }, [Direction.WEST]: { destination: aviary.id }, [Direction.SOUTH]: { destination: supplyRoom.id, via: staffGate.id } };
    pettingZoo.get(RoomTrait)!.exits = { [Direction.WEST]: { destination: mainPath.id } };
    aviary.get(RoomTrait)!.exits = { [Direction.EAST]: { destination: mainPath.id }, [Direction.WEST]: { destination: giftShop.id } };
    supplyRoom.get(RoomTrait)!.exits = { [Direction.NORTH]: { destination: mainPath.id, via: staffGate.id }, [Direction.SOUTH]: { destination: nocturnalExhibit.id } };
    nocturnalExhibit.get(RoomTrait)!.exits = { [Direction.NORTH]: { destination: supplyRoom.id } };
    giftShop.get(RoomTrait)!.exits = { [Direction.EAST]: { destination: aviary.id } };

    // SCENERY — abbreviated
    for (const [name, desc, aliases, room] of [
      ['welcome sign', 'A brightly painted wooden sign reads: "WELCOME TO WILLOWBROOK FAMILY ZOO."', ['sign', 'welcome sign'], entrance],
      ['ticket booth', 'A small wooden booth with a "Self-Guided Tours" sign.', ['booth', 'ticket booth'], entrance],
      ['iron fence', 'A tall wrought-iron fence with animal silhouettes.', ['fence', 'iron fence', 'railing'], entrance],
      ['direction signs', 'Arrow signs: PETTING ZOO (east), AVIARY (west), EXIT (north).', ['signs', 'direction signs', 'arrow signs'], mainPath],
      ['flower beds', 'Tidy beds of marigolds and petunias.', ['flowers', 'flower beds'], mainPath],
      ['hay bale', 'A large round bale of golden hay.', ['hay', 'hay bale', 'bale'], pettingZoo],
      ['toucan', 'A Toco toucan with an enormous orange-and-black bill.', ['toucan', 'toco toucan'], aviary],
      ['waterfall', 'A gentle artificial waterfall cascading into a stone basin.', ['waterfall', 'water', 'basin'], aviary],
      ['rope perches', 'Thick sisal ropes strung between wooden posts.', ['perches', 'rope perches', 'ropes'], aviary],
      ['metal shelves', 'Industrial metal shelving units stacked with supplies.', ['shelves', 'metal shelves', 'shelf'], supplyRoom],
      ['sugar gliders', 'A family of tiny sugar gliders with enormous dark eyes.', ['sugar gliders', 'gliders'], nocturnalExhibit],
      ['bush babies', 'Two bush babies with impossibly large round eyes.', ['bush babies', 'galagos'], nocturnalExhibit],
      ['barn owl', 'An enormous barn owl with a heart-shaped white face.', ['barn owl', 'owl'], nocturnalExhibit],
      ['stuffed animals', 'Shelves of plush tigers, pandas, and penguins.', ['stuffed animals', 'plush', 'toys'], giftShop],
      ['postcards', 'A spinning rack of postcards showing the zoo\'s greatest hits.', ['postcards', 'cards', 'postcard rack'], giftShop],
    ] as [string, string, string[], IFEntity][]) {
      const e = world.createEntity(name, EntityType.SCENERY);
      e.add(new IdentityTrait({ name, description: desc, aliases, properName: false, article: 'a' }));
      world.moveEntity(e.id, room.id);
    }

    // Additional scenery with special traits (readable, switchable)
    const corkBoard = world.createEntity('cork board', EntityType.SCENERY);
    corkBoard.add(new IdentityTrait({ name: 'cork board', description: 'A cork board with staff schedules. A note in red marker: "DON\'T FORGET: nocturnal exhibit lights need new batteries!"', aliases: ['cork board', 'board', 'notices'], properName: false, article: 'a' }));
    world.moveEntity(corkBoard.id, supplyRoom.id);

    const radio = world.createEntity('radio', EntityType.ITEM);
    radio.add(new IdentityTrait({ name: 'radio', description: 'A battered portable radio held together with duct tape. The antenna is bent at a jaunty angle. A faded sticker on the side reads "ZOO FM — All Animals, All The Time."', aliases: ['radio', 'portable radio'], properName: false, article: 'a' }));
    radio.add(new SwitchableTrait({ isOn: false }));
    radio.add(new SceneryTrait());
    world.moveEntity(radio.id, supplyRoom.id);

    const pettingPlaque = world.createEntity('info plaque', EntityType.SCENERY);
    pettingPlaque.add(new IdentityTrait({ name: 'info plaque', description: 'A brass plaque mounted on a wooden post near the petting zoo gate.', aliases: ['plaque', 'info plaque', 'brass plaque'], properName: false, article: 'an' }));
    pettingPlaque.add(new ReadableTrait({ text: 'PYGMY GOATS — These Nigerian Dwarf goats are gentle, curious, and always hungry.\n\nHOLLAND LOP RABBITS — Known for their floppy ears. Our pair, Biscuit and Marmalade, were born here in 2023.' }));
    world.moveEntity(pettingPlaque.id, pettingZoo.id);

    const aviaryPlaque = world.createEntity('aviary plaque', EntityType.SCENERY);
    aviaryPlaque.add(new IdentityTrait({ name: 'aviary plaque', description: 'A colorful information board near the aviary entrance.', aliases: ['plaque', 'aviary plaque', 'information board'], properName: false, article: 'an' }));
    aviaryPlaque.add(new ReadableTrait({ text: 'WELCOME TO THE AVIARY — Home to over 30 species!\n\nTOCO TOUCAN — Its bill weighs less than a smartphone.\n\nSCARLET MACAW — Can live over 75 years. Our oldest, Captain, is 42.' }));
    world.moveEntity(aviaryPlaque.id, aviary.id);

    const warningSign = world.createEntity('warning sign', EntityType.SCENERY);
    warningSign.add(new IdentityTrait({ name: 'warning sign', description: 'A yellow warning sign near the nocturnal exhibit entrance.', aliases: ['warning', 'warning sign', 'yellow sign'], properName: false, article: 'a' }));
    warningSign.add(new ReadableTrait({ text: 'CAUTION: The Nocturnal Animals Exhibit is kept dark. Please use a flashlight. Do NOT use camera flash. (We don\'t talk about the Great Owl Incident of 2022.)' }));
    world.moveEntity(warningSign.id, supplyRoom.id);

    const brochure = world.createEntity('zoo brochure', EntityType.ITEM);
    brochure.add(new IdentityTrait({ name: 'zoo brochure', description: 'A glossy tri-fold brochure with "WILLOWBROOK FAMILY ZOO" on the cover.', aliases: ['brochure', 'zoo brochure', 'pamphlet', 'leaflet'], properName: false, article: 'a' }));
    brochure.add(new ReadableTrait({ text: 'WILLOWBROOK FAMILY ZOO — Your Guide\n\nEXHIBITS:\n  Petting Zoo — East from Main Path\n  Aviary — West from Main Path\n  Gift Shop — West from Aviary\n  Nocturnal Animals — Staff Area\n\n"Where every visit is a wild adventure!"' }));
    world.moveEntity(brochure.id, entrance.id);

    // PETTABLE ANIMALS — NEW IN V14
    //
    // These scenery entities also get PettableTrait, which declares
    // that they respond to the 'zoo.action.petting' capability.
    // Each gets a different behavior registered below.

    const goats = world.createEntity('pygmy goats', EntityType.SCENERY);
    goats.add(new IdentityTrait({ name: 'pygmy goats', grammaticalNumber: 'plural', description: 'Three pygmy goats hoping you have food.', aliases: ['goats', 'pygmy goats', 'goat'], properName: false, article: 'some' }));
    goats.add(new PettableTrait('goats'));  // ← PettableTrait with 'goats' kind
    world.moveEntity(goats.id, pettingZoo.id);

    const rabbits = world.createEntity('rabbits', EntityType.SCENERY);
    rabbits.add(new IdentityTrait({ name: 'rabbits', grammaticalNumber: 'plural', description: 'A pair of Holland Lop rabbits with floppy ears.', aliases: ['rabbits', 'rabbit', 'bunnies'], properName: false, article: 'some' }));
    rabbits.add(new PettableTrait('rabbits'));  // ← PettableTrait with 'rabbits' kind
    world.moveEntity(rabbits.id, pettingZoo.id);

    const parrots = world.createEntity('parrots', EntityType.SCENERY);
    parrots.add(new IdentityTrait({ name: 'parrots', grammaticalNumber: 'plural', description: 'A raucous flock of scarlet macaws and grey African parrots.', aliases: ['parrots', 'macaws', 'birds'], properName: false, article: 'some' }));
    world.moveEntity(parrots.id, aviary.id);

    // PORTABLE OBJECTS
    const zooMap = world.createEntity('zoo map', EntityType.ITEM);
    zooMap.add(new IdentityTrait({ name: 'zoo map', description: 'A colorful folding map of the Willowbrook Family Zoo.', aliases: ['map', 'zoo map', 'folding map'], properName: false, article: 'a' }));
    world.moveEntity(zooMap.id, entrance.id);

    const animalFeed = world.createEntity('bag of animal feed', EntityType.ITEM);
    animalFeed.add(new IdentityTrait({ name: 'bag of animal feed', description: 'A small brown paper bag filled with dried corn and pellets.', aliases: ['feed', 'animal feed', 'bag of feed', 'corn'], properName: false, article: 'a' }));
    world.moveEntity(animalFeed.id, pettingZoo.id);

    const penny = world.createEntity('souvenir penny', EntityType.ITEM);
    penny.add(new IdentityTrait({ name: 'souvenir penny', description: 'A shiny copper penny.', aliases: ['penny', 'souvenir penny', 'coin'], properName: false, article: 'a' }));
    world.moveEntity(penny.id, mainPath.id);

    const flashlight = world.createEntity('flashlight', EntityType.ITEM);
    flashlight.add(new IdentityTrait({ name: 'flashlight', description: 'A heavy-duty yellow flashlight.', aliases: ['flashlight', 'torch', 'light', 'lamp'], properName: false, article: 'a' }));
    flashlight.add(new SwitchableTrait({ isOn: false }));
    flashlight.add(new LightSourceTrait({ brightness: 8, isLit: false }));
    world.moveEntity(flashlight.id, supplyRoom.id);

    const camera = world.createEntity('disposable camera', EntityType.ITEM);
    camera.add(new IdentityTrait({ name: 'disposable camera', description: 'A cheap yellow disposable camera with "ZOO MEMORIES" printed on the side.', aliases: ['camera', 'disposable camera'], properName: false, article: 'a' }));
    world.moveEntity(camera.id, giftShop.id);

    this.entityIds = { animalFeed: animalFeed.id, penny: penny.id, souvenirPress: '' };

    // CONTAINERS
    const backpack = world.createEntity('backpack', EntityType.CONTAINER);
    backpack.add(new IdentityTrait({ name: 'backpack', description: 'A small red canvas backpack.', aliases: ['backpack', 'rucksack', 'pack'], properName: false, article: 'a' }));
    backpack.add(new ContainerTrait({ capacity: { maxItems: 5 } }));
    world.moveEntity(backpack.id, entrance.id);

    const parkBench = world.createEntity('park bench', EntityType.SUPPORTER);
    parkBench.add(new IdentityTrait({ name: 'park bench', description: 'A sturdy park bench painted forest green.', aliases: ['bench', 'park bench', 'benches', 'seat'], properName: false, article: 'a' }));
    parkBench.add(new SupporterTrait({ capacity: { maxItems: 3 } }));
    parkBench.add(new SceneryTrait());
    world.moveEntity(parkBench.id, mainPath.id);

    const lunchbox = world.createEntity('lunchbox', EntityType.CONTAINER);
    lunchbox.add(new IdentityTrait({ name: 'lunchbox', description: 'A dented metal lunchbox decorated with cartoon zoo animals.', aliases: ['lunchbox', 'lunch box', 'box'], properName: false, article: 'a' }));
    lunchbox.add(new ContainerTrait({ capacity: { maxItems: 3 } }));
    lunchbox.add(new OpenableTrait({ isOpen: false }));
    world.moveEntity(lunchbox.id, mainPath.id);

    lunchbox.get(OpenableTrait)!.isOpen = true;
    const juice = world.createEntity('juice box', EntityType.ITEM);
    juice.add(new IdentityTrait({ name: 'juice box', description: 'A small juice box with a picture of a happy elephant.', aliases: ['juice', 'juice box', 'drink'], properName: false, article: 'a' }));
    world.moveEntity(juice.id, lunchbox.id);
    lunchbox.get(OpenableTrait)!.isOpen = false;

    const dispenser = world.createEntity('feed dispenser', EntityType.CONTAINER);
    dispenser.add(new IdentityTrait({ name: 'feed dispenser', description: 'A coin-operated feed dispenser mounted on a wooden post. Sign: "FREE — Just Turn!"', aliases: ['dispenser', 'feed dispenser'], properName: false, article: 'a' }));
    dispenser.add(new ContainerTrait({ capacity: { maxItems: 3 } }));
    dispenser.add(new OpenableTrait({ isOpen: false }));
    dispenser.add(new SceneryTrait());
    world.moveEntity(dispenser.id, pettingZoo.id);

    const souvenirPress = world.createEntity('souvenir press', EntityType.CONTAINER);
    souvenirPress.add(new IdentityTrait({ name: 'souvenir press', description: 'A heavy cast-iron machine with a big crank handle. A slot on top accepts pennies, and the mechanism stamps them with a zoo animal design. A sign reads: "INSERT PENNY, TURN HANDLE, KEEP FOREVER!"', aliases: ['press', 'souvenir press', 'penny press', 'machine'], properName: false, article: 'a' }));
    souvenirPress.add(new ContainerTrait({ capacity: { maxItems: 1 } }));
    souvenirPress.add(new SceneryTrait());
    world.moveEntity(souvenirPress.id, giftShop.id);
    this.entityIds.souvenirPress = souvenirPress.id;

    // NPCs
    const zookeeper = world.createEntity('zookeeper', EntityType.ACTOR);
    zookeeper.add(new IdentityTrait({ name: 'zookeeper', description: 'A friendly zookeeper in khaki overalls. A name tag reads "Sam."', aliases: ['keeper', 'zookeeper', 'sam'], properName: false, article: 'a' }));
    zookeeper.add(new ActorTrait({ isPlayer: false }));
    zookeeper.add(new NpcTrait({ behaviorId: 'zoo-keeper-patrol', canMove: true, announcesMovement: true, isAlive: true, isConscious: true }));
    world.moveEntity(zookeeper.id, mainPath.id);

    // The parrot NPC also gets PettableTrait so "pet parrot" works via capability dispatch
    const parrot = world.createEntity('parrot', EntityType.ACTOR);
    parrot.add(new IdentityTrait({ name: 'parrot', description: 'A magnificent scarlet macaw perched on a rope. It tilts its head and watches you with one bright eye.', aliases: ['parrot', 'macaw', 'scarlet macaw'], properName: false, article: 'a' }));
    parrot.add(new ActorTrait({ isPlayer: false }));
    parrot.add(new NpcTrait({ behaviorId: 'zoo-parrot', canMove: false, isAlive: true, isConscious: true }));
    parrot.add(new PettableTrait('parrot'));  // ← Parrot is also pettable!
    world.moveEntity(parrot.id, aviary.id);

    // ========================================================================
    // REGISTER CAPABILITY BEHAVIOR — NEW IN V14
    // ========================================================================
    //
    // world.registerCapabilityBehavior(traitType, actionId, behavior) tells
    // the engine: "when someone does actionId on an entity with this trait,
    // use this behavior."
    //
    // The binding map belongs to THIS world (ADR-207): every game registers
    // its own behaviors in initializeWorld(), and registration is idempotent
    // (re-registering a key just overwrites it), so there is no need to
    // check whether it is already registered.
    // Our unified pettingBehavior dispatches internally by animalKind.

    world.registerCapabilityBehavior(
      PettableTrait.type,
      PETTING_ACTION_ID,
      pettingBehavior,
    );

    // PLAYER STARTING LOCATION
    const player = world.getPlayer();
    if (player) world.moveEntity(player.id, entrance.id);
  }


  getCustomActions(): any[] {
    // Return all custom actions — both from V13 and V14
    return [feedAction, photographAction, pettingAction];
  }


  extendParser(parser: Parser): void {
    const grammar = parser.getStoryGrammar();

    // V13 grammar
    grammar.define('feed :thing').mapsTo(FEED_ACTION_ID).withPriority(150).build();
    grammar.define('photograph :thing').mapsTo(PHOTOGRAPH_ACTION_ID).withPriority(150).build();
    grammar.define('photo :thing').mapsTo(PHOTOGRAPH_ACTION_ID).withPriority(150).build();
    grammar.define('snap :thing').mapsTo(PHOTOGRAPH_ACTION_ID).withPriority(150).build();

    // V14 grammar — "pet" maps to the petting action
    grammar.define('pet :thing').mapsTo(PETTING_ACTION_ID).withPriority(150).build();
    grammar.define('stroke :thing').mapsTo(PETTING_ACTION_ID).withPriority(150).build();
  }


  extendLanguage(language: LanguageProvider): void {
    // V13 messages
    language.addMessage(FeedMessages.NO_FEED, "You don't have any animal feed.");
    language.addMessage(FeedMessages.NOT_AN_ANIMAL, "That's not something you can feed.");
    language.addMessage(FeedMessages.ALREADY_FED, "You've already fed them. They look contentedly full.");
    language.addMessage(FeedMessages.FED_GOATS, 'You scatter some feed on the ground. The pygmy goats rush over, bleating excitedly, and devour the corn and pellets in seconds. The smallest goat looks up at you with big grateful eyes.');
    language.addMessage(FeedMessages.FED_RABBITS, 'You sprinkle some pellets near the rabbits. Biscuit and Marmalade hop over cautiously, then munch away happily.');
    language.addMessage(FeedMessages.FED_GENERIC, 'You offer some feed. The animal eats it gratefully.');
    language.addMessage(PhotoMessages.NO_CAMERA, "You don't have a camera. There's one in the gift shop.");
    language.addMessage(PhotoMessages.TOOK_PHOTO, 'Click! You snap a photo of {the target}. That one\'s going on the fridge.');

    // V14 messages — petting responses
    language.addMessage(PetMessages.PET_GOATS,
      'You reach down and pet the nearest goat. It leans into your hand and bleats happily. The others crowd around, demanding equal attention.');
    language.addMessage(PetMessages.PET_RABBITS,
      'You gently stroke one of the rabbits. Its fur is incredibly soft. It twitches its nose at you contentedly.');
    language.addMessage(PetMessages.PET_PARROT,
      'You reach toward the parrot. CHOMP! It nips your finger with its beak. "NO TOUCHING!" it squawks indignantly.');
    language.addMessage(PetMessages.CANT_PET,
      "You can't pet that.");
  }


  onEngineReady(engine: GameEngine): void {
    const world = engine.getWorld();

    // NPC Plugin
    const npcPlugin = new NpcPlugin();
    engine.getPluginRegistry().register(npcPlugin);
    const npcService = npcPlugin.getNpcService();
    const keeperPatrol = createPatrolBehavior({ route: [this.roomIds.mainPath, this.roomIds.pettingZoo, this.roomIds.aviary], loop: true, waitTurns: 1 });
    keeperPatrol.id = 'zoo-keeper-patrol';
    npcService.registerBehavior(keeperPatrol);
    npcService.registerBehavior(parrotBehavior);

    // Event chain handlers (from V12)
    const feedId = this.entityIds.animalFeed;
    const pettingZooId = this.roomIds.pettingZoo;
    world.chainEvent('if.event.dropped', (event: ISemanticEvent, w: IWorldModel): ISemanticEvent | null => {
      const data = event.data as Record<string, any>;
      if (data.itemId !== feedId || data.toLocation !== pettingZooId) return null;
      if (w.getStateValue('goats-fed')) return null;
      w.setStateValue('goats-fed', true);
      return { id: `zoo-goats-${Date.now()}`, type: 'zoo.event.goats_react', timestamp: Date.now(), entities: {}, data: { text: 'The pygmy goats spot the bag of feed and rush over! They crowd around, bleating excitedly, and devour the corn and pellets in seconds. The smallest goat looks up at you with big grateful eyes.' } };
    }, { key: 'zoo.chain.goats-eat-feed' });

    const pennyId = this.entityIds.penny;
    const pressId = this.entityIds.souvenirPress;
    world.chainEvent('if.event.put_in', (event: ISemanticEvent, w: IWorldModel): ISemanticEvent | null => {
      const data = event.data as Record<string, any>;
      if (data.itemId !== pennyId || data.targetId !== pressId) return null;
      w.removeEntity(pennyId);
      const pp = w.createEntity('pressed penny', EntityType.ITEM);
      pp.add(new IdentityTrait({ name: 'pressed penny', description: 'A flattened oval of copper with an embossed toucan.', aliases: ['pressed penny', 'pressed coin', 'souvenir'], properName: false, article: 'a' }));
      const player = w.getPlayer();
      if (player) w.moveEntity(pp.id, player.id);
      return { id: `zoo-press-${Date.now()}`, type: 'zoo.event.penny_pressed', timestamp: Date.now(), entities: {}, data: { text: 'CLUNK! CRUNCH! WHIRRR! The souvenir press produces a beautiful pressed penny with an embossed toucan.' } };
    }, { key: 'zoo.chain.penny-press' });
  }
}

export const story: Story = new FamilyZooStory();
export default story;

Chapter 21: Scenes: Named Windows of Story Time

Book source: docs/book/v2.0.0/parts/part-6/21-scenes.md · 5 step(s)

Creating a scene

step 1 typescript
world.createScene('scene-petting-zoo', {
  name: 'Among the Animals',
  begin: (w) =>
    w.getLocation(w.getPlayer()!.id) === pettingZoo.id,
  end:   (w) =>
    w.getLocation(w.getPlayer()!.id) !== pettingZoo.id,
  recurring: true,
});

Querying a scene

step 2 typescript
if (world.isSceneActive('scene-petting-zoo')) {
  // the player is among the animals right now
}

if (world.hasSceneHappened('scene-finale')) {
  // the finale has run at least once
}

Reacting to transitions

step 3 typescript
world.createScene('scene-petting-zoo', {
  name: 'Among the Animals',
  begin: (w) =>
    w.getLocation(w.getPlayer()!.id) === pettingZoo.id,
  end:   (w) =>
    w.getLocation(w.getPlayer()!.id) !== pettingZoo.id,
  recurring: true,
  onBegin: () =>
    ({ text: 'A waft of hay and warm fur greets you.' }),
  onEnd:   () => ({ text: 'The animal sounds fade behind you.' }),
});
step 4 typescript
  onEnd: (ctx) => ({
    text: `You spent ${ctx.totalTurns} turns among the animals.`,
  }),

Common shapes

step 5 typescript
world.createScene('scene-storm', {
  name: 'Thunderstorm',
  begin: (w) => w.getStateValue('stormTriggered') === true,
  end:   (w) =>
    (w.getEntity('scene-storm')
      ?.get(SceneTrait)?.activeTurns ?? 0) >= 15,
});

Combined runnable file

No story-code change this chapter; the runnable file is unchanged from the previous checkpoint (tutorials/familyzoo/v2.0.0/src/ch20-npcs.ts).

Chapter 22: Turns, Timed Events & Daemons: Giving the World a Clock

Book source: docs/book/v2.0.0/parts/part-6/22-timed-events-and-daemons.md · 6 step(s)

How the scheduler ticks

step 1 typescript
import { SchedulerPlugin } from '@sharpee/plugin-scheduler';
import type {
  Daemon, Fuse, SchedulerContext,
} from '@sharpee/plugin-scheduler';
import { ISemanticEvent } from '@sharpee/core';
import { IdentityTrait } from '@sharpee/world-model';

onEngineReady(engine: GameEngine): void {
  // … the NPC plugin registration from Chapter 20 stays here …

  const schedulerPlugin = new SchedulerPlugin();
  engine.getPluginRegistry().register(schedulerPlugin);
  const scheduler = schedulerPlugin.getScheduler();

  scheduler.registerDaemon(createPAAnnouncementDaemon());
  scheduler.setFuse(createFeedingTimeFuse());
  scheduler.registerDaemon(createGoatBleatingDaemon());
}
step 2 typescript
const TimedMessages = {
  PA_CLOSING_3: 'zoo.pa.closing_3',
  PA_CLOSING_2: 'zoo.pa.closing_2',
  PA_CLOSING_1: 'zoo.pa.closing_1',
  PA_CLOSED:    'zoo.pa.closed',
  FEEDING_TIME: 'zoo.feeding_time.announced',
  GOATS_BLEATING: 'zoo.goats.bleating',
} as const;

Daemons: run every turn

step 3 typescript
function createPAAnnouncementDaemon(): Daemon {
  // internal state, kept across turns
  let announcementCount = 0;

  return {
    id: 'zoo.daemon.pa_announcements',
    name: 'Zoo PA Announcements',
    // low; runs after more important daemons
    priority: 5,

    // Only run every 5th turn, and only four times total
    condition: (ctx: SchedulerContext): boolean =>
      ctx.turn > 0 && ctx.turn % 5 === 0 && announcementCount < 4,

    run: (ctx: SchedulerContext): ISemanticEvent[] => {
      announcementCount++;
      let messageId: string;
      switch (announcementCount) {
        case 1: messageId = TimedMessages.PA_CLOSING_3; break;
        case 2: messageId = TimedMessages.PA_CLOSING_2; break;
        case 3: messageId = TimedMessages.PA_CLOSING_1; break;
        default: messageId = TimedMessages.PA_CLOSED; break;
      }
      return [{
        id: `zoo-pa-${ctx.turn}`,
        type: 'game.message',
        timestamp: Date.now(),
        entities: {},
        data: { messageId },
        narrate: true,
      }];
    },

    // Save/restore the internal counter so it survives
    // a save/load
    getRunnerState() { return { announcementCount }; },
    restoreRunnerState(state) {
      announcementCount =
        (state.announcementCount as number) ?? 0;
    },
  };
}

Conditional daemons: react to state

step 4 typescript
function createGoatBleatingDaemon(): Daemon {
  return {
    id: 'zoo.daemon.goat_bleating',
    name: 'Goat Bleating',
    priority: 3,

    condition: (ctx: SchedulerContext): boolean => {
      const active = ctx.world
        .getStateValue('zoo.feeding_time_active') as boolean;
      const left = ctx.world
        .getStateValue('zoo.bleat_turns_remaining') as number;
      return active === true && (left ?? 0) > 0;
    },

    run: (ctx: SchedulerContext): ISemanticEvent[] => {
      const left = (ctx.world.getStateValue(
        'zoo.bleat_turns_remaining'
      ) as number) ?? 0;
      if (left <= 1) {
        ctx.world.setStateValue('zoo.feeding_time_active', false);
        ctx.world.setStateValue('zoo.bleat_turns_remaining', 0);
      } else {
        ctx.world
          .setStateValue('zoo.bleat_turns_remaining', left - 1);
      }

      // Ambient sound, only heard if the player is in the
      // petting zoo
      const room = ctx.world.getEntity(ctx.playerLocation);
      if ((room?.get(IdentityTrait)?.name || '')
        .includes('Petting Zoo')) {
        return [{
          id: `zoo-bleat-${ctx.turn}`, type: 'game.message',
          timestamp: Date.now(), entities: {},
          data: { messageId: TimedMessages.GOATS_BLEATING },
          narrate: true,
        }];
      }
      return [];
    },
  };
}

Fuses: count down and fire

step 5 typescript
function createFeedingTimeFuse(): Fuse {
  return {
    id: 'zoo.fuse.feeding_time',
    name: 'Feeding Time',
    turns: 10,            // first feeding time at turn 10
    repeat: true,         // keep re-arming
    originalTurns: 8,     // subsequent feedings every 8 turns
    priority: 10,

    trigger: (ctx: SchedulerContext): ISemanticEvent[] => {
      ctx.world.setStateValue('zoo.feeding_time_active', true);
      ctx.world.setStateValue('zoo.bleat_turns_remaining', 3);
      return [{
        id: `zoo-feeding-${ctx.turn}`, type: 'game.message',
        timestamp: Date.now(), entities: {},
        data: { messageId: TimedMessages.FEEDING_TIME },
        narrate: true,
      }];
    },
  };
}

Giving the announcements their words

step 6 typescript
extendLanguage(language: LanguageProvider): void {
  language.addMessage(TimedMessages.PA_CLOSING_3,
    '*DING DONG* "Attention visitors! The zoo closes in ' +
    'three hours. Please visit all exhibits before closing ' +
    'time!"');
  language.addMessage(TimedMessages.PA_CLOSING_2,
    '*DING DONG* "Two hours until closing. ' +
    'Don\'t forget the gift shop!"');
  language.addMessage(TimedMessages.PA_CLOSING_1,
    '*DING DONG* "One hour until closing. Please make ' +
    'your way toward the exit."');
  language.addMessage(TimedMessages.PA_CLOSED,
    '*DING DONG* "The zoo is now closed. ' +
    'Thank you for visiting!"');
  language.addMessage(TimedMessages.FEEDING_TIME,
    '*DING DONG* "It\'s FEEDING TIME at the Petting ' +
    'Zoo! Come watch the goats and rabbits enjoy their ' +
    'snacks!"');
  language.addMessage(TimedMessages.GOATS_BLEATING,
    'The pygmy goats are bleating loudly and headbutting ' +
    'the fence. They seem very hungry!');
}

Combined runnable file

The complete, runnable story at the end of this chapter (tested source: tutorials/familyzoo/v2.0.0/src/ch22-timed-events.ts).

runnable ch22-timed-events.ts typescript
/**
 * Family Zoo Tutorial — Version 15: Timed Events (Daemons and Fuses)
 *
 * NEW IN THIS VERSION:
 *   - SchedulerPlugin — the engine's timed event system
 *   - Daemons — functions that run every turn (repeating processes)
 *   - Fuses — countdown timers that trigger after N turns
 *   - Repeating fuses — fuses that re-trigger on a cycle
 *   - Conditional daemons — only run when a condition is met
 *
 * WHAT YOU'LL LEARN:
 *   - Daemons run every turn (like a background process)
 *   - Fuses count down and trigger once (like a timer bomb)
 *   - Repeating fuses re-arm after triggering (like an alarm clock)
 *   - Use condition() to control WHEN a daemon runs
 *   - Use tickCondition() to control WHEN a fuse counts down
 *   - The scheduler runs after the player's turn, before the next prompt
 *
 * TRY IT:
 *   > wait                           (repeat several times — PA announcements)
 *   > south / east                   (go to petting zoo)
 *   > wait                           (wait for feeding time announcement)
 *   > feed goats                     (stops the bleating)
 *
 * BUILD & RUN:
 *   ./build.sh -s familyzoo
 *   node dist/cli/sharpee.js --story tutorials/familyzoo --play
 */

// ============================================================================
// IMPORTS
// ============================================================================

import { Story, StoryConfig, GameEngine } from '@sharpee/engine';
import {
  WorldModel,
  IFEntity,
  EntityType,
  Direction,
  NpcTrait,
  IWorldModel,
  CapabilityBehavior,
  CapabilityValidationResult,
  CapabilitySharedData,
  CapabilityEffect,
  createEffect,
  findTraitWithCapability,
  ITrait,
} from '@sharpee/world-model';
import {
  IdentityTrait,
  ActorTrait,
  ContainerTrait,
  SupporterTrait,
  RoomTrait,
  SceneryTrait,
  OpenableTrait,
  LockableTrait,
  DoorTrait,
  SwitchableTrait,
  LightSourceTrait,
  ReadableTrait,
} from '@sharpee/world-model';
import { ISemanticEvent } from '@sharpee/core';
import { NpcPlugin } from '@sharpee/plugin-npc';

// --- NEW IN V15: SchedulerPlugin for timed events ---
import { SchedulerPlugin } from '@sharpee/plugin-scheduler';
import type { ISchedulerService, Daemon, Fuse, SchedulerContext } from '@sharpee/plugin-scheduler';

import {
  NpcBehavior, NpcContext, NpcAction, createPatrolBehavior,
  Action, ActionContext, ValidationResult,
} from '@sharpee/stdlib';
import type { Parser } from '@sharpee/parser-en-us';
import type { LanguageProvider } from '@sharpee/lang-en-us';


// ============================================================================
// STORY CONFIGURATION
// ============================================================================

const config: StoryConfig = {
  id: 'familyzoo',
  title: 'Family Zoo',
  author: 'Sharpee Tutorial',
  version: '0.15.0',
  description: 'A small family zoo — learn Sharpee one concept at a time.',
};


// ============================================================================
// CUSTOM TRAIT: PettableTrait (from V14)
// ============================================================================

class PettableTrait implements ITrait {
  static readonly type = 'zoo.trait.pettable' as const;
  static readonly capabilities = ['zoo.action.petting'] as const;
  readonly type = PettableTrait.type;
  readonly animalKind: 'goats' | 'rabbits' | 'parrot' | 'snake';
  constructor(kind: 'goats' | 'rabbits' | 'parrot' | 'snake') {
    this.animalKind = kind;
  }
}


// ============================================================================
// CAPABILITY BEHAVIORS (from V14)
// ============================================================================

const PetMessages = {
  PET_GOATS: 'zoo.petting.goats',
  PET_RABBITS: 'zoo.petting.rabbits',
  PET_PARROT: 'zoo.petting.parrot',
  PET_SNAKE: 'zoo.petting.snake_glass',
  CANT_PET: 'zoo.petting.cant_pet',
} as const;

const PETTING_ACTION_ID = 'zoo.action.petting';

const pettingBehavior: CapabilityBehavior = {
  validate(_entity: IFEntity, _world: WorldModel, _actorId: string, _sharedData: CapabilitySharedData): CapabilityValidationResult {
    return { valid: true };
  },
  execute(_entity: IFEntity, _world: WorldModel, _actorId: string, _sharedData: CapabilitySharedData): void {},
  report(entity: IFEntity, _world: WorldModel, _actorId: string, _sharedData: CapabilitySharedData): CapabilityEffect[] {
    const pettable = entity.get(PettableTrait);
    let messageId: string = PetMessages.CANT_PET;
    switch (pettable?.animalKind) {
      case 'goats':   messageId = PetMessages.PET_GOATS; break;
      case 'rabbits': messageId = PetMessages.PET_RABBITS; break;
      case 'parrot':  messageId = PetMessages.PET_PARROT; break;
    }
    return [createEffect('zoo.event.petted', { messageId, params: { target: entity.name } })];
  },
  blocked(entity: IFEntity, _world: WorldModel, _actorId: string, error: string, _sharedData: CapabilitySharedData): CapabilityEffect[] {
    return [createEffect('zoo.event.petting_blocked', { messageId: error, params: { target: entity.name } })];
  },
};


// ============================================================================
// PETTING ACTION (from V14)
// ============================================================================

const pettingAction: Action = {
  id: PETTING_ACTION_ID,
  group: 'interaction',
  validate(context: ActionContext): ValidationResult {
    const entity = context.command.directObject?.entity;
    if (!entity) return { valid: false, error: PetMessages.CANT_PET };
    const trait = findTraitWithCapability(entity, PETTING_ACTION_ID);
    if (!trait) return { valid: false, error: PetMessages.CANT_PET };
    const behavior = context.world.getBehaviorForCapability(trait, PETTING_ACTION_ID);
    if (!behavior) return { valid: false, error: PetMessages.CANT_PET };
    const sharedData: CapabilitySharedData = {};
    const behaviorResult = behavior.validate(entity, context.world, context.player.id, sharedData);
    if (!behaviorResult.valid) return { valid: false, error: behaviorResult.error };
    context.sharedData.capEntity = entity;
    context.sharedData.capBehavior = behavior;
    context.sharedData.capSharedData = sharedData;
    return { valid: true };
  },
  execute(context: ActionContext): void {
    const entity = context.sharedData.capEntity as IFEntity;
    const behavior = context.sharedData.capBehavior as CapabilityBehavior;
    const sharedData = context.sharedData.capSharedData as CapabilitySharedData;
    if (entity && behavior) behavior.execute(entity, context.world, context.player.id, sharedData);
  },
  report(context: ActionContext): ISemanticEvent[] {
    const entity = context.sharedData.capEntity as IFEntity;
    const behavior = context.sharedData.capBehavior as CapabilityBehavior;
    const sharedData = context.sharedData.capSharedData as CapabilitySharedData;
    if (!entity || !behavior) return [];
    const effects = behavior.report(entity, context.world, context.player.id, sharedData);
    return effects.map(effect => context.event(effect.type, effect.payload));
  },
  blocked(context: ActionContext, result: ValidationResult): ISemanticEvent[] {
    return [context.event('zoo.event.petting_blocked', { messageId: result.error || PetMessages.CANT_PET })];
  },
};


// ============================================================================
// CUSTOM ACTIONS FROM V13 (carried forward)
// ============================================================================

const FEED_ACTION_ID = 'zoo.action.feeding';
const FeedMessages = {
  NO_FEED: 'zoo.feeding.no_feed', NOT_AN_ANIMAL: 'zoo.feeding.not_animal',
  ALREADY_FED: 'zoo.feeding.already_fed', FED_GOATS: 'zoo.feeding.fed_goats',
  FED_RABBITS: 'zoo.feeding.fed_rabbits', FED_GENERIC: 'zoo.feeding.fed_generic',
} as const;

const feedAction: Action = {
  id: FEED_ACTION_ID, group: 'interaction',
  validate(context: ActionContext): ValidationResult {
    const target = context.command.directObject?.entity;
    const inventory = context.world.getContents(context.player.id);
    const hasFeed = inventory.some(i => i.get(IdentityTrait)?.aliases?.includes('feed'));
    if (!hasFeed) return { valid: false, error: FeedMessages.NO_FEED };
    if (!target) return { valid: false, error: FeedMessages.NOT_AN_ANIMAL };
    const name = target.get(IdentityTrait)?.name?.toLowerCase() || '';
    if (!['pygmy goats', 'rabbits'].some(a => name.includes(a))) return { valid: false, error: FeedMessages.NOT_AN_ANIMAL };
    if (context.world.getStateValue(`fed-${target.id}`)) return { valid: false, error: FeedMessages.ALREADY_FED };
    context.sharedData.feedTarget = target;
    return { valid: true };
  },
  execute(context: ActionContext): void {
    const target = context.sharedData.feedTarget as IFEntity;
    if (target) context.world.setStateValue(`fed-${target.id}`, true);
  },
  report(context: ActionContext): ISemanticEvent[] {
    const target = context.sharedData.feedTarget as IFEntity;
    const name = target?.get(IdentityTrait)?.name?.toLowerCase() || '';
    let messageId: string = FeedMessages.FED_GENERIC;
    if (name.includes('goats')) messageId = FeedMessages.FED_GOATS;
    else if (name.includes('rabbits')) messageId = FeedMessages.FED_RABBITS;
    return [context.event('zoo.event.fed', { messageId, params: { animal: target?.get(IdentityTrait)?.name } })];
  },
  blocked(_context: ActionContext, result: ValidationResult): ISemanticEvent[] {
    return [_context.event('zoo.event.feeding_blocked', { messageId: result.error || FeedMessages.NOT_AN_ANIMAL })];
  },
};

const PHOTOGRAPH_ACTION_ID = 'zoo.action.photographing';
const PhotoMessages = { NO_CAMERA: 'zoo.photo.no_camera', TOOK_PHOTO: 'zoo.photo.took_photo' } as const;

const photographAction: Action = {
  id: PHOTOGRAPH_ACTION_ID, group: 'interaction',
  validate(context: ActionContext): ValidationResult {
    const inventory = context.world.getContents(context.player.id);
    const hasCamera = inventory.some(i => i.get(IdentityTrait)?.aliases?.includes('camera'));
    if (!hasCamera) return { valid: false, error: PhotoMessages.NO_CAMERA };
    const target = context.command.directObject?.entity;
    if (target) context.sharedData.photoTarget = target;
    return { valid: true };
  },
  execute(): void {},
  report(context: ActionContext): ISemanticEvent[] {
    const target = context.sharedData.photoTarget as IFEntity | undefined;
    const name = target?.get(IdentityTrait)?.name || 'the scenery';
    return [context.event('zoo.event.photographed', { messageId: PhotoMessages.TOOK_PHOTO, params: { target: name } })];
  },
  blocked(context: ActionContext, result: ValidationResult): ISemanticEvent[] {
    return [context.event('zoo.event.photographing_blocked', { messageId: result.error || PhotoMessages.NO_CAMERA })];
  },
};


// ============================================================================
// PARROT BEHAVIOR (from V11)
// ============================================================================

const PARROT_PHRASES = ['Polly wants a cracker!', 'SQUAWK! Pretty bird! Pretty bird!', 'Pieces of eight! Pieces of eight!', 'Who\'s a good bird? WHO\'S A GOOD BIRD?', 'BAWK! Welcome to the zoo!'];

const parrotBehavior: NpcBehavior = {
  id: 'zoo-parrot', name: 'Parrot Behavior',
  onTurn(context: NpcContext): NpcAction[] {
    if (!context.playerVisible) return [];
    if (context.random.chance(0.5)) return [{ type: 'speak', messageId: 'npc.speech', data: { text: context.random.pick(PARROT_PHRASES) } }];
    return [];
  },
  onPlayerEnters(): NpcAction[] {
    return [{ type: 'emote', messageId: 'npc.emote', data: { text: 'The parrot ruffles its feathers and eyes you with interest.' } }];
  },
};


// ============================================================================
// TIMED EVENT MESSAGES — NEW IN V15
// ============================================================================
//
// Message IDs for all the timed events. These are registered in
// extendLanguage() with their actual text strings.

const TimedMessages = {
  // PA announcements — countdown to zoo closing
  PA_CLOSING_3: 'zoo.pa.closing_3',
  PA_CLOSING_2: 'zoo.pa.closing_2',
  PA_CLOSING_1: 'zoo.pa.closing_1',
  PA_CLOSED: 'zoo.pa.closed',

  // Feeding time
  FEEDING_TIME: 'zoo.feeding_time.announced',

  // Goat bleating (when feeding time is missed)
  GOATS_BLEATING: 'zoo.goats.bleating',
} as const;


// ============================================================================
// DAEMONS AND FUSES — NEW IN V15
// ============================================================================
//
// DAEMONS are functions that run every turn. Think of them as background
// processes — like a clock ticking, or an NPC checking for danger.
//
//   interface Daemon {
//     id: string;              // Unique ID
//     name: string;            // Human-readable name (for debugging)
//     condition?: (ctx) => boolean;  // Optional guard — only runs when true
//     run: (ctx) => ISemanticEvent[];  // What happens each turn
//     priority?: number;       // Higher runs first (default 0)
//     runOnce?: boolean;       // Auto-remove after first run
//   }
//
// FUSES are countdown timers. They tick down each turn and trigger once.
//
//   interface Fuse {
//     id: string;              // Unique ID
//     name: string;            // Human-readable name
//     turns: number;           // Countdown (ticks down each turn)
//     trigger: (ctx) => ISemanticEvent[];  // What happens when it hits 0
//     repeat?: boolean;        // Re-arm after triggering?
//     originalTurns?: number;  // Reset value for repeating fuses
//     tickCondition?: (ctx) => boolean;  // Only tick when true
//   }
//
// Both receive a SchedulerContext:
//   { world, turn, random, playerLocation, playerId }

/**
 * PA Announcement Daemon — runs every turn, emits closing announcements.
 *
 * This daemon tracks an internal counter and emits PA announcements
 * at turn 5, 10, 15, and 20. It demonstrates:
 *   - A daemon with internal state (announcementCount)
 *   - Using getRunnerState/restoreRunnerState for save/load
 *   - Cosmetic-only events (no world mutation)
 */
function createPAAnnouncementDaemon(): Daemon {
  // Internal state — how many announcements we've made
  let announcementCount = 0;

  return {
    id: 'zoo.daemon.pa_announcements',
    name: 'Zoo PA Announcements',
    priority: 5,  // Low priority — runs after more important daemons

    // Only run on specific turns (every 5th turn)
    condition: (ctx: SchedulerContext): boolean => {
      return ctx.turn > 0 && ctx.turn % 5 === 0 && announcementCount < 4;
    },

    // Emit the appropriate announcement
    run: (ctx: SchedulerContext): ISemanticEvent[] => {
      announcementCount++;

      // Pick the right message based on how many we've sent
      let messageId: string;
      switch (announcementCount) {
        case 1: messageId = TimedMessages.PA_CLOSING_3; break;
        case 2: messageId = TimedMessages.PA_CLOSING_2; break;
        case 3: messageId = TimedMessages.PA_CLOSING_1; break;
        default: messageId = TimedMessages.PA_CLOSED; break;
      }

      return [{
        id: `zoo-pa-${ctx.turn}`,
        type: 'game.message',
        timestamp: Date.now(),
        entities: {},
        data: { messageId },
        narrate: true,
      }];
    },

    // Save/restore state for game saves (ADR-123)
    getRunnerState(): Record<string, unknown> {
      return { announcementCount };
    },
    restoreRunnerState(state: Record<string, unknown>): void {
      announcementCount = (state.announcementCount as number) ?? 0;
    },
  };
}


/**
 * Feeding Time Fuse — triggers after 10 turns, then repeats every 8 turns.
 *
 * This demonstrates:
 *   - A fuse with a countdown (10 turns initially)
 *   - Repeating fuses (repeat: true, originalTurns: 8)
 *   - Setting world state when the fuse triggers
 */
function createFeedingTimeFuse(): Fuse {
  return {
    id: 'zoo.fuse.feeding_time',
    name: 'Feeding Time',
    turns: 10,            // First feeding time at turn 10
    repeat: true,         // Keep triggering
    originalTurns: 8,     // Subsequent feeding times every 8 turns
    priority: 10,

    // When the fuse triggers: announce feeding time and start bleating countdown
    trigger: (ctx: SchedulerContext): ISemanticEvent[] => {
      // Set state so the goat bleating daemon knows feeding time happened
      ctx.world.setStateValue('zoo.feeding_time_active', true);
      ctx.world.setStateValue('zoo.bleat_turns_remaining', 3);

      return [{
        id: `zoo-feeding-${ctx.turn}`,
        type: 'game.message',
        timestamp: Date.now(),
        entities: {},
        data: { messageId: TimedMessages.FEEDING_TIME },
        narrate: true,
      }];
    },
  };
}


/**
 * Goat Bleating Daemon — bleats for 3 turns after feeding time if not fed.
 *
 * This demonstrates:
 *   - A conditional daemon (only runs when feeding time is active)
 *   - Checking world state to decide behavior
 *   - Decrementing a counter and auto-stopping
 */
function createGoatBleatingDaemon(): Daemon {
  return {
    id: 'zoo.daemon.goat_bleating',
    name: 'Goat Bleating',
    priority: 3,  // Runs after PA announcements

    // Only run when feeding time is active and goats haven't been fed
    condition: (ctx: SchedulerContext): boolean => {
      const feedingActive = ctx.world.getStateValue('zoo.feeding_time_active') as boolean;
      const bleatsLeft = ctx.world.getStateValue('zoo.bleat_turns_remaining') as number;
      return feedingActive === true && (bleatsLeft ?? 0) > 0;
    },

    run: (ctx: SchedulerContext): ISemanticEvent[] => {
      const bleatsLeft = (ctx.world.getStateValue('zoo.bleat_turns_remaining') as number) ?? 0;

      // Decrement the counter
      if (bleatsLeft <= 1) {
        // Last bleat — reset feeding time state
        ctx.world.setStateValue('zoo.feeding_time_active', false);
        ctx.world.setStateValue('zoo.bleat_turns_remaining', 0);
      } else {
        ctx.world.setStateValue('zoo.bleat_turns_remaining', bleatsLeft - 1);
      }

      // Only emit bleating if the player is in the petting zoo
      // (ambient sound — you only hear it if you're there)
      const playerRoom = ctx.world.getEntity(ctx.playerLocation);
      const roomName = playerRoom?.get(IdentityTrait)?.name || '';
      if (roomName.includes('Petting Zoo')) {
        return [{
          id: `zoo-bleat-${ctx.turn}`,
          type: 'game.message',
          timestamp: Date.now(),
          entities: {},
          data: { messageId: TimedMessages.GOATS_BLEATING },
          narrate: true,
        }];
      }

      return [];
    },
  };
}


// ============================================================================
// THE STORY CLASS
// ============================================================================

class FamilyZooStory implements Story {
  config = config;
  private roomIds = { entrance: '', mainPath: '', pettingZoo: '', aviary: '', supplyRoom: '', nocturnalExhibit: '', giftShop: '' };
  private entityIds = { animalFeed: '', penny: '', souvenirPress: '' };

  createPlayer(world: WorldModel): IFEntity {
    const player = world.createEntity('yourself', EntityType.ACTOR);
    player.add(new IdentityTrait({ name: 'yourself', description: 'Just an ordinary visitor to the zoo.', aliases: ['self', 'myself', 'me'], properName: true, article: '' }));
    player.add(new ActorTrait({ isPlayer: true }));
    player.add(new ContainerTrait({ capacity: { maxItems: 10 } }));
    return player;
  }

  initializeWorld(world: WorldModel): void {

    // ROOMS — Same as V14 (see v14.ts for full explanation)
    const entrance = world.createEntity('Zoo Entrance', EntityType.ROOM);
    entrance.add(new RoomTrait({ exits: {}, isDark: false }));
    entrance.add(new IdentityTrait({ name: 'Zoo Entrance', description: 'You stand before the wrought-iron gates of the Willowbrook Family Zoo. A cheerful welcome sign arches over the entrance, and a small ticket booth sits to one side. A sturdy iron fence runs along either side of the gates. The main path leads south into the zoo grounds.', aliases: ['entrance', 'gates', 'gate'], properName: false, article: 'the' }));

    const mainPath = world.createEntity('Main Path', EntityType.ROOM);
    mainPath.add(new RoomTrait({ exits: {}, isDark: false }));
    mainPath.add(new IdentityTrait({ name: 'Main Path', description: 'A wide gravel path winds through the heart of the zoo. Colorful direction signs point every which way. A park bench sits beside the path. To the east, the petting zoo. To the west, the aviary. A staff gate blocks the path to the south. The entrance is back to the north.', aliases: ['path', 'main path', 'gravel path'], properName: false, article: 'the' }));

    const pettingZoo = world.createEntity('Petting Zoo', EntityType.ROOM);
    pettingZoo.add(new RoomTrait({ exits: {}, isDark: false }));
    pettingZoo.add(new IdentityTrait({ name: 'Petting Zoo', description: 'A cheerful open-air enclosure filled with friendly animals. Pygmy goats trot around nibbling at visitors\' shoelaces, while a pair of fluffy rabbits hop lazily near a hay bale. A feed dispenser is mounted on a post. An info plaque is posted by the gate. The main path is back to the west.', aliases: ['petting zoo', 'petting area', 'pen'], properName: false, article: 'the' }));

    const aviary = world.createEntity('Aviary', EntityType.ROOM);
    aviary.add(new RoomTrait({ exits: {}, isDark: false }));
    aviary.add(new IdentityTrait({ name: 'Aviary', description: 'You step inside a soaring mesh dome. Brilliantly colored parrots chatter from rope perches, and a toucan eyes you curiously from a branch overhead. A small waterfall splashes into a stone basin. An info plaque hangs near the entrance. The gift shop is to the west. The main path is back to the east.', aliases: ['aviary', 'bird house', 'dome'], properName: false, article: 'the' }));

    const supplyRoom = world.createEntity('Supply Room', EntityType.ROOM);
    supplyRoom.add(new RoomTrait({ exits: {}, isDark: false }));
    supplyRoom.add(new IdentityTrait({ name: 'Supply Room', description: 'A cluttered storage room behind the staff gate. Metal shelves line the walls. A cork board on the wall is covered with staff schedules. A battered radio sits on one of the shelves. The staff gate leads back north.', aliases: ['supply room', 'storage room', 'storeroom'], properName: false, article: 'the' }));

    const nocturnalExhibit = world.createEntity('Nocturnal Animals Exhibit', EntityType.ROOM);
    nocturnalExhibit.add(new RoomTrait({ exits: {}, isDark: true }));
    nocturnalExhibit.add(new IdentityTrait({ name: 'Nocturnal Animals Exhibit', description: 'A cool, dimly lit cavern designed to simulate nighttime. Glass enclosures line both walls with soft red lights. You can see sugar gliders, bush babies, and a barn owl. A warning sign is posted near the entrance. The exit leads back north to the supply room.', aliases: ['nocturnal exhibit', 'nocturnal animals', 'exhibit'], properName: false, article: 'the' }));

    const giftShop = world.createEntity('Gift Shop', EntityType.ROOM);
    giftShop.add(new RoomTrait({ exits: {}, isDark: false }));
    giftShop.add(new IdentityTrait({ name: 'Gift Shop', description: 'A small zoo gift shop crammed with stuffed animals and postcards. A large souvenir penny press machine stands near the door. A disposable camera sits on the counter. The aviary is back to the east.', aliases: ['gift shop', 'shop', 'store'], properName: false, article: 'the' }));

    this.roomIds = { entrance: entrance.id, mainPath: mainPath.id, pettingZoo: pettingZoo.id, aviary: aviary.id, supplyRoom: supplyRoom.id, nocturnalExhibit: nocturnalExhibit.id, giftShop: giftShop.id };

    // STAFF GATE — Same as V14
    const keycard = world.createEntity('staff keycard', EntityType.ITEM);
    keycard.add(new IdentityTrait({ name: 'staff keycard', description: 'A white plastic keycard with "WILLOWBROOK ZOO — STAFF ONLY" printed in blue.', aliases: ['keycard', 'key card', 'card', 'key', 'staff keycard'], properName: false, article: 'a' }));
    world.moveEntity(keycard.id, entrance.id);

    const staffGate = world.createEntity('staff gate', EntityType.DOOR);
    staffGate.add(new IdentityTrait({ name: 'staff gate', description: 'A sturdy metal gate with a "STAFF ONLY" sign.', aliases: ['gate', 'staff gate', 'metal gate', 'staff door'], properName: false, article: 'a' }));
    staffGate.add(new DoorTrait({ room1: mainPath.id, room2: supplyRoom.id, bidirectional: true }));
    staffGate.add(new OpenableTrait({ isOpen: false }));
    staffGate.add(new LockableTrait({ isLocked: true, keyId: keycard.id }));
    staffGate.add(new SceneryTrait());
    world.moveEntity(staffGate.id, mainPath.id);

    // EXITS — Same as V14
    entrance.get(RoomTrait)!.exits = { [Direction.SOUTH]: { destination: mainPath.id } };
    mainPath.get(RoomTrait)!.exits = { [Direction.NORTH]: { destination: entrance.id }, [Direction.EAST]: { destination: pettingZoo.id }, [Direction.WEST]: { destination: aviary.id }, [Direction.SOUTH]: { destination: supplyRoom.id, via: staffGate.id } };
    pettingZoo.get(RoomTrait)!.exits = { [Direction.WEST]: { destination: mainPath.id } };
    aviary.get(RoomTrait)!.exits = { [Direction.EAST]: { destination: mainPath.id }, [Direction.WEST]: { destination: giftShop.id } };
    supplyRoom.get(RoomTrait)!.exits = { [Direction.NORTH]: { destination: mainPath.id, via: staffGate.id }, [Direction.SOUTH]: { destination: nocturnalExhibit.id } };
    nocturnalExhibit.get(RoomTrait)!.exits = { [Direction.NORTH]: { destination: supplyRoom.id } };
    giftShop.get(RoomTrait)!.exits = { [Direction.EAST]: { destination: aviary.id } };

    // SCENERY — Same as V14 (abbreviated)
    for (const [name, desc, aliases, room] of [
      ['welcome sign', 'A brightly painted wooden sign reads: "WELCOME TO WILLOWBROOK FAMILY ZOO."', ['sign', 'welcome sign'], entrance],
      ['ticket booth', 'A small wooden booth with a "Self-Guided Tours" sign.', ['booth', 'ticket booth'], entrance],
      ['iron fence', 'A tall wrought-iron fence with animal silhouettes.', ['fence', 'iron fence', 'railing'], entrance],
      ['direction signs', 'Arrow signs: PETTING ZOO (east), AVIARY (west), EXIT (north).', ['signs', 'direction signs', 'arrow signs'], mainPath],
      ['flower beds', 'Tidy beds of marigolds and petunias.', ['flowers', 'flower beds'], mainPath],
      ['hay bale', 'A large round bale of golden hay.', ['hay', 'hay bale', 'bale'], pettingZoo],
      ['toucan', 'A Toco toucan with an enormous orange-and-black bill.', ['toucan', 'toco toucan'], aviary],
      ['waterfall', 'A gentle artificial waterfall cascading into a stone basin.', ['waterfall', 'water', 'basin'], aviary],
      ['rope perches', 'Thick sisal ropes strung between wooden posts.', ['perches', 'rope perches', 'ropes'], aviary],
      ['metal shelves', 'Industrial metal shelving units stacked with supplies.', ['shelves', 'metal shelves', 'shelf'], supplyRoom],
      ['sugar gliders', 'A family of tiny sugar gliders with enormous dark eyes.', ['sugar gliders', 'gliders'], nocturnalExhibit],
      ['bush babies', 'Two bush babies with impossibly large round eyes.', ['bush babies', 'galagos'], nocturnalExhibit],
      ['barn owl', 'An enormous barn owl with a heart-shaped white face.', ['barn owl', 'owl'], nocturnalExhibit],
      ['stuffed animals', 'Shelves of plush tigers, pandas, and penguins.', ['stuffed animals', 'plush', 'toys'], giftShop],
      ['postcards', 'A spinning rack of postcards showing the zoo\'s greatest hits.', ['postcards', 'cards', 'postcard rack'], giftShop],
    ] as [string, string, string[], IFEntity][]) {
      const e = world.createEntity(name, EntityType.SCENERY);
      e.add(new IdentityTrait({ name, description: desc, aliases, properName: false, article: 'a' }));
      world.moveEntity(e.id, room.id);
    }

    // Additional scenery with special traits — Same as V14
    const corkBoard = world.createEntity('cork board', EntityType.SCENERY);
    corkBoard.add(new IdentityTrait({ name: 'cork board', description: 'A cork board with staff schedules. A note in red marker: "DON\'T FORGET: nocturnal exhibit lights need new batteries!"', aliases: ['cork board', 'board', 'notices'], properName: false, article: 'a' }));
    world.moveEntity(corkBoard.id, supplyRoom.id);

    const radio = world.createEntity('radio', EntityType.ITEM);
    radio.add(new IdentityTrait({ name: 'radio', description: 'A battered portable radio held together with duct tape. The antenna is bent at a jaunty angle. A faded sticker on the side reads "ZOO FM — All Animals, All The Time."', aliases: ['radio', 'portable radio'], properName: false, article: 'a' }));
    radio.add(new SwitchableTrait({ isOn: false }));
    radio.add(new SceneryTrait());
    world.moveEntity(radio.id, supplyRoom.id);

    const pettingPlaque = world.createEntity('info plaque', EntityType.SCENERY);
    pettingPlaque.add(new IdentityTrait({ name: 'info plaque', description: 'A brass plaque mounted on a wooden post near the petting zoo gate.', aliases: ['plaque', 'info plaque', 'brass plaque'], properName: false, article: 'an' }));
    pettingPlaque.add(new ReadableTrait({ text: 'PYGMY GOATS — These Nigerian Dwarf goats are gentle, curious, and always hungry.\n\nHOLLAND LOP RABBITS — Known for their floppy ears. Our pair, Biscuit and Marmalade, were born here in 2023.' }));
    world.moveEntity(pettingPlaque.id, pettingZoo.id);

    const aviaryPlaque = world.createEntity('aviary plaque', EntityType.SCENERY);
    aviaryPlaque.add(new IdentityTrait({ name: 'aviary plaque', description: 'A colorful information board near the aviary entrance.', aliases: ['plaque', 'aviary plaque', 'information board'], properName: false, article: 'an' }));
    aviaryPlaque.add(new ReadableTrait({ text: 'WELCOME TO THE AVIARY — Home to over 30 species!\n\nTOCO TOUCAN — Its bill weighs less than a smartphone.\n\nSCARLET MACAW — Can live over 75 years. Our oldest, Captain, is 42.' }));
    world.moveEntity(aviaryPlaque.id, aviary.id);

    const warningSign = world.createEntity('warning sign', EntityType.SCENERY);
    warningSign.add(new IdentityTrait({ name: 'warning sign', description: 'A yellow warning sign near the nocturnal exhibit entrance.', aliases: ['warning', 'warning sign', 'yellow sign'], properName: false, article: 'a' }));
    warningSign.add(new ReadableTrait({ text: 'CAUTION: The Nocturnal Animals Exhibit is kept dark. Please use a flashlight. Do NOT use camera flash. (We don\'t talk about the Great Owl Incident of 2022.)' }));
    world.moveEntity(warningSign.id, supplyRoom.id);

    const brochure = world.createEntity('zoo brochure', EntityType.ITEM);
    brochure.add(new IdentityTrait({ name: 'zoo brochure', description: 'A glossy tri-fold brochure with "WILLOWBROOK FAMILY ZOO" on the cover.', aliases: ['brochure', 'zoo brochure', 'pamphlet', 'leaflet'], properName: false, article: 'a' }));
    brochure.add(new ReadableTrait({ text: 'WILLOWBROOK FAMILY ZOO — Your Guide\n\nEXHIBITS:\n  Petting Zoo — East from Main Path\n  Aviary — West from Main Path\n  Gift Shop — West from Aviary\n  Nocturnal Animals — Staff Area\n\n"Where every visit is a wild adventure!"' }));
    world.moveEntity(brochure.id, entrance.id);

    // PETTABLE ANIMALS — Same as V14
    const goats = world.createEntity('pygmy goats', EntityType.SCENERY);
    goats.add(new IdentityTrait({ name: 'pygmy goats', grammaticalNumber: 'plural', description: 'Three pygmy goats hoping you have food.', aliases: ['goats', 'pygmy goats', 'goat'], properName: false, article: 'some' }));
    goats.add(new PettableTrait('goats'));
    world.moveEntity(goats.id, pettingZoo.id);

    const rabbits = world.createEntity('rabbits', EntityType.SCENERY);
    rabbits.add(new IdentityTrait({ name: 'rabbits', grammaticalNumber: 'plural', description: 'A pair of Holland Lop rabbits with floppy ears.', aliases: ['rabbits', 'rabbit', 'bunnies'], properName: false, article: 'some' }));
    rabbits.add(new PettableTrait('rabbits'));
    world.moveEntity(rabbits.id, pettingZoo.id);

    const parrots = world.createEntity('parrots', EntityType.SCENERY);
    parrots.add(new IdentityTrait({ name: 'parrots', grammaticalNumber: 'plural', description: 'A raucous flock of scarlet macaws and grey African parrots.', aliases: ['parrots', 'macaws', 'birds'], properName: false, article: 'some' }));
    world.moveEntity(parrots.id, aviary.id);

    // PORTABLE OBJECTS — Same as V14
    const zooMap = world.createEntity('zoo map', EntityType.ITEM);
    zooMap.add(new IdentityTrait({ name: 'zoo map', description: 'A colorful folding map of the Willowbrook Family Zoo.', aliases: ['map', 'zoo map', 'folding map'], properName: false, article: 'a' }));
    world.moveEntity(zooMap.id, entrance.id);

    const animalFeed = world.createEntity('bag of animal feed', EntityType.ITEM);
    animalFeed.add(new IdentityTrait({ name: 'bag of animal feed', description: 'A small brown paper bag filled with dried corn and pellets.', aliases: ['feed', 'animal feed', 'bag of feed', 'corn'], properName: false, article: 'a' }));
    world.moveEntity(animalFeed.id, pettingZoo.id);

    const penny = world.createEntity('souvenir penny', EntityType.ITEM);
    penny.add(new IdentityTrait({ name: 'souvenir penny', description: 'A shiny copper penny.', aliases: ['penny', 'souvenir penny', 'coin'], properName: false, article: 'a' }));
    world.moveEntity(penny.id, mainPath.id);

    const flashlight = world.createEntity('flashlight', EntityType.ITEM);
    flashlight.add(new IdentityTrait({ name: 'flashlight', description: 'A heavy-duty yellow flashlight.', aliases: ['flashlight', 'torch', 'light', 'lamp'], properName: false, article: 'a' }));
    flashlight.add(new SwitchableTrait({ isOn: false }));
    flashlight.add(new LightSourceTrait({ brightness: 8, isLit: false }));
    world.moveEntity(flashlight.id, supplyRoom.id);

    const camera = world.createEntity('disposable camera', EntityType.ITEM);
    camera.add(new IdentityTrait({ name: 'disposable camera', description: 'A cheap yellow disposable camera with "ZOO MEMORIES" printed on the side.', aliases: ['camera', 'disposable camera'], properName: false, article: 'a' }));
    world.moveEntity(camera.id, giftShop.id);

    this.entityIds = { animalFeed: animalFeed.id, penny: penny.id, souvenirPress: '' };

    // CONTAINERS — Same as V14
    const backpack = world.createEntity('backpack', EntityType.CONTAINER);
    backpack.add(new IdentityTrait({ name: 'backpack', description: 'A small red canvas backpack.', aliases: ['backpack', 'rucksack', 'pack'], properName: false, article: 'a' }));
    backpack.add(new ContainerTrait({ capacity: { maxItems: 5 } }));
    world.moveEntity(backpack.id, entrance.id);

    const parkBench = world.createEntity('park bench', EntityType.SUPPORTER);
    parkBench.add(new IdentityTrait({ name: 'park bench', description: 'A sturdy park bench painted forest green.', aliases: ['bench', 'park bench', 'benches', 'seat'], properName: false, article: 'a' }));
    parkBench.add(new SupporterTrait({ capacity: { maxItems: 3 } }));
    parkBench.add(new SceneryTrait());
    world.moveEntity(parkBench.id, mainPath.id);

    const lunchbox = world.createEntity('lunchbox', EntityType.CONTAINER);
    lunchbox.add(new IdentityTrait({ name: 'lunchbox', description: 'A dented metal lunchbox decorated with cartoon zoo animals.', aliases: ['lunchbox', 'lunch box', 'box'], properName: false, article: 'a' }));
    lunchbox.add(new ContainerTrait({ capacity: { maxItems: 3 } }));
    lunchbox.add(new OpenableTrait({ isOpen: false }));
    world.moveEntity(lunchbox.id, mainPath.id);

    lunchbox.get(OpenableTrait)!.isOpen = true;
    const juice = world.createEntity('juice box', EntityType.ITEM);
    juice.add(new IdentityTrait({ name: 'juice box', description: 'A small juice box with a picture of a happy elephant.', aliases: ['juice', 'juice box', 'drink'], properName: false, article: 'a' }));
    world.moveEntity(juice.id, lunchbox.id);
    lunchbox.get(OpenableTrait)!.isOpen = false;

    const dispenser = world.createEntity('feed dispenser', EntityType.CONTAINER);
    dispenser.add(new IdentityTrait({ name: 'feed dispenser', description: 'A coin-operated feed dispenser mounted on a wooden post. Sign: "FREE — Just Turn!"', aliases: ['dispenser', 'feed dispenser'], properName: false, article: 'a' }));
    dispenser.add(new ContainerTrait({ capacity: { maxItems: 3 } }));
    dispenser.add(new OpenableTrait({ isOpen: false }));
    dispenser.add(new SceneryTrait());
    world.moveEntity(dispenser.id, pettingZoo.id);

    const souvenirPress = world.createEntity('souvenir press', EntityType.CONTAINER);
    souvenirPress.add(new IdentityTrait({ name: 'souvenir press', description: 'A heavy cast-iron machine with a big crank handle. A slot on top accepts pennies, and the mechanism stamps them with a zoo animal design. A sign reads: "INSERT PENNY, TURN HANDLE, KEEP FOREVER!"', aliases: ['press', 'souvenir press', 'penny press', 'machine'], properName: false, article: 'a' }));
    souvenirPress.add(new ContainerTrait({ capacity: { maxItems: 1 } }));
    souvenirPress.add(new SceneryTrait());
    world.moveEntity(souvenirPress.id, giftShop.id);
    this.entityIds.souvenirPress = souvenirPress.id;

    // NPCs — Same as V14
    const zookeeper = world.createEntity('zookeeper', EntityType.ACTOR);
    zookeeper.add(new IdentityTrait({ name: 'zookeeper', description: 'A friendly zookeeper in khaki overalls. A name tag reads "Sam."', aliases: ['keeper', 'zookeeper', 'sam'], properName: false, article: 'a' }));
    zookeeper.add(new ActorTrait({ isPlayer: false }));
    zookeeper.add(new NpcTrait({ behaviorId: 'zoo-keeper-patrol', canMove: true, announcesMovement: true, isAlive: true, isConscious: true }));
    world.moveEntity(zookeeper.id, mainPath.id);

    const parrot = world.createEntity('parrot', EntityType.ACTOR);
    parrot.add(new IdentityTrait({ name: 'parrot', description: 'A magnificent scarlet macaw perched on a rope. It tilts its head and watches you with one bright eye.', aliases: ['parrot', 'macaw', 'scarlet macaw'], properName: false, article: 'a' }));
    parrot.add(new ActorTrait({ isPlayer: false }));
    parrot.add(new NpcTrait({ behaviorId: 'zoo-parrot', canMove: false, isAlive: true, isConscious: true }));
    parrot.add(new PettableTrait('parrot'));
    world.moveEntity(parrot.id, aviary.id);

    // REGISTER CAPABILITY BEHAVIOR — Same as V14 (per-world and idempotent
    // per ADR-207, so no already-registered guard is needed)
    world.registerCapabilityBehavior(PettableTrait.type, PETTING_ACTION_ID, pettingBehavior);

    // PLAYER STARTING LOCATION
    const player = world.getPlayer();
    if (player) world.moveEntity(player.id, entrance.id);
  }


  getCustomActions(): any[] {
    return [feedAction, photographAction, pettingAction];
  }


  extendParser(parser: Parser): void {
    const grammar = parser.getStoryGrammar();

    // V13 grammar
    grammar.define('feed :thing').mapsTo(FEED_ACTION_ID).withPriority(150).build();
    grammar.define('photograph :thing').mapsTo(PHOTOGRAPH_ACTION_ID).withPriority(150).build();
    grammar.define('photo :thing').mapsTo(PHOTOGRAPH_ACTION_ID).withPriority(150).build();
    grammar.define('snap :thing').mapsTo(PHOTOGRAPH_ACTION_ID).withPriority(150).build();

    // V14 grammar
    grammar.define('pet :thing').mapsTo(PETTING_ACTION_ID).withPriority(150).build();
    grammar.define('stroke :thing').mapsTo(PETTING_ACTION_ID).withPriority(150).build();
  }


  extendLanguage(language: LanguageProvider): void {
    // V13 messages
    language.addMessage(FeedMessages.NO_FEED, "You don't have any animal feed.");
    language.addMessage(FeedMessages.NOT_AN_ANIMAL, "That's not something you can feed.");
    language.addMessage(FeedMessages.ALREADY_FED, "You've already fed them. They look contentedly full.");
    language.addMessage(FeedMessages.FED_GOATS, 'You scatter some feed on the ground. The pygmy goats rush over, bleating excitedly, and devour the corn and pellets in seconds. The smallest goat looks up at you with big grateful eyes.');
    language.addMessage(FeedMessages.FED_RABBITS, 'You sprinkle some pellets near the rabbits. Biscuit and Marmalade hop over cautiously, then munch away happily.');
    language.addMessage(FeedMessages.FED_GENERIC, 'You offer some feed. The animal eats it gratefully.');
    language.addMessage(PhotoMessages.NO_CAMERA, "You don't have a camera. There's one in the gift shop.");
    language.addMessage(PhotoMessages.TOOK_PHOTO, 'Click! You snap a photo of {target}. That one\'s going on the fridge.');

    // V14 messages
    language.addMessage(PetMessages.PET_GOATS, 'You reach down and pet the nearest goat. It leans into your hand and bleats happily. The others crowd around, demanding equal attention.');
    language.addMessage(PetMessages.PET_RABBITS, 'You gently stroke one of the rabbits. Its fur is incredibly soft. It twitches its nose at you contentedly.');
    language.addMessage(PetMessages.PET_PARROT, 'You reach toward the parrot. CHOMP! It nips your finger with its beak. "NO TOUCHING!" it squawks indignantly.');
    language.addMessage(PetMessages.CANT_PET, "You can't pet that.");

    // ========================================================================
    // V15 MESSAGES — NEW: Timed event text
    // ========================================================================

    // PA announcements — these play over the zoo's speaker system
    language.addMessage(TimedMessages.PA_CLOSING_3,
      '*DING DONG* "Attention visitors! The Willowbrook Family Zoo will be closing in three hours. Please make sure to visit all exhibits before closing time!"');
    language.addMessage(TimedMessages.PA_CLOSING_2,
      '*DING DONG* "Attention visitors! Two hours until closing. Don\'t forget to stop by the gift shop for souvenirs!"');
    language.addMessage(TimedMessages.PA_CLOSING_1,
      '*DING DONG* "Attention visitors! One hour until closing. Please begin making your way toward the exit."');
    language.addMessage(TimedMessages.PA_CLOSED,
      '*DING DONG* "The Willowbrook Family Zoo is now closed. Thank you for visiting! We hope to see you again soon!"');

    // Feeding time
    language.addMessage(TimedMessages.FEEDING_TIME,
      '*DING DONG* "It\'s FEEDING TIME at the Petting Zoo! Come watch our pygmy goats and rabbits enjoy their favorite snacks!"');

    // Goat bleating
    language.addMessage(TimedMessages.GOATS_BLEATING,
      'The pygmy goats are bleating loudly and headbutting the fence. They seem very hungry!');
  }


  // ==========================================================================
  // onEngineReady — NEW IN V15: Register SchedulerPlugin
  // ==========================================================================
  //
  // The SchedulerPlugin hooks into the engine's turn cycle. After each
  // player turn, the scheduler "ticks" — running all active daemons and
  // counting down all active fuses.
  //
  // Registration order:
  //   1. Create and register the SchedulerPlugin with the engine
  //   2. Get the ISchedulerService from the plugin
  //   3. Register your daemons and fuses with the service

  onEngineReady(engine: GameEngine): void {
    const world = engine.getWorld();

    // NPC Plugin — Same as V14
    const npcPlugin = new NpcPlugin();
    engine.getPluginRegistry().register(npcPlugin);
    const npcService = npcPlugin.getNpcService();
    const keeperPatrol = createPatrolBehavior({ route: [this.roomIds.mainPath, this.roomIds.pettingZoo, this.roomIds.aviary], loop: true, waitTurns: 1 });
    keeperPatrol.id = 'zoo-keeper-patrol';
    npcService.registerBehavior(keeperPatrol);
    npcService.registerBehavior(parrotBehavior);

    // ========================================================================
    // SCHEDULER PLUGIN — NEW IN V15
    // ========================================================================
    //
    // Step 1: Create the plugin. The optional seed parameter makes random
    // number generation deterministic (useful for testing).
    const schedulerPlugin = new SchedulerPlugin();

    // Step 2: Register with the engine's plugin registry.
    // This hooks the scheduler into the turn cycle.
    engine.getPluginRegistry().register(schedulerPlugin);

    // Step 3: Get the scheduler service for registering daemons and fuses.
    const scheduler = schedulerPlugin.getScheduler();

    // Step 4: Register our timed events.
    //
    // PA Announcement Daemon — emits closing announcements every 5 turns
    scheduler.registerDaemon(createPAAnnouncementDaemon());

    // Feeding Time Fuse — triggers at turn 10, then every 8 turns
    scheduler.setFuse(createFeedingTimeFuse());

    // Goat Bleating Daemon — bleats for 3 turns after feeding time if not fed
    scheduler.registerDaemon(createGoatBleatingDaemon());

    // Event chain handlers (from V12) — Same as V14
    const feedId = this.entityIds.animalFeed;
    const pettingZooId = this.roomIds.pettingZoo;
    world.chainEvent('if.event.dropped', (event: ISemanticEvent, w: IWorldModel): ISemanticEvent | null => {
      const data = event.data as Record<string, any>;
      if (data.itemId !== feedId || data.toLocation !== pettingZooId) return null;
      if (w.getStateValue('goats-fed')) return null;
      w.setStateValue('goats-fed', true);
      return { id: `zoo-goats-${Date.now()}`, type: 'zoo.event.goats_react', timestamp: Date.now(), entities: {}, data: { text: 'The pygmy goats spot the bag of feed and rush over! They crowd around, bleating excitedly, and devour the corn and pellets in seconds. The smallest goat looks up at you with big grateful eyes.' } };
    }, { key: 'zoo.chain.goats-eat-feed' });

    const pennyId = this.entityIds.penny;
    const pressId = this.entityIds.souvenirPress;
    world.chainEvent('if.event.put_in', (event: ISemanticEvent, w: IWorldModel): ISemanticEvent | null => {
      const data = event.data as Record<string, any>;
      if (data.itemId !== pennyId || data.targetId !== pressId) return null;
      w.removeEntity(pennyId);
      const pp = w.createEntity('pressed penny', EntityType.ITEM);
      pp.add(new IdentityTrait({ name: 'pressed penny', description: 'A flattened oval of copper with an embossed toucan.', aliases: ['pressed penny', 'pressed coin', 'souvenir'], properName: false, article: 'a' }));
      const player = w.getPlayer();
      if (player) w.moveEntity(pp.id, player.id);
      return { id: `zoo-press-${Date.now()}`, type: 'zoo.event.penny_pressed', timestamp: Date.now(), entities: {}, data: { text: 'CLUNK! CRUNCH! WHIRRR! The souvenir press produces a beautiful pressed penny with an embossed toucan.' } };
    }, { key: 'zoo.chain.penny-press' });
  }
}

export const story: Story = new FamilyZooStory();
export default story;

Chapter 23: Scoring & Endgame: Winning the Game

Book source: docs/book/v2.0.0/parts/part-6/23-scoring-and-endgame.md · 12 step(s)

The score ledger

step 1 typescript
// Set the maximum in initializeWorld() so "score" can show
// "X out of Y"
world.setMaxScore(75);

// Award points (idempotent): the same ID never scores twice
const awarded = world.awardScore(
  'zoo.visit.petting_zoo',  // unique ID
  5,                        // points
  // description (for debugging / transcripts)
  'Visited the petting zoo'
);
// awarded === true the first time, false on every call after

// Read the score back
const current = world.getScore();
const max = world.getMaxScore();

Defining the scoring table

step 2 typescript
const MAX_SCORE = 75;

const ScoreIds = {
  VISIT_PETTING_ZOO: 'zoo.visit.petting_zoo',
  VISIT_AVIARY: 'zoo.visit.aviary',
  VISIT_GIFT_SHOP: 'zoo.visit.gift_shop',
  VISIT_SUPPLY_ROOM: 'zoo.visit.supply_room',
  VISIT_NOCTURNAL: 'zoo.visit.nocturnal',
  FEED_GOATS: 'zoo.action.fed_goats',
  FEED_RABBITS: 'zoo.action.fed_rabbits',
  COLLECT_MAP: 'zoo.collect.map',
  COLLECT_PRESSED_PENNY: 'zoo.collect.pressed_penny',
  PHOTOGRAPH_ANIMAL: 'zoo.action.photographed',
  PET_ANIMAL: 'zoo.action.petted',
  READ_BROCHURE: 'zoo.action.read_brochure',
} as const;

const ScorePoints: Record<string, number> = {
  [ScoreIds.VISIT_PETTING_ZOO]: 5,
  [ScoreIds.VISIT_AVIARY]: 5,
  [ScoreIds.VISIT_GIFT_SHOP]: 5,
  [ScoreIds.VISIT_SUPPLY_ROOM]: 5,
  [ScoreIds.VISIT_NOCTURNAL]: 5,
  [ScoreIds.FEED_GOATS]: 10,
  [ScoreIds.FEED_RABBITS]: 10,
  [ScoreIds.COLLECT_MAP]: 5,
  [ScoreIds.COLLECT_PRESSED_PENNY]: 10,
  [ScoreIds.PHOTOGRAPH_ANIMAL]: 5,
  [ScoreIds.PET_ANIMAL]: 5,
  [ScoreIds.READ_BROCHURE]: 5,
};
step 3 typescript
const ROOM_SCORE_MAP: Record<string, string> = {
  'Petting Zoo': ScoreIds.VISIT_PETTING_ZOO,
  'Aviary': ScoreIds.VISIT_AVIARY,
  'Gift Shop': ScoreIds.VISIT_GIFT_SHOP,
  'Supply Room': ScoreIds.VISIT_SUPPLY_ROOM,
  'Nocturnal Animals Exhibit': ScoreIds.VISIT_NOCTURNAL,
};

const ScoreMessages = {
  VICTORY: 'zoo.victory',
} as const;

Awarding points as the player plays

step 4 typescript
execute(_entity, world, _actorId, _shared): void {
  world.awardScore(ScoreIds.PET_ANIMAL,
    ScorePoints[ScoreIds.PET_ANIMAL], 'Petted an animal');
},
step 5 typescript
world.chainEvent('if.event.actor_moved', (event, w) => {
  const data = event.data as Record<string, any>;
  const toRoom = data.toRoom || data.destination;
  if (!toRoom) return null;

  const roomName =
    w.getEntity(toRoom)?.get(IdentityTrait)?.name || '';
  const scoreId = ROOM_SCORE_MAP[roomName];
  if (!scoreId) return null;

  w.awardScore(scoreId, ScorePoints[scoreId],
    `Visited ${roomName}`);
  return null;   // scoring is silent; no custom event
}, { key: 'zoo.chain.room-visit-scoring' });
step 6 typescript
const mapId = this.entityIds.zooMap;
world.chainEvent(
  'if.event.taken',
  (event: ISemanticEvent, w: IWorldModel) => {
    const data = event.data as Record<string, any>;
    if (data.itemId === mapId) {
      w.awardScore(ScoreIds.COLLECT_MAP,
        ScorePoints[ScoreIds.COLLECT_MAP],
        'Collected the zoo map');
    }
    return null;
  },
  { key: 'zoo.chain.take-scoring' },
);

const brochureId = this.entityIds.brochure;
world.chainEvent(
  'if.event.read',
  (event: ISemanticEvent, w: IWorldModel) => {
    const data = event.data as Record<string, any>;
    if (data.entityId === brochureId ||
        data.targetId === brochureId) {
      w.awardScore(ScoreIds.READ_BROCHURE,
        ScorePoints[ScoreIds.READ_BROCHURE],
        'Read the zoo brochure');
    }
    return null;
  },
  { key: 'zoo.chain.read-scoring' },
);
step 7 typescript
private entityIds: {
  animalFeed: string;
  penny: string;
  souvenirPress: string;
  zooMap: string;
  brochure: string;
} = {
  animalFeed: '', penny: '', souvenirPress: '',
  zooMap: '', brochure: '',
};
step 8 typescript
this.entityIds.zooMap = zooMap.id;
this.entityIds.brochure = brochure.id;
step 9 typescript
// inside the feeding action's execute(), after its existing
// `const target = ...` line, keyed on which animal was fed:
const name = target?.name?.toLowerCase() ?? '';
if (name.includes('goat')) {
  context.world.awardScore(ScoreIds.FEED_GOATS,
    ScorePoints[ScoreIds.FEED_GOATS], 'Fed the goats');
} else if (name.includes('rabbit')) {
  context.world.awardScore(ScoreIds.FEED_RABBITS,
    ScorePoints[ScoreIds.FEED_RABBITS], 'Fed the rabbits');
}

// inside the photograph action's execute() (rename its unused
// `_context` parameter to `context`, now that the body uses it):
context.world.awardScore(ScoreIds.PHOTOGRAPH_ANIMAL,
  ScorePoints[ScoreIds.PHOTOGRAPH_ANIMAL],
  'Photographed an animal');

// in the penny-press chain (Chapter 13), after the pressed penny
// is handed to the player and before the handler returns:
w.awardScore(ScoreIds.COLLECT_PRESSED_PENNY,
  ScorePoints[ScoreIds.COLLECT_PRESSED_PENNY],
  'Pressed a souvenir penny');

The victory daemon

step 10 typescript
function createVictoryDaemon(): Daemon {
  let victoryTriggered = false;

  return {
    id: 'zoo.daemon.victory',
    name: 'Victory Check',
    // runs first among daemons; the turn's scoring is
    // already settled
    priority: 100,

    condition: (ctx: SchedulerContext): boolean => {
      if (victoryTriggered) return false;
      return ctx.world.getScore() >= MAX_SCORE;
    },

    run: (ctx: SchedulerContext): ISemanticEvent[] => {
      victoryTriggered = true;
      ctx.world.setStateValue('game.victory', true);
      ctx.world.setStateValue('game.ended', true);
      return [{
        id: `zoo-victory-${ctx.turn}`, type: 'game.message',
        timestamp: Date.now(), entities: {},
        data: { messageId: ScoreMessages.VICTORY }, narrate: true,
      }];
    },

    getRunnerState() { return { victoryTriggered }; },
    restoreRunnerState(state) {
      victoryTriggered =
        (state.victoryTriggered as boolean) ?? false;
    },
  };
}
step 11 typescript
scheduler.registerDaemon(createVictoryDaemon());
step 12 typescript
language.addMessage(ScoreMessages.VICTORY,
  "Congratulations! You've earned your JUNIOR ZOOKEEPER " +
  'badge. You visited every exhibit, fed the animals, ' +
  'collected souvenirs, and made memories to last a ' +
  'lifetime.\n\n*** You have won ***');

Combined runnable file

The complete, runnable story at the end of this chapter (tested source: tutorials/familyzoo/v2.0.0/src/ch23-scoring.ts).

runnable ch23-scoring.ts typescript
/**
 * Family Zoo Tutorial — Version 16: Scoring and Endgame
 *
 * NEW IN THIS VERSION:
 *   - world.awardScore() — award points for achievements (idempotent)
 *   - world.setMaxScore() — set the maximum possible score
 *   - world.getScore() — check current score
 *   - Victory daemon — checks for win condition each turn
 *   - Score command — built-in "score" command reports progress
 *
 * WHAT YOU'LL LEARN:
 *   - Points are awarded with unique IDs (prevents double-scoring)
 *   - awardScore() returns true if new, false if already awarded
 *   - A victory daemon watches for the win condition each turn
 *   - The game ends with a congratulatory message when max score is reached
 *
 * SCORING BREAKDOWN (75 points total):
 *   Visit Petting Zoo:     5 pts
 *   Visit Aviary:          5 pts
 *   Visit Gift Shop:       5 pts
 *   Visit Supply Room:     5 pts
 *   Visit Nocturnal Exhibit: 5 pts
 *   Feed the goats:       10 pts
 *   Feed the rabbits:     10 pts
 *   Collect zoo map:       5 pts
 *   Collect pressed penny: 10 pts
 *   Photograph an animal:  5 pts
 *   Pet an animal:         5 pts
 *   Read the brochure:     5 pts
 *
 * TRY IT:
 *   > score                           (check current score)
 *   > south                           (visit main path)
 *   > east                            (visit petting zoo — 5 pts!)
 *   > score                           (score increased)
 *
 * BUILD & RUN:
 *   ./build.sh -s familyzoo
 *   node dist/cli/sharpee.js --story tutorials/familyzoo --play
 */

// ============================================================================
// IMPORTS
// ============================================================================

import { Story, StoryConfig, GameEngine } from '@sharpee/engine';
import {
  WorldModel,
  IFEntity,
  EntityType,
  Direction,
  NpcTrait,
  IWorldModel,
  CapabilityBehavior,
  CapabilityValidationResult,
  CapabilitySharedData,
  CapabilityEffect,
  createEffect,
  findTraitWithCapability,
  ITrait,
} from '@sharpee/world-model';
import {
  IdentityTrait,
  ActorTrait,
  ContainerTrait,
  SupporterTrait,
  RoomTrait,
  SceneryTrait,
  OpenableTrait,
  LockableTrait,
  DoorTrait,
  SwitchableTrait,
  LightSourceTrait,
  ReadableTrait,
} from '@sharpee/world-model';
import { ISemanticEvent } from '@sharpee/core';
import { NpcPlugin } from '@sharpee/plugin-npc';
import { SchedulerPlugin } from '@sharpee/plugin-scheduler';
import type { ISchedulerService, Daemon, Fuse, SchedulerContext } from '@sharpee/plugin-scheduler';
import {
  NpcBehavior, NpcContext, NpcAction, createPatrolBehavior,
  Action, ActionContext, ValidationResult,
} from '@sharpee/stdlib';
import type { Parser } from '@sharpee/parser-en-us';
import type { LanguageProvider } from '@sharpee/lang-en-us';


// ============================================================================
// STORY CONFIGURATION
// ============================================================================

const config: StoryConfig = {
  id: 'familyzoo',
  title: 'Family Zoo',
  author: 'Sharpee Tutorial',
  version: '1.0.0',
  description: 'A small family zoo — learn Sharpee one concept at a time.',
};


// ============================================================================
// SCORING CONSTANTS — NEW IN V16
// ============================================================================
//
// Define score IDs and point values as constants. The IDs must be unique
// because awardScore() is idempotent — awarding the same ID twice does
// nothing the second time. This prevents double-scoring.

const MAX_SCORE = 75;

const ScoreIds = {
  // Room visits (5 pts each)
  VISIT_PETTING_ZOO: 'zoo.visit.petting_zoo',
  VISIT_AVIARY: 'zoo.visit.aviary',
  VISIT_GIFT_SHOP: 'zoo.visit.gift_shop',
  VISIT_SUPPLY_ROOM: 'zoo.visit.supply_room',
  VISIT_NOCTURNAL: 'zoo.visit.nocturnal',

  // Actions (10 pts each for feeding, 5 pts for others)
  FEED_GOATS: 'zoo.action.fed_goats',
  FEED_RABBITS: 'zoo.action.fed_rabbits',
  COLLECT_MAP: 'zoo.collect.map',
  COLLECT_PRESSED_PENNY: 'zoo.collect.pressed_penny',
  PHOTOGRAPH_ANIMAL: 'zoo.action.photographed',
  PET_ANIMAL: 'zoo.action.petted',
  READ_BROCHURE: 'zoo.action.read_brochure',
} as const;

const ScorePoints: Record<string, number> = {
  [ScoreIds.VISIT_PETTING_ZOO]: 5,
  [ScoreIds.VISIT_AVIARY]: 5,
  [ScoreIds.VISIT_GIFT_SHOP]: 5,
  [ScoreIds.VISIT_SUPPLY_ROOM]: 5,
  [ScoreIds.VISIT_NOCTURNAL]: 5,
  [ScoreIds.FEED_GOATS]: 10,
  [ScoreIds.FEED_RABBITS]: 10,
  [ScoreIds.COLLECT_MAP]: 5,
  [ScoreIds.COLLECT_PRESSED_PENNY]: 10,
  [ScoreIds.PHOTOGRAPH_ANIMAL]: 5,
  [ScoreIds.PET_ANIMAL]: 5,
  [ScoreIds.READ_BROCHURE]: 5,
};

// Score message IDs
const ScoreMessages = {
  VICTORY: 'zoo.victory',
  SCORE_GAINED: 'zoo.score.gained',
} as const;

// Room name → score ID mapping for visit scoring
const ROOM_SCORE_MAP: Record<string, string> = {
  'Petting Zoo': ScoreIds.VISIT_PETTING_ZOO,
  'Aviary': ScoreIds.VISIT_AVIARY,
  'Gift Shop': ScoreIds.VISIT_GIFT_SHOP,
  'Supply Room': ScoreIds.VISIT_SUPPLY_ROOM,
  'Nocturnal Animals Exhibit': ScoreIds.VISIT_NOCTURNAL,
};


// ============================================================================
// CUSTOM TRAIT: PettableTrait (from V14)
// ============================================================================

class PettableTrait implements ITrait {
  static readonly type = 'zoo.trait.pettable' as const;
  static readonly capabilities = ['zoo.action.petting'] as const;
  readonly type = PettableTrait.type;
  readonly animalKind: 'goats' | 'rabbits' | 'parrot' | 'snake';
  constructor(kind: 'goats' | 'rabbits' | 'parrot' | 'snake') {
    this.animalKind = kind;
  }
}


// ============================================================================
// CAPABILITY BEHAVIORS (from V14)
// ============================================================================

const PetMessages = {
  PET_GOATS: 'zoo.petting.goats', PET_RABBITS: 'zoo.petting.rabbits',
  PET_PARROT: 'zoo.petting.parrot', PET_SNAKE: 'zoo.petting.snake_glass',
  CANT_PET: 'zoo.petting.cant_pet',
} as const;

const PETTING_ACTION_ID = 'zoo.action.petting';

const pettingBehavior: CapabilityBehavior = {
  validate(_entity: IFEntity, _world: WorldModel, _actorId: string, _sharedData: CapabilitySharedData): CapabilityValidationResult {
    return { valid: true };
  },
  execute(_entity: IFEntity, world: WorldModel, _actorId: string, _sharedData: CapabilitySharedData): void {
    // --- V16: Award score for petting ---
    world.awardScore(ScoreIds.PET_ANIMAL, ScorePoints[ScoreIds.PET_ANIMAL], 'Petted an animal');
  },
  report(entity: IFEntity, _world: WorldModel, _actorId: string, _sharedData: CapabilitySharedData): CapabilityEffect[] {
    const pettable = entity.get(PettableTrait);
    let messageId: string = PetMessages.CANT_PET;
    switch (pettable?.animalKind) {
      case 'goats':   messageId = PetMessages.PET_GOATS; break;
      case 'rabbits': messageId = PetMessages.PET_RABBITS; break;
      case 'parrot':  messageId = PetMessages.PET_PARROT; break;
    }
    return [createEffect('zoo.event.petted', { messageId, params: { target: entity.name } })];
  },
  blocked(entity: IFEntity, _world: WorldModel, _actorId: string, error: string, _sharedData: CapabilitySharedData): CapabilityEffect[] {
    return [createEffect('zoo.event.petting_blocked', { messageId: error, params: { target: entity.name } })];
  },
};


// ============================================================================
// PETTING ACTION (from V14) — Modified in V16 to award score
// ============================================================================

const pettingAction: Action = {
  id: PETTING_ACTION_ID,
  group: 'interaction',
  validate(context: ActionContext): ValidationResult {
    const entity = context.command.directObject?.entity;
    if (!entity) return { valid: false, error: PetMessages.CANT_PET };
    const trait = findTraitWithCapability(entity, PETTING_ACTION_ID);
    if (!trait) return { valid: false, error: PetMessages.CANT_PET };
    const behavior = context.world.getBehaviorForCapability(trait, PETTING_ACTION_ID);
    if (!behavior) return { valid: false, error: PetMessages.CANT_PET };
    const sharedData: CapabilitySharedData = {};
    const behaviorResult = behavior.validate(entity, context.world, context.player.id, sharedData);
    if (!behaviorResult.valid) return { valid: false, error: behaviorResult.error };
    context.sharedData.capEntity = entity;
    context.sharedData.capBehavior = behavior;
    context.sharedData.capSharedData = sharedData;
    return { valid: true };
  },
  execute(context: ActionContext): void {
    const entity = context.sharedData.capEntity as IFEntity;
    const behavior = context.sharedData.capBehavior as CapabilityBehavior;
    const sharedData = context.sharedData.capSharedData as CapabilitySharedData;
    if (entity && behavior) {
      behavior.execute(entity, context.world, context.player.id, sharedData);
    }
  },
  report(context: ActionContext): ISemanticEvent[] {
    const entity = context.sharedData.capEntity as IFEntity;
    const behavior = context.sharedData.capBehavior as CapabilityBehavior;
    const sharedData = context.sharedData.capSharedData as CapabilitySharedData;
    if (!entity || !behavior) return [];
    const effects = behavior.report(entity, context.world, context.player.id, sharedData);
    return effects.map(effect => context.event(effect.type, effect.payload));
  },
  blocked(context: ActionContext, result: ValidationResult): ISemanticEvent[] {
    return [context.event('zoo.event.petting_blocked', { messageId: result.error || PetMessages.CANT_PET })];
  },
};


// ============================================================================
// CUSTOM ACTIONS FROM V13 (carried forward) — Modified in V16 for scoring
// ============================================================================

const FEED_ACTION_ID = 'zoo.action.feeding';
const FeedMessages = {
  NO_FEED: 'zoo.feeding.no_feed', NOT_AN_ANIMAL: 'zoo.feeding.not_animal',
  ALREADY_FED: 'zoo.feeding.already_fed', FED_GOATS: 'zoo.feeding.fed_goats',
  FED_RABBITS: 'zoo.feeding.fed_rabbits', FED_GENERIC: 'zoo.feeding.fed_generic',
} as const;

const feedAction: Action = {
  id: FEED_ACTION_ID, group: 'interaction',
  validate(context: ActionContext): ValidationResult {
    const target = context.command.directObject?.entity;
    const inventory = context.world.getContents(context.player.id);
    const hasFeed = inventory.some(i => i.get(IdentityTrait)?.aliases?.includes('feed'));
    if (!hasFeed) return { valid: false, error: FeedMessages.NO_FEED };
    if (!target) return { valid: false, error: FeedMessages.NOT_AN_ANIMAL };
    const name = target.get(IdentityTrait)?.name?.toLowerCase() || '';
    if (!['pygmy goats', 'rabbits'].some(a => name.includes(a))) return { valid: false, error: FeedMessages.NOT_AN_ANIMAL };
    if (context.world.getStateValue(`fed-${target.id}`)) return { valid: false, error: FeedMessages.ALREADY_FED };
    context.sharedData.feedTarget = target;
    return { valid: true };
  },
  execute(context: ActionContext): void {
    const target = context.sharedData.feedTarget as IFEntity;
    if (target) {
      context.world.setStateValue(`fed-${target.id}`, true);

      // --- NEW IN V16: Award score for feeding ---
      const name = target.get(IdentityTrait)?.name?.toLowerCase() || '';
      if (name.includes('goats')) {
        context.world.awardScore(ScoreIds.FEED_GOATS, ScorePoints[ScoreIds.FEED_GOATS], 'Fed the pygmy goats');
        // Also clear feeding time bleating
        context.world.setStateValue('zoo.feeding_time_active', false);
        context.world.setStateValue('zoo.bleat_turns_remaining', 0);
      } else if (name.includes('rabbits')) {
        context.world.awardScore(ScoreIds.FEED_RABBITS, ScorePoints[ScoreIds.FEED_RABBITS], 'Fed the rabbits');
      }
    }
  },
  report(context: ActionContext): ISemanticEvent[] {
    const target = context.sharedData.feedTarget as IFEntity;
    const name = target?.get(IdentityTrait)?.name?.toLowerCase() || '';
    let messageId: string = FeedMessages.FED_GENERIC;
    if (name.includes('goats')) messageId = FeedMessages.FED_GOATS;
    else if (name.includes('rabbits')) messageId = FeedMessages.FED_RABBITS;
    return [context.event('zoo.event.fed', { messageId, params: { animal: target?.get(IdentityTrait)?.name } })];
  },
  blocked(_context: ActionContext, result: ValidationResult): ISemanticEvent[] {
    return [_context.event('zoo.event.feeding_blocked', { messageId: result.error || FeedMessages.NOT_AN_ANIMAL })];
  },
};

const PHOTOGRAPH_ACTION_ID = 'zoo.action.photographing';
const PhotoMessages = { NO_CAMERA: 'zoo.photo.no_camera', TOOK_PHOTO: 'zoo.photo.took_photo' } as const;

const photographAction: Action = {
  id: PHOTOGRAPH_ACTION_ID, group: 'interaction',
  validate(context: ActionContext): ValidationResult {
    const inventory = context.world.getContents(context.player.id);
    const hasCamera = inventory.some(i => i.get(IdentityTrait)?.aliases?.includes('camera'));
    if (!hasCamera) return { valid: false, error: PhotoMessages.NO_CAMERA };
    const target = context.command.directObject?.entity;
    if (target) context.sharedData.photoTarget = target;
    return { valid: true };
  },
  execute(context: ActionContext): void {
    // --- NEW IN V16: Award score for photographing ---
    context.world.awardScore(ScoreIds.PHOTOGRAPH_ANIMAL, ScorePoints[ScoreIds.PHOTOGRAPH_ANIMAL], 'Took a photograph');
  },
  report(context: ActionContext): ISemanticEvent[] {
    const target = context.sharedData.photoTarget as IFEntity | undefined;
    const name = target?.get(IdentityTrait)?.name || 'the scenery';
    return [context.event('zoo.event.photographed', { messageId: PhotoMessages.TOOK_PHOTO, params: { target: name } })];
  },
  blocked(context: ActionContext, result: ValidationResult): ISemanticEvent[] {
    return [context.event('zoo.event.photographing_blocked', { messageId: result.error || PhotoMessages.NO_CAMERA })];
  },
};


// ============================================================================
// PARROT BEHAVIOR (from V11)
// ============================================================================

const PARROT_PHRASES = ['Polly wants a cracker!', 'SQUAWK! Pretty bird! Pretty bird!', 'Pieces of eight! Pieces of eight!', 'Who\'s a good bird? WHO\'S A GOOD BIRD?', 'BAWK! Welcome to the zoo!'];

const parrotBehavior: NpcBehavior = {
  id: 'zoo-parrot', name: 'Parrot Behavior',
  onTurn(context: NpcContext): NpcAction[] {
    if (!context.playerVisible) return [];
    if (context.random.chance(0.5)) return [{ type: 'speak', messageId: 'npc.speech', data: { text: context.random.pick(PARROT_PHRASES) } }];
    return [];
  },
  onPlayerEnters(): NpcAction[] {
    return [{ type: 'emote', messageId: 'npc.emote', data: { text: 'The parrot ruffles its feathers and eyes you with interest.' } }];
  },
};


// ============================================================================
// TIMED EVENT MESSAGES (from V15)
// ============================================================================

const TimedMessages = {
  PA_CLOSING_3: 'zoo.pa.closing_3',
  PA_CLOSING_2: 'zoo.pa.closing_2',
  PA_CLOSING_1: 'zoo.pa.closing_1',
  PA_CLOSED: 'zoo.pa.closed',
  FEEDING_TIME: 'zoo.feeding_time.announced',
  GOATS_BLEATING: 'zoo.goats.bleating',
} as const;


// ============================================================================
// DAEMONS AND FUSES (from V15)
// ============================================================================

function createPAAnnouncementDaemon(): Daemon {
  let announcementCount = 0;
  return {
    id: 'zoo.daemon.pa_announcements', name: 'Zoo PA Announcements', priority: 5,
    condition: (ctx: SchedulerContext): boolean => ctx.turn > 0 && ctx.turn % 5 === 0 && announcementCount < 4,
    run: (ctx: SchedulerContext): ISemanticEvent[] => {
      announcementCount++;
      let messageId: string;
      switch (announcementCount) {
        case 1: messageId = TimedMessages.PA_CLOSING_3; break;
        case 2: messageId = TimedMessages.PA_CLOSING_2; break;
        case 3: messageId = TimedMessages.PA_CLOSING_1; break;
        default: messageId = TimedMessages.PA_CLOSED; break;
      }
      return [{ id: `zoo-pa-${ctx.turn}`, type: 'game.message', timestamp: Date.now(), entities: {}, data: { messageId }, narrate: true }];
    },
    getRunnerState(): Record<string, unknown> { return { announcementCount }; },
    restoreRunnerState(state: Record<string, unknown>): void { announcementCount = (state.announcementCount as number) ?? 0; },
  };
}

function createFeedingTimeFuse(): Fuse {
  return {
    id: 'zoo.fuse.feeding_time', name: 'Feeding Time', turns: 10,
    repeat: true, originalTurns: 8, priority: 10,
    trigger: (ctx: SchedulerContext): ISemanticEvent[] => {
      ctx.world.setStateValue('zoo.feeding_time_active', true);
      ctx.world.setStateValue('zoo.bleat_turns_remaining', 3);
      return [{ id: `zoo-feeding-${ctx.turn}`, type: 'game.message', timestamp: Date.now(), entities: {}, data: { messageId: TimedMessages.FEEDING_TIME }, narrate: true }];
    },
  };
}

function createGoatBleatingDaemon(): Daemon {
  return {
    id: 'zoo.daemon.goat_bleating', name: 'Goat Bleating', priority: 3,
    condition: (ctx: SchedulerContext): boolean => {
      const feedingActive = ctx.world.getStateValue('zoo.feeding_time_active') as boolean;
      const bleatsLeft = ctx.world.getStateValue('zoo.bleat_turns_remaining') as number;
      return feedingActive === true && (bleatsLeft ?? 0) > 0;
    },
    run: (ctx: SchedulerContext): ISemanticEvent[] => {
      const bleatsLeft = (ctx.world.getStateValue('zoo.bleat_turns_remaining') as number) ?? 0;
      if (bleatsLeft <= 1) {
        ctx.world.setStateValue('zoo.feeding_time_active', false);
        ctx.world.setStateValue('zoo.bleat_turns_remaining', 0);
      } else {
        ctx.world.setStateValue('zoo.bleat_turns_remaining', bleatsLeft - 1);
      }
      const playerRoom = ctx.world.getEntity(ctx.playerLocation);
      const roomName = playerRoom?.get(IdentityTrait)?.name || '';
      if (roomName.includes('Petting Zoo')) {
        return [{ id: `zoo-bleat-${ctx.turn}`, type: 'game.message', timestamp: Date.now(), entities: {}, data: { messageId: TimedMessages.GOATS_BLEATING }, narrate: true }];
      }
      return [];
    },
  };
}


// ============================================================================
// VICTORY DAEMON — NEW IN V16
// ============================================================================
//
// A daemon that checks each turn whether the player has reached the maximum
// score. When they do, it emits a victory message and marks the game as ended.
//
// The shape is the same as every daemon in Chapter 22: a condition that
// watches for a specific game state, and a run function that triggers
// the endgame when it arrives.

function createVictoryDaemon(): Daemon {
  let victoryTriggered = false;

  return {
    id: 'zoo.daemon.victory',
    name: 'Victory Check',
    priority: 100,  // Runs first among daemons (higher = earlier); scoring is already settled

    // Only check once, and only after some progress
    condition: (ctx: SchedulerContext): boolean => {
      if (victoryTriggered) return false;
      return ctx.world.getScore() >= MAX_SCORE;
    },

    run: (ctx: SchedulerContext): ISemanticEvent[] => {
      victoryTriggered = true;

      // Mark the game as won
      ctx.world.setStateValue('game.victory', true);
      ctx.world.setStateValue('game.ended', true);

      return [{
        id: `zoo-victory-${ctx.turn}`,
        type: 'game.message',
        timestamp: Date.now(),
        entities: {},
        data: { messageId: ScoreMessages.VICTORY },
        narrate: true,
      }];
    },

    getRunnerState(): Record<string, unknown> { return { victoryTriggered }; },
    restoreRunnerState(state: Record<string, unknown>): void { victoryTriggered = (state.victoryTriggered as boolean) ?? false; },
  };
}


// ============================================================================
// THE STORY CLASS
// ============================================================================

class FamilyZooStory implements Story {
  config = config;
  private roomIds = { entrance: '', mainPath: '', pettingZoo: '', aviary: '', supplyRoom: '', nocturnalExhibit: '', giftShop: '' };
  private entityIds = { animalFeed: '', penny: '', souvenirPress: '', brochure: '', zooMap: '' };

  createPlayer(world: WorldModel): IFEntity {
    const player = world.createEntity('yourself', EntityType.ACTOR);
    player.add(new IdentityTrait({ name: 'yourself', description: 'Just an ordinary visitor to the zoo.', aliases: ['self', 'myself', 'me'], properName: true, article: '' }));
    player.add(new ActorTrait({ isPlayer: true }));
    player.add(new ContainerTrait({ capacity: { maxItems: 10 } }));
    return player;
  }

  initializeWorld(world: WorldModel): void {

    // ========================================================================
    // SET MAX SCORE — NEW IN V16
    // ========================================================================
    //
    // This tells the engine what the maximum score is, so the built-in
    // "score" command can show "X out of Y points".
    world.setMaxScore(MAX_SCORE);

    // ROOMS — Same as V15
    const entrance = world.createEntity('Zoo Entrance', EntityType.ROOM);
    entrance.add(new RoomTrait({ exits: {}, isDark: false }));
    entrance.add(new IdentityTrait({ name: 'Zoo Entrance', description: 'You stand before the wrought-iron gates of the Willowbrook Family Zoo. A cheerful welcome sign arches over the entrance, and a small ticket booth sits to one side. A sturdy iron fence runs along either side of the gates. The main path leads south into the zoo grounds.', aliases: ['entrance', 'gates', 'gate'], properName: false, article: 'the' }));

    const mainPath = world.createEntity('Main Path', EntityType.ROOM);
    mainPath.add(new RoomTrait({ exits: {}, isDark: false }));
    mainPath.add(new IdentityTrait({ name: 'Main Path', description: 'A wide gravel path winds through the heart of the zoo. Colorful direction signs point every which way. A park bench sits beside the path. To the east, the petting zoo. To the west, the aviary. A staff gate blocks the path to the south. The entrance is back to the north.', aliases: ['path', 'main path', 'gravel path'], properName: false, article: 'the' }));

    const pettingZoo = world.createEntity('Petting Zoo', EntityType.ROOM);
    pettingZoo.add(new RoomTrait({ exits: {}, isDark: false }));
    pettingZoo.add(new IdentityTrait({ name: 'Petting Zoo', description: 'A cheerful open-air enclosure filled with friendly animals. Pygmy goats trot around nibbling at visitors\' shoelaces, while a pair of fluffy rabbits hop lazily near a hay bale. A feed dispenser is mounted on a post. An info plaque is posted by the gate. The main path is back to the west.', aliases: ['petting zoo', 'petting area', 'pen'], properName: false, article: 'the' }));

    const aviary = world.createEntity('Aviary', EntityType.ROOM);
    aviary.add(new RoomTrait({ exits: {}, isDark: false }));
    aviary.add(new IdentityTrait({ name: 'Aviary', description: 'You step inside a soaring mesh dome. Brilliantly colored parrots chatter from rope perches, and a toucan eyes you curiously from a branch overhead. A small waterfall splashes into a stone basin. An info plaque hangs near the entrance. The gift shop is to the west. The main path is back to the east.', aliases: ['aviary', 'bird house', 'dome'], properName: false, article: 'the' }));

    const supplyRoom = world.createEntity('Supply Room', EntityType.ROOM);
    supplyRoom.add(new RoomTrait({ exits: {}, isDark: false }));
    supplyRoom.add(new IdentityTrait({ name: 'Supply Room', description: 'A cluttered storage room behind the staff gate. Metal shelves line the walls. A cork board on the wall is covered with staff schedules. A battered radio sits on one of the shelves. The staff gate leads back north.', aliases: ['supply room', 'storage room', 'storeroom'], properName: false, article: 'the' }));

    const nocturnalExhibit = world.createEntity('Nocturnal Animals Exhibit', EntityType.ROOM);
    nocturnalExhibit.add(new RoomTrait({ exits: {}, isDark: true }));
    nocturnalExhibit.add(new IdentityTrait({ name: 'Nocturnal Animals Exhibit', description: 'A cool, dimly lit cavern designed to simulate nighttime. Glass enclosures line both walls with soft red lights. You can see sugar gliders, bush babies, and a barn owl. A warning sign is posted near the entrance. The exit leads back north to the supply room.', aliases: ['nocturnal exhibit', 'nocturnal animals', 'exhibit'], properName: false, article: 'the' }));

    const giftShop = world.createEntity('Gift Shop', EntityType.ROOM);
    giftShop.add(new RoomTrait({ exits: {}, isDark: false }));
    giftShop.add(new IdentityTrait({ name: 'Gift Shop', description: 'A small zoo gift shop crammed with stuffed animals and postcards. A large souvenir penny press machine stands near the door. A disposable camera sits on the counter. The aviary is back to the east.', aliases: ['gift shop', 'shop', 'store'], properName: false, article: 'the' }));

    this.roomIds = { entrance: entrance.id, mainPath: mainPath.id, pettingZoo: pettingZoo.id, aviary: aviary.id, supplyRoom: supplyRoom.id, nocturnalExhibit: nocturnalExhibit.id, giftShop: giftShop.id };

    // STAFF GATE
    const keycard = world.createEntity('staff keycard', EntityType.ITEM);
    keycard.add(new IdentityTrait({ name: 'staff keycard', description: 'A white plastic keycard with "WILLOWBROOK ZOO — STAFF ONLY" printed in blue.', aliases: ['keycard', 'key card', 'card', 'key', 'staff keycard'], properName: false, article: 'a' }));
    world.moveEntity(keycard.id, entrance.id);

    const staffGate = world.createEntity('staff gate', EntityType.DOOR);
    staffGate.add(new IdentityTrait({ name: 'staff gate', description: 'A sturdy metal gate with a "STAFF ONLY" sign.', aliases: ['gate', 'staff gate', 'metal gate', 'staff door'], properName: false, article: 'a' }));
    staffGate.add(new DoorTrait({ room1: mainPath.id, room2: supplyRoom.id, bidirectional: true }));
    staffGate.add(new OpenableTrait({ isOpen: false }));
    staffGate.add(new LockableTrait({ isLocked: true, keyId: keycard.id }));
    staffGate.add(new SceneryTrait());
    world.moveEntity(staffGate.id, mainPath.id);

    // EXITS
    entrance.get(RoomTrait)!.exits = { [Direction.SOUTH]: { destination: mainPath.id } };
    mainPath.get(RoomTrait)!.exits = { [Direction.NORTH]: { destination: entrance.id }, [Direction.EAST]: { destination: pettingZoo.id }, [Direction.WEST]: { destination: aviary.id }, [Direction.SOUTH]: { destination: supplyRoom.id, via: staffGate.id } };
    pettingZoo.get(RoomTrait)!.exits = { [Direction.WEST]: { destination: mainPath.id } };
    aviary.get(RoomTrait)!.exits = { [Direction.EAST]: { destination: mainPath.id }, [Direction.WEST]: { destination: giftShop.id } };
    supplyRoom.get(RoomTrait)!.exits = { [Direction.NORTH]: { destination: mainPath.id, via: staffGate.id }, [Direction.SOUTH]: { destination: nocturnalExhibit.id } };
    nocturnalExhibit.get(RoomTrait)!.exits = { [Direction.NORTH]: { destination: supplyRoom.id } };
    giftShop.get(RoomTrait)!.exits = { [Direction.EAST]: { destination: aviary.id } };

    // SCENERY
    for (const [name, desc, aliases, room] of [
      ['welcome sign', 'A brightly painted wooden sign reads: "WELCOME TO WILLOWBROOK FAMILY ZOO."', ['sign', 'welcome sign'], entrance],
      ['ticket booth', 'A small wooden booth with a "Self-Guided Tours" sign.', ['booth', 'ticket booth'], entrance],
      ['iron fence', 'A tall wrought-iron fence with animal silhouettes.', ['fence', 'iron fence', 'railing'], entrance],
      ['direction signs', 'Arrow signs: PETTING ZOO (east), AVIARY (west), EXIT (north).', ['signs', 'direction signs', 'arrow signs'], mainPath],
      ['flower beds', 'Tidy beds of marigolds and petunias.', ['flowers', 'flower beds'], mainPath],
      ['hay bale', 'A large round bale of golden hay.', ['hay', 'hay bale', 'bale'], pettingZoo],
      ['toucan', 'A Toco toucan with an enormous orange-and-black bill.', ['toucan', 'toco toucan'], aviary],
      ['waterfall', 'A gentle artificial waterfall cascading into a stone basin.', ['waterfall', 'water', 'basin'], aviary],
      ['rope perches', 'Thick sisal ropes strung between wooden posts.', ['perches', 'rope perches', 'ropes'], aviary],
      ['metal shelves', 'Industrial metal shelving units stacked with supplies.', ['shelves', 'metal shelves', 'shelf'], supplyRoom],
      ['sugar gliders', 'A family of tiny sugar gliders with enormous dark eyes.', ['sugar gliders', 'gliders'], nocturnalExhibit],
      ['bush babies', 'Two bush babies with impossibly large round eyes.', ['bush babies', 'galagos'], nocturnalExhibit],
      ['barn owl', 'An enormous barn owl with a heart-shaped white face.', ['barn owl', 'owl'], nocturnalExhibit],
      ['stuffed animals', 'Shelves of plush tigers, pandas, and penguins.', ['stuffed animals', 'plush', 'toys'], giftShop],
      ['postcards', 'A spinning rack of postcards showing the zoo\'s greatest hits.', ['postcards', 'cards', 'postcard rack'], giftShop],
    ] as [string, string, string[], IFEntity][]) {
      const e = world.createEntity(name, EntityType.SCENERY);
      e.add(new IdentityTrait({ name, description: desc, aliases, properName: false, article: 'a' }));
      world.moveEntity(e.id, room.id);
    }

    // Additional scenery with special traits
    const corkBoard = world.createEntity('cork board', EntityType.SCENERY);
    corkBoard.add(new IdentityTrait({ name: 'cork board', description: 'A cork board with staff schedules. A note in red marker: "DON\'T FORGET: nocturnal exhibit lights need new batteries!"', aliases: ['cork board', 'board', 'notices'], properName: false, article: 'a' }));
    world.moveEntity(corkBoard.id, supplyRoom.id);

    const radio = world.createEntity('radio', EntityType.ITEM);
    radio.add(new IdentityTrait({ name: 'radio', description: 'A battered portable radio held together with duct tape. The antenna is bent at a jaunty angle. A faded sticker on the side reads "ZOO FM — All Animals, All The Time."', aliases: ['radio', 'portable radio'], properName: false, article: 'a' }));
    radio.add(new SwitchableTrait({ isOn: false }));
    radio.add(new SceneryTrait());
    world.moveEntity(radio.id, supplyRoom.id);

    const pettingPlaque = world.createEntity('info plaque', EntityType.SCENERY);
    pettingPlaque.add(new IdentityTrait({ name: 'info plaque', description: 'A brass plaque mounted on a wooden post near the petting zoo gate.', aliases: ['plaque', 'info plaque', 'brass plaque'], properName: false, article: 'an' }));
    pettingPlaque.add(new ReadableTrait({ text: 'PYGMY GOATS — These Nigerian Dwarf goats are gentle, curious, and always hungry.\n\nHOLLAND LOP RABBITS — Known for their floppy ears. Our pair, Biscuit and Marmalade, were born here in 2023.' }));
    world.moveEntity(pettingPlaque.id, pettingZoo.id);

    const aviaryPlaque = world.createEntity('aviary plaque', EntityType.SCENERY);
    aviaryPlaque.add(new IdentityTrait({ name: 'aviary plaque', description: 'A colorful information board near the aviary entrance.', aliases: ['plaque', 'aviary plaque', 'information board'], properName: false, article: 'an' }));
    aviaryPlaque.add(new ReadableTrait({ text: 'WELCOME TO THE AVIARY — Home to over 30 species!\n\nTOCO TOUCAN — Its bill weighs less than a smartphone.\n\nSCARLET MACAW — Can live over 75 years. Our oldest, Captain, is 42.' }));
    world.moveEntity(aviaryPlaque.id, aviary.id);

    const warningSign = world.createEntity('warning sign', EntityType.SCENERY);
    warningSign.add(new IdentityTrait({ name: 'warning sign', description: 'A yellow warning sign near the nocturnal exhibit entrance.', aliases: ['warning', 'warning sign', 'yellow sign'], properName: false, article: 'a' }));
    warningSign.add(new ReadableTrait({ text: 'CAUTION: The Nocturnal Animals Exhibit is kept dark. Please use a flashlight. Do NOT use camera flash. (We don\'t talk about the Great Owl Incident of 2022.)' }));
    world.moveEntity(warningSign.id, supplyRoom.id);

    const brochure = world.createEntity('zoo brochure', EntityType.ITEM);
    brochure.add(new IdentityTrait({ name: 'zoo brochure', description: 'A glossy tri-fold brochure with "WILLOWBROOK FAMILY ZOO" on the cover.', aliases: ['brochure', 'zoo brochure', 'pamphlet', 'leaflet'], properName: false, article: 'a' }));
    brochure.add(new ReadableTrait({ text: 'WILLOWBROOK FAMILY ZOO — Your Guide\n\nEXHIBITS:\n  Petting Zoo — East from Main Path\n  Aviary — West from Main Path\n  Gift Shop — West from Aviary\n  Nocturnal Animals — Staff Area\n\n"Where every visit is a wild adventure!"' }));
    world.moveEntity(brochure.id, entrance.id);
    this.entityIds.brochure = brochure.id;

    // PETTABLE ANIMALS
    const goats = world.createEntity('pygmy goats', EntityType.SCENERY);
    goats.add(new IdentityTrait({ name: 'pygmy goats', grammaticalNumber: 'plural', description: 'Three pygmy goats hoping you have food.', aliases: ['goats', 'pygmy goats', 'goat'], properName: false, article: 'some' }));
    goats.add(new PettableTrait('goats'));
    world.moveEntity(goats.id, pettingZoo.id);

    const rabbits = world.createEntity('rabbits', EntityType.SCENERY);
    rabbits.add(new IdentityTrait({ name: 'rabbits', grammaticalNumber: 'plural', description: 'A pair of Holland Lop rabbits with floppy ears.', aliases: ['rabbits', 'rabbit', 'bunnies'], properName: false, article: 'some' }));
    rabbits.add(new PettableTrait('rabbits'));
    world.moveEntity(rabbits.id, pettingZoo.id);

    const parrots = world.createEntity('parrots', EntityType.SCENERY);
    parrots.add(new IdentityTrait({ name: 'parrots', grammaticalNumber: 'plural', description: 'A raucous flock of scarlet macaws and grey African parrots.', aliases: ['parrots', 'macaws', 'birds'], properName: false, article: 'some' }));
    world.moveEntity(parrots.id, aviary.id);

    // PORTABLE OBJECTS
    const zooMap = world.createEntity('zoo map', EntityType.ITEM);
    zooMap.add(new IdentityTrait({ name: 'zoo map', description: 'A colorful folding map of the Willowbrook Family Zoo.', aliases: ['map', 'zoo map', 'folding map'], properName: false, article: 'a' }));
    world.moveEntity(zooMap.id, entrance.id);
    this.entityIds.zooMap = zooMap.id;

    const animalFeed = world.createEntity('bag of animal feed', EntityType.ITEM);
    animalFeed.add(new IdentityTrait({ name: 'bag of animal feed', description: 'A small brown paper bag filled with dried corn and pellets.', aliases: ['feed', 'animal feed', 'bag of feed', 'corn'], properName: false, article: 'a' }));
    world.moveEntity(animalFeed.id, pettingZoo.id);

    const penny = world.createEntity('souvenir penny', EntityType.ITEM);
    penny.add(new IdentityTrait({ name: 'souvenir penny', description: 'A shiny copper penny.', aliases: ['penny', 'souvenir penny', 'coin'], properName: false, article: 'a' }));
    world.moveEntity(penny.id, mainPath.id);

    const flashlight = world.createEntity('flashlight', EntityType.ITEM);
    flashlight.add(new IdentityTrait({ name: 'flashlight', description: 'A heavy-duty yellow flashlight.', aliases: ['flashlight', 'torch', 'light', 'lamp'], properName: false, article: 'a' }));
    flashlight.add(new SwitchableTrait({ isOn: false }));
    flashlight.add(new LightSourceTrait({ brightness: 8, isLit: false }));
    world.moveEntity(flashlight.id, supplyRoom.id);

    const camera = world.createEntity('disposable camera', EntityType.ITEM);
    camera.add(new IdentityTrait({ name: 'disposable camera', description: 'A cheap yellow disposable camera with "ZOO MEMORIES" printed on the side.', aliases: ['camera', 'disposable camera'], properName: false, article: 'a' }));
    world.moveEntity(camera.id, giftShop.id);

    this.entityIds = { ...this.entityIds, animalFeed: animalFeed.id, penny: penny.id, souvenirPress: '' };

    // CONTAINERS
    const backpack = world.createEntity('backpack', EntityType.CONTAINER);
    backpack.add(new IdentityTrait({ name: 'backpack', description: 'A small red canvas backpack.', aliases: ['backpack', 'rucksack', 'pack'], properName: false, article: 'a' }));
    backpack.add(new ContainerTrait({ capacity: { maxItems: 5 } }));
    world.moveEntity(backpack.id, entrance.id);

    const parkBench = world.createEntity('park bench', EntityType.SUPPORTER);
    parkBench.add(new IdentityTrait({ name: 'park bench', description: 'A sturdy park bench painted forest green.', aliases: ['bench', 'park bench', 'benches', 'seat'], properName: false, article: 'a' }));
    parkBench.add(new SupporterTrait({ capacity: { maxItems: 3 } }));
    parkBench.add(new SceneryTrait());
    world.moveEntity(parkBench.id, mainPath.id);

    const lunchbox = world.createEntity('lunchbox', EntityType.CONTAINER);
    lunchbox.add(new IdentityTrait({ name: 'lunchbox', description: 'A dented metal lunchbox decorated with cartoon zoo animals.', aliases: ['lunchbox', 'lunch box', 'box'], properName: false, article: 'a' }));
    lunchbox.add(new ContainerTrait({ capacity: { maxItems: 3 } }));
    lunchbox.add(new OpenableTrait({ isOpen: false }));
    world.moveEntity(lunchbox.id, mainPath.id);

    lunchbox.get(OpenableTrait)!.isOpen = true;
    const juice = world.createEntity('juice box', EntityType.ITEM);
    juice.add(new IdentityTrait({ name: 'juice box', description: 'A small juice box with a picture of a happy elephant.', aliases: ['juice', 'juice box', 'drink'], properName: false, article: 'a' }));
    world.moveEntity(juice.id, lunchbox.id);
    lunchbox.get(OpenableTrait)!.isOpen = false;

    const dispenser = world.createEntity('feed dispenser', EntityType.CONTAINER);
    dispenser.add(new IdentityTrait({ name: 'feed dispenser', description: 'A coin-operated feed dispenser mounted on a wooden post. Sign: "FREE — Just Turn!"', aliases: ['dispenser', 'feed dispenser'], properName: false, article: 'a' }));
    dispenser.add(new ContainerTrait({ capacity: { maxItems: 3 } }));
    dispenser.add(new OpenableTrait({ isOpen: false }));
    dispenser.add(new SceneryTrait());
    world.moveEntity(dispenser.id, pettingZoo.id);

    const souvenirPress = world.createEntity('souvenir press', EntityType.CONTAINER);
    souvenirPress.add(new IdentityTrait({ name: 'souvenir press', description: 'A heavy cast-iron machine with a big crank handle. A slot on top accepts pennies, and the mechanism stamps them with a zoo animal design. A sign reads: "INSERT PENNY, TURN HANDLE, KEEP FOREVER!"', aliases: ['press', 'souvenir press', 'penny press', 'machine'], properName: false, article: 'a' }));
    souvenirPress.add(new ContainerTrait({ capacity: { maxItems: 1 } }));
    souvenirPress.add(new SceneryTrait());
    world.moveEntity(souvenirPress.id, giftShop.id);
    this.entityIds.souvenirPress = souvenirPress.id;

    // NPCs
    const zookeeper = world.createEntity('zookeeper', EntityType.ACTOR);
    zookeeper.add(new IdentityTrait({ name: 'zookeeper', description: 'A friendly zookeeper in khaki overalls. A name tag reads "Sam."', aliases: ['keeper', 'zookeeper', 'sam'], properName: false, article: 'a' }));
    zookeeper.add(new ActorTrait({ isPlayer: false }));
    zookeeper.add(new NpcTrait({ behaviorId: 'zoo-keeper-patrol', canMove: true, announcesMovement: true, isAlive: true, isConscious: true }));
    world.moveEntity(zookeeper.id, mainPath.id);

    const parrot = world.createEntity('parrot', EntityType.ACTOR);
    parrot.add(new IdentityTrait({ name: 'parrot', description: 'A magnificent scarlet macaw perched on a rope. It tilts its head and watches you with one bright eye.', aliases: ['parrot', 'macaw', 'scarlet macaw'], properName: false, article: 'a' }));
    parrot.add(new ActorTrait({ isPlayer: false }));
    parrot.add(new NpcTrait({ behaviorId: 'zoo-parrot', canMove: false, isAlive: true, isConscious: true }));
    parrot.add(new PettableTrait('parrot'));
    world.moveEntity(parrot.id, aviary.id);

    // REGISTER CAPABILITY BEHAVIOR (per-world and idempotent per ADR-207,
    // so no already-registered guard is needed)
    world.registerCapabilityBehavior(PettableTrait.type, PETTING_ACTION_ID, pettingBehavior);

    // PLAYER STARTING LOCATION
    const player = world.getPlayer();
    if (player) world.moveEntity(player.id, entrance.id);
  }


  getCustomActions(): any[] {
    return [feedAction, photographAction, pettingAction];
  }


  extendParser(parser: Parser): void {
    const grammar = parser.getStoryGrammar();
    grammar.define('feed :thing').mapsTo(FEED_ACTION_ID).withPriority(150).build();
    grammar.define('photograph :thing').mapsTo(PHOTOGRAPH_ACTION_ID).withPriority(150).build();
    grammar.define('photo :thing').mapsTo(PHOTOGRAPH_ACTION_ID).withPriority(150).build();
    grammar.define('snap :thing').mapsTo(PHOTOGRAPH_ACTION_ID).withPriority(150).build();
    grammar.define('pet :thing').mapsTo(PETTING_ACTION_ID).withPriority(150).build();
    grammar.define('stroke :thing').mapsTo(PETTING_ACTION_ID).withPriority(150).build();
  }


  extendLanguage(language: LanguageProvider): void {
    // V13 messages
    language.addMessage(FeedMessages.NO_FEED, "You don't have any animal feed.");
    language.addMessage(FeedMessages.NOT_AN_ANIMAL, "That's not something you can feed.");
    language.addMessage(FeedMessages.ALREADY_FED, "You've already fed them. They look contentedly full.");
    language.addMessage(FeedMessages.FED_GOATS, 'You scatter some feed on the ground. The pygmy goats rush over, bleating excitedly, and devour the corn and pellets in seconds. The smallest goat looks up at you with big grateful eyes.');
    language.addMessage(FeedMessages.FED_RABBITS, 'You sprinkle some pellets near the rabbits. Biscuit and Marmalade hop over cautiously, then munch away happily.');
    language.addMessage(FeedMessages.FED_GENERIC, 'You offer some feed. The animal eats it gratefully.');
    language.addMessage(PhotoMessages.NO_CAMERA, "You don't have a camera. There's one in the gift shop.");
    language.addMessage(PhotoMessages.TOOK_PHOTO, 'Click! You snap a photo of {target}. That one\'s going on the fridge.');

    // V14 messages
    language.addMessage(PetMessages.PET_GOATS, 'You reach down and pet the nearest goat. It leans into your hand and bleats happily. The others crowd around, demanding equal attention.');
    language.addMessage(PetMessages.PET_RABBITS, 'You gently stroke one of the rabbits. Its fur is incredibly soft. It twitches its nose at you contentedly.');
    language.addMessage(PetMessages.PET_PARROT, 'You reach toward the parrot. CHOMP! It nips your finger with its beak. "NO TOUCHING!" it squawks indignantly.');
    language.addMessage(PetMessages.CANT_PET, "You can't pet that.");

    // V15 messages — timed events
    language.addMessage(TimedMessages.PA_CLOSING_3, '*DING DONG* "Attention visitors! The Willowbrook Family Zoo will be closing in three hours. Please make sure to visit all exhibits before closing time!"');
    language.addMessage(TimedMessages.PA_CLOSING_2, '*DING DONG* "Attention visitors! Two hours until closing. Don\'t forget to stop by the gift shop for souvenirs!"');
    language.addMessage(TimedMessages.PA_CLOSING_1, '*DING DONG* "Attention visitors! One hour until closing. Please begin making your way toward the exit."');
    language.addMessage(TimedMessages.PA_CLOSED, '*DING DONG* "The Willowbrook Family Zoo is now closed. Thank you for visiting! We hope to see you again soon!"');
    language.addMessage(TimedMessages.FEEDING_TIME, '*DING DONG* "It\'s FEEDING TIME at the Petting Zoo! Come watch our pygmy goats and rabbits enjoy their favorite snacks!"');
    language.addMessage(TimedMessages.GOATS_BLEATING, 'The pygmy goats are bleating loudly and headbutting the fence. They seem very hungry!');

    // ========================================================================
    // V16 MESSAGES — NEW: Victory and scoring
    // ========================================================================

    language.addMessage(ScoreMessages.VICTORY,
      'Congratulations! You\'ve earned your JUNIOR ZOOKEEPER badge! You\'ve visited every exhibit, fed the animals, collected souvenirs, and made memories to last a lifetime. The Willowbrook Family Zoo thanks you for being such an outstanding visitor!\n\n*** You have won ***');
  }


  onEngineReady(engine: GameEngine): void {
    const world = engine.getWorld();

    // NPC Plugin
    const npcPlugin = new NpcPlugin();
    engine.getPluginRegistry().register(npcPlugin);
    const npcService = npcPlugin.getNpcService();
    const keeperPatrol = createPatrolBehavior({ route: [this.roomIds.mainPath, this.roomIds.pettingZoo, this.roomIds.aviary], loop: true, waitTurns: 1 });
    keeperPatrol.id = 'zoo-keeper-patrol';
    npcService.registerBehavior(keeperPatrol);
    npcService.registerBehavior(parrotBehavior);

    // Scheduler Plugin (from V15)
    const schedulerPlugin = new SchedulerPlugin();
    engine.getPluginRegistry().register(schedulerPlugin);
    const scheduler = schedulerPlugin.getScheduler();
    scheduler.registerDaemon(createPAAnnouncementDaemon());
    scheduler.setFuse(createFeedingTimeFuse());
    scheduler.registerDaemon(createGoatBleatingDaemon());

    // ========================================================================
    // VICTORY DAEMON — NEW IN V16
    // ========================================================================
    //
    // Register a daemon that checks for the win condition each turn.
    // When the player reaches MAX_SCORE, the daemon triggers the ending.
    scheduler.registerDaemon(createVictoryDaemon());

    // ========================================================================
    // ROOM VISIT SCORING — NEW IN V16
    // ========================================================================
    //
    // Award points when the player enters certain rooms for the first time.
    // We use world.chainEvent() to listen for the 'if.event.actor_moved'
    // event and check if the destination is a scoring room.
    //
    // awardScore() is idempotent — if the player re-enters a room they
    // already scored for, awardScore() returns false and nothing happens.

    world.chainEvent('if.event.actor_moved', (event: ISemanticEvent, w: IWorldModel): ISemanticEvent | null => {
      const data = event.data as Record<string, any>;
      const toRoom = data.toRoom || data.destination;
      if (!toRoom) return null;

      const roomEntity = w.getEntity(toRoom);
      if (!roomEntity) return null;

      const roomName = roomEntity.get(IdentityTrait)?.name || '';
      const scoreId = ROOM_SCORE_MAP[roomName];
      if (!scoreId) return null;

      // Award points — awardScore returns true only on first award
      w.awardScore(scoreId, ScorePoints[scoreId], `Visited ${roomName}`);

      return null;  // No custom event needed — scoring is silent
    }, { key: 'zoo.chain.room-visit-scoring' });

    // ========================================================================
    // ITEM COLLECTION SCORING — NEW IN V16
    // ========================================================================
    //
    // Award points when the player picks up specific items.

    const mapId = this.entityIds.zooMap;
    world.chainEvent('if.event.taken', (event: ISemanticEvent, w: IWorldModel): ISemanticEvent | null => {
      const data = event.data as Record<string, any>;
      if (data.itemId === mapId) {
        w.awardScore(ScoreIds.COLLECT_MAP, ScorePoints[ScoreIds.COLLECT_MAP], 'Collected the zoo map');
      }
      return null;
    }, { key: 'zoo.chain.take-scoring' });

    // ========================================================================
    // READING SCORING — NEW IN V16
    // ========================================================================
    //
    // Award points when the player reads the brochure.

    const brochureId = this.entityIds.brochure;
    world.chainEvent('if.event.read', (event: ISemanticEvent, w: IWorldModel): ISemanticEvent | null => {
      const data = event.data as Record<string, any>;
      if (data.entityId === brochureId || data.targetId === brochureId) {
        w.awardScore(ScoreIds.READ_BROCHURE, ScorePoints[ScoreIds.READ_BROCHURE], 'Read the zoo brochure');
      }
      return null;
    }, { key: 'zoo.chain.read-scoring' });

    // ========================================================================
    // PRESSED PENNY SCORING — NEW IN V16
    // ========================================================================
    //
    // Award points when the penny press creates the pressed penny.

    const pennyId = this.entityIds.penny;
    const pressId = this.entityIds.souvenirPress;

    // Event chain handlers (from V12) — Modified in V16 to award score
    const feedId = this.entityIds.animalFeed;
    const pettingZooId = this.roomIds.pettingZoo;
    world.chainEvent('if.event.dropped', (event: ISemanticEvent, w: IWorldModel): ISemanticEvent | null => {
      const data = event.data as Record<string, any>;
      if (data.itemId !== feedId || data.toLocation !== pettingZooId) return null;
      if (w.getStateValue('goats-fed')) return null;
      w.setStateValue('goats-fed', true);
      return { id: `zoo-goats-${Date.now()}`, type: 'zoo.event.goats_react', timestamp: Date.now(), entities: {}, data: { text: 'The pygmy goats spot the bag of feed and rush over! They crowd around, bleating excitedly, and devour the corn and pellets in seconds. The smallest goat looks up at you with big grateful eyes.' } };
    }, { key: 'zoo.chain.goats-eat-feed' });

    world.chainEvent('if.event.put_in', (event: ISemanticEvent, w: IWorldModel): ISemanticEvent | null => {
      const data = event.data as Record<string, any>;
      if (data.itemId !== pennyId || data.targetId !== pressId) return null;
      w.removeEntity(pennyId);
      const pp = w.createEntity('pressed penny', EntityType.ITEM);
      pp.add(new IdentityTrait({ name: 'pressed penny', description: 'A flattened oval of copper with an embossed toucan.', aliases: ['pressed penny', 'pressed coin', 'souvenir'], properName: false, article: 'a' }));
      const player = w.getPlayer();
      if (player) w.moveEntity(pp.id, player.id);

      // --- NEW IN V16: Award score for pressed penny ---
      w.awardScore(ScoreIds.COLLECT_PRESSED_PENNY, ScorePoints[ScoreIds.COLLECT_PRESSED_PENNY], 'Pressed a souvenir penny');

      return { id: `zoo-press-${Date.now()}`, type: 'zoo.event.penny_pressed', timestamp: Date.now(), entities: {}, data: { text: 'CLUNK! CRUNCH! WHIRRR! The souvenir press produces a beautiful pressed penny with an embossed toucan.' } };
    }, { key: 'zoo.chain.penny-press' });
  }
}

export const story: Story = new FamilyZooStory();
export default story;

Chapter 24: Channels: The Universal UI Surface

Book source: docs/book/v2.0.0/parts/part-7/24-channels.md · 2 step(s)

Defining your own channel

step 1 typescript
import type {
  IChannelRegistry,
  ChannelProduceContext,
} from '@sharpee/if-domain';
step 2 typescript
// 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.',
};

registerChannels(registry: IChannelRegistry): void {
  registry.add({
    id: 'zoo.ambience',
    contentType: 'text',
    mode: 'replace',
    // only re-emit when the value changes
    emit: 'sparse',
    produce: (ctx: ChannelProduceContext) => {
      const world = ctx.world as WorldModel;
      const room = world.getEntity(
        world.getLocation(world.getPlayer()!.id)!);
      // a mood line for the current room, or '' to clear the line
      return room ? AMBIENCE_BY_ROOM[room.name] ?? '' : '';
    },
  });
}

Combined runnable file

No story-code change this chapter; the runnable file is unchanged from the previous checkpoint (tutorials/familyzoo/v2.0.0/src/ch23-scoring.ts).

Chapter 25: The Web Client: A Framework-Free UI

Book source: docs/book/v2.0.0/parts/part-7/25-the-web-client.md · 4 step(s)

What the client is responsible for

step 1 typescript
import {
  STORY_VERSION,
  ENGINE_VERSION,
  BUILD_DATE,
} from './version.js';

const client = new BrowserClient({
  storagePrefix: 'familyzoo-',
  // the theme applied on first load / restore
  defaultTheme: 'zoo-sunny',
  // The clickable theme menu is generated at build time
  // from your package.json `sharpee.themes` (Chapter 26);
  // this array is metadata the generator fills in.
  themes: [
    { id: 'zoo-sunny', name: 'Zoo Sunny' },
    { id: 'modern-dark', name: 'Modern Dark' },
    { id: 'paper', name: 'Paper' },
  ],
  storyInfo: {
    title: 'Family Zoo',
    authors: 'You',
    // all three stamped into './version.js'
    version: STORY_VERSION,
    engineVersion: ENGINE_VERSION, // by `sharpee build`
    buildDate: BUILD_DATE,
  },
});

// page elements (after DOMContentLoaded)
client.initialize(elements);
client.connectEngine(engine, world);  // wire the engine
// boot, restore autosave, first look
await client.start();

How a turn reaches the screen

step 2 typescript
engine.on('channel:manifest', (cmgt) => renderer.applyCmgt(cmgt));
engine.on('channel:packet',
  (packet) => renderer.applyTurnPacket(packet));

Overriding a renderer

step 3 typescript
const renderer = client.getChannelRenderer();
renderer.registerRenderer('score', {
  onValue: (value) => {
    const { current } = value as { current: number };
    // the platform status element
    const el = document.getElementById('score-turns');
    if (el) el.textContent = `★ ${current}`;
  },
});
step 4 typescript
const renderer = client.getChannelRenderer();
renderer.registerRenderer('zoo.ambience', {
  onValue: (value) => {
    // a stable platform container
    const main = document.getElementById('main-window');
    if (!main) return;
    let line = document.getElementById('zoo-ambience');
    if (!line) {
      line = document.createElement('div');
      line.id = 'zoo-ambience';
      main.prepend(line); // a mood line above the prose
    }
    line.textContent = String(value ?? '');
  },
});

Combined runnable file

No story-code change this chapter; the runnable file is unchanged from the previous checkpoint (tutorials/familyzoo/v2.0.0/src/ch23-scoring.ts).

Chapter 26: Decoration & Theming: Style Without HTML on the Wire

Book source: docs/book/v2.0.0/parts/part-7/26-decoration-and-theming.md · 4 step(s)

Theming: one DOM, many skins

step 1 css
/* the engine, shipped by the platform,
   written once, never per theme */
body {
  background: var(--theme-bg);
  color: var(--theme-text);
}
.sharpee-status-bar {
  background: var(--theme-accent);
  color: var(--theme-accent-text);
}
/* …every .sharpee-* component, all consuming var(--theme-*) … */
step 2 css
[data-theme="modern-dark"] {
  --theme-bg: #1e1e2e;
  --theme-text: #cdd6f4;
  --theme-accent: #89b4fa;
  --theme-font: "Inter", system-ui, sans-serif;
}

Built-in themes: list the id

step 3 jsonc
// the story's package.json
"sharpee": { "themes": ["modern-dark", "paper", "system-6"] }

Your own theme: three lines of CSS and one list entry

step 4 css
[data-theme="zoo-sunny"] .sharpee-menu-bar {
  background: var(--theme-menu-bg);
}
[data-theme="zoo-sunny"] .sharpee-status-bar {
  background: var(--theme-bg-alt);
}

Combined runnable file

No story-code change this chapter; the runnable file is unchanged from the previous checkpoint (tutorials/familyzoo/v2.0.0/src/ch23-scoring.ts).

Chapter 27: Media & Audio: Sight and Sound as Channels

Book source: docs/book/v2.0.0/parts/part-7/27-media-and-audio.md · 5 step(s)

Room atmospheres in practice

step 1 typescript
import { AudioRegistry } from '@sharpee/media';

const audio = new AudioRegistry();
step 2 typescript
// in initializeWorld, after the rooms are created:
audio.atmosphere(aviary.id)
  .ambient('audio/aviary-birdsong.mp3', 'environment', 0.4)
  .build();
audio.atmosphere(nocturnalExhibit.id)
  .ambient('audio/night-crickets.mp3', 'environment', 0.3)
  .build();
step 3 typescript
import type { Effect } from '@sharpee/event-processor';

let mediaCounter = 0;
function mediaEvent(
  type: string,
  data: Record<string, unknown>,
): ISemanticEvent {
  return {
    id: `zoo-media-${++mediaCounter}`,
    type,
    timestamp: Date.now(),
    entities: {},
    data,
  };
}
function emit(
  type: string,
  data: Record<string, unknown>,
): Effect {
  return { type: 'emit', event: mediaEvent(type, data) };
}
step 4 typescript
// in onEngineReady, alongside the plugin registrations:
engine.getEventProcessor().registerHandler(
  'if.event.actor_moved',
  (event: ISemanticEvent): Effect[] => {
    const data = event.data as
      { toRoom?: string; destination?: string } | undefined;
    const toRoom = data?.toRoom ?? data?.destination;
    if (!toRoom) return [];

    const effects: Effect[] = [];
    const atmosphere = audio.getAtmosphere(toRoom);
    if (atmosphere) {
      for (const a of atmosphere.ambient) {
        effects.push(emit('media.ambient.play', {
          src: a.src,
          channel: a.channel,
          volume: a.volume,
          loop: true,
        }));
      }
    } else {
      effects.push(emit('media.ambient.stop', {
        channel: 'environment',
      }));
    }
    return effects;
  },
);
step 5 typescript
// Engine side, in Story.registerChannels:
import { createAmbientChannel } from '@sharpee/stdlib';

registry.add(createAmbientChannel('environment'));

// Browser side, in the browser entry:
import {
  createAmbientChannelRenderer,
} from '@sharpee/platform-browser';

client.getChannelRenderer().registerRenderer(
  'ambient:environment',
  createAmbientChannelRenderer(
    client.getAudioManager(),
    'environment',
  ),
);

Combined runnable file

No story-code change this chapter; the runnable file is unchanged from the previous checkpoint (tutorials/familyzoo/v2.0.0/src/ch23-scoring.ts).

Chapter 28: The Multi-File Story: Putting It All Together

Book source: docs/book/v2.0.0/parts/part-8/28-the-multi-file-story.md · 2 step(s)

Each file exports a builder and its IDs

step 1 typescript
// zoo-map.ts
export interface RoomIds {
  entrance: string;
  pettingZoo: string;
  aviary: string;
  // …
}

export function createZooMap(
  world: WorldModel,
): { rooms: RoomIds; /* … */ } {
  // create rooms, wire exits, add scenery
  // return the IDs the rest of the story needs
}

The index wires it together

step 2 typescript
initializeWorld(world: WorldModel): void {
  world.setMaxScore(MAX_SCORE);

  const { rooms } = createZooMap(world);
  this.roomIds = rooms;
  this.itemIds = createZooItems(world, rooms);
  this.characterIds = createCharacters(world, rooms);

  // register the petting capability, place the player…
}

Combined runnable file

No story-code change this chapter; the runnable file is unchanged from the previous checkpoint (tutorials/familyzoo/v2.0.0/src/ch23-scoring.ts).

Chapter 29: Transcript Testing & Walkthroughs: Proving the Game Still Works

Book source: docs/book/v2.0.0/parts/part-8/29-transcript-testing-and-walkthroughs.md · 1 step(s)

Running them

step 1 bash
# run every transcript it finds
npx sharpee build --test
npx sharpee build --test --stop-on-failure

Combined runnable file

No story-code change this chapter; the runnable file is unchanged from the previous checkpoint (tutorials/familyzoo/v2.0.0/src/ch23-scoring.ts).

Chapter 30: Saving & Restoring: State Lives in the World

Book source: docs/book/v2.0.0/parts/part-8/30-saving-and-restoring.md · 2 step(s)

The one thing you must save yourself

step 1 typescript
let behaviorSwapped = false;
scheduler.registerDaemon({
  id: 'zoo.daemon.parrot_behavior_swap',
  condition: (ctx) =>
    !behaviorSwapped &&
    ctx.world.getStateValue('zoo.after_hours') === true,
  run: () => {
    behaviorSwapped = true;
    npcService.removeBehavior('zoo-parrot');
    npcService.registerBehavior(parrotAfterHoursBehavior);
    return [];
  },
  // …
});
step 2 typescript
  getRunnerState(): Record<string, unknown> {
    return { behaviorSwapped };
  },
  restoreRunnerState(state): void {
    behaviorSwapped =
      (state.behaviorSwapped as boolean) ?? false;
  },

Combined runnable file

No story-code change this chapter; the runnable file is unchanged from the previous checkpoint (tutorials/familyzoo/v2.0.0/src/ch23-scoring.ts).

Chapter 31: Building & Publishing: The Single-Player Browser Client

Book source: docs/book/v2.0.0/parts/part-8/31-building-and-publishing.md · 3 step(s)

The author toolchain

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

Adding the browser client

step 2 bash
sharpee init-browser   # adds src/browser-entry.ts (once)
sharpee build          # now also emits the web client → dist/web/

Hosting it

step 3 bash
sharpee build
python3 -m http.server -d dist/web
# then open the printed http://localhost:8000

Combined runnable file

No story-code change this chapter; the runnable file is unchanged from the previous checkpoint (tutorials/familyzoo/v2.0.0/src/ch23-scoring.ts).