Add scenario schema and validation script for Break Escape scenarios

- Introduced `scenario-schema.json` to define the structure and requirements for scenario.json.erb files.
- Implemented `validate_scenario.rb` to render ERB templates to JSON and validate against the schema.
- Created a comprehensive `SCENARIO_JSON_FORMAT_GUIDE.md` to outline the correct format for scenario files, including required fields, room definitions, objectives, and common mistakes.
This commit is contained in:
Z. Cliffe Schreuders
2025-12-01 15:45:24 +00:00
parent 47eaffa4c3
commit 0cf9e0ba62
33 changed files with 4604 additions and 1389 deletions

View File

@@ -1,19 +1,23 @@
#!/bin/bash
# Compile all .ink files in scenarios/ink to JSON
# Usage: ./scripts/compile-ink.sh
# Compile all .ink files in scenario ink directories to JSON
# Usage: ./scripts/compile-ink.sh [scenario_name]
# Examples:
# ./scripts/compile-ink.sh # Compile all scenarios
# ./scripts/compile-ink.sh m01_first_contact # Compile only m01_first_contact
# Get the directory where the script is located
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
# Paths
INK_DIR="$PROJECT_ROOT/scenarios/ink"
SCENARIOS_DIR="$PROJECT_ROOT/scenarios"
INKLECATE="$PROJECT_ROOT/bin/inklecate"
# Colors for output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Check if inklecate exists
@@ -22,42 +26,78 @@ if [ ! -f "$INKLECATE" ]; then
exit 1
fi
# Check if ink directory exists
if [ ! -d "$INK_DIR" ]; then
echo -e "${RED}Error: Ink directory not found at $INK_DIR${NC}"
# Check if scenarios directory exists
if [ ! -d "$SCENARIOS_DIR" ]; then
echo -e "${RED}Error: Scenarios directory not found at $SCENARIOS_DIR${NC}"
exit 1
fi
echo -e "${GREEN}Compiling ink files in $INK_DIR${NC}"
# Check for optional scenario directory argument
if [ -n "$1" ]; then
TARGET_DIR="$SCENARIOS_DIR/$1"
if [ ! -d "$TARGET_DIR" ]; then
echo -e "${RED}Error: Scenario directory not found: $TARGET_DIR${NC}"
exit 1
fi
echo -e "${GREEN}Compiling ink files in $1${NC}"
SEARCH_DIR="$TARGET_DIR"
else
echo -e "${GREEN}Compiling ink files in all scenario directories${NC}"
SEARCH_DIR="$SCENARIOS_DIR"
fi
echo "----------------------------------------"
# Counter for compiled files
compiled=0
failed=0
warnings=0
# Iterate through all .ink files
for ink_file in "$INK_DIR"/*.ink; do
# Check if any .ink files exist
[ -e "$ink_file" ] || continue
# Find all ink directories within scenario directories
ink_dirs=$(find "$SEARCH_DIR" -type d -name "ink")
# Get the filename without path
filename=$(basename "$ink_file")
if [ -z "$ink_dirs" ]; then
echo -e "${YELLOW}No ink directories found in $SCENARIOS_DIR${NC}"
exit 0
fi
# Get output JSON filename
json_file="${ink_file%.ink}.json"
for ink_dir in $ink_dirs; do
echo -e "${CYAN}Found ink directory: $ink_dir${NC}"
# Iterate through all .ink files in this directory
for ink_file in "$ink_dir"/*.ink; do
# Check if any .ink files exist
[ -e "$ink_file" ] || continue
echo -e "${YELLOW}Compiling: $filename${NC}"
# Get the filename without path
filename=$(basename "$ink_file")
# Compile the ink file
if "$INKLECATE" -o "$json_file" "$ink_file"; then
echo -e "${GREEN}✓ Success: $filename -> $(basename "$json_file")${NC}"
((compiled++))
else
echo -e "${RED}✗ Failed: $filename${NC}"
((failed++))
fi
# Get output JSON filename
json_file="${ink_file%.ink}.json"
echo ""
echo -e "${YELLOW}Compiling: $filename${NC}"
# Check for END tags (warning about hub return convention)
if grep -qE '^\s*->?\s*END\s*$' "$ink_file"; then
echo -e "${RED}⚠ Warning: END detected - doesn't follow BreakEscape hub return convention${NC}"
echo " File: $ink_file"
# Show the lines with END
grep -nE '^\s*->?\s*END\s*$' "$ink_file" | while read -r line; do
echo -e " ${RED}Line $line${NC}"
done
((warnings++))
fi
# Compile the ink file
if "$INKLECATE" -o "$json_file" "$ink_file"; then
echo -e "${GREEN}✓ Success: $filename -> $(basename "$json_file")${NC}"
((compiled++))
else
echo -e "${RED}✗ Failed: $filename${NC}"
((failed++))
fi
echo ""
done
done
# Summary
@@ -69,3 +109,6 @@ if [ $failed -gt 0 ]; then
else
echo " Failed: 0 files"
fi
if [ $warnings -gt 0 ]; then
echo -e " ${YELLOW}Warnings: $warnings files with END tags${NC}"
fi

View File

@@ -0,0 +1,392 @@
{
"title": "Break Escape Scenario Schema",
"description": "Schema for validating Break Escape scenario.json.erb files",
"type": "object",
"required": ["scenario_brief", "startRoom", "rooms"],
"properties": {
"scenario_brief": {
"type": "string",
"description": "Brief description of the scenario"
},
"endGoal": {
"type": "string",
"description": "Optional end goal description"
},
"version": {
"type": "string",
"description": "Optional version string"
},
"startRoom": {
"type": "string",
"description": "ID of the starting room"
},
"startItemsInInventory": {
"type": "array",
"description": "Items that start in player inventory",
"items": { "$ref": "#/definitions/item" }
},
"globalVariables": {
"type": "object",
"description": "Global Ink variables",
"additionalProperties": {
"oneOf": [
{ "type": "boolean" },
{ "type": "number" },
{ "type": "string" }
]
}
},
"player": {
"type": "object",
"description": "Player configuration",
"properties": {
"id": { "type": "string" },
"displayName": { "type": "string" },
"spriteSheet": { "type": "string" },
"spriteTalk": { "type": "string" },
"spriteConfig": {
"type": "object",
"properties": {
"idleFrameStart": { "type": "integer" },
"idleFrameEnd": { "type": "integer" }
}
}
}
},
"objectives": {
"type": "array",
"description": "Mission objectives",
"items": { "$ref": "#/definitions/objective" }
},
"rooms": {
"type": "object",
"description": "Map of room IDs to room definitions",
"additionalProperties": { "$ref": "#/definitions/room" }
}
},
"definitions": {
"objective": {
"type": "object",
"required": ["aimId", "title", "status", "order"],
"properties": {
"aimId": { "type": "string" },
"title": { "type": "string" },
"description": { "type": "string" },
"status": {
"type": "string",
"enum": ["active", "locked", "completed"]
},
"order": { "type": "integer" },
"unlockCondition": {
"type": "object",
"properties": {
"aimCompleted": { "type": "string" }
}
},
"tasks": {
"type": "array",
"items": { "$ref": "#/definitions/task" }
}
}
},
"task": {
"type": "object",
"required": ["taskId", "title", "type", "status"],
"properties": {
"taskId": { "type": "string" },
"title": { "type": "string" },
"type": {
"type": "string",
"enum": ["collect_items", "unlock_room", "unlock_object", "enter_room", "npc_conversation"]
},
"status": {
"type": "string",
"enum": ["active", "locked", "completed"]
},
"targetRoom": { "type": "string" },
"targetObject": { "type": "string" },
"targetNPC": { "type": "string" },
"targetItems": {
"type": "array",
"items": { "type": "string" }
},
"targetCount": { "type": "integer" },
"currentCount": { "type": "integer" },
"showProgress": { "type": "boolean" },
"onComplete": {
"type": "object",
"properties": {
"unlockTask": { "type": "string" }
}
}
}
},
"room": {
"type": "object",
"required": ["type", "connections"],
"properties": {
"type": {
"type": "string",
"enum": [
"room_reception",
"room_office",
"room_ceo",
"room_closet",
"room_servers",
"room_lab"
]
},
"connections": {
"type": "object",
"description": "Room connections (direction -> room_id or array of room_ids)",
"additionalProperties": {
"oneOf": [
{ "type": "string" },
{ "type": "array", "items": { "type": "string" } }
]
}
},
"locked": { "type": "boolean" },
"lockType": {
"type": "string",
"enum": ["key", "pin", "rfid", "password", "bluetooth"]
},
"requires": {
"oneOf": [
{ "type": "string" },
{ "type": "array", "items": { "type": "string" } }
]
},
"keyPins": {
"type": "array",
"items": { "type": "integer" }
},
"difficulty": {
"type": "string",
"enum": ["easy", "medium", "hard"]
},
"door_sign": { "type": "string" },
"objects": {
"type": "array",
"items": { "$ref": "#/definitions/item" }
},
"npcs": {
"type": "array",
"items": { "$ref": "#/definitions/npc" }
}
}
},
"npc": {
"type": "object",
"required": ["id", "displayName", "npcType"],
"properties": {
"id": { "type": "string" },
"displayName": { "type": "string" },
"npcType": {
"type": "string",
"enum": ["person", "phone"]
},
"position": {
"type": "object",
"properties": {
"x": { "type": "integer" },
"y": { "type": "integer" }
},
"required": ["x", "y"]
},
"spriteSheet": { "type": "string" },
"spriteTalk": { "type": "string" },
"spriteConfig": {
"type": "object",
"properties": {
"idleFrameStart": { "type": "integer" },
"idleFrameEnd": { "type": "integer" }
}
},
"storyPath": { "type": "string" },
"currentKnot": { "type": "string" },
"avatar": { "type": "string" },
"phoneId": { "type": "string" },
"unlockable": {
"type": "array",
"items": { "type": "string" }
},
"externalVariables": {
"type": "object",
"additionalProperties": true
},
"persistentVariables": {
"type": "object",
"additionalProperties": true
},
"timedMessages": {
"type": "array",
"items": {
"type": "object",
"properties": {
"delay": { "type": "integer" },
"message": { "type": "string" },
"type": { "type": "string" }
}
}
},
"timedConversation": {
"type": "object",
"properties": {
"delay": { "type": "integer" },
"targetKnot": { "type": "string" },
"background": { "type": "string" }
}
},
"eventMappings": {
"type": "array",
"items": { "$ref": "#/definitions/eventMapping" }
},
"itemsHeld": {
"type": "array",
"items": { "$ref": "#/definitions/item" }
}
}
},
"eventMapping": {
"type": "object",
"required": ["eventPattern", "targetKnot"],
"properties": {
"eventPattern": { "type": "string" },
"targetKnot": { "type": "string" },
"conversationMode": { "type": "string" },
"condition": { "type": "string" },
"cooldown": { "type": "integer" },
"onceOnly": { "type": "boolean" },
"maxTriggers": { "type": "integer" },
"_comment": { "type": "string" }
}
},
"item": {
"type": "object",
"required": ["type", "name"],
"properties": {
"type": {
"type": "string",
"enum": [
"notes",
"notes4",
"phone",
"workstation",
"lockpick",
"key",
"keycard",
"pc",
"tablet",
"safe",
"suitcase",
"bluetooth_scanner",
"fingerprint_kit",
"pin-cracker",
"vm-launcher",
"flag-station",
"text_file"
]
},
"id": { "type": "string" },
"name": { "type": "string" },
"takeable": { "type": "boolean" },
"readable": { "type": "boolean" },
"interactable": { "type": "boolean" },
"active": { "type": "boolean" },
"locked": { "type": "boolean" },
"x": { "type": "integer" },
"y": { "type": "integer" },
"observations": { "type": "string" },
"text": { "type": "string" },
"voice": { "type": "string" },
"sender": { "type": "string" },
"timestamp": { "type": "string" },
"lockType": {
"type": "string",
"enum": ["key", "pin", "rfid", "password", "bluetooth"]
},
"requires": {
"oneOf": [
{ "type": "string" },
{ "type": "array", "items": { "type": "string" } }
]
},
"key_id": { "type": "string" },
"keyPins": {
"type": "array",
"items": { "type": "integer" }
},
"card_id": { "type": "string" },
"difficulty": {
"type": "string",
"enum": ["easy", "medium", "hard"]
},
"passwordHint": { "type": "string" },
"showHint": { "type": "boolean" },
"showKeyboard": { "type": "boolean" },
"maxAttempts": { "type": "integer" },
"postitNote": { "type": "string" },
"showPostit": { "type": "boolean" },
"hasFingerprint": { "type": "boolean" },
"fingerprintOwner": { "type": "string" },
"fingerprintDifficulty": {
"type": "string",
"enum": ["easy", "medium", "hard"]
},
"mac": { "type": "string" },
"canScanBluetooth": { "type": "boolean" },
"phoneId": { "type": "string" },
"npcIds": {
"type": "array",
"items": { "type": "string" }
},
"hacktivityMode": { "type": "boolean" },
"vm": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"title": { "type": "string" },
"ip": { "type": "string" },
"enable_console": { "type": "boolean" }
}
},
"acceptsVms": {
"type": "array",
"items": { "type": "string" }
},
"flags": {
"type": "array",
"items": { "type": "string" }
},
"flagRewards": {
"type": "array",
"items": { "$ref": "#/definitions/flagReward" }
},
"itemsHeld": {
"type": "array",
"items": { "$ref": "#/definitions/item" }
},
"contents": {
"type": "array",
"items": { "$ref": "#/definitions/item" }
}
}
},
"flagReward": {
"type": "object",
"required": ["type"],
"properties": {
"type": {
"type": "string",
"enum": ["give_item", "unlock_door", "emit_event", "reveal_secret"]
},
"item_name": { "type": "string" },
"target_room": { "type": "string" },
"event_name": { "type": "string" },
"description": { "type": "string" }
}
}
}
}

346
scripts/validate_scenario.rb Executable file
View File

@@ -0,0 +1,346 @@
#!/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'
# 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 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')
# Check for locked objects without difficulty
if obj['locked'] && !obj['difficulty']
warnings << "Missing recommended field: '#{path}/difficulty' - helps players gauge lock complexity"
end
# Check for key locks without keyPins
if obj['lockType'] == 'key' && !obj['keyPins']
warnings << "Missing recommended field: '#{path}/keyPins' - key locks should specify keyPins array for lockpicking minigame"
end
# Check for key items without keyPins
if obj['type'] == 'key' && !obj['keyPins']
warnings << "Missing recommended field: '#{path}/keyPins' - key items should specify keyPins array for lockpicking"
end
end
end
# Check locked rooms with key lockType without keyPins
if room['locked'] && room['lockType'] == 'key' && !room['keyPins']
warnings << "Missing recommended field: 'rooms/#{room_id}/keyPins' - key locks should specify keyPins array for lockpicking minigame"
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
# Check locked rooms without difficulty
if room['locked'] && !room['difficulty']
warnings << "Missing recommended field: 'rooms/#{room_id}/difficulty' - helps players gauge lock complexity"
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 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 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