$ErrorActionPreference = "Stop"

$Root = Split-Path -Parent $PSScriptRoot

function Read-Json($RelativePath) {
    $path = Join-Path $Root $RelativePath
    if (-not (Test-Path $path)) {
        throw "Missing file: $RelativePath"
    }
    return Get-Content $path -Raw | ConvertFrom-Json
}

function Has-Prop($Object, [string]$Name) {
    if ($null -eq $Object) {
        return $false
    }
    return $null -ne ($Object.PSObject.Properties | Where-Object { $_.Name -eq $Name })
}

function Get-Prop($Object, [string]$Name, $Default = $null) {
    if (Has-Prop $Object $Name) {
        return $Object.$Name
    }
    return $Default
}

function Get-Names($Object) {
    return @($Object.PSObject.Properties.Name)
}

function Fail($Message) {
    throw $Message
}

function Check-Raw-Skills-Arrays() {
    $relativePaths = @(
        "data\defs\classes.json",
        "data\defs\officers.json"
    )
    $scenarioDir = Join-Path $Root "data\scenarios"
    foreach ($file in Get-ChildItem -Path $scenarioDir -Filter "*.json") {
        $relativePaths += "data\scenarios\$($file.Name)"
    }
    foreach ($relativePath in $relativePaths) {
        $path = Join-Path $Root $relativePath
        $text = Get-Content $path -Raw
        if ($text -match '"skills"\s*:\s*"') {
            Fail "$relativePath has skills as a string; use an array."
        }
    }
}

function Get-Flag-Value-Kind($Value) {
    if ($Value -is [bool]) {
        return "bool"
    }
    if ($Value -is [string]) {
        return "string"
    }
    if ($Value -is [int] -or $Value -is [long] -or $Value -is [double] -or $Value -is [decimal]) {
        return "number"
    }
    return "other"
}

function Terrain-At($Rows, [int]$X, [int]$Y) {
    return [string]$Rows[$Y].Substring($X, 1)
}

$campaign = Read-Json "data\campaign\campaign.json"
$classes = Read-Json "data\defs\classes.json"
$items = Read-Json "data\defs\items.json"
$officers = Read-Json "data\defs\officers.json"
$skills = Read-Json "data\defs\skills.json"
$terrain = Read-Json "data\defs\terrain.json"

$classIds = Get-Names $classes
$itemIds = Get-Names $items
$officerIds = Get-Names $officers
$skillIds = Get-Names $skills
$terrainKeys = Get-Names $terrain

$validConditionTypes = @(
    "all_units_defeated",
    "all_enemies_defeated",
    "all_players_defeated",
    "any_unit_defeated",
    "any_officer_defeated",
    "unit_reaches_tile",
    "turn_limit",
    "turn_reached",
    "all",
    "any"
)
$validTriggers = @("battle_start", "battle_begin", "turn_start", "unit_reaches_tile", "unit_defeated")
$validActions = @("log", "dialogue", "set_objective", "grant_item", "grant_items", "grant_gold", "spawn_deployment", "spawn_deployments", "withdraw_unit", "withdraw_units", "set_ai_target_priority")
$validEffects = @("heal_hp", "heal_mp", "cure_status", "cleanse_debuffs")
$validItemKinds = @("weapon", "armor", "accessory", "consumable")
$validItemRarities = @("common", "named")
$validBonusStats = @("hp", "mp", "atk", "def", "int", "agi")
$validSkillKinds = @("damage", "heal", "support")
$validSkillTargets = @("enemy", "ally", "self", "any")
$validSkillStats = @("atk", "def", "int", "agi")
$validStatusStats = @("atk", "def", "int", "agi", "move")
$validSkillEffectTypes = @("stat_bonus", "damage_over_time", "action_lock")
$validSkillAreaShapes = @("single", "diamond")
$validDialogueSides = @("left", "right")
$validCampConversationGroups = @("officer", "topic")
$validPortraitPathPattern = '^res://.+\.(png|jpg|jpeg|webp)$'
$minimumPortraitDimension = 512
$portraitImageInspectorReady = $false
try {
    Add-Type -AssemblyName System.Drawing -ErrorAction Stop
    $portraitImageInspectorReady = $true
} catch {
    $portraitImageInspectorReady = $false
}
$validMoveTypes = New-Object System.Collections.Generic.HashSet[string]
$knownFlagIds = New-Object System.Collections.Generic.HashSet[string]
$knownFlagValueKinds = @{}
$joinedOfficerIds = New-Object System.Collections.Generic.HashSet[string]

foreach ($classProp in $classes.PSObject.Properties) {
    $moveType = [string](Get-Prop $classProp.Value "move_type" "foot")
    if (-not [string]::IsNullOrWhiteSpace($moveType)) {
        $validMoveTypes.Add($moveType) | Out-Null
    }
}
foreach ($terrainProp in $terrain.PSObject.Properties) {
    $moveCost = Get-Prop $terrainProp.Value "move_cost" $null
    if ($null -eq $moveCost) {
        continue
    }
    foreach ($moveCostProp in $moveCost.PSObject.Properties) {
        $moveType = [string]$moveCostProp.Name
        if (-not [string]::IsNullOrWhiteSpace($moveType)) {
            $validMoveTypes.Add($moveType) | Out-Null
        }
    }
}

foreach ($officerIdValue in (Get-Prop $campaign "initial_joined_officers" @())) {
    $officerId = [string]$officerIdValue
    if ([string]::IsNullOrWhiteSpace($officerId)) {
        Fail "Campaign initial_joined_officers has an empty officer id."
    }
    if (-not $officerIds.Contains($officerId)) {
        Fail "Campaign initial_joined_officers references unknown officer: $officerId"
    }
    if (-not $joinedOfficerIds.Add($officerId)) {
        Fail "Campaign initial_joined_officers has duplicate officer: $officerId"
    }
}

$campaignScenarioIds = @()
$campaignScenarioIdSet = New-Object System.Collections.Generic.HashSet[string]
foreach ($scenario in @($campaign.scenarios)) {
    if ($scenario -is [System.Array] -or $null -eq $scenario) {
        Fail "Campaign has a malformed scenario entry."
    }
    $scenarioId = [string](Get-Prop $scenario "id" "")
    $scenarioTitle = [string](Get-Prop $scenario "title" "")
    $scenarioPath = [string](Get-Prop $scenario "path" "")
    if ([string]::IsNullOrWhiteSpace($scenarioId)) {
        Fail "Campaign has a scenario with an empty id."
    }
    if (-not ($scenarioId -match '^[a-z0-9_]+$')) {
        Fail "Campaign scenario has unstable id: $scenarioId"
    }
    if ([string]::IsNullOrWhiteSpace($scenarioTitle)) {
        Fail "Campaign scenario $scenarioId has an empty title."
    }
    if ([string]::IsNullOrWhiteSpace($scenarioPath)) {
        Fail "Campaign scenario $scenarioId has an empty path."
    }
    if (-not $campaignScenarioIdSet.Add($scenarioId)) {
        Fail "Campaign has duplicate scenario id: $scenarioId"
    }
    $campaignScenarioIds += $scenarioId
}
if ($campaignScenarioIds.Count -le 0) {
    Fail "Campaign has no scenarios."
}
if (-not $campaignScenarioIdSet.Contains([string]$campaign.start_scenario)) {
    Fail "Campaign start_scenario references unknown scenario: $($campaign.start_scenario)"
}

if (Has-Prop $campaign "chapters") {
    $chaptersValue = Get-Prop $campaign "chapters" $null
    if ($chaptersValue -isnot [System.Array]) {
        Fail "Campaign chapters must be an array."
    }
    if (@($chaptersValue).Count -le 0) {
        Fail "Campaign chapters must not be empty."
    }
    $chapterIds = New-Object System.Collections.Generic.HashSet[string]
    $coveredScenarioIds = New-Object System.Collections.Generic.HashSet[string]
    foreach ($chapter in @($chaptersValue)) {
        if ($chapter -is [System.Array] -or $null -eq $chapter) {
            Fail "Campaign has a malformed chapter entry."
        }
        $chapterId = [string](Get-Prop $chapter "id" "")
        $chapterTitle = [string](Get-Prop $chapter "title" "")
        $startScenario = [string](Get-Prop $chapter "start_scenario" "")
        $endScenario = [string](Get-Prop $chapter "end_scenario" "")
        if ([string]::IsNullOrWhiteSpace($chapterId)) {
            Fail "Campaign chapter has an empty id."
        }
        if (-not ($chapterId -match '^[a-z0-9_]+$')) {
            Fail "Campaign chapter has unstable id: $chapterId"
        }
        if (-not $chapterIds.Add($chapterId)) {
            Fail "Campaign has duplicate chapter id: $chapterId"
        }
        if ([string]::IsNullOrWhiteSpace($chapterTitle)) {
            Fail "Campaign chapter $chapterId has an empty title."
        }
        if (-not $campaignScenarioIdSet.Contains($startScenario)) {
            Fail "Campaign chapter $chapterId references unknown start_scenario: $startScenario"
        }
        if (-not $campaignScenarioIdSet.Contains($endScenario)) {
            Fail "Campaign chapter $chapterId references unknown end_scenario: $endScenario"
        }
        $startIndex = [Array]::IndexOf($campaignScenarioIds, $startScenario)
        $endIndex = [Array]::IndexOf($campaignScenarioIds, $endScenario)
        if ($startIndex -lt 0 -or $endIndex -lt 0 -or $endIndex -lt $startIndex) {
            Fail "Campaign chapter $chapterId has an invalid scenario range."
        }
        for ($index = $startIndex; $index -le $endIndex; $index++) {
            $coveredScenarioId = [string]$campaignScenarioIds[$index]
            if (-not $coveredScenarioIds.Add($coveredScenarioId)) {
                Fail "Campaign chapter $chapterId overlaps scenario: $coveredScenarioId"
            }
        }
    }
    foreach ($scenarioId in $campaignScenarioIds) {
        if (-not $coveredScenarioIds.Contains($scenarioId)) {
            Fail "Campaign chapters do not cover scenario: $scenarioId"
        }
    }
}

function Check-Unit-Reaches-Cells($Source, [string]$Context, [int]$Width, [int]$Height, $Rows) {
    $hasPos = Has-Prop $Source "pos"
    $hasCells = Has-Prop $Source "cells"
    if ($hasPos -and $hasCells) {
        Fail "$Context must use either pos or cells, not both."
    }
    if (-not $hasPos -and -not $hasCells) {
        Fail "$Context is missing pos or cells."
    }

    $cellEntries = @()
    if ($hasPos) {
        $cellEntries += ,$Source.pos
    } else {
        if (-not ($Source.cells -is [System.Array])) {
            Fail "$Context cells must be an array."
        }
        $cellEntries = @($Source.cells)
        if ($cellEntries.Count -le 0) {
            Fail "$Context cells must not be empty."
        }
    }

    $seenCells = New-Object System.Collections.Generic.HashSet[string]
    foreach ($entry in $cellEntries) {
        $cell = Resolve-Cell $entry $Context
        $x = [int]$cell.X
        $y = [int]$cell.Y
        if ($Width -ge 0 -and $Height -ge 0 -and ($x -lt 0 -or $y -lt 0 -or $x -ge $Width -or $y -ge $Height)) {
            Fail "$Context is outside map at [$x,$y]."
        }
        if ($null -ne $Rows) {
            $terrainKey = Terrain-At $Rows $x $y
            if ([int]$terrain.$terrainKey.move_cost.foot -ge 99 -and [int]$terrain.$terrainKey.move_cost.mounted -ge 99 -and [int]$terrain.$terrainKey.move_cost.archer -ge 99) {
                Fail "$Context targets impassable terrain at [$x,$y]."
            }
        }
        $key = "$x,$y"
        if (-not $seenCells.Add($key)) {
            Fail "$Context has duplicate cell [$x,$y]."
        }
    }
}

