Wanderland

Wanderland Core: Scenario

YAML-driven scenario loader, runner, verifier, and game API. Extracted from Crossing::Scenario, made operation-agnostic via the boundary registry.

One Format, Three Consumers

The same YAML file serves three purposes:

YAML Format

name: "parameter extraction from path"
description: |
  The route pattern /node/:slug has one dynamic segment.
  When the request path is /node/sprout-api, :slug captures "sprout-api".

level:
  world: 1
  number: 2
  hint: "Dynamic segments start with : and capture the path value"

operation: route_match

input:
  method: GET
  path: /node/sprout-api
  routes:
    - pattern: /node/:slug
      layout: node-view
      middleware: [fetch-node, fetch-edges]

expected:
  matched: true
  route:
    layout: node-view
    middleware: [fetch-node, fetch-edges]
  params:
    slug: sprout-api

blanks:
  slug_value:
    path: params.slug
    answer: sprout-api
  layout:
    path: route.layout
    answer: node-view
    hint: The layout declared on the matching route

Loading

# All scenarios in a directory (recursive, sorted)
scenarios = Wanderland::Scenario.load_all("spec/scenarios/route-match/")

# Filter by operation
walks = Wanderland::Scenario.load_by_operation("spec/scenarios/", :route_match)

# Filter by world (game progression)
world_1 = Wanderland::Scenario.load_by_world("spec/scenarios/", 1)

# From a hash (for API/programmatic use)
scenario = Wanderland::Scenario.from_hash(data, source: "inline")

Execution

scenario.run
# → calls Wanderland::Boundary.execute(scenario.operation, scenario.input)
# → stores result in scenario.actual

Verification (for tests)

failures = scenario.verify
# → ShapeMatcher compares scenario.actual against scenario.expected
# → returns array of failure messages (empty = pass)

scenario.passed?  # boolean

Game API (for blanks)

scenario.blank_paths          # ["slug_value", "layout"]
scenario.blank_info("layout") # { path: "route.layout", hint: "The layout..." }

# Check one answer
result = scenario.check_answer("slug_value", "sprout-api")
# { blank: "slug_value", path: "params.slug", user: "sprout-api",
#   expected: "sprout-api", actual: "sprout-api",
#   user_correct: true, engine_correct: true }

# Check all answers with scoring
result = scenario.check_all_answers({
  "slug_value" => "sprout-api",
  "layout" => "node-view"
})
# { results: [...], score: { correct: 2, total: 2, percentage: 100.0 } }

Results Access

scenario.actual               # full result hash from boundary
scenario.expected             # full expected hash from YAML
scenario.actual_at("params.slug")    # dot-path dig into actual
scenario.expected_at("route.layout") # dot-path dig into expected

ShapeMatcher

Deep comparison with matchers. Expected is a shape — actual must fit through it.

Exact values: strings, numbers, booleans compared with == (symbol/string normalized).

Hash subset: expected keys must exist in actual. Extra keys in actual are allowed.

Matchers (special keys in expected hashes):

Matcher What it checks
count: N Array/hash has exactly N elements
gte: N / lte: N / gt: N / lt: N Numeric comparison
matches: pattern String matches regex
contains: value Array contains value
includes: [...] Array includes all values
excludes: [...] Array excludes all values
first: shape / last: shape First/last element matches
any: shape At least one element matches
keys: [...] Hash has exactly these keys
has_key: key Hash has this key
not: shape Value does NOT match
empty: true/false Is empty?