Files
BreakEscape/scripts/validate_scenario.rb
Z. Cliffe Schreuders 91d4670b2a fix: update event mapping structure and validation for NPCs
- Changed 'eventMapping' to 'eventMappings' in NPC definitions for consistency.
- Updated target knot for closing debrief NPC to 'start' and adjusted story path.
- Enhanced validation script to check for correct eventMappings structure and properties.
- Added checks for missing properties in eventMappings and timedMessages.
- Provided best practice guidance for event-driven cutscenes and closing debrief implementation.
2026-02-18 00:47:37 +00:00

908 lines
37 KiB
Ruby
Executable File

#!/usr/bin/env ruby
# frozen_string_literal: true
# Break Escape Scenario Validator
# Validates scenario.json.erb files by rendering ERB to JSON and checking against schema
#
# Usage:
# ruby scripts/validate_scenario.rb scenarios/ceo_exfil/scenario.json.erb
# ruby scripts/validate_scenario.rb scenarios/ceo_exfil/scenario.json.erb --schema scripts/scenario-schema.json
require 'erb'
require 'json'
require 'optparse'
require 'pathname'
require 'set'
# Try to load json-schema gem, provide helpful error if missing
begin
require 'json-schema'
rescue LoadError
$stderr.puts <<~ERROR
ERROR: json-schema gem is required for validation.
Install it with:
gem install json-schema
Or add to Gemfile:
gem 'json-schema'
Then run: bundle install
ERROR
exit 1
end
# ScenarioBinding class - replicates the one from app/models/break_escape/mission.rb
class ScenarioBinding
def initialize(vm_context = {})
require 'securerandom'
@random_password = SecureRandom.alphanumeric(8)
@random_pin = rand(1000..9999).to_s
@random_code = SecureRandom.hex(4)
@vm_context = vm_context || {}
end
attr_reader :random_password, :random_pin, :random_code, :vm_context
# Get a VM from the context by title, or return a fallback VM object
def vm_object(title, fallback = {})
if vm_context && vm_context['hacktivity_mode'] && vm_context['vms']
vm = vm_context['vms'].find { |v| v['title'] == title }
return vm.to_json if vm
end
result = fallback.dup
if vm_context && vm_context['vm_ips'] && vm_context['vm_ips'][title]
result['ip'] = vm_context['vm_ips'][title]
end
result.to_json
end
# Get flags for a specific VM from the context
def flags_for_vm(vm_name, fallback = [])
if vm_context && vm_context['flags_by_vm']
flags = vm_context['flags_by_vm'][vm_name]
return flags.to_json if flags
end
fallback.to_json
end
def get_binding
binding
end
end
# Render ERB template to JSON
def render_erb_to_json(erb_path, vm_context = {})
unless File.exist?(erb_path)
raise "ERB file not found: #{erb_path}"
end
erb_content = File.read(erb_path)
erb = ERB.new(erb_content)
binding_context = ScenarioBinding.new(vm_context)
json_output = erb.result(binding_context.get_binding)
JSON.parse(json_output)
rescue JSON::ParserError => e
raise "Invalid JSON after ERB processing: #{e.message}\n\nGenerated JSON:\n#{json_output}"
rescue StandardError => e
raise "Error processing ERB: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
end
# Validate JSON against schema
def validate_json(json_data, schema_path)
unless File.exist?(schema_path)
raise "Schema file not found: #{schema_path}"
end
schema = JSON.parse(File.read(schema_path))
errors = JSON::Validator.fully_validate(schema, json_data, strict: false)
errors
rescue JSON::ParserError => e
raise "Invalid JSON schema: #{e.message}"
end
# Check for common issues and structural problems
def check_common_issues(json_data)
issues = []
start_room_id = json_data['startRoom']
# Valid directions for room connections
valid_directions = %w[north south east west]
# Track features for suggestions
has_vm_launcher = false
has_flag_station = false
has_pc_with_files = false
has_phone_npc_with_messages = false
has_phone_npc_with_events = false
has_opening_cutscene = false
has_closing_debrief = false
has_person_npcs = false
has_npc_with_waypoints = false
has_phone_contacts = false
phone_npcs_without_messages = []
lock_types_used = Set.new
has_rfid_lock = false
has_bluetooth_lock = false
has_pin_lock = false
has_password_lock = false
has_key_lock = false
has_security_tools = false
has_container_with_contents = false
has_readable_items = false
# Check rooms
if json_data['rooms']
json_data['rooms'].each do |room_id, room|
# Check for invalid room connection directions (diagonal directions)
if room['connections']
room['connections'].each do |direction, target|
unless valid_directions.include?(direction)
issues << "❌ INVALID: Room '#{room_id}' uses invalid direction '#{direction}' - only north, south, east, west are valid (not northeast, southeast, etc.)"
end
# Check reverse connections if target is a single room
if target.is_a?(String) && json_data['rooms'][target]
reverse_dir = case direction
when 'north' then 'south'
when 'south' then 'north'
when 'east' then 'west'
when 'west' then 'east'
end
target_room = json_data['rooms'][target]
if target_room['connections']
has_reverse = target_room['connections'].any? do |dir, targets|
(dir == reverse_dir) && (targets == room_id || (targets.is_a?(Array) && targets.include?(room_id)))
end
unless has_reverse
issues << "⚠ WARNING: Room '#{room_id}' connects #{direction} to '#{target}', but '#{target}' doesn't connect #{reverse_dir} back - bidirectional connections recommended"
end
end
end
end
end
# Check room objects
if room['objects']
room['objects'].each_with_index do |obj, idx|
path = "rooms/#{room_id}/objects[#{idx}]"
# Check for incorrect VM launcher configuration (type: "pc" with vmAccess)
if obj['type'] == 'pc' && obj['vmAccess']
issues << "❌ INVALID: '#{path}' uses type: 'pc' with vmAccess - should use type: 'vm-launcher' instead. See scenarios/secgen_vm_lab/scenario.json.erb for example"
end
# Track VM launchers
if obj['type'] == 'vm-launcher'
has_vm_launcher = true
unless obj['vm']
issues << "⚠ WARNING: '#{path}' (vm-launcher) missing 'vm' object - use ERB helper vm_object()"
end
unless obj.key?('hacktivityMode')
issues << "⚠ WARNING: '#{path}' (vm-launcher) missing 'hacktivityMode' field"
end
end
# Track flag stations
if obj['type'] == 'flag-station'
has_flag_station = true
unless obj['acceptsVms'] && !obj['acceptsVms'].empty?
issues << "⚠ WARNING: '#{path}' (flag-station) missing or empty 'acceptsVms' array"
end
unless obj['flags']
issues << "⚠ WARNING: '#{path}' (flag-station) missing 'flags' array - use ERB helper flags_for_vm()"
end
end
# Check for PC containers with files
if obj['type'] == 'pc' && obj['contents'] && obj['contents'].any? { |item| item['type'] == 'text_file' || item['readable'] }
has_pc_with_files = true
end
# Track containers with contents (safes, suitcases, etc.)
if (obj['type'] == 'safe' || obj['type'] == 'suitcase') && obj['contents'] && !obj['contents'].empty?
has_container_with_contents = true
end
# REQUIRED: Containers with contents must specify locked field explicitly
container_types = ['briefcase', 'bag', 'bag1', 'suitcase', 'safe', 'pc', 'bin1']
if container_types.include?(obj['type']) && obj['contents'] && !obj['contents'].empty?
unless obj.key?('locked')
issues << "❌ INVALID: '#{path}' is a container with contents but missing required 'locked' field - must be explicitly true or false for server-side validation"
end
end
# Track readable items (notes, documents)
if obj['readable'] || (obj['type'] == 'notes' && obj['text'])
has_readable_items = true
end
# Track security tools
if ['fingerprint_kit', 'pin-cracker', 'bluetooth_scanner', 'rfid_cloner'].include?(obj['type'])
has_security_tools = true
end
# Track lock types
if obj['locked'] && obj['lockType']
lock_types_used.add(obj['lockType'])
case obj['lockType']
when 'rfid'
has_rfid_lock = true
when 'bluetooth'
has_bluetooth_lock = true
when 'pin'
has_pin_lock = true
when 'password'
has_password_lock = true
when 'key'
has_key_lock = true
# Check for key locks without keyPins (REQUIRED, not recommended)
unless obj['keyPins']
issues << "❌ INVALID: '#{path}' has lockType: 'key' but missing required 'keyPins' array - key locks must specify keyPins array for lockpicking minigame"
end
end
end
# Check for key items without keyPins (REQUIRED, not recommended)
if obj['type'] == 'key' && !obj['keyPins']
issues << "❌ INVALID: '#{path}' (key item) missing required 'keyPins' array - key items must specify keyPins array for lockpicking"
end
# Check for items with id field (should use type field for #give_item tags)
if obj['itemsHeld']
obj['itemsHeld'].each_with_index do |item, item_idx|
if item['id']
issues << "❌ INVALID: '#{path}/itemsHeld[#{item_idx}]' has 'id' field - items should NOT have 'id' field. Use 'type' field to match #give_item tag parameter"
end
end
end
end
end
# Track room lock types
if room['locked'] && room['lockType']
lock_types_used.add(room['lockType'])
case room['lockType']
when 'rfid'
has_rfid_lock = true
when 'bluetooth'
has_bluetooth_lock = true
when 'pin'
has_pin_lock = true
when 'password'
has_password_lock = true
when 'key'
has_key_lock = true
# Check for key locks without keyPins (REQUIRED, not recommended)
unless room['keyPins']
issues << "❌ INVALID: 'rooms/#{room_id}' has lockType: 'key' but missing required 'keyPins' array - key locks must specify keyPins array for lockpicking minigame"
end
end
end
# Check NPCs in rooms
if room['npcs']
room['npcs'].each_with_index do |npc, idx|
path = "rooms/#{room_id}/npcs[#{idx}]"
# Track person NPCs
if npc['npcType'] == 'person' || (!npc['npcType'] && npc['position'])
has_person_npcs = true
# Check for waypoints in behavior.patrol
if npc['behavior'] && npc['behavior']['patrol']
patrol = npc['behavior']['patrol']
# Check for single-room waypoints
if patrol['waypoints'] && !patrol['waypoints'].empty?
has_npc_with_waypoints = true
end
# Check for multi-room route waypoints
if patrol['route'] && patrol['route'].is_a?(Array) && patrol['route'].any? { |segment| segment['waypoints'] && !segment['waypoints'].empty? }
has_npc_with_waypoints = true
end
end
end
# Check for opening cutscene in starting room
if room_id == start_room_id && npc['timedConversation']
has_opening_cutscene = true
if npc['timedConversation']['delay'] != 0
issues << "⚠ WARNING: '#{path}' timedConversation delay is #{npc['timedConversation']['delay']} - opening cutscenes typically use delay: 0"
end
end
# Validate timedConversation structure
if npc['timedConversation']
tc = npc['timedConversation']
# Check for incorrect property name
if tc['knot'] && !tc['targetKnot']
issues << "❌ INVALID: '#{path}' timedConversation uses 'knot' property - should use 'targetKnot' instead. Change 'knot' to 'targetKnot'"
end
# Check for missing targetKnot
unless tc['targetKnot']
issues << "❌ INVALID: '#{path}' timedConversation missing required 'targetKnot' property - must specify the Ink knot to navigate to"
end
# Check for missing delay
unless tc.key?('delay')
issues << "⚠ WARNING: '#{path}' timedConversation missing 'delay' property - should specify delay in milliseconds (0 for immediate)"
end
end
# Validate eventMapping vs eventMappings (parameter name mismatch)
if npc['eventMapping'] && !npc['eventMappings']
issues << "❌ INVALID: '#{path}' uses 'eventMapping' (singular) - should use 'eventMappings' (plural). The NPCManager expects 'eventMappings' and won't register event listeners with 'eventMapping'"
end
# Validate eventMappings structure
if npc['eventMappings']
# Check if it's an array
unless npc['eventMappings'].is_a?(Array)
issues << "❌ INVALID: '#{path}' eventMappings is not an array - must be an array of event mapping objects"
else
npc['eventMappings'].each_with_index do |mapping, idx|
mapping_path = "#{path}/eventMappings[#{idx}]"
# Check for incorrect property name (knot vs targetKnot)
if mapping['knot'] && !mapping['targetKnot']
issues << "❌ INVALID: '#{mapping_path}' uses 'knot' property - should use 'targetKnot' instead. Change 'knot' to 'targetKnot'"
end
# Check for missing eventPattern
unless mapping['eventPattern']
issues << "❌ INVALID: '#{mapping_path}' missing required 'eventPattern' property - must specify the event pattern to listen for (e.g., 'global_variable_changed:varName')"
end
# Check for missing conversationMode when targetKnot is present
if mapping['targetKnot'] && !mapping['conversationMode']
issues << "⚠ WARNING: '#{mapping_path}' has targetKnot but no conversationMode - should specify 'phone-chat' or 'person-chat' to indicate which UI to use"
end
# Check for missing background when conversationMode is person-chat
if mapping['conversationMode'] == 'person-chat' && !mapping['background']
issues << "⚠ WARNING: '#{mapping_path}' has conversationMode: 'person-chat' but no background - person-chat cutscenes typically need a background image (e.g., 'assets/backgrounds/hq1.png')"
end
end
end
end
# Track phone NPCs (phone contacts)
if npc['npcType'] == 'phone'
has_phone_contacts = true
# Validate phone NPC structure - should have phoneId
unless npc['phoneId']
issues << "❌ INVALID: '#{path}' (phone NPC) missing required 'phoneId' field - phone NPCs must specify which phone they appear on (e.g., 'player_phone')"
end
# Validate phone NPC structure - should have storyPath
unless npc['storyPath']
issues << "❌ INVALID: '#{path}' (phone NPC) missing required 'storyPath' field - phone NPCs must have a path to their Ink story JSON file"
end
# Validate phone NPC structure - should NOT have position (phone NPCs don't have positions)
if npc['position']
issues << "⚠ WARNING: '#{path}' (phone NPC) has 'position' field - phone NPCs should NOT have position (they're not in-world sprites). Remove the position field."
end
# Validate phone NPC structure - should NOT have spriteSheet (phone NPCs don't have sprites)
if npc['spriteSheet']
issues << "⚠ WARNING: '#{path}' (phone NPC) has 'spriteSheet' field - phone NPCs should NOT have spriteSheet (they're not in-world sprites). Remove the spriteSheet field."
end
# Validate timedMessages structure for phone NPCs
if npc['timedMessages']
unless npc['timedMessages'].is_a?(Array)
issues << "❌ INVALID: '#{path}' timedMessages is not an array - must be an array of timed message objects"
else
npc['timedMessages'].each_with_index do |msg, idx|
msg_path = "#{path}/timedMessages[#{idx}]"
# Check for missing message field
unless msg['message']
issues << "❌ INVALID: '#{msg_path}' missing required 'message' field - must specify the text content of the message"
end
# Check for incorrect property name (text vs message)
if msg['text'] && !msg['message']
issues << "❌ INVALID: '#{msg_path}' uses 'text' property - should use 'message' instead. The NPCManager reads msg.message, not msg.text"
end
# Check for missing delay field
unless msg.key?('delay')
issues << "⚠ WARNING: '#{msg_path}' missing 'delay' property - should specify delay in milliseconds (0 for immediate)"
end
# Check for incorrect property name (knot vs targetKnot) in timed messages
if msg['knot'] && !msg['targetKnot']
issues << "❌ INVALID: '#{msg_path}' uses 'knot' property - should use 'targetKnot' instead. Change 'knot' to 'targetKnot'"
end
end
end
end
# Track phone NPCs with messages in rooms
if npc['timedMessages'] && !npc['timedMessages'].empty?
has_phone_npc_with_messages = true
else
# Track phone NPCs without timed messages
phone_npcs_without_messages << "#{path} (#{npc['displayName'] || npc['id']})"
end
# Track phone NPCs with event mappings in rooms
if npc['eventMappings'] && !npc['eventMappings'].empty?
has_phone_npc_with_events = true
end
end
# Check for items with id field in NPC itemsHeld
if npc['itemsHeld']
npc['itemsHeld'].each_with_index do |item, item_idx|
if item['id']
issues << "❌ INVALID: '#{path}/itemsHeld[#{item_idx}]' has 'id' field - items should NOT have 'id' field. Use 'type' field to match #give_item tag parameter (e.g., type: 'id_badge' matches #give_item:id_badge)"
end
# Track security tools in NPC itemsHeld
if ['fingerprint_kit', 'pin-cracker', 'bluetooth_scanner', 'rfid_cloner'].include?(item['type'])
has_security_tools = true
end
end
end
end
end
end
end
# Check startItemsInInventory for security tools and readable items
if json_data['startItemsInInventory']
json_data['startItemsInInventory'].each do |item|
# Track security tools
if ['fingerprint_kit', 'pin-cracker', 'bluetooth_scanner', 'rfid_cloner'].include?(item['type'])
has_security_tools = true
end
# Track readable items
if item['readable'] || (item['type'] == 'notes' && item['text'])
has_readable_items = true
end
end
end
# Check phoneNPCs section - this is the OLD/INCORRECT format
if json_data['phoneNPCs']
json_data['phoneNPCs'].each_with_index do |npc, idx|
path = "phoneNPCs[#{idx}]"
# Flag incorrect structure - phone NPCs should be in rooms, not phoneNPCs section
issues << "❌ INVALID: '#{path}' - Phone NPCs should be defined in 'rooms/{room_id}/npcs[]' arrays, NOT in a separate 'phoneNPCs' section. See scenarios/npc-sprite-test3/scenario.json.erb for correct format. Phone NPCs should be in the starting room (or room where phone is accessible) with npcType: 'phone'"
# Track phone NPCs (phone contacts) - but note they're in wrong location
has_phone_contacts = true
# Track phone NPCs with messages
if npc['timedMessages'] && !npc['timedMessages'].empty?
has_phone_npc_with_messages = true
else
# Track phone NPCs without timed messages
phone_npcs_without_messages << "#{path} (#{npc['displayName'] || npc['id']})"
end
# Track phone NPCs with event mappings (for closing debriefs)
if npc['eventMappings'] && !npc['eventMappings'].any? { |m| m['eventPattern']&.include?('global_variable_changed') }
has_phone_npc_with_events = true
end
# Check for closing debrief trigger
if npc['eventMappings']
npc['eventMappings'].each do |mapping|
if mapping['eventPattern']&.include?('global_variable_changed')
has_closing_debrief = true
end
end
end
end
end
# Check for event-driven cutscene architecture patterns
person_npcs_with_event_cutscenes = []
global_variables_referenced = Set.new
global_variables_defined = Set.new
# Collect global variables defined in scenario
if json_data['globalVariables']
global_variables_defined.merge(json_data['globalVariables'].keys)
end
# Check all NPCs for event-driven cutscene patterns
json_data['rooms']&.each do |room_id, room|
room['npcs']&.each_with_index do |npc, idx|
path = "rooms/#{room_id}/npcs[#{idx}]"
# Check for person NPCs with eventMappings (cutscene NPCs)
if npc['npcType'] == 'person' && npc['eventMappings']
npc['eventMappings'].each_with_index do |mapping, mapping_idx|
mapping_path = "#{path}/eventMappings[#{mapping_idx}]"
# Check if this is a cutscene trigger (has conversationMode)
if mapping['conversationMode'] == 'person-chat'
person_npcs_with_event_cutscenes << {
npc_id: npc['id'],
path: path,
mapping: mapping
}
# Extract global variable name from event pattern
if mapping['eventPattern']&.match(/global_variable_changed:(\w+)/)
var_name = $1
global_variables_referenced << var_name
# Check if the global variable is defined
unless global_variables_defined.include?(var_name)
issues << "❌ INVALID: '#{mapping_path}' references global variable '#{var_name}' in eventPattern, but it's not defined in scenario.globalVariables. Add '#{var_name}' with an initial value (typically false) to globalVariables"
end
end
# Check for missing spriteTalk when using non-numeric frame sprites
if !npc['spriteTalk'] && npc['spriteSheet']
# Sprites with named frames (not numeric indices) need spriteTalk
named_frame_sprites = ['female_spy', 'male_spy', 'female_hacker_hood', 'male_doctor']
if named_frame_sprites.include?(npc['spriteSheet'])
issues << "⚠ WARNING: '#{path}' uses spriteSheet '#{npc['spriteSheet']}' which has named frames, but no 'spriteTalk' property. Person-chat cutscenes will show frame errors. Add 'spriteTalk' property pointing to a headshot image (e.g., 'assets/characters/#{npc['spriteSheet']}_headshot.png')"
end
end
# Validate background for person-chat cutscenes
unless mapping['background']
issues << "⚠ WARNING: '#{mapping_path}' is a person-chat cutscene but has no 'background' property. Person-chat cutscenes should have a background image for better visual presentation (e.g., 'assets/backgrounds/hq1.png')"
end
# Check for onceOnly to prevent repeated cutscenes
unless mapping['onceOnly']
issues << "⚠ WARNING: '#{mapping_path}' is a person-chat cutscene without 'onceOnly: true'. Cutscenes typically should only trigger once. Add 'onceOnly: true' unless you want the cutscene to repeat"
end
end
end
end
# Check for phone NPCs setting global variables in their stories
if npc['npcType'] == 'phone' && npc['storyPath']
# Note: We can't easily check the Ink story content from Ruby, but we can suggest best practices
if npc['eventMappings']
# This phone NPC has both a story and event mappings, which suggests it might be setting up a cutscene
cutscene_event_mappings = npc['eventMappings'].select { |m| m['sendTimedMessage'] }
if cutscene_event_mappings.any?
# This looks like a mission-ending phone NPC
issues << "💡 BEST PRACTICE: '#{path}' appears to be a mission-ending phone NPC with sendTimedMessage. Consider using event-driven cutscene architecture instead: 1) Add #set_global:variable_name:true tag in Ink story, 2) Add #exit_conversation tag to close phone, 3) Create separate person NPC with eventMapping listening for global_variable_changed:variable_name. See scenarios/m01_first_contact/scenario.json.erb for reference implementation"
end
end
end
end
end
# Check for orphaned global variable references
orphaned_vars = global_variables_referenced - global_variables_defined
orphaned_vars.each do |var_name|
issues << "❌ INVALID: Global variable '#{var_name}' is referenced in eventPatterns but not defined in scenario.globalVariables. Add '#{var_name}' to globalVariables with an initial value (typically false for cutscene triggers)"
end
# Provide best practice guidance for event-driven cutscenes
if person_npcs_with_event_cutscenes.any?
issues << "✅ GOOD PRACTICE: Scenario uses event-driven cutscene architecture with #{person_npcs_with_event_cutscenes.size} person-chat cutscene(s). Ensure corresponding phone NPCs use #set_global tags to trigger these cutscenes"
end
# Feature suggestions
unless has_vm_launcher
issues << "💡 SUGGESTION: Consider adding VM launcher terminals (type: 'vm-launcher') - see scenarios/secgen_vm_lab/scenario.json.erb for example"
end
unless has_flag_station
issues << "💡 SUGGESTION: Consider adding flag station terminals (type: 'flag-station') for VM flag submission - see scenarios/secgen_vm_lab/scenario.json.erb for example"
end
unless has_pc_with_files
issues << "💡 SUGGESTION: Consider adding at least one PC container (type: 'pc') with files in 'contents' array and optional post-it notes - see scenarios/ceo_exfil/scenario.json.erb for example"
end
unless has_phone_npc_with_messages || has_phone_npc_with_events
issues << "💡 SUGGESTION: Consider adding at least one phone NPC (in rooms or phoneNPCs section) with timedMessages or eventMappings - see scenarios/ceo_exfil/scenario.json.erb for example"
end
unless has_opening_cutscene
issues << "💡 SUGGESTION: Consider adding opening briefing cutscene - NPC with timedConversation (delay: 0) in starting room - see scenarios/m01_first_contact/scenario.json.erb for example"
end
unless has_closing_debrief
issues << "💡 SUGGESTION: Consider adding event-driven closing debrief cutscene using this architecture:"
issues << " 1. Add global variable to scenario.globalVariables (e.g., 'start_debrief_cutscene': false)"
issues << " 2. In phone NPC's Ink story, add tags: #set_global:start_debrief_cutscene:true and #exit_conversation"
issues << " 3. Create person NPC with eventMappings: [{eventPattern: 'global_variable_changed:start_debrief_cutscene', condition: 'value === true', conversationMode: 'person-chat', targetKnot: 'start', background: 'assets/backgrounds/hq1.png', onceOnly: true}]"
issues << " 4. Add behavior: {initiallyHidden: true} to person NPC so it doesn't appear in-world"
issues << " See scenarios/m01_first_contact/scenario.json.erb for complete reference implementation"
end
# Check for NPCs without waypoints
if has_person_npcs && !has_npc_with_waypoints
issues << "💡 SUGGESTION: Consider adding waypoints to at least one person NPC for more dynamic patrol behavior - see scenarios/test-npc-waypoints/scenario.json.erb for example. Add 'behavior.patrol.waypoints' array with {x, y} coordinates"
end
# Check for phone contacts without timed messages
if has_phone_contacts && !phone_npcs_without_messages.empty?
npc_list = phone_npcs_without_messages.join(', ')
issues << "💡 SUGGESTION: Consider adding timedMessages to phone contacts for more engaging interactions - see scenarios/npc-sprite-test3/scenario.json.erb for example. Phone NPCs without timed messages: #{npc_list}"
end
# Suggest variety in lock types
if lock_types_used.size < 2
issues << "💡 SUGGESTION: Consider adding variety in lock types - scenarios typically use 2+ different lock mechanisms (key, pin, rfid, password). Currently using: #{lock_types_used.to_a.join(', ') || 'none'}. See scenarios/ceo_exfil/scenario.json.erb for examples"
end
# Suggest RFID locks
unless has_rfid_lock
issues << "💡 SUGGESTION: Consider adding RFID locks for modern security scenarios - see scenarios/test-rfid/scenario.json.erb for examples"
end
# Suggest PIN locks
unless has_pin_lock
issues << "💡 SUGGESTION: Consider adding PIN locks for numeric code challenges - see scenarios/ceo_exfil/scenario.json.erb for examples"
end
# Suggest password locks
unless has_password_lock
issues << "💡 SUGGESTION: Consider adding password locks for computer/device access - see scenarios/ceo_exfil/scenario.json.erb for examples"
end
# Suggest security tools
unless has_security_tools
issues << "💡 SUGGESTION: Consider adding security tools (fingerprint_kit, pin-cracker, bluetooth_scanner, rfid_cloner) for more interactive gameplay - see scenarios/ceo_exfil/scenario.json.erb for examples"
end
# Suggest containers with contents
unless has_container_with_contents
issues << "💡 SUGGESTION: Consider adding containers (safes, suitcases) with contents for hidden items and rewards - see scenarios/ceo_exfil/scenario.json.erb for examples"
end
# Suggest readable items
unless has_readable_items
issues << "💡 SUGGESTION: Consider adding readable items (notes, documents) for storytelling and clues - see scenarios/ceo_exfil/scenario.json.erb for examples"
end
issues
end
# Check for recommended fields and return warnings
def check_recommended_fields(json_data)
warnings = []
# Top-level recommended fields
warnings << "Missing recommended field: 'globalVariables' - useful for Ink dialogue state management" unless json_data.key?('globalVariables')
warnings << "Missing recommended field: 'player' - player sprite configuration improves visual experience" unless json_data.key?('player')
# Check for objectives with tasks (recommended for structured gameplay)
if !json_data['objectives'] || json_data['objectives'].empty?
warnings << "Missing recommended: 'objectives' array with tasks - helps structure gameplay and track progress"
elsif json_data['objectives'].none? { |obj| obj['tasks'] && !obj['tasks'].empty? }
warnings << "Missing recommended: objectives should include tasks - objectives without tasks don't provide clear goals"
end
# Track if there's at least one NPC with timed conversation (for cut-scenes)
has_timed_conversation_npc = false
# Check rooms
if json_data['rooms']
json_data['rooms'].each do |room_id, room|
# Check room objects
if room['objects']
room['objects'].each_with_index do |obj, idx|
path = "rooms/#{room_id}/objects[#{idx}]"
warnings << "Missing recommended field: '#{path}/observations' - helps players understand what items are" unless obj.key?('observations')
end
end
# Check NPCs
if room['npcs']
room['npcs'].each_with_index do |npc, idx|
path = "rooms/#{room_id}/npcs[#{idx}]"
# Phone NPCs should have avatar
if npc['npcType'] == 'phone' && !npc['avatar']
warnings << "Missing recommended field: '#{path}/avatar' - phone NPCs should have avatar images"
end
# Person NPCs should have position
if npc['npcType'] == 'person' && !npc['position']
warnings << "Missing recommended field: '#{path}/position' - person NPCs need x,y coordinates"
end
# NPCs with storyPath should have currentKnot
if npc['storyPath'] && !npc['currentKnot']
warnings << "Missing recommended field: '#{path}/currentKnot' - specifies starting dialogue knot"
end
# Check for NPCs without behavior (no storyPath, no timedMessages, no timedConversation, no eventMappings)
has_behavior = npc['storyPath'] ||
(npc['timedMessages'] && !npc['timedMessages'].empty?) ||
npc['timedConversation'] ||
(npc['eventMappings'] && !npc['eventMappings'].empty?)
unless has_behavior
warnings << "Missing recommended: '#{path}' has no behavior - NPCs should have storyPath, timedMessages, timedConversation, or eventMappings"
end
# Track timed conversations (for cut-scene recommendation)
if npc['timedConversation']
has_timed_conversation_npc = true
end
end
end
end
end
# Check for at least one NPC with timed conversation (recommended for starting cut-scenes)
unless has_timed_conversation_npc
warnings << "Missing recommended: No NPCs with 'timedConversation' - consider adding one for immersive starting cut-scenes"
end
# Check objectives
if json_data['objectives']
json_data['objectives'].each_with_index do |objective, idx|
path = "objectives[#{idx}]"
warnings << "Missing recommended field: '#{path}/description' - helps players understand the objective" unless objective.key?('description')
if objective['tasks']
objective['tasks'].each_with_index do |task, task_idx|
task_path = "#{path}/tasks[#{task_idx}]"
# Tasks with targetCount should have showProgress
if task['targetCount'] && !task['showProgress']
warnings << "Missing recommended field: '#{task_path}/showProgress' - shows progress for collect_items tasks"
end
end
end
end
end
# Check startItemsInInventory
if json_data['startItemsInInventory']
json_data['startItemsInInventory'].each_with_index do |item, idx|
path = "startItemsInInventory[#{idx}]"
warnings << "Missing recommended field: '#{path}/observations' - helps players understand starting items" unless item.key?('observations')
end
end
warnings
end
# Main execution
def main
options = {
schema_path: File.join(__dir__, 'scenario-schema.json'),
verbose: false,
output_json: false
}
OptionParser.new do |opts|
opts.banner = "Usage: #{$PROGRAM_NAME} <scenario.json.erb> [options]"
opts.on('-s', '--schema PATH', 'Path to JSON schema file') do |path|
options[:schema_path] = path
end
opts.on('-v', '--verbose', 'Show detailed validation output') do
options[:verbose] = true
end
opts.on('-o', '--output-json', 'Output the rendered JSON to stdout') do
options[:output_json] = true
end
opts.on('-h', '--help', 'Show this help message') do
puts opts
exit 0
end
end.parse!
erb_path = ARGV[0]
if erb_path.nil? || erb_path.empty?
$stderr.puts "ERROR: No scenario.json.erb file specified"
$stderr.puts "Usage: #{$PROGRAM_NAME} <scenario.json.erb> [options]"
exit 1
end
erb_path = File.expand_path(erb_path)
schema_path = File.expand_path(options[:schema_path])
puts "Validating scenario: #{erb_path}"
puts "Using schema: #{schema_path}"
puts
begin
# Render ERB to JSON
puts "Rendering ERB template..."
json_data = render_erb_to_json(erb_path)
puts "✓ ERB rendered successfully"
puts
# Output JSON if requested
if options[:output_json]
puts "Rendered JSON:"
puts JSON.pretty_generate(json_data)
puts
end
# Validate against schema
puts "Validating against schema..."
errors = validate_json(json_data, schema_path)
# Check for common issues and structural problems
puts "Checking for common issues..."
common_issues = check_common_issues(json_data)
# Check for recommended fields
puts "Checking recommended fields..."
warnings = check_recommended_fields(json_data)
# Report errors
if errors.empty?
puts "✓ Schema validation passed!"
else
puts "✗ Schema validation failed with #{errors.length} error(s):"
puts
errors.each_with_index do |error, index|
puts "#{index + 1}. #{error}"
puts
end
if options[:verbose]
puts "Full JSON structure:"
puts JSON.pretty_generate(json_data)
end
exit 1
end
# Report common issues
if common_issues.empty?
puts "✓ No common issues found."
puts
else
puts "⚠ Found #{common_issues.length} issue(s) and suggestion(s):"
puts
common_issues.each_with_index do |issue, index|
puts "#{index + 1}. #{issue}"
end
puts
end
# Report warnings
if warnings.empty?
puts "✓ No missing recommended fields."
puts
else
puts "⚠ Found #{warnings.length} missing recommended field(s):"
puts
warnings.each_with_index do |warning, index|
puts "#{index + 1}. #{warning}"
end
puts
end
# Exit with success (warnings don't cause failure)
puts "✓ Validation complete!"
exit 0
rescue StandardError => e
$stderr.puts "ERROR: #{e.message}"
if options[:verbose]
$stderr.puts e.backtrace.join("\n")
end
exit 1
end
end
main if __FILE__ == $PROGRAM_NAME