function Check-Condition($Condition, [string]$ScenarioId, $EventIds, [int]$Width = -1, [int]$Height = -1, $Rows = $null, $KnownUnitIds = $null) {
    if ($Condition -is [System.Array]) {
        foreach ($entry in $Condition) {
            Check-Condition $entry $ScenarioId $EventIds $Width $Height $Rows $KnownUnitIds
        }
        return
    }
    if (-not (Has-Prop $Condition "type")) {
        Fail "Scenario $ScenarioId has a malformed condition."
    }

    $type = [string]$Condition.type
    if (-not $validConditionTypes.Contains($type)) {
        Fail "Scenario $ScenarioId has unknown condition type: $type"
    }
    if ((Has-Prop $Condition "after_event") -and (-not $EventIds.Contains([string]$Condition.after_event))) {
        Fail "Scenario $ScenarioId condition references missing event: $($Condition.after_event)"
    }
    if (($type -eq "all" -or $type -eq "any") -and (Has-Prop $Condition "conditions")) {
        Check-Condition $Condition.conditions $ScenarioId $EventIds $Width $Height $Rows $KnownUnitIds
    }
    if ($type -eq "any_unit_defeated") {
        $unitIdsValue = @()
        if (Has-Prop $Condition "unit_ids") {
            $unitIdsValue = @($Condition.unit_ids)
        }
        if ($unitIdsValue.Count -le 0) {
            Fail "Scenario $ScenarioId any_unit_defeated needs unit_ids."
        }
        foreach ($unitIdValue in $unitIdsValue) {
            $unitId = [string]$unitIdValue
            if ([string]::IsNullOrWhiteSpace($unitId)) {
                Fail "Scenario $ScenarioId any_unit_defeated has empty unit id."
            }
            if ($null -ne $KnownUnitIds -and (-not $KnownUnitIds.Contains($unitId))) {
                Fail "Scenario $ScenarioId any_unit_defeated references unknown unit: $unitId"
            }
        }
    }
    if ($type -eq "unit_reaches_tile") {
        Check-Unit-Reaches-Cells $Condition "Scenario $ScenarioId unit_reaches_tile condition" $Width $Height $Rows
        $unitIdsValue = $null
        if (Has-Prop $Condition "unit_ids") {
            $unitIdsValue = $Condition.unit_ids
        }
        if ($null -ne $unitIdsValue -and -not ($unitIdsValue -is [System.Array])) {
            Fail "Scenario $ScenarioId unit_reaches_tile unit_ids must be an array."
        }
        if ($null -ne $unitIdsValue) {
            foreach ($unitIdValue in @($unitIdsValue)) {
                $unitId = [string]$unitIdValue
                if ([string]::IsNullOrWhiteSpace($unitId)) {
                    Fail "Scenario $ScenarioId unit_reaches_tile has empty unit id."
                }
                if ($null -ne $KnownUnitIds -and (-not $KnownUnitIds.Contains($unitId))) {
                    Fail "Scenario $ScenarioId unit_reaches_tile references unknown unit: $unitId"
                }
            }
        }
        $officerIdsValue = $null
        if (Has-Prop $Condition "officer_ids") {
            $officerIdsValue = $Condition.officer_ids
            if ($null -ne $officerIdsValue -and -not ($officerIdsValue -is [System.Array])) {
                Fail "Scenario $ScenarioId unit_reaches_tile officer_ids must be an array."
            }
            foreach ($officerIdValue in @($officerIdsValue)) {
                $officerId = [string]$officerIdValue
                if ([string]::IsNullOrWhiteSpace($officerId) -or (-not $officerIds.Contains($officerId))) {
                    Fail "Scenario $ScenarioId unit_reaches_tile references unknown officer: $officerId"
                }
            }
        }
        if (($null -eq $unitIdsValue -or @($unitIdsValue).Count -le 0) -and ((-not (Has-Prop $Condition "officer_ids")) -or @($officerIdsValue).Count -le 0)) {
            Fail "Scenario $ScenarioId unit_reaches_tile needs unit_ids or officer_ids."
        }
        $team = [string](Get-Prop $Condition "team" "")
        if (-not [string]::IsNullOrWhiteSpace($team) -and $team -ne "player" -and $team -ne "enemy") {
            Fail "Scenario $ScenarioId unit_reaches_tile has unknown team: $team"
        }
    }
}

function Check-Unit-Reaches-Event-When($When, [string]$ScenarioId, [int]$Width, [int]$Height, $Rows, $KnownUnitIds) {
    Check-Unit-Reaches-Cells $When "Scenario $ScenarioId unit_reaches_tile event" $Width $Height $Rows

    $unitIdsValue = $null
    if (Has-Prop $When "unit_ids") {
        $unitIdsValue = $When.unit_ids
        if ($null -ne $unitIdsValue -and -not ($unitIdsValue -is [System.Array])) {
            Fail "Scenario $ScenarioId unit_reaches_tile event unit_ids must be an array."
        }
        foreach ($unitIdValue in @($unitIdsValue)) {
            $unitId = [string]$unitIdValue
            if ([string]::IsNullOrWhiteSpace($unitId)) {
                Fail "Scenario $ScenarioId unit_reaches_tile event has empty unit id."
            }
            if ($null -ne $KnownUnitIds -and (-not $KnownUnitIds.Contains($unitId))) {
                Fail "Scenario $ScenarioId unit_reaches_tile event references unknown unit: $unitId"
            }
        }
    }

    $officerIdsValue = $null
    if (Has-Prop $When "officer_ids") {
        $officerIdsValue = $When.officer_ids
        if ($null -ne $officerIdsValue -and -not ($officerIdsValue -is [System.Array])) {
            Fail "Scenario $ScenarioId unit_reaches_tile event officer_ids must be an array."
        }
        foreach ($officerIdValue in @($officerIdsValue)) {
            $officerId = [string]$officerIdValue
            if ([string]::IsNullOrWhiteSpace($officerId) -or (-not $officerIds.Contains($officerId))) {
                Fail "Scenario $ScenarioId unit_reaches_tile event references unknown officer: $officerId"
            }
        }
    }

    $team = [string](Get-Prop $When "team" "")
    if (-not [string]::IsNullOrWhiteSpace($team) -and $team -ne "player" -and $team -ne "enemy") {
        Fail "Scenario $ScenarioId unit_reaches_tile event has unknown team: $team"
    }
    $unitIdCount = 0
    if ($null -ne $unitIdsValue) {
        $unitIdCount = @($unitIdsValue).Count
    }
    $officerIdCount = 0
    if ($null -ne $officerIdsValue) {
        $officerIdCount = @($officerIdsValue).Count
    }
    if ([string]::IsNullOrWhiteSpace($team) -and $unitIdCount -le 0 -and $officerIdCount -le 0) {
        Fail "Scenario $ScenarioId unit_reaches_tile event needs team, unit_ids, or officer_ids."
    }
}

function Check-Unit-Defeated-Event-When($When, [string]$ScenarioId, $KnownUnitIds) {
    $unitIdsValue = $null
    if (Has-Prop $When "unit_ids") {
        $unitIdsValue = $When.unit_ids
        if ($null -ne $unitIdsValue -and -not ($unitIdsValue -is [System.Array])) {
            Fail "Scenario $ScenarioId unit_defeated event unit_ids must be an array."
        }
        foreach ($unitIdValue in @($unitIdsValue)) {
            $unitId = [string]$unitIdValue
            if ([string]::IsNullOrWhiteSpace($unitId)) {
                Fail "Scenario $ScenarioId unit_defeated event has empty unit id."
            }
            if ($null -ne $KnownUnitIds -and (-not $KnownUnitIds.Contains($unitId))) {
                Fail "Scenario $ScenarioId unit_defeated event references unknown unit: $unitId"
            }
        }
    }

    $officerIdsValue = $null
    if (Has-Prop $When "officer_ids") {
        $officerIdsValue = $When.officer_ids
        if ($null -ne $officerIdsValue -and -not ($officerIdsValue -is [System.Array])) {
            Fail "Scenario $ScenarioId unit_defeated event officer_ids must be an array."
        }
        foreach ($officerIdValue in @($officerIdsValue)) {
            $officerId = [string]$officerIdValue
            if ([string]::IsNullOrWhiteSpace($officerId) -or (-not $officerIds.Contains($officerId))) {
                Fail "Scenario $ScenarioId unit_defeated event references unknown officer: $officerId"
            }
        }
    }

    $team = [string](Get-Prop $When "team" "")
    if (-not [string]::IsNullOrWhiteSpace($team) -and $team -ne "player" -and $team -ne "enemy") {
        Fail "Scenario $ScenarioId unit_defeated event has unknown team: $team"
    }
    $unitIdCount = 0
    if ($null -ne $unitIdsValue) {
        $unitIdCount = @($unitIdsValue).Count
    }
    $officerIdCount = 0
    if ($null -ne $officerIdsValue) {
        $officerIdCount = @($officerIdsValue).Count
    }
    if ([string]::IsNullOrWhiteSpace($team) -and $unitIdCount -le 0 -and $officerIdCount -le 0) {
        Fail "Scenario $ScenarioId unit_defeated event needs team, unit_ids, or officer_ids."
    }
}

function Resolve-Class-Id($Deployment, [string]$ScenarioId) {
    $classId = [string](Get-Prop $Deployment "class_id" "")
    if (-not [string]::IsNullOrWhiteSpace($classId)) {
        return $classId
    }

    $officerId = [string](Get-Prop $Deployment "officer_id" "")
    if ([string]::IsNullOrWhiteSpace($officerId)) {
        Fail "Scenario $ScenarioId deployment $($Deployment.unit_id) has no class_id or officer_id."
    }
    if (-not $officerIds.Contains($officerId)) {
        Fail "Scenario $ScenarioId deployment $($Deployment.unit_id) references unknown officer: $officerId"
    }
    return [string]$officers.$officerId.class_id
}

