$ErrorActionPreference = "Stop" $repositoryRoot = Split-Path -Parent $PSScriptRoot $releaseRoot = Join-Path $repositoryRoot "42.20" $failures = [Collections.Generic.List[string]]::new() function Assert-True { param([bool]$Condition, [string]$Message) if (-not $Condition) { $script:failures.Add($Message) } } $expectedFiles = @( "media/lua/shared/TowBar/WreckerUtils.lua", "media/lua/client/TowBar/WreckerTimedAction.lua", "media/lua/client/TowBar/WreckerUI.lua", "media/lua/client/TowBar/WreckerSyncClient.lua", "media/lua/server/WreckerCommands.lua" ) foreach ($relativePath in $expectedFiles) { Assert-True (Test-Path -LiteralPath (Join-Path $releaseRoot $relativePath)) "Missing wrecker runtime file: $relativePath" } $sharedPath = Join-Path $releaseRoot "media/lua/shared/TowBar/WreckerUtils.lua" $timedActionPath = Join-Path $releaseRoot "media/lua/client/TowBar/WreckerTimedAction.lua" $uiPath = Join-Path $releaseRoot "media/lua/client/TowBar/WreckerUI.lua" $syncPath = Join-Path $releaseRoot "media/lua/client/TowBar/WreckerSyncClient.lua" $serverPath = Join-Path $releaseRoot "media/lua/server/WreckerCommands.lua" $persistencePath = Join-Path $releaseRoot "media/lua/shared/TowBar/Persistence.lua" $menuPath = Join-Path $releaseRoot "media/lua/client/TowBar/TowingUI.lua" $shared = if (Test-Path $sharedPath) { Get-Content $sharedPath -Raw } else { "" } $timedAction = if (Test-Path $timedActionPath) { Get-Content $timedActionPath -Raw } else { "" } $ui = if (Test-Path $uiPath) { Get-Content $uiPath -Raw } else { "" } $sync = if (Test-Path $syncPath) { Get-Content $syncPath -Raw } else { "" } $server = if (Test-Path $serverPath) { Get-Content $serverPath -Raw } else { "" } $persistence = if (Test-Path $persistencePath) { Get-Content $persistencePath -Raw } else { "" } $menu = Get-Content $menuPath -Raw $supportedWreckers = @( "Base.76chevyC30CCwrecker", "Base.76chevyC30SCwrecker", "Base.76chevyK30CCwrecker", "Base.76chevyK30SCwrecker", "Base.93chevySilveradoK3500wrecker", "Base.93chevySilveradoK3500mechanic", "Base.78amgeneralM62" ) foreach ($fullName in $supportedWreckers) { Assert-True ($shared.Contains($fullName)) "Supported wrecker missing from exact allowlist: $fullName" } $allowlistStart = $shared.IndexOf("local SupportedWreckers = {") $allowlistEnd = $shared.IndexOf("local HeightAttachmentIds", $allowlistStart) Assert-True ($allowlistStart -ge 0 -and $allowlistEnd -gt $allowlistStart) "Could not isolate the supported wrecker allowlist." if ($allowlistStart -ge 0 -and $allowlistEnd -gt $allowlistStart) { $allowlistBody = $shared.Substring($allowlistStart, $allowlistEnd - $allowlistStart) $actualWreckers = [regex]::Matches($allowlistBody, '\["(Base\.[^"]+)"\]\s*=\s*true') | ForEach-Object { $_.Groups[1].Value } | Sort-Object Assert-True (-not (Compare-Object ($supportedWreckers | Sort-Object) $actualWreckers)) "Supported wrecker allowlist must contain exactly the seven verified KI5 vehicle scripts." } Assert-True ($shared -match '(?s)function Wrecker\.isSupportedWrecker.*?SupportedWreckers\[fullName\].*?attachmentExist\("hook"\)') "Wrecker detection must require an exact script name and a hook attachment." Assert-True ($shared -notmatch 'Chevalier_Rhino_TowTruck') "The competing Rhino tow-truck implementation must not be supported by this wrecker system." Assert-True ($shared -match 'Wrecker\.MaxAttachDistance = 0\.6') "Wrecker target selection must use the discovered 0.6-unit hook range." Assert-True ($shared -match 'Wrecker\.HeightStep = 0\.30') "Hook height must move in 0.30-unit steps." Assert-True ($shared -match 'Wrecker\.MinHeightLevel = 0' -and $shared -match 'Wrecker\.MaxHeightLevel = 2') "Hook height must begin at bottom and allow exactly two upward steps." Assert-True ($shared -match 'towbarWreckerHookLow' -and $shared -match 'towbarWreckerHookMid' -and $shared -match 'towbarWreckerHookHigh') "Wreckers must use immutable low/mid/high hook attachment IDs." Assert-True ($shared -match '(?s)registerHookAttachments.*?ModelAttachment\.new.*?baseOffset:y\(\) \+ level \* Wrecker\.HeightStep') "Hook banks must copy each wrecker's own hook geometry and vary only height." Assert-True ($shared -match '(?s)resolveNearestTarget.*?MaxAttachDistance.*?trailerfront.*?trailer.*?distanceSquared') "Target selection must server-recompute the nearest front/rear tow point." Assert-True ($menu -match '(?s)playerObj:getVehicle\(\).*?TowBarMod\.WreckerUI\.addOptionsToMenu.*?return') "The seated driver radial path must run before the existing early return." Assert-True ($ui -match 'media/textures/tow_car_icon\.png') "Attach radial action must use the supplied tow-car icon." Assert-True ($ui -match 'media/textures/untow_car_icon\.png') "Detach radial action must use the supplied untow-car icon." Assert-True ($ui -match 'media/textures/arrow_up\.png' -and $ui -match 'media/textures/arrow_down\.png') "Height controls must use the supplied arrow icons." Assert-True ($ui -match 'sendClientCommand\(playerObj, "towbar", "attachWrecker"' -and $ui -match 'sendClientCommand\(playerObj, "towbar", "adjustWreckerHeight"') "Wrecker radial actions must request server-authoritative commands." Assert-True ($ui -match 'local AttachDuration = 300' -and $ui -match 'local DetachDuration = 200') "Wrecker attach and detach must take about three and two seconds respectively." Assert-True ($ui -match '(?s)requestAttach.*?WreckerTimedAction:new.*?AttachDuration') "Attach must complete through the three-second timed action." Assert-True ($ui -match '(?s)requestDetach.*?WreckerTimedAction:new.*?DetachDuration') "Detach must complete through the two-second timed action." Assert-True ($timedAction -match 'ISBaseTimedAction:derive\("WreckerTimedAction"\)' -and $timedAction -match 'self\.isValidFunc') "Wrecker timed actions must revalidate while waiting." Assert-True ($server -match 'function Commands\.attachWrecker') "Missing server-authoritative wrecker attach command." Assert-True ($server -match 'function Commands\.detachWrecker') "Missing server-authoritative wrecker detach command." Assert-True ($server -match 'function Commands\.adjustWreckerHeight') "Missing server-authoritative hook-height command." Assert-True ($server -match '(?s)attachWrecker.*?isDriver\(player\).*?isSupportedWrecker.*?resolveNearestTarget.*?syncPairState\(wrecker, target, hookAttachment, targetAttachment, heightLevel, player\)') "Attach must validate driver, wrecker, geometry, and canonical target before synchronizing a constraint." Assert-True ($server -match '(?s)local function syncPairState.*?if isServer\(\) then.*?addPointConstraint\(player, target, hookAttachment, targetAttachment\).*?broadcastWreckerSync') "Dedicated servers must own the logical towing relation while SP uses the client rigid sync path." Assert-True ($server -match '(?s)auditWreckerPairs.*?shouldSnapshot.*?wreckerAttachSync') "The server must periodically reconcile active wrecker pairs for joining clients." Assert-True ($server -match 'local confirmedWreckerPairs = \{\}') "Wrecker persistence must distinguish restored pairs from live constraint breaks." Assert-True ($server -match 'wreckerTowedVehicleSqlId' -and $server -match 'wreckerTowingVehicleSqlId' -and $server -match 'getSqlId') "Wrecker persistence must use stable save IDs in addition to live network IDs." Assert-True ($server -match 'Persistence\.savePair\(\s*"wrecker"' -and $server -match 'Persistence\.forEachPair\("wrecker"') "Wrecker persistence must use the server-owned saved-pair registry." Assert-True ($server -match '(?s)auditWreckerPairs.*?isLinked\(wrecker, target\).*?wreckerTowedVehicleId.*?setPairState') "Adopting an engine-restored pair must refresh stale live IDs immediately." Assert-True ($server -match '(?s)auditWreckerPairs.*?Persistence\.advanceRecoveryState.*?RestoreRetryMs, SustainedBreakMs.*?action == "break".*?detachPair.*?action == "restore".*?syncPairState') "Missing wrecker constraints must use the shared bounded recovery state machine." Assert-True ($persistence -match '(?s)function Persistence\.advanceRecoveryState.*?state\.unlinkedSince = state\.unlinkedSince or now.*?now - state\.unlinkedSince >= breakMs.*?return state, "break"' -and $persistence -match '(?s)if not state\.pendingUntil or now >= state\.pendingUntil then.*?state\.pendingUntil = now \+ retryMs.*?return state, "restore"') "Wrecker recovery must debounce restores and require a sustained break before cleanup." Assert-True ($server -notmatch '(?s)elseif not target then.*?wreckerTowActive = nil') "Temporarily unloaded targets must not erase saved wrecker state." Assert-True ($server -match '(?s)auditWreckerPairs.*?wreckerTowingVehicleId.*?if towingVehicle and not isExpectedPair.*?wreckerDetachSync') "Loaded reciprocal mismatches must receive orphan cleanup." Assert-True ($server -notmatch '(?s)wreckerTowingVehicleId.*?if not towingVehicle.*?wreckerTowingVehicleId = nil') "Temporarily unloaded wreckers must not erase saved target state." Assert-True ($server -match '(?s)adjustWreckerHeight.*?direction ~= -1 and direction ~= 1.*?nextHeightLevel.*?syncPairState') "Height changes must validate direction, clamp on the server, and broadcast canonical state." Assert-True ($server -notmatch 'TowBar\.TowBar|consumeTowBar|giveTowBar|dropTowBarOnGround|inventory:AddItem') "Wrecker commands must not mutate the separate towbar item lifecycle." Assert-True ($server -match '(?s)onClientCommand.*?getTimestampMs.*?CommandCooldownMs') "Wrecker commands must be rate-limited at the server boundary." Assert-True ($sync -match 'notTowingA_Trailer') "Client sync must use the existing fake-trailer path to select vanilla rigid constraints." Assert-True ($sync -match '(?s)applyAttachSync.*?breakWreckerPair.*?setScriptSafely\(target, "notTowingA_Trailer"\).*?addPointConstraint.*?setScriptSafely\(target, originalScript\)') "Client sync must rebuild the constraint while the target uses the fake trailer script, then restore it." Assert-True ($sync -match 'addPointConstraint\(nil, target, hookAttachment, targetAttachment, true\)') "Wrecker local physics rebuilds must suppress server detach/attach traffic." Assert-True ($sync -match '(?s)local appliedLevel = Sync\.appliedLevels\[key\].*?if isPairLinked\(wrecker, target\).*?and appliedLevel == canonicalLevel then.*?return') "Only a locally applied wrecker constraint at the requested height may be reused." Assert-True ($sync -match '(?s)local appliedLevel = Sync\.appliedLevels\[key\].*?appliedLevel == canonicalLevel.*?return\s+end.*?breakWreckerPair\(wrecker, target\).*?addPointConstraint') "A native-restored wrecker rope without a locally applied height must be rebuilt through the rigid local constraint path." Assert-True ($sync -match '(?s)local function clearAppliedLevelForVehicle.*?Sync\.appliedLevels\[key\] = nil.*?Events\.OnSpawnVehicleEnd\.Add\(clearAppliedLevelForVehicle\)') "Vehicle streaming must invalidate cached local wrecker constraints." Assert-True ($sync -match '(?s)if isPairLinked\(wrecker, target\).*?appliedLevel.*?canonicalLevel.*?breakWreckerPair\(wrecker, target\).*?addPointConstraint') "A requested wrecker height change must rebuild the local constraint at the new hook attachment." Assert-True ($sync -match '(?s)local function applyFreeRollingState.*?wreckerOriginalMass.*?wreckerOriginalBrakingForce.*?setMass\(200\).*?setBrakingForce\(0\)') "Attached vehicles must be made free-rolling like trailers." Assert-True ($sync -match '(?s)local function restoreFreeRollingState.*?setMass.*?setBrakingForce.*?wreckerOriginalMass = nil') "Detach must restore the target vehicle's pre-tow mass and braking state." Assert-True ($sync -match '(?s)local function applyDetachSync.*?if not target then return end.*?if wrecker then breakWreckerPair.*?restoreFreeRollingState\(target\)') "Detach sync must restore a reloaded target even when the former wrecker is unavailable." Assert-True ($sync -match '(?s)applyDetachSync.*?wreckerTowingVehicleSqlId.*?args\.wreckerSqlId.*?expectedWreckerSqlId ~= commandWreckerSqlId') "Detach sync must validate stable IDs when live IDs changed across restart." foreach ($name in @("tow_car_icon.png", "untow_car_icon.png", "arrow_up.png", "arrow_down.png")) { $iconPath = Join-Path $repositoryRoot "common/media/textures/$name" Assert-True (Test-Path -LiteralPath $iconPath) "Missing runtime icon: $name" if (Test-Path -LiteralPath $iconPath) { $bytes = [IO.File]::ReadAllBytes($iconPath) Assert-True ($bytes.Length -gt 24) "$name is not a valid PNG payload." if ($bytes.Length -gt 24) { Assert-True ($bytes[0] -eq 0x89 -and $bytes[1] -eq 0x50 -and $bytes[2] -eq 0x4E -and $bytes[3] -eq 0x47) "$name lacks a PNG signature." $width = [Net.IPAddress]::NetworkToHostOrder([BitConverter]::ToInt32($bytes, 16)) $height = [Net.IPAddress]::NetworkToHostOrder([BitConverter]::ToInt32($bytes, 20)) Assert-True ($width -eq 64 -and $height -eq 64) "$name must be 64x64." } } } $luaFiles = @($sharedPath, $timedActionPath, $uiPath, $syncPath, $serverPath) | Where-Object { Test-Path $_ } if ($luaFiles.Count -gt 0) { & npx --yes luaparse --quiet @luaFiles Assert-True ($LASTEXITCODE -eq 0) "One or more wrecker Lua files failed to parse." } $releaseInfo = Get-Content -LiteralPath (Join-Path $releaseRoot "mod.info") $rootInfo = Get-Content -LiteralPath (Join-Path $repositoryRoot "mod.info") Assert-True ($releaseInfo -contains "incompatible=\STowTruck_B42") "42.20 must mark the competing Tow Truck [B42] implementation incompatible." Assert-True ($rootInfo -contains "incompatible=\STowTruck_B42") "Root metadata must mark the competing Tow Truck [B42] implementation incompatible." Assert-True (@($releaseInfo | Where-Object { $_ -like "incompatible=*" }).Count -eq 1) "42.20 must contain exactly one incompatibility declaration." Assert-True (@($rootInfo | Where-Object { $_ -like "incompatible=*" }).Count -eq 1) "Root metadata must contain exactly one incompatibility declaration." $readme = Get-Content -LiteralPath (Join-Path $repositoryRoot "README.md") -Raw Assert-True ($readme -match '## Supported wrecker mods') "README must publish the supported-wrecker mod list." foreach ($workshopId in @("3161951724", "3152529790", "2799152995")) { Assert-True ($readme.Contains($workshopId)) "README supported list must include Workshop ID $workshopId." } foreach ($modId in @("76chevyKseries", "93chevySuburban", "93chevySuburbanExpanded", "78amgeneralM35A2")) { Assert-True ($readme.Contains($modId)) "README supported list must include Mod ID $modId." } foreach ($fullName in $supportedWreckers) { Assert-True ($readme.Contains($fullName)) "README supported list must include vehicle $fullName." } Assert-True ($readme.Contains("3446203945") -and $readme.Contains("STowTruck_B42") -and $readme -match 'incompatible') "README must identify and explain the competing Tow Truck mod incompatibility." if ($failures.Count -gt 0) { $failures | ForEach-Object { Write-Error $_ } exit 1 } Write-Output "PASS: Build 42.20 wrecker compatibility contracts."