mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-21 11:18:08 +00:00
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:
@@ -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
|
||||
|
||||
392
scripts/scenario-schema.json
Normal file
392
scripts/scenario-schema.json
Normal 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
346
scripts/validate_scenario.rb
Executable 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
|
||||
|
||||
Reference in New Issue
Block a user