function Check-Deployment($Deployment, [string]$ScenarioId, [int]$Width, [int]$Height, $Rows, $SeenIds) {
    if (-not (Has-Prop $Deployment "unit_id")) {
        Fail "Scenario $ScenarioId has a deployment without unit_id."
    }

    $unitId = [string]$Deployment.unit_id
    if ($SeenIds.Contains($unitId)) {
        Fail "Scenario $ScenarioId has duplicate deployment id: $unitId"
    }
    $SeenIds.Add($unitId) | Out-Null

    $team = [string](Get-Prop $Deployment "team" "enemy")
    $officerId = [string](Get-Prop $Deployment "officer_id" "")
    if (-not [string]::IsNullOrWhiteSpace($officerId) -and (-not $officerIds.Contains($officerId))) {
        Fail "Scenario $ScenarioId deployment $unitId references unknown officer: $officerId"
    }
    if (Has-Prop $Deployment "portrait") {
        Check-Portrait-Path (Get-Prop $Deployment "portrait" "") "Scenario $ScenarioId deployment $unitId portrait"
    }
    if (Has-Prop $Deployment "sprite") {
        Check-Portrait-Path (Get-Prop $Deployment "sprite" "") "Scenario $ScenarioId deployment $unitId sprite"
    }
    if (Has-Prop $Deployment "requires_joined") {
        if (-not ((Get-Prop $Deployment "requires_joined" $false) -is [bool])) {
            Fail "Scenario $ScenarioId deployment $unitId requires_joined must be boolean."
        }
        if ([bool](Get-Prop $Deployment "requires_joined" $false)) {
            if ($team -ne "player") {
                Fail "Scenario $ScenarioId deployment $unitId requires_joined is only valid for player deployments."
            }
            if ([string]::IsNullOrWhiteSpace($officerId)) {
                Fail "Scenario $ScenarioId deployment $unitId requires_joined needs officer_id."
            }
        }
    }
    if ((Has-Prop $Deployment "controllable") -and (-not ((Get-Prop $Deployment "controllable" $true) -is [bool]))) {
        Fail "Scenario $ScenarioId deployment $unitId controllable must be boolean."
    }
    if ((Has-Prop $Deployment "persist_progression") -and (-not ((Get-Prop $Deployment "persist_progression" $true) -is [bool]))) {
        Fail "Scenario $ScenarioId deployment $unitId persist_progression must be boolean."
    }
    if (Has-Prop $Deployment "ai_target_priority") {
        $priorityValue = Get-Prop $Deployment "ai_target_priority" 0
        if (-not ($priorityValue -is [int] -or $priorityValue -is [long])) {
            Fail "Scenario $ScenarioId deployment $unitId ai_target_priority must be an integer."
        }
        $priority = [int]$priorityValue
        if ($priority -lt 0 -or $priority -gt 20) {
            Fail "Scenario $ScenarioId deployment $unitId ai_target_priority must be between 0 and 20."
        }
    }

    $classId = Resolve-Class-Id $Deployment $ScenarioId
    if (-not $classIds.Contains($classId)) {
        Fail "Scenario $ScenarioId deployment $unitId references unknown class: $classId"
    }
    $classDef = $classes.$classId
    Check-Skill-Refs (Get-Prop $Deployment "skills" @()) "Scenario $ScenarioId deployment $unitId"
    Check-Equipment-Loadout (Get-Prop $Deployment "equipment" $null) $classDef "Scenario $ScenarioId deployment $unitId"

    $pos = Get-Prop $Deployment "pos" @()
    if ($pos.Count -lt 2) {
        Fail "Scenario $ScenarioId deployment $unitId has malformed pos."
    }
    $x = [int]$pos[0]
    $y = [int]$pos[1]
    if ($x -lt 0 -or $y -lt 0 -or $x -ge $Width -or $y -ge $Height) {
        Fail "Scenario $ScenarioId deployment $unitId is outside map at [$x,$y]."
    }

    $terrainKey = Terrain-At $Rows $x $y
    $moveType = [string](Get-Prop $Deployment "move_type" (Get-Prop $classDef "move_type" "foot"))
    $moveCost = $terrain.$terrainKey.move_cost.$moveType
    if ($null -eq $moveCost) {
        $moveCost = $terrain.$terrainKey.move_cost.foot
    }
    if ([int]$moveCost -ge 99) {
        Fail "Scenario $ScenarioId deployment $unitId starts on impassable terrain."
    }
}

function Add-Deployment-Unit-Id($Deployment, $Result) {
    $unitId = [string](Get-Prop $Deployment "unit_id" (Get-Prop $Deployment "id" ""))
    if (-not [string]::IsNullOrWhiteSpace($unitId)) {
        $Result.Add($unitId) | Out-Null
    }
}

function Collect-Scenario-Unit-Ids($ScenarioData) {
    $result = New-Object System.Collections.Generic.HashSet[string]
    foreach ($deployment in (Get-Prop $ScenarioData "deployments" @())) {
        Add-Deployment-Unit-Id $deployment $result
    }
    foreach ($event in (Get-Prop $ScenarioData "events" @())) {
        foreach ($action in (Get-Prop $event "actions" @())) {
            $actionType = [string](Get-Prop $action "type" "")
            if ($actionType -eq "spawn_deployment") {
                Add-Deployment-Unit-Id $action.deployment $result
            }
            if ($actionType -eq "spawn_deployments") {
                foreach ($deployment in (Get-Prop $action "deployments" @())) {
                    Add-Deployment-Unit-Id $deployment $result
                }
            }
        }
    }
    return $result
}

function Find-Item-Def([string]$ItemId) {
    $prop = $items.PSObject.Properties | Where-Object { $_.Name -eq $ItemId } | Select-Object -First 1
    if ($null -eq $prop) {
        return $null
    }
    return $prop.Value
}

function Resolve-Event-Grant-Item-Id($Entry, [string]$Context) {
    if ($Entry -is [string]) {
        return [string]$Entry
    }
    if ($null -eq $Entry -or $Entry -is [System.Array]) {
        Fail "$Context has malformed item grant."
    }
    return [string](Get-Prop $Entry "item_id" (Get-Prop $Entry "id" ""))
}

function Check-Event-Grant-Item($Entry, [string]$Context) {
    $itemId = Resolve-Event-Grant-Item-Id $Entry $Context
    if ([string]::IsNullOrWhiteSpace($itemId)) {
        Fail "$Context has empty item id."
    }
    if (-not $itemIds.Contains($itemId)) {
        Fail "$Context references unknown item: $itemId"
    }
    if (-not ($Entry -is [string]) -and (Has-Prop $Entry "count")) {
        $count = [int](Get-Prop $Entry "count" 0)
        if ($count -le 0) {
            Fail "$Context has invalid count for item $itemId."
        }
    }
}

function Check-Event-Grant-Gold($Action, [string]$Context) {
    if (-not (Has-Prop $Action "amount")) {
        Fail "$Context must set amount."
    }
    $amountValue = Get-Prop $Action "amount" 0
    if (-not ($amountValue -is [int] -or $amountValue -is [long])) {
        Fail "$Context amount must be an integer."
    }
    if ([int]$amountValue -le 0) {
        Fail "$Context amount must be positive."
    }
    if ((Has-Prop $Action "text") -and [string]::IsNullOrWhiteSpace([string](Get-Prop $Action "text" ""))) {
        Fail "$Context text must not be empty."
    }
}

function Check-Event-Withdraw-Unit-Id([string]$UnitId, $KnownUnitIds, [string]$Context) {
    if ([string]::IsNullOrWhiteSpace($UnitId)) {
        Fail "$Context has empty unit id."
    }
    if (-not $KnownUnitIds.Contains($UnitId)) {
        Fail "$Context references unknown unit: $UnitId"
    }
}

function Check-Event-Withdraw-Units($UnitIds, $KnownUnitIds, [string]$Context) {
    if ($null -eq $UnitIds -or -not ($UnitIds -is [System.Array])) {
        Fail "$Context unit_ids must be an array."
    }
    if (@($UnitIds).Count -le 0) {
        Fail "$Context unit_ids must not be empty."
    }
    $seenWithdrawals = New-Object System.Collections.Generic.HashSet[string]
    foreach ($unitIdValue in @($UnitIds)) {
        $unitId = [string]$unitIdValue
        Check-Event-Withdraw-Unit-Id $unitId $KnownUnitIds $Context
        if (-not $seenWithdrawals.Add($unitId)) {
            Fail "$Context has duplicate unit: $unitId"
        }
    }
}

function Check-Event-Unit-Ids($Action, $KnownUnitIds, [string]$Context) {
    $unitIds = @()
    if (Has-Prop $Action "unit_ids") {
        if (-not ($Action.unit_ids -is [System.Array])) {
            Fail "$Context unit_ids must be an array."
        }
        $unitIds = @($Action.unit_ids)
    } else {
        $unitId = [string](Get-Prop $Action "unit_id" (Get-Prop $Action "id" ""))
        if (-not [string]::IsNullOrWhiteSpace($unitId)) {
            $unitIds = @($unitId)
        }
    }

    if ($unitIds.Count -le 0) {
        Fail "$Context needs unit_id or unit_ids."
    }
    $seenUnitIds = New-Object System.Collections.Generic.HashSet[string]
    foreach ($unitIdValue in $unitIds) {
        $unitId = [string]$unitIdValue
        if ([string]::IsNullOrWhiteSpace($unitId)) {
            Fail "$Context has empty unit id."
        }
        if (-not $KnownUnitIds.Contains($unitId)) {
            Fail "$Context references unknown unit: $unitId"
        }
        if (-not $seenUnitIds.Add($unitId)) {
            Fail "$Context has duplicate unit: $unitId"
        }
    }
}

function Check-Event-Ai-Target-Priority($Action, $KnownUnitIds, [string]$Context) {
    Check-Event-Unit-Ids $Action $KnownUnitIds $Context
    if (-not (Has-Prop $Action "priority")) {
        Fail "$Context must set priority."
    }
    $priorityValue = Get-Prop $Action "priority" 0
    if (-not ($priorityValue -is [int] -or $priorityValue -is [long])) {
        Fail "$Context priority must be an integer."
    }
    $priority = [int]$priorityValue
    if ($priority -lt 0 -or $priority -gt 20) {
        Fail "$Context priority must be between 0 and 20."
    }
    if ((Has-Prop $Action "text") -and [string]::IsNullOrWhiteSpace([string](Get-Prop $Action "text" ""))) {
        Fail "$Context text must not be empty."
    }
    if ((Has-Prop $Action "silent") -and -not ((Get-Prop $Action "silent" $false) -is [bool])) {
        Fail "$Context silent must be boolean."
    }
}

function Check-Class-Equipment-Slots() {
    $weaponTypes = @()
    $armorTypes = @()
    $accessoryTypes = @()
    foreach ($itemProp in $items.PSObject.Properties) {
        $item = $itemProp.Value
        $kind = [string](Get-Prop $item "kind" "")
        if ($kind -eq "weapon") {
            $weaponTypes += [string](Get-Prop $item "weapon_type" "")
        } elseif ($kind -eq "armor") {
            $armorTypes += [string](Get-Prop $item "armor_type" "")
        } elseif ($kind -eq "accessory") {
            $accessoryTypes += [string](Get-Prop $item "accessory_type" "accessory")
        }
    }

    foreach ($classProp in $classes.PSObject.Properties) {
        $classId = $classProp.Name
        $slots = Get-Prop $classProp.Value "equipment_slots" $null
        if ($null -eq $slots) {
            continue
        }
        foreach ($weaponType in (Get-Prop $slots "weapon" @())) {
            if (-not $weaponTypes.Contains([string]$weaponType)) {
                Fail "Class $classId allows unknown weapon type: $weaponType"
            }
        }
        foreach ($armorType in (Get-Prop $slots "armor" @())) {
            if (-not $armorTypes.Contains([string]$armorType)) {
                Fail "Class $classId allows unknown armor type: $armorType"
            }
        }
        foreach ($accessoryType in (Get-Prop $slots "accessory" @())) {
            if (-not $accessoryTypes.Contains([string]$accessoryType)) {
                Fail "Class $classId allows unknown accessory type: $accessoryType"
            }
        }
    }
}

