mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-20 13:50:46 +00:00
- 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.
908 lines
37 KiB
Ruby
Executable File
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
|