Files
Towbar/tests/validate-wrecker-42.20.ps1
2026-08-19 12:12:12 -04:00

160 lines
15 KiB
PowerShell

$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 not forceReattach and isPairLinked\(wrecker, target\).*?and appliedLevel == canonicalLevel then.*?return') "Only an ordinary sync may reuse a locally applied wrecker constraint at the requested height."
Assert-True ($sync -match '(?s)local function forceReattachForDriver.*?wreckerTowActive.*?wreckerTowedVehicleId.*?wreckerTowingVehicleId.*?applyAttachSync\(\{.*?\}, true\)') "Wrecker driver entry must force a rebuild only for reciprocal persisted wrecker state."
Assert-True ($sync -match 'Events\.OnEnterVehicle\.Add\(forceReattachForDriver\)') "Entering the wrecker as driver must force one rigid reattach."
Assert-True ($sync -notmatch 'Events\.OnTick\.Add') "Wrecker entry repair must remain event-driven without a retry loop."
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 not forceReattach and isPairLinked\(wrecker, target\).*?appliedLevel.*?canonicalLevel.*?breakWreckerPair\(wrecker, target\).*?addPointConstraint') "A requested height or forced driver entry must rebuild the local wrecker constraint."
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."