function Check-Class-Promotions() {
    foreach ($classProp in $classes.PSObject.Properties) {
        $classId = [string]$classProp.Name
        $promotion = Get-Prop $classProp.Value "promotion" $null
        if ($null -eq $promotion) {
            continue
        }
        if ($promotion -is [string] -or $promotion -is [System.Array]) {
            Fail "Class $classId promotion must be an object."
        }
        $level = [int](Get-Prop $promotion "level" 0)
        if ($level -le 1) {
            Fail "Class $classId promotion has invalid level."
        }
        $targetClassId = [string](Get-Prop $promotion "to" "")
        if ([string]::IsNullOrWhiteSpace($targetClassId) -or (-not $classIds.Contains($targetClassId))) {
            Fail "Class $classId promotes to unknown class: $targetClassId"
        }
        if ($targetClassId -eq $classId) {
            Fail "Class $classId cannot promote to itself."
        }
        $targetClass = Get-Prop $classes $targetClassId $null
        $currentTier = [int](Get-Prop $classProp.Value "tier" 1)
        $targetTier = [int](Get-Prop $targetClass "tier" $currentTier)
        if ($targetTier -le $currentTier) {
            Fail "Class $classId promotion target must have higher tier: $targetClassId"
        }

        $sourceSlots = Get-Prop $classProp.Value "equipment_slots" $null
        $targetSlots = Get-Prop $targetClass "equipment_slots" $null
        foreach ($slot in @("weapon", "armor", "accessory")) {
            $sourceAllowed = @()
            if ($null -ne $sourceSlots) {
                $sourceAllowed = @(Get-Prop $sourceSlots $slot @())
            }
            $targetAllowed = @()
            if ($null -ne $targetSlots) {
                $targetAllowed = @(Get-Prop $targetSlots $slot @())
            }
            foreach ($allowed in $sourceAllowed) {
                if (-not $targetAllowed.Contains([string]$allowed)) {
                    Fail "Class $classId promotion target $targetClassId must keep $slot type: $allowed"
                }
            }
        }
    }
}

function Check-Equipment-Loadout($Equipment, $ClassDef, [string]$Context) {
    $slots = Get-Prop $classDef "equipment_slots" $null
    if ($null -eq $Equipment) {
        return
    }
    if ($Equipment -is [string] -or $Equipment -is [System.Array]) {
        Fail "$Context equipment must be an object."
    }

    foreach ($slot in $Equipment.PSObject.Properties) {
        $slotName = [string]$slot.Name
        $itemId = [string]$slot.Value
        if ([string]::IsNullOrWhiteSpace($itemId)) {
            continue
        }
        if (-not @("weapon", "armor", "accessory").Contains($slotName)) {
            Fail "$Context has unknown equipment slot: $slotName"
        }

        $item = Find-Item-Def $itemId
        if ($null -eq $item) {
            Fail "$Context references unknown equipment item: $itemId"
        }

        $kind = [string](Get-Prop $item "kind" "")
        if ($slotName -eq "weapon") {
            if ($kind -ne "weapon") {
                Fail "$Context has non-weapon item $itemId in weapon slot."
            }
            $weaponType = [string](Get-Prop $item "weapon_type" "")
            if ($null -ne $slots -and (-not @((Get-Prop $slots "weapon" @())).Contains($weaponType))) {
                Fail "$Context cannot equip weapon type $weaponType."
            }
        } elseif ($slotName -eq "armor") {
            if ($kind -ne "armor") {
                Fail "$Context has non-armor item $itemId in armor slot."
            }
            $armorType = [string](Get-Prop $item "armor_type" "")
            if ($null -ne $slots -and (-not @((Get-Prop $slots "armor" @())).Contains($armorType))) {
                Fail "$Context cannot equip armor type $armorType."
            }
        } elseif ($slotName -eq "accessory") {
            if ($kind -ne "accessory") {
                Fail "$Context has non-accessory item $itemId in accessory slot."
            }
            $accessoryType = [string](Get-Prop $item "accessory_type" "accessory")
            if ($null -ne $slots -and (-not @((Get-Prop $slots "accessory" @())).Contains($accessoryType))) {
                Fail "$Context cannot equip accessory type $accessoryType."
            }
        }
    }
}

function Check-Officer-Equipment([string]$OfficerId, $Officer) {
    $classId = [string](Get-Prop $Officer "class_id" "")
    if ([string]::IsNullOrWhiteSpace($classId) -or (-not $classIds.Contains($classId))) {
        Fail "Officer $OfficerId references unknown class: $classId"
    }

    Check-Equipment-Loadout (Get-Prop $Officer "equipment" $null) $classes.$classId "Officer $OfficerId"
}

function Check-Item-Effects() {
    foreach ($itemProp in $items.PSObject.Properties) {
        $itemId = $itemProp.Name
        $item = $itemProp.Value
        $kind = [string](Get-Prop $item "kind" "")
        if (Has-Prop $item "icon") {
            Check-Portrait-Path (Get-Prop $item "icon" "") "Item $itemId icon"
        }
        if (-not $validItemKinds.Contains($kind)) {
            Fail "Item $itemId has unknown kind: $kind"
        }
        if ([int](Get-Prop $item "price" 0) -lt 0) {
            Fail "Item $itemId has negative price."
        }
        if (Has-Prop $item "rarity") {
            $rarity = [string](Get-Prop $item "rarity" "")
            if (-not $validItemRarities.Contains($rarity)) {
                Fail "Item $itemId has unknown rarity: $rarity"
            }
        }
        if ($kind -eq "weapon") {
            if (-not (Has-Prop $item "weapon_type")) {
                Fail "Weapon item $itemId is missing weapon_type."
            }
            Check-Skill-Range (Get-Prop $item "range" 1) "Item $itemId"
            $hasEffectiveTypes = Has-Prop $item "effective_vs_move_types"
            $hasEffectiveBonus = Has-Prop $item "effective_bonus_damage"
            if ($hasEffectiveTypes -or $hasEffectiveBonus) {
                if (-not $hasEffectiveTypes -or -not $hasEffectiveBonus) {
                    Fail "Weapon item $itemId effectiveness requires effective_vs_move_types and effective_bonus_damage."
                }
                $effectiveTypes = @((Get-Prop $item "effective_vs_move_types" @()))
                if ($effectiveTypes.Count -eq 0) {
                    Fail "Weapon item $itemId effective_vs_move_types must be a non-empty array."
                }
                $seenEffectiveTypes = New-Object System.Collections.Generic.HashSet[string]
                foreach ($moveTypeValue in $effectiveTypes) {
                    $moveType = [string]$moveTypeValue
                    if ([string]::IsNullOrWhiteSpace($moveType) -or -not ($moveType -match '^[a-z0-9_]+$')) {
                        Fail "Weapon item $itemId has unstable effective move type: $moveType"
                    }
                    if (-not $validMoveTypes.Contains($moveType)) {
                        Fail "Weapon item $itemId references unknown effective move type: $moveType"
                    }
                    if (-not $seenEffectiveTypes.Add($moveType)) {
                        Fail "Weapon item $itemId duplicates effective move type: $moveType"
                    }
                }
                $effectiveBonus = Get-Prop $item "effective_bonus_damage" 0
                if (-not ($effectiveBonus -is [int] -or $effectiveBonus -is [long])) {
                    Fail "Weapon item $itemId effective_bonus_damage must be an integer."
                }
                $effectiveBonusValue = [int]$effectiveBonus
                if ($effectiveBonusValue -lt 1 -or $effectiveBonusValue -gt 5) {
                    Fail "Weapon item $itemId effective_bonus_damage must be between 1 and 5."
                }
            }
        }
        if ($kind -ne "weapon") {
            if (Has-Prop $item "effective_vs_move_types") {
                Fail "Item $itemId has effectiveness move types but is not a weapon."
            }
            if (Has-Prop $item "effective_bonus_damage") {
                Fail "Item $itemId has effectiveness bonus but is not a weapon."
            }
        }
        if ($kind -eq "armor" -and (-not (Has-Prop $item "armor_type"))) {
            Fail "Armor item $itemId is missing armor_type."
        }
        if ($kind -eq "accessory") {
            $accessoryType = [string](Get-Prop $item "accessory_type" "")
            if ([string]::IsNullOrWhiteSpace($accessoryType)) {
                Fail "Accessory item $itemId is missing accessory_type."
            }
        }
        if ($kind -eq "consumable") {
            if ([int](Get-Prop $item "uses" 0) -le 0) {
                Fail "Consumable item $itemId must have positive uses."
            }
            $target = [string](Get-Prop $item "target" "ally")
            if (-not $validSkillTargets.Contains($target)) {
                Fail "Consumable item $itemId has unknown target: $target"
            }
            Check-Skill-Range (Get-Prop $item "range" @(0, 1)) "Item $itemId"
        }
        $bonuses = Get-Prop $item "bonuses" $null
        if ($null -ne $bonuses) {
            foreach ($bonus in $bonuses.PSObject.Properties) {
                if (-not $validBonusStats.Contains([string]$bonus.Name)) {
                    Fail "Item $itemId has unsupported bonus stat: $($bonus.Name)"
                }
            }
        }
        foreach ($effect in (Get-Prop $item "effects" @())) {
            if (-not (Has-Prop $effect "type")) {
                Fail "Item $itemId has malformed effect."
            }
            if (-not $validEffects.Contains([string]$effect.type)) {
                Fail "Item $itemId has unknown effect: $($effect.type)"
            }
            $effectType = [string]$effect.type
            if ($effectType -eq "heal_hp" -or $effectType -eq "heal_mp") {
                if ([int](Get-Prop $effect "amount" 0) -le 0) {
                    Fail "Item $itemId effect $effectType must have positive amount."
                }
            } elseif ($effectType -eq "cure_status") {
                $status = [string](Get-Prop $effect "status" "")
                if ([string]::IsNullOrWhiteSpace($status) -or -not ($status -match '^[a-z0-9_]+$')) {
                    Fail "Item $itemId cure_status effect must have a stable status."
                }
            }
        }
    }
}

function Check-Terrain-Definitions() {
    foreach ($terrainProp in $terrain.PSObject.Properties) {
        $terrainKey = [string]$terrainProp.Name
        $terrainDef = $terrainProp.Value
        $defense = [int](Get-Prop $terrainDef "defense" 0)
        if ($defense -lt 0) {
            Fail "Terrain $terrainKey has negative defense."
        }
        $avoid = [int](Get-Prop $terrainDef "avoid" 0)
        if ($avoid -lt 0 -or $avoid -gt 100) {
            Fail "Terrain $terrainKey avoid must be between 0 and 100."
        }
    }
}

function Check-Skill-Range($Value, [string]$Context) {
    if ($Value -is [System.Array]) {
        if ($Value.Count -lt 1 -or $Value.Count -gt 2) {
            Fail "$Context has malformed range."
        }
        $minRange = [int]$Value[0]
        $maxRange = [int]$Value[$Value.Count - 1]
        if ($minRange -lt 0 -or $maxRange -lt $minRange) {
            Fail "$Context has invalid range."
        }
        return
    }
    $range = [int]$Value
    if ($range -lt 0) {
        Fail "$Context has invalid range."
    }
}

function Check-Skill-Definitions() {
    foreach ($skillProp in $skills.PSObject.Properties) {
        $skillId = $skillProp.Name
        $skill = $skillProp.Value
        $kind = [string](Get-Prop $skill "kind" "")
        if (-not $validSkillKinds.Contains($kind)) {
            Fail "Skill $skillId has unknown kind: $kind"
        }
        $target = [string](Get-Prop $skill "target" "")
        if (-not $validSkillTargets.Contains($target)) {
            Fail "Skill $skillId has unknown target: $target"
        }
        Check-Skill-Range (Get-Prop $skill "range" 1) "Skill $skillId"
        $areaShape = [string](Get-Prop $skill "area_shape" "single")
        if (-not $validSkillAreaShapes.Contains($areaShape)) {
            Fail "Skill $skillId has unsupported area_shape: $areaShape"
        }
        $areaRadius = [int](Get-Prop $skill "area_radius" 0)
        if ($areaRadius -lt 0) {
            Fail "Skill $skillId has invalid area_radius."
        }
        if ($areaShape -eq "single" -and $areaRadius -gt 0) {
            Fail "Skill $skillId area_radius requires a non-single area_shape."
        }
        if ($areaShape -ne "single" -and $areaRadius -le 0) {
            Fail "Skill $skillId area_shape requires a positive area_radius."
        }
        if ([int](Get-Prop $skill "mp_cost" 0) -lt 0) {
            Fail "Skill $skillId has negative mp_cost."
        }
        if ([int](Get-Prop $skill "power" 0) -lt 0) {
            Fail "Skill $skillId has negative power."
        }
        $stat = [string](Get-Prop $skill "stat" "int")
        if (-not $validSkillStats.Contains($stat)) {
            Fail "Skill $skillId references unsupported stat: $stat"
        }
        $effects = Get-Prop $skill "effects" @()
        if ($kind -eq "support" -and @($effects).Count -eq 0) {
            Fail "Support skill $skillId must define at least one effect."
        }
        foreach ($effect in @($effects)) {
            if ($effect -isnot [pscustomobject]) {
                Fail "Skill $skillId has malformed effect."
                continue
            }
            $effectType = [string](Get-Prop $effect "type" "")
            if (-not $validSkillEffectTypes.Contains($effectType)) {
                Fail "Skill $skillId has unknown effect: $effectType"
            }
            if ($effectType -eq "stat_bonus") {
                $effectStat = [string](Get-Prop $effect "stat" "")
                if (-not $validStatusStats.Contains($effectStat)) {
                    Fail "Skill $skillId effect references unsupported stat: $effectStat"
                }
                if ([int](Get-Prop $effect "amount" 0) -eq 0) {
                    Fail "Skill $skillId stat_bonus effect must have non-zero amount."
                }
                if ([int](Get-Prop $effect "duration_turns" 1) -le 0) {
                    Fail "Skill $skillId stat_bonus effect must have positive duration_turns."
                }
            } elseif ($effectType -eq "damage_over_time") {
                $status = [string](Get-Prop $effect "status" "")
                if ([string]::IsNullOrWhiteSpace($status) -or -not ($status -match '^[a-z0-9_]+$')) {
                    Fail "Skill $skillId damage_over_time effect must have a stable status."
                }
                if ([int](Get-Prop $effect "amount" 0) -le 0) {
                    Fail "Skill $skillId damage_over_time effect must have positive amount."
                }
                if ([int](Get-Prop $effect "duration_turns" 1) -le 0) {
                    Fail "Skill $skillId damage_over_time effect must have positive duration_turns."
                }
            } elseif ($effectType -eq "action_lock") {
                $status = [string](Get-Prop $effect "status" "")
                if ([string]::IsNullOrWhiteSpace($status) -or -not ($status -match '^[a-z0-9_]+$')) {
                    Fail "Skill $skillId action_lock effect must have a stable status."
                }
                $action = [string](Get-Prop $effect "action" "")
                if ($action -ne "skill" -and $action -ne "move" -and $action -ne "attack") {
                    Fail "Skill $skillId action_lock effect currently supports only action=skill, action=move, or action=attack."
                }
                if ([int](Get-Prop $effect "duration_turns" 1) -le 0) {
                    Fail "Skill $skillId action_lock effect must have positive duration_turns."
                }
            }
        }
    }
}

function Check-Skill-Refs($Value, [string]$Context) {
    if ($null -eq $Value) {
        return
    }
    if ($Value -is [pscustomobject]) {
        Fail "$Context skills must be an array."
    }
    foreach ($skillId in @($Value)) {
        if (-not $skillIds.Contains([string]$skillId)) {
            Fail "$Context references unknown skill: $skillId"
        }
    }
}

function Resolve-Shop-Item-Id($Entry, [string]$Context) {
    if ($Entry -is [string]) {
        return [string]$Entry
    }
    if (Has-Prop $Entry "id") {
        return [string]$Entry.id
    }
    Fail "$Context has a malformed shop item entry."
}

function Check-Shop-Stock-Value($Value, [string]$Context) {
    $isInteger = (
        $Value -is [byte] -or
        $Value -is [int16] -or
        $Value -is [int] -or
        $Value -is [long]
    )
    if (-not $isInteger -or [long]$Value -lt 0) {
        Fail "$Context stock must be a non-negative integer."
    }
}

function Check-Shop-Entry-Stock($Entry, [string]$ItemId, [string]$Context) {
    if (-not (Has-Prop $Entry "stock")) {
        return
    }
    Check-Shop-Stock-Value (Get-Prop $Entry "stock" $null) "$Context item $ItemId"
}

function Check-Shop-Stock-Block($Stock, $AllowedItemIds, [string]$Context) {
    if ($null -eq $Stock -or $Stock -is [System.Array] -or $Stock -is [string] -or $Stock -is [ValueType]) {
        Fail "$Context stock must be an object."
    }
    foreach ($prop in @($Stock.PSObject.Properties)) {
        $itemId = [string]$prop.Name
        if ([string]::IsNullOrWhiteSpace($itemId)) {
            Fail "$Context stock has an empty item id."
        }
        if (-not $AllowedItemIds.Contains($itemId)) {
            Fail "$Context stock references item outside that shop branch: $itemId"
        }
        Check-Shop-Stock-Value $prop.Value "$Context stock $itemId"
    }
}

function Check-Shop($Shop, [string]$ScenarioId) {
    if ($null -eq $Shop) {
        return
    }
    if (($Shop -is [System.Array]) -or ($Shop -is [string])) {
        Fail "Scenario $ScenarioId shop must be an object."
    }
    if (-not (Has-Prop $Shop "items")) {
        Fail "Scenario $ScenarioId shop is missing items."
    }

    $shopItemsValue = $Shop.items
    if (-not ($shopItemsValue -is [System.Array])) {
        Fail "Scenario $ScenarioId shop items must be an array."
    }
    $shopItems = @($shopItemsValue)

    $seenShopItems = New-Object System.Collections.Generic.HashSet[string]
    foreach ($entry in $shopItems) {
        $itemId = Resolve-Shop-Item-Id $entry "Scenario $ScenarioId shop"
        if ([string]::IsNullOrWhiteSpace($itemId)) {
            Fail "Scenario $ScenarioId shop has an empty item id."
        }
        if (-not $seenShopItems.Add($itemId)) {
            Fail "Scenario $ScenarioId shop has duplicate item: $itemId"
        }
        if (-not $itemIds.Contains($itemId)) {
            Fail "Scenario $ScenarioId shop references unknown item: $itemId"
        }
        Check-Shop-Entry-Stock $entry $itemId "Scenario $ScenarioId shop"
        $item = Find-Item-Def $itemId
        if ([int](Get-Prop $item "price" 0) -le 0) {
            Fail "Scenario $ScenarioId shop item $itemId has no positive price."
        }
    }
    if (Has-Prop $Shop "stock") {
        Check-Shop-Stock-Block $Shop.stock $seenShopItems "Scenario $ScenarioId shop"
    }
    if (Has-Prop $Shop "merchant") {
        $merchant = Get-Prop $Shop "merchant" $null
        if ($null -eq $merchant -or $merchant -is [string] -or $merchant -is [System.Array]) {
            Fail "Scenario $ScenarioId shop merchant must be an object."
        }
        $merchantName = [string](Get-Prop $merchant "name" "Merchant")
        if ([string]::IsNullOrWhiteSpace($merchantName)) {
            Fail "Scenario $ScenarioId shop merchant has an empty name."
        }
        $merchantLines = Get-Prop $merchant "lines" $null
        if ($null -eq $merchantLines -or -not ($merchantLines -is [System.Array])) {
            Fail "Scenario $ScenarioId shop merchant must have line array."
        }
        foreach ($merchantLine in @($merchantLines)) {
            if ([string]::IsNullOrWhiteSpace([string]$merchantLine)) {
                Fail "Scenario $ScenarioId shop merchant has an empty line."
            }
        }
    }

    $conditionalItems = Get-Prop $Shop "conditional_items" @()
    foreach ($block in @($conditionalItems)) {
        if ($block -is [string] -or $block -is [System.Array]) {
            Fail "Scenario $ScenarioId shop has malformed conditional_items entry."
        }
        $blockFlags = Get-Prop $block "campaign_flags" $null
        if ($null -eq $blockFlags -or @($blockFlags.PSObject.Properties).Count -le 0) {
            Fail "Scenario $ScenarioId shop conditional_items entry must have campaign_flags."
        }
        Check-Required-Flags $blockFlags $ScenarioId "shop conditional_items"
        $blockItems = Get-Prop $block "items" $null
        if ($null -eq $blockItems) {
            Fail "Scenario $ScenarioId shop conditional_items entry must have item array."
        }
        $seenConditionalItems = New-Object System.Collections.Generic.HashSet[string]
        foreach ($baseItem in $shopItems) {
            $seenConditionalItems.Add((Resolve-Shop-Item-Id $baseItem "Scenario $ScenarioId shop")) | Out-Null
        }
        foreach ($entry in @($blockItems)) {
            $itemId = Resolve-Shop-Item-Id $entry "Scenario $ScenarioId shop conditional_items"
            if ([string]::IsNullOrWhiteSpace($itemId)) {
                Fail "Scenario $ScenarioId shop conditional_items has an empty item id."
            }
            if (-not $seenConditionalItems.Add($itemId)) {
                Fail "Scenario $ScenarioId shop conditional_items duplicates item already in that branch: $itemId"
            }
            if (-not $itemIds.Contains($itemId)) {
                Fail "Scenario $ScenarioId shop conditional_items references unknown item: $itemId"
            }
            Check-Shop-Entry-Stock $entry $itemId "Scenario $ScenarioId shop conditional_items"
            $item = Find-Item-Def $itemId
            if ([int](Get-Prop $item "price" 0) -le 0) {
                Fail "Scenario $ScenarioId shop conditional item $itemId has no positive price."
            }
        }
        if (Has-Prop $block "stock") {
            Check-Shop-Stock-Block $block.stock $seenConditionalItems "Scenario $ScenarioId shop conditional_items"
        }
    }
}

function Resolve-Cell($Cell, [string]$Context) {
    if (-not ($Cell -is [System.Array]) -or $Cell.Count -lt 2) {
        Fail "$Context has a malformed cell."
    }
    return @{ X = [int]$Cell[0]; Y = [int]$Cell[1] }
}

function Cell-Key([int]$X, [int]$Y) {
    return "$X,$Y"
}

function Check-Formation($Formation, [string]$ScenarioId, [int]$Width, [int]$Height, $Rows, $Deployments) {
    if ($null -eq $Formation) {
        return
    }
    if (($Formation -is [System.Array]) -or ($Formation -is [string])) {
        Fail "Scenario $ScenarioId formation must be an object."
    }
    if (-not (Has-Prop $Formation "cells")) {
        Fail "Scenario $ScenarioId formation is missing cells."
    }
    $cellsValue = $Formation.cells
    if (-not ($cellsValue -is [System.Array])) {
        Fail "Scenario $ScenarioId formation cells must be an array."
    }

    $formationKeys = New-Object System.Collections.Generic.HashSet[string]
    foreach ($cellEntry in @($cellsValue)) {
        $cell = Resolve-Cell $cellEntry "Scenario $ScenarioId formation"
        $x = [int]$cell.X
        $y = [int]$cell.Y
        if ($x -lt 0 -or $y -lt 0 -or $x -ge $Width -or $y -ge $Height) {
            Fail "Scenario $ScenarioId formation cell is outside map at [$x,$y]."
        }
        if (-not $formationKeys.Add((Cell-Key $x $y))) {
            Fail "Scenario $ScenarioId formation has duplicate cell: [$x,$y]"
        }
        $terrainKey = Terrain-At $Rows $x $y
        if ([int]$terrain.$terrainKey.move_cost.foot -ge 99 -and [int]$terrain.$terrainKey.move_cost.mounted -ge 99 -and [int]$terrain.$terrainKey.move_cost.archer -ge 99) {
            Fail "Scenario $ScenarioId formation cell [$x,$y] is impassable for normal units."
        }
    }

    $playerCount = 0
    foreach ($deployment in $Deployments) {
        $team = [string](Get-Prop $deployment "team" "enemy")
        $pos = Get-Prop $deployment "pos" @()
        if ($pos.Count -lt 2) {
            continue
        }
        $x = [int]$pos[0]
        $y = [int]$pos[1]
        $key = Cell-Key $x $y
        if ($team -eq "player") {
            $playerCount += 1
            if (-not $formationKeys.Contains($key)) {
                Fail "Scenario $ScenarioId player deployment $($deployment.unit_id) starts outside formation cells."
            }
        } elseif ($formationKeys.Contains($key)) {
            Fail "Scenario $ScenarioId formation overlaps enemy deployment at [$x,$y]."
        }
    }
    if ($formationKeys.Count -lt $playerCount) {
        Fail "Scenario $ScenarioId formation has fewer cells than player deployments."
    }
}

function Check-Roster($Roster, [string]$ScenarioId, $Deployments) {
    if ($null -eq $Roster) {
        return
    }
    if (($Roster -is [System.Array]) -or ($Roster -is [string])) {
        Fail "Scenario $ScenarioId roster must be an object."
    }

    $playerOfficerIds = New-Object System.Collections.Generic.HashSet[string]
    $playerUnitIds = New-Object System.Collections.Generic.HashSet[string]
    $officerToUnitIds = @{}
    $playerCount = 0
    foreach ($deployment in $Deployments) {
        if ([string](Get-Prop $deployment "team" "enemy") -ne "player") {
            continue
        }
        $playerCount += 1
        $unitId = [string](Get-Prop $deployment "unit_id" (Get-Prop $deployment "id" ""))
        if (-not [string]::IsNullOrWhiteSpace($unitId)) {
            $playerUnitIds.Add($unitId) | Out-Null
        }
        $officerId = [string](Get-Prop $deployment "officer_id" "")
        if (-not [string]::IsNullOrWhiteSpace($officerId)) {
            $playerOfficerIds.Add($officerId) | Out-Null
            if (-not $officerToUnitIds.ContainsKey($officerId)) {
                $officerToUnitIds[$officerId] = @()
            }
            $officerToUnitIds[$officerId] += $unitId
        }
    }
    if ($playerCount -le 0) {
        Fail "Scenario $ScenarioId roster has no player deployments."
    }

    $maxUnits = [int](Get-Prop $Roster "max_units" $playerCount)
    if ($maxUnits -le 0 -or $maxUnits -gt $playerCount) {
        Fail "Scenario $ScenarioId roster max_units must be between 1 and player deployment count."
    }

    $requiredValue = @()
    if (Has-Prop $Roster "required_officers") {
        $requiredValue = $Roster.required_officers
    }
    if (-not ($requiredValue -is [System.Array])) {
        Fail "Scenario $ScenarioId roster required_officers must be an array."
    }
    $required = New-Object System.Collections.Generic.HashSet[string]
    $requiredDeploymentUnitIds = New-Object System.Collections.Generic.HashSet[string]
    foreach ($officerIdValue in @($requiredValue)) {
        $officerId = [string]$officerIdValue
        if ([string]::IsNullOrWhiteSpace($officerId)) {
            Fail "Scenario $ScenarioId roster has an empty required officer."
        }
        if (-not $required.Add($officerId)) {
            Fail "Scenario $ScenarioId roster has duplicate required officer: $officerId"
        }
        if (-not $playerOfficerIds.Contains($officerId)) {
            Fail "Scenario $ScenarioId roster requires officer not in player deployments: $officerId"
        }
        foreach ($unitId in @($officerToUnitIds[$officerId])) {
            if (-not [string]::IsNullOrWhiteSpace([string]$unitId)) {
                $requiredDeploymentUnitIds.Add([string]$unitId) | Out-Null
            }
        }
    }

    $requiredUnitsValue = @()
    if (Has-Prop $Roster "required_units") {
        $requiredUnitsValue = $Roster.required_units
    }
    if (-not ($requiredUnitsValue -is [System.Array])) {
        Fail "Scenario $ScenarioId roster required_units must be an array."
    }
    $requiredUnits = New-Object System.Collections.Generic.HashSet[string]
    foreach ($unitIdValue in @($requiredUnitsValue)) {
        $unitId = [string]$unitIdValue
        if ([string]::IsNullOrWhiteSpace($unitId)) {
            Fail "Scenario $ScenarioId roster has an empty required unit."
        }
        if (-not $requiredUnits.Add($unitId)) {
            Fail "Scenario $ScenarioId roster has duplicate required unit: $unitId"
        }
        if (-not $playerUnitIds.Contains($unitId)) {
            Fail "Scenario $ScenarioId roster requires unit not in player deployments: $unitId"
        }
        $requiredDeploymentUnitIds.Add($unitId) | Out-Null
    }
    if ($requiredDeploymentUnitIds.Count -gt $maxUnits) {
        Fail "Scenario $ScenarioId roster requires more units than max_units."
    }
}

function Check-Joined-Deployment-Requirements($Deployments, [string]$ScenarioId) {
    foreach ($deployment in $Deployments) {
        if ([string](Get-Prop $deployment "team" "enemy") -ne "player") {
            continue
        }
        if (-not [bool](Get-Prop $deployment "requires_joined" $false)) {
            continue
        }
        $officerId = [string](Get-Prop $deployment "officer_id" "")
        if ([string]::IsNullOrWhiteSpace($officerId)) {
            Fail "Scenario $ScenarioId deployment $($deployment.unit_id) requires joined officer but has no officer_id."
        }
        if (-not $joinedOfficerIds.Contains($officerId)) {
            Fail "Scenario $ScenarioId deployment $($deployment.unit_id) requires officer before they join: $officerId"
        }
    }
}

function Check-Required-Officers-Joined($Roster, $Conditions, [string]$ScenarioId) {
    if ($null -ne $Roster -and (Has-Prop $Roster "required_officers")) {
        foreach ($officerIdValue in @($Roster.required_officers)) {
            $officerId = [string]$officerIdValue
            if (-not $joinedOfficerIds.Contains($officerId)) {
                Fail "Scenario $ScenarioId roster requires officer before they join: $officerId"
            }
        }
    }

    $conditionRequired = New-Object System.Collections.Generic.HashSet[string]
    if ($null -ne $Conditions -and (Has-Prop $Conditions "defeat")) {
        Collect-Required-Player-Officers $Conditions.defeat $conditionRequired
    }
    foreach ($officerId in $conditionRequired) {
        if (-not $joinedOfficerIds.Contains($officerId)) {
            Fail "Scenario $ScenarioId defeat condition references officer before they join: $officerId"
        }
    }
}

function Collect-Required-Player-Officers($Condition, $Result) {
    if ($Condition -is [System.Array]) {
        foreach ($entry in $Condition) {
            Collect-Required-Player-Officers $entry $Result
        }
        return
    }
    if (-not (Has-Prop $Condition "type")) {
        return
    }
    $type = [string]$Condition.type
    if ($type -eq "any_officer_defeated" -and [string](Get-Prop $Condition "team" "") -eq "player") {
        foreach ($officerId in (Get-Prop $Condition "officer_ids" @())) {
            $Result.Add([string]$officerId) | Out-Null
        }
    }
    if (($type -eq "all" -or $type -eq "any") -and (Has-Prop $Condition "conditions")) {
        Collect-Required-Player-Officers $Condition.conditions $Result
    }
}

function Check-Roster-Condition-Consistency($Roster, $Conditions, [string]$ScenarioId) {
    $conditionRequired = New-Object System.Collections.Generic.HashSet[string]
    if ($null -ne $Conditions -and (Has-Prop $Conditions "defeat")) {
        Collect-Required-Player-Officers $Conditions.defeat $conditionRequired
    }
    if ($conditionRequired.Count -le 0) {
        return
    }

    $rosterRequired = New-Object System.Collections.Generic.HashSet[string]
    if ($null -ne $Roster -and (Has-Prop $Roster "required_officers")) {
        foreach ($officerIdValue in @($Roster.required_officers)) {
            $rosterRequired.Add([string]$officerIdValue) | Out-Null
        }
    }
    foreach ($officerId in $conditionRequired) {
        if (-not $rosterRequired.Contains($officerId)) {
            Fail "Scenario $ScenarioId defeat condition officer must be required in roster: $officerId"
        }
    }
}

function Check-Officer-Transition-List($Value, [string]$ScenarioId, [string]$Context) {
    $result = New-Object System.Collections.Generic.HashSet[string]
    if ($null -eq $Value) {
        return ,$result
    }
    foreach ($officerIdValue in @($Value)) {
        $officerId = [string]$officerIdValue
        if ([string]::IsNullOrWhiteSpace($officerId)) {
            Fail "Scenario $ScenarioId $Context has empty officer id."
        }
        if (-not $officerIds.Contains($officerId)) {
            Fail "Scenario $ScenarioId $Context references unknown officer: $officerId"
        }
        if (-not $result.Add($officerId)) {
            Fail "Scenario $ScenarioId $Context has duplicate officer: $officerId"
        }
    }
    return ,$result
}

function Check-Post-Battle-Choices($Choices, [string]$ScenarioId) {
    if ($null -eq $Choices) {
        return
    }
    if (-not ($Choices -is [System.Array])) {
        Fail "Scenario $ScenarioId post_battle_choices must be an array."
    }

    $seenChoices = New-Object System.Collections.Generic.HashSet[string]
    foreach ($choice in @($Choices)) {
        if ($choice -is [string] -or $choice -is [System.Array]) {
            Fail "Scenario $ScenarioId has malformed post-battle choice."
        }
        $choiceId = [string](Get-Prop $choice "id" "")
        if ([string]::IsNullOrWhiteSpace($choiceId)) {
            Fail "Scenario $ScenarioId post-battle choice has empty id."
        }
        if (-not ($choiceId -match "^[a-z0-9_]+$")) {
            Fail "Scenario $ScenarioId post-battle choice has unstable id: $choiceId"
        }
        if (-not $seenChoices.Add($choiceId)) {
            Fail "Scenario $ScenarioId has duplicate post-battle choice: $choiceId"
        }
        if ([string]::IsNullOrWhiteSpace([string](Get-Prop $choice "label" ""))) {
            Fail "Scenario $ScenarioId post-battle choice $choiceId has empty label."
        }
        if ([int](Get-Prop $choice "gold" 0) -lt 0) {
            Fail "Scenario $ScenarioId post-battle choice $choiceId has negative gold."
        }
        $choiceItems = @()
        if (Has-Prop $choice "items") {
            $choiceItems = @($choice.items)
        }
        foreach ($itemId in @($choiceItems)) {
            if (-not $itemIds.Contains([string]$itemId)) {
                Fail "Scenario $ScenarioId post-battle choice $choiceId grants unknown item: $itemId"
            }
        }
        $choiceJoins = Check-Officer-Transition-List (Get-Prop $choice "join_officers" $null) $ScenarioId "post-battle choice $choiceId join_officers"
        $choiceLeaves = Check-Officer-Transition-List (Get-Prop $choice "leave_officers" $null) $ScenarioId "post-battle choice $choiceId leave_officers"
        foreach ($officerId in $choiceJoins) {
            if ($choiceLeaves.Contains($officerId)) {
                Fail "Scenario $ScenarioId post-battle choice $choiceId both joins and leaves officer: $officerId"
            }
            if ($joinedOfficerIds.Contains($officerId)) {
                Fail "Scenario $ScenarioId post-battle choice $choiceId joins already joined officer: $officerId"
            }
        }
        foreach ($officerId in $choiceLeaves) {
            if (-not $joinedOfficerIds.Contains($officerId)) {
                Fail "Scenario $ScenarioId post-battle choice $choiceId leaves officer before they join: $officerId"
            }
        }
        $setFlags = Get-Prop $choice "set_flags" $null
        if ($null -eq $setFlags) {
            Fail "Scenario $ScenarioId post-battle choice $choiceId has no set_flags."
        }
        if ($setFlags -is [string] -or $setFlags -is [System.Array]) {
            Fail "Scenario $ScenarioId post-battle choice $choiceId set_flags must be an object."
        }
        foreach ($flag in $setFlags.PSObject.Properties) {
            if ([string]::IsNullOrWhiteSpace([string]$flag.Name)) {
                Fail "Scenario $ScenarioId post-battle choice $choiceId has empty flag name."
            }
            if (-not ([string]$flag.Name -match "^[a-z0-9_]+$")) {
                Fail "Scenario $ScenarioId post-battle choice $choiceId has unstable flag name: $($flag.Name)"
            }
            if ($null -eq $flag.Value -or $flag.Value -is [System.Array] -or $flag.Value -is [pscustomobject]) {
                Fail "Scenario $ScenarioId post-battle choice $choiceId flag $($flag.Name) must be scalar."
            }
            $flagName = [string]$flag.Name
            $flagKind = Get-Flag-Value-Kind $flag.Value
            if ($knownFlagValueKinds.ContainsKey($flagName) -and $knownFlagValueKinds[$flagName] -ne $flagKind) {
                Fail "Scenario $ScenarioId post-battle choice $choiceId changes flag type: $flagName"
            }
            $knownFlagIds.Add($flagName) | Out-Null
            $knownFlagValueKinds[$flagName] = $flagKind
        }
    }
}

function Check-Event-Ids($Events, [string]$ScenarioId) {
    $eventIds = New-Object System.Collections.Generic.HashSet[string]
    if ($null -eq $Events) {
        return ,$eventIds
    }
    if (-not ($Events -is [System.Array])) {
        Fail "Scenario $ScenarioId events must be an array."
    }
    foreach ($event in @($Events)) {
        if ($event -is [string] -or $event -is [System.Array]) {
            Fail "Scenario $ScenarioId has malformed event."
        }
        if (-not (Has-Prop $event "id")) {
            Fail "Scenario $ScenarioId event has empty id."
        }
        $eventId = [string](Get-Prop $event "id" "")
        if ([string]::IsNullOrWhiteSpace($eventId)) {
            Fail "Scenario $ScenarioId event has empty id."
        }
        if (-not ($eventId -match "^[a-z0-9_]+$")) {
            Fail "Scenario $ScenarioId event has unstable id: $eventId"
        }
        if (-not $eventIds.Add($eventId)) {
            Fail "Scenario $ScenarioId has duplicate event id: $eventId"
        }
    }
    return ,$eventIds
}

function Check-Required-Flags($RequiredFlags, [string]$ScenarioId, [string]$Context) {
    if ($null -eq $RequiredFlags) {
        return
    }
    if ($RequiredFlags -is [string] -or $RequiredFlags -is [System.Array]) {
        Fail "Scenario $ScenarioId $Context campaign_flags must be an object."
    }
    foreach ($flag in $RequiredFlags.PSObject.Properties) {
        $flagName = [string]$flag.Name
        if ([string]::IsNullOrWhiteSpace($flagName)) {
            Fail "Scenario $ScenarioId $Context has empty required flag name."
        }
        if (-not ($flagName -match "^[a-z0-9_]+$")) {
            Fail "Scenario $ScenarioId $Context has unstable required flag name: $flagName"
        }
        if (-not $knownFlagIds.Contains($flagName)) {
            Fail "Scenario $ScenarioId $Context requires unknown campaign flag: $flagName"
        }
        if ($null -eq $flag.Value -or $flag.Value -is [System.Array] -or $flag.Value -is [pscustomobject]) {
            Fail "Scenario $ScenarioId $Context required flag $flagName must be scalar."
        }
        $flagKind = Get-Flag-Value-Kind $flag.Value
        if ($knownFlagValueKinds.ContainsKey($flagName) -and $knownFlagValueKinds[$flagName] -ne $flagKind) {
            Fail "Scenario $ScenarioId $Context required flag $flagName has type $flagKind but was defined as $($knownFlagValueKinds[$flagName])."
        }
    }
}

function Check-Event-After-Event($When, [string]$ScenarioId, [string]$EventId, $EventIds) {
    if (-not (Has-Prop $When "after_event")) {
        return
    }
    $afterEvent = [string](Get-Prop $When "after_event" "")
    if ([string]::IsNullOrWhiteSpace($afterEvent)) {
        Fail "Scenario $ScenarioId event $EventId has empty after_event."
    }
    if (-not ($afterEvent -match "^[a-z0-9_]+$")) {
        Fail "Scenario $ScenarioId event $EventId has unstable after_event: $afterEvent"
    }
    if ($afterEvent -eq $EventId) {
        Fail "Scenario $ScenarioId event $EventId cannot depend on itself."
    }
    if (-not $EventIds.Contains($afterEvent)) {
        Fail "Scenario $ScenarioId event $EventId references missing after_event: $afterEvent"
    }
}

function Check-Briefing($Briefing, [string]$ScenarioId) {
    if ($null -eq $Briefing) {
        return
    }
    if ($Briefing -is [string] -or $Briefing -is [System.Array]) {
        Fail "Scenario $ScenarioId briefing must be an object."
    }
    $lines = Get-Prop $Briefing "lines" @()
    foreach ($line in @($lines)) {
        if ([string]::IsNullOrWhiteSpace([string]$line)) {
            Fail "Scenario $ScenarioId briefing has an empty line."
        }
    }
    foreach ($line in @((Get-Prop $Briefing "camp_dialogue" @()))) {
        Check-Dialogue-Line $line "Scenario $ScenarioId briefing camp_dialogue"
    }
    Check-Camp-Conversations (Get-Prop $Briefing "camp_conversations" $null) $ScenarioId

    $conditionalLines = Get-Prop $Briefing "conditional_lines" @()
    foreach ($block in @($conditionalLines)) {
        if ($block -is [string] -or $block -is [System.Array]) {
            Fail "Scenario $ScenarioId briefing has malformed conditional_lines entry."
        }
        $blockFlags = Get-Prop $block "campaign_flags" $null
        if ($null -eq $blockFlags -or @($blockFlags.PSObject.Properties).Count -le 0) {
            Fail "Scenario $ScenarioId briefing conditional_lines entry must have campaign_flags."
        }
        Check-Required-Flags $blockFlags $ScenarioId "briefing conditional_lines"
        $blockLines = Get-Prop $block "lines" $null
        if ($null -eq $blockLines) {
            Fail "Scenario $ScenarioId briefing conditional_lines entry must have line array."
        }
        foreach ($line in @($blockLines)) {
            if ([string]::IsNullOrWhiteSpace([string]$line)) {
                Fail "Scenario $ScenarioId briefing conditional_lines has an empty line."
            }
        }
    }
}

function Check-Camp-Conversations($Conversations, [string]$ScenarioId) {
    if ($null -eq $Conversations) {
        return
    }
    if (-not ($Conversations -is [System.Array])) {
        Fail "Scenario $ScenarioId briefing camp_conversations must be an array."
    }
    $seenConversationIds = New-Object System.Collections.Generic.HashSet[string]
    foreach ($conversation in @($Conversations)) {
        if ($conversation -is [string] -or $conversation -is [System.Array]) {
            Fail "Scenario $ScenarioId briefing camp_conversations has malformed entry."
        }
        $conversationId = [string](Get-Prop $conversation "id" "")
        if ([string]::IsNullOrWhiteSpace($conversationId)) {
            Fail "Scenario $ScenarioId briefing camp_conversations has an empty id."
        }
        if (-not ($conversationId -match '^[a-z0-9_]+$')) {
            Fail "Scenario $ScenarioId briefing camp_conversations has unstable id: $conversationId"
        }
        if (-not $seenConversationIds.Add($conversationId)) {
            Fail "Scenario $ScenarioId briefing camp_conversations has duplicate id: $conversationId"
        }
        $label = [string](Get-Prop $conversation "label" "")
        if ([string]::IsNullOrWhiteSpace($label)) {
            Fail "Scenario $ScenarioId briefing camp_conversations $conversationId has an empty label."
        }
        $group = ([string](Get-Prop $conversation "group" "topic")).Trim().ToLowerInvariant()
        if (-not $validCampConversationGroups.Contains($group)) {
            Fail "Scenario $ScenarioId briefing camp_conversations $conversationId has unknown group: $group"
        }
        $officerId = [string](Get-Prop $conversation "officer_id" "")
        if ($group -eq "officer") {
            if ([string]::IsNullOrWhiteSpace($officerId)) {
                Fail "Scenario $ScenarioId briefing camp_conversations $conversationId needs officer_id."
            }
            if (-not $officerIds.Contains($officerId)) {
                Fail "Scenario $ScenarioId briefing camp_conversations $conversationId references unknown officer: $officerId"
            }
        }
        $conversationLines = Get-Prop $conversation "lines" $null
        if ($null -eq $conversationLines -or -not ($conversationLines -is [System.Array]) -or @($conversationLines).Count -le 0) {
            Fail "Scenario $ScenarioId briefing camp_conversations $conversationId must have non-empty lines."
        }
        foreach ($conversationLine in @($conversationLines)) {
            Check-Dialogue-Line $conversationLine "Scenario $ScenarioId briefing camp_conversations $conversationId"
        }
    }
}

function Check-Dialogue-Line($Line, [string]$Context) {
    if ($Line -is [string]) {
        if ([string]::IsNullOrWhiteSpace([string]$Line)) {
            Fail "$Context has an empty dialogue line."
        }
        return
    }
    if ($Line -is [System.Array] -or -not (Has-Prop $Line "text")) {
        Fail "$Context has malformed dialogue line."
    }
    if ([string]::IsNullOrWhiteSpace([string](Get-Prop $Line "text" ""))) {
        Fail "$Context has an empty dialogue text."
    }
    if (Has-Prop $Line "portrait") {
        Check-Portrait-Path (Get-Prop $Line "portrait" "") "$Context portrait"
    }
    if (Has-Prop $Line "side") {
        $side = ([string](Get-Prop $Line "side" "")).Trim().ToLowerInvariant()
        if (-not $validDialogueSides.Contains($side)) {
            Fail "$Context has unknown dialogue side: $side"
        }
    }
}

