Validate, Execute, Report

The standard library in Sharpee has easily received the most attention with change. We've mowed through at least a dozen refactors because my design thoughts keep finding improvement opportunities.

The latest changes are about how events are emitted (not what - that was covered in the last blog post). This also led to a discussion of the data we need within each action and decided to make this configurable. There is required data elements for each action, but the author can inject extra data to be used in event reporting.

We also refactored the engine's command-executor so that it's just orchestrating the action functions and nothing else. We dropped it from over 700 lines to around 150.

Now we have validate and execute returning events so report has all of the info for the completed action and is responsible for all event output.

/**
 * Examining action - looks at objects in detail
 * 
 * This is a read-only action that provides detailed information about objects.
 * It validates visibility but doesn't change state.
 * 
 * Uses three-phase pattern:
 * 1. validate: Check target exists and is visible
 * 2. execute: No mutations (read-only action)
 * 3. report: Generate events with complete entity snapshot
 */

The data configuration is built into each action.

/**
 * Build examining action success data
 * 
 * Creates the complete data structure for examined events,
 * including entity snapshots and trait-specific information.
 */
export const buildExaminingData: ActionDataBuilder<Record<string, unknown>> = (
  context: ActionContext,
  preState?: WorldModel,
  postState?: WorldModel
): Record<string, unknown> => {
  const actor = context.player;
  const noun = context.command.directObject?.entity;
  
  if (!noun) {
    // Shouldn't happen if validation passed, but handle gracefully
    return {
      targetId: '',
      targetName: 'nothing'
    };
  }
  
  const isSelf = noun.id === actor.id;
  
  // Capture complete entity snapshot for atomic event
  const entitySnapshot = captureEntitySnapshot(noun, context.world, true);
  
  // Build base event data
  const eventData: Record<string, unknown> = {
    // New atomic structure
    target: entitySnapshot,
    // Backward compatibility fields
    targetId: noun.id,
    targetName: isSelf ? 'yourself' : noun.name
  };
  
  if (isSelf) {
    eventData.self = true;
    return eventData; // No trait checking for self-examination
  }
  
  // Add trait-specific information
  
  // Identity trait (description/brief)
  if (noun.has(TraitType.IDENTITY)) {
    const identityTrait = noun.get(TraitType.IDENTITY);
    if (identityTrait) {
      eventData.hasDescription = !!(identityTrait as any).description;
      eventData.hasBrief = !!(identityTrait as any).brief;
    }
  }
  
  // Container trait
  if (noun.has(TraitType.CONTAINER)) {
    const contents = context.world.getContents(noun.id);
    const contentsSnapshots = captureEntitySnapshots(contents, context.world);
    
    eventData.isContainer = true;
    eventData.hasContents = contents.length > 0;
    eventData.contentCount = contents.length;
    // New: full snapshots
    eventData.contentsSnapshots = contentsSnapshots;
    // Backward compatibility: simple references
    eventData.contents = contents.map(e => ({ id: e.id, name: e.name }));
    
    // Check if open/closed
    if (noun.has(TraitType.OPENABLE)) {
      eventData.isOpenable = true;
      eventData.isOpen = OpenableBehavior.isOpen(noun);
    } else {
      eventData.isOpen = true; // Containers without openable trait are always open
    }
  }
  
  // Supporter trait
  if (noun.has(TraitType.SUPPORTER)) {
    const contents = context.world.getContents(noun.id);
    const contentsSnapshots = captureEntitySnapshots(contents, context.world);
    
    eventData.isSupporter = true;
    eventData.hasContents = contents.length > 0;
    eventData.contentCount = contents.length;
    // New: full snapshots
    eventData.contentsSnapshots = contentsSnapshots;
    // Backward compatibility: simple references
    eventData.contents = contents.map(e => ({ id: e.id, name: e.name }));
  }
  
  // Switchable trait
  if (noun.has(TraitType.SWITCHABLE)) {
    eventData.isSwitchable = true;
    eventData.isOn = SwitchableBehavior.isOn(noun);
  }
  
  // Readable trait
  if (noun.has(TraitType.READABLE)) {
    const readableTrait = noun.get(TraitType.READABLE);
    eventData.isReadable = true;
    eventData.hasText = readableTrait ? !!(readableTrait as any).text : false;
  }
  
  // Wearable trait
  if (noun.has(TraitType.WEARABLE)) {
    eventData.isWearable = true;
    eventData.isWorn = WearableBehavior.isWorn(noun);
  }
  
  // Door trait
  if (noun.has(TraitType.DOOR)) {
    eventData.isDoor = true;
    
    // Check if door is openable
    if (noun.has(TraitType.OPENABLE)) {
      eventData.isOpenable = true;
      eventData.isOpen = OpenableBehavior.isOpen(noun);
    }
    
    // Add lock status
    if (noun.has(TraitType.LOCKABLE)) {
      eventData.isLockable = true;
      eventData.isLocked = LockableBehavior.isLocked(noun);
    }
  }
  
  return eventData;
};

This refactor was merged last night and I'm going through unit tests to make sure we get back to a functioning Cloak of Darkness.

Subscribe to My So Called Interactive Fiction Life

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe