#!/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} [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} [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