function Check-Portrait-Path($Portrait, [string]$Context) {
    if ($Portrait -is [System.Array] -or $Portrait -is [System.Management.Automation.PSCustomObject]) {
        Fail "$Context must be a string resource path."
    }
    if ([string]::IsNullOrWhiteSpace([string]$Portrait)) {
        Fail "$Context must not be empty."
    }
    if ([string]$Portrait -notmatch $validPortraitPathPattern) {
        Fail "$Context must be a res:// image path."
    }
    $relativePath = ([string]$Portrait).Substring("res://".Length)
    $rootPath = [System.IO.Path]::GetFullPath($Root)
    $absolutePath = [System.IO.Path]::GetFullPath((Join-Path $rootPath $relativePath))
    $trimChars = @([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
    $rootPathWithSeparator = $rootPath.TrimEnd($trimChars) + [System.IO.Path]::DirectorySeparatorChar
    $insideRoot = $absolutePath.Equals($rootPath, [System.StringComparison]::OrdinalIgnoreCase) -or $absolutePath.StartsWith($rootPathWithSeparator, [System.StringComparison]::OrdinalIgnoreCase)
    if (-not $insideRoot) {
        Fail "$Context must stay inside the project: $Portrait"
    }
    if (-not (Test-Path -LiteralPath $absolutePath -PathType Leaf)) {
        Fail "$Context references missing file: $Portrait"
    }
    Check-Portrait-Image $absolutePath ([string]$Portrait) $Context
}

function Check-Portrait-Image([string]$AbsolutePath, [string]$Portrait, [string]$Context) {
    if (-not $portraitImageInspectorReady) {
        Fail "$Context cannot inspect portrait image metadata: $Portrait"
    }
    $image = $null
    $width = 0
    $height = 0
    try {
        $image = [System.Drawing.Image]::FromFile($AbsolutePath)
        $width = [int]$image.Width
        $height = [int]$image.Height
    } catch {
        Fail "$Context references an unreadable image file: $Portrait"
    } finally {
        if ($null -ne $image) {
            $image.Dispose()
        }
    }
    if ($width -lt $minimumPortraitDimension -or $height -lt $minimumPortraitDimension) {
        Fail "$Context image is too small: $Portrait ($width x $height, minimum ${minimumPortraitDimension}x${minimumPortraitDimension})"
    }
}

function Check-Post-Battle-Dialogue($Dialogue, [string]$ScenarioId) {
    if ($null -eq $Dialogue) {
        return
    }
    $lines = @($Dialogue)
    if ($lines.Count -le 0) {
        Fail "Scenario $ScenarioId post_battle_dialogue must not be empty."
    }
    foreach ($line in $lines) {
        Check-Dialogue-Line $line "Scenario $ScenarioId post_battle_dialogue"
    }
}

function Check-Join-Officer-Rewards($Rewards, [string]$ScenarioId) {
    if ($null -eq $Rewards) {
        return
    }
    $joinOfficers = Check-Officer-Transition-List (Get-Prop $Rewards "join_officers" $null) $ScenarioId "rewards join_officers"
    $leaveOfficers = Check-Officer-Transition-List (Get-Prop $Rewards "leave_officers" $null) $ScenarioId "rewards leave_officers"
    foreach ($officerId in $joinOfficers) {
        if ($leaveOfficers.Contains($officerId)) {
            Fail "Scenario $ScenarioId rewards both joins and leaves officer: $officerId"
        }
        if ($joinedOfficerIds.Contains($officerId)) {
            Fail "Scenario $ScenarioId rewards joins already joined officer: $officerId"
        }
    }
    foreach ($officerId in $leaveOfficers) {
        if (-not $joinedOfficerIds.Contains($officerId)) {
            Fail "Scenario $ScenarioId rewards leaves officer before they join: $officerId"
        }
    }
    foreach ($officerId in $joinOfficers) {
        $joinedOfficerIds.Add($officerId) | Out-Null
    }
    foreach ($officerId in $leaveOfficers) {
        $joinedOfficerIds.Remove($officerId) | Out-Null
    }
}

Check-Raw-Skills-Arrays
Check-Item-Effects
Check-Terrain-Definitions
Check-Skill-Definitions
Check-Class-Equipment-Slots
Check-Class-Promotions

foreach ($classProp in $classes.PSObject.Properties) {
    Check-Skill-Refs (Get-Prop $classProp.Value "skills" @()) "Class $($classProp.Name)"
    if (Has-Prop $classProp.Value "sprite") {
        Check-Portrait-Path (Get-Prop $classProp.Value "sprite" "") "Class $($classProp.Name) sprite"
    }
}

$officerNames = New-Object System.Collections.Generic.HashSet[string]
foreach ($officerProp in $officers.PSObject.Properties) {
    $officerName = [string](Get-Prop $officerProp.Value "name" "")
    if ([string]::IsNullOrWhiteSpace($officerName)) {
        Fail "Officer $($officerProp.Name) has empty name."
    }
    $normalizedOfficerName = $officerName.Trim().ToLowerInvariant()
    if (-not $officerNames.Add($normalizedOfficerName)) {
        Fail "Officer $($officerProp.Name) has duplicate name: $officerName"
    }
    Check-Skill-Refs (Get-Prop $officerProp.Value "skills" @()) "Officer $($officerProp.Name)"
    Check-Officer-Equipment $officerProp.Name $officerProp.Value
    if (Has-Prop $officerProp.Value "portrait") {
        Check-Portrait-Path (Get-Prop $officerProp.Value "portrait" "") "Officer $($officerProp.Name) portrait"
    }
}

foreach ($scenario in $campaign.scenarios) {
    $scenarioPath = ([string]$scenario.path).Replace("res://", "")
    $scenarioData = Read-Json $scenarioPath
    $scenarioId = [string]$scenarioData.id
    if ($scenarioId -ne [string]$scenario.id) {
        Fail "Campaign id $($scenario.id) does not match scenario file id $scenarioId."
    }

    $map = $scenarioData.map
    if (Has-Prop $map "background") {
        Check-Portrait-Path (Get-Prop $map "background" "") "Scenario $scenarioId map background"
    }
    $width = [int]$map.width
    $height = [int]$map.height
    $rows = @($map.terrain)
    if ($rows.Count -ne $height) {
        Fail "Scenario $scenarioId map height mismatch."
    }
    foreach ($row in $rows) {
        if ([string]$row.Length -ne [string]$width) {
            Fail "Scenario $scenarioId map row width mismatch: $row"
        }
        foreach ($char in $row.ToCharArray()) {
            if (-not $terrainKeys.Contains([string]$char)) {
                Fail "Scenario $scenarioId uses unknown terrain key: $char"
            }
        }
    }

    $eventIds = Check-Event-Ids (Get-Prop $scenarioData "events" $null) $scenarioId
    $knownUnitIds = Collect-Scenario-Unit-Ids $scenarioData

    if (Has-Prop $scenarioData "conditions") {
        if (Has-Prop $scenarioData.conditions "victory") {
            Check-Condition $scenarioData.conditions.victory $scenarioId $eventIds $width $height $rows $knownUnitIds
        }
        if (Has-Prop $scenarioData.conditions "defeat") {
            Check-Condition $scenarioData.conditions.defeat $scenarioId $eventIds $width $height $rows $knownUnitIds
        }
    }

    $seenIds = New-Object System.Collections.Generic.HashSet[string]
    foreach ($deployment in $scenarioData.deployments) {
        Check-Deployment $deployment $scenarioId $width $height $rows $seenIds
    }

    Check-Briefing (Get-Prop $scenarioData "briefing" $null) $scenarioId
    Check-Shop (Get-Prop $scenarioData "shop" $null) $scenarioId
    Check-Joined-Deployment-Requirements $scenarioData.deployments $scenarioId
    Check-Roster (Get-Prop $scenarioData "roster" $null) $scenarioId $scenarioData.deployments
    Check-Required-Officers-Joined (Get-Prop $scenarioData "roster" $null) (Get-Prop $scenarioData "conditions" $null) $scenarioId
    Check-Roster-Condition-Consistency (Get-Prop $scenarioData "roster" $null) (Get-Prop $scenarioData "conditions" $null) $scenarioId
    Check-Formation (Get-Prop $scenarioData "formation" $null) $scenarioId $width $height $rows $scenarioData.deployments

    foreach ($event in (Get-Prop $scenarioData "events" @())) {
        $eventId = [string](Get-Prop $event "id" "event")
        $when = Get-Prop $event "when" $null
        if ($null -eq $when -or $when -is [string] -or $when -is [System.Array]) {
            Fail "Scenario $scenarioId event $eventId has malformed when."
        }
        Check-Event-After-Event $when $scenarioId $eventId $eventIds
        Check-Required-Flags (Get-Prop $when "campaign_flags" $null) $scenarioId "event $eventId"
        $trigger = [string]$event.when.type
        if (-not $validTriggers.Contains($trigger)) {
            Fail "Scenario $scenarioId has unknown trigger: $trigger"
        }
        if ($trigger -eq "unit_reaches_tile") {
            Check-Unit-Reaches-Event-When $when $scenarioId $width $height $rows $knownUnitIds
        }
        if ($trigger -eq "unit_defeated") {
            Check-Unit-Defeated-Event-When $when $scenarioId $knownUnitIds
        }
        foreach ($action in (Get-Prop $event "actions" @())) {
            $actionType = [string]$action.type
            if (-not $validActions.Contains($actionType)) {
                Fail "Scenario $scenarioId has unknown event action: $actionType"
            }
            if ($actionType -eq "spawn_deployment") {
                Check-Deployment $action.deployment $scenarioId $width $height $rows $seenIds
            }
            if ($actionType -eq "spawn_deployments") {
                foreach ($deployment in $action.deployments) {
                    Check-Deployment $deployment $scenarioId $width $height $rows $seenIds
                }
            }
            if ($actionType -eq "dialogue") {
                if (Has-Prop $action "lines") {
                    $rawLines = Get-Prop $action "lines" $null
                    $lines = @($rawLines)
                    if ($null -eq $rawLines -or $lines.Count -le 0) {
                        Fail "Scenario $scenarioId event $eventId dialogue action has malformed lines."
                    }
                    foreach ($line in $lines) {
                        Check-Dialogue-Line $line "Scenario $scenarioId event $eventId dialogue"
                    }
                } else {
                    Check-Dialogue-Line $action "Scenario $scenarioId event $eventId dialogue"
                }
            }
            if ($actionType -eq "grant_item") {
                Check-Event-Grant-Item $action "Scenario $scenarioId event $eventId grant_item"
            }
            if ($actionType -eq "grant_items") {
                $rawItems = Get-Prop $action "items" $null
                $grantItems = @($rawItems)
                if ($null -eq $rawItems -or $grantItems.Count -le 0) {
                    Fail "Scenario $scenarioId event $eventId grant_items action has malformed items."
                }
                foreach ($entry in $grantItems) {
                    Check-Event-Grant-Item $entry "Scenario $scenarioId event $eventId grant_items"
                }
            }
            if ($actionType -eq "grant_gold") {
                Check-Event-Grant-Gold $action "Scenario $scenarioId event $eventId grant_gold"
            }
            if ($actionType -eq "withdraw_unit") {
                $unitId = [string](Get-Prop $action "unit_id" (Get-Prop $action "id" ""))
                Check-Event-Withdraw-Unit-Id $unitId $knownUnitIds "Scenario $scenarioId event $eventId withdraw_unit"
            }
            if ($actionType -eq "withdraw_units") {
                Check-Event-Withdraw-Units (Get-Prop $action "unit_ids" $null) $knownUnitIds "Scenario $scenarioId event $eventId withdraw_units"
            }
            if ($actionType -eq "set_ai_target_priority") {
                Check-Event-Ai-Target-Priority $action $knownUnitIds "Scenario $scenarioId event $eventId set_ai_target_priority"
            }
        }
    }

    foreach ($itemId in (Get-Prop (Get-Prop $scenarioData "rewards" @{}) "items" @())) {
        if (-not $itemIds.Contains([string]$itemId)) {
            Fail "Scenario $scenarioId rewards unknown item: $itemId"
        }
    }

    Check-Join-Officer-Rewards (Get-Prop $scenarioData "rewards" $null) $scenarioId
    Check-Post-Battle-Dialogue (Get-Prop $scenarioData "post_battle_dialogue" $null) $scenarioId
    Check-Post-Battle-Choices (Get-Prop $scenarioData "post_battle_choices" $null) $scenarioId
}

"data validation ok"
