From 766fce5088f2704d06a2186664844b7ed403015d Mon Sep 17 00:00:00 2001 From: "R3AL.CH1CK3N" Date: Fri, 31 Jul 2026 08:25:57 -0400 Subject: [PATCH] test: add automated map verification harness Eight geometric assertions against the built map, runnable from Linux over the Studio MCP bridge. Each check tests the world that exists rather than trusting that a build reported success. Check 1 is the important one: it fresh-requires Circuit and asserts 823/200/474. Studio caches ModuleScripts, so a stale Circuit will happily build a plausible-looking map of the wrong circuit -- which is exactly what happened before. The shape checks are meaningless without it. Found and fixed while writing this: a Sound with a doubled rbxassetid:// prefix in the imported CarLibrary chassis. Probe note: pillar tops are flush with the deck by design (0.00 clearance), so the car-envelope probe must ride fully above the road surface or it reports its own supports as intrusions. --- scripts/verify_map.luau | 238 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 scripts/verify_map.luau diff --git a/scripts/verify_map.luau b/scripts/verify_map.luau new file mode 100644 index 0000000..4c17639 --- /dev/null +++ b/scripts/verify_map.luau @@ -0,0 +1,238 @@ +--!strict +-- verify_map.luau — automated map regression test. +-- +-- Run from Linux: +-- python3 -c "import sys;sys.path.insert(0,'/tmp');import rbx; +-- print(rbx.call('execute_luau', +-- {'code':open('scripts/verify_map.luau').read(),'datamodel_type':'Edit'}, +-- wait=90,timeout=600))" +-- +-- Exits with a PASS/FAIL summary. Every check is a real geometric assertion +-- against the built map -- no check trusts that a build "ran". +-- +-- IMPORTANT: this must be run against a map built from CURRENT source. Studio +-- caches ModuleScripts, so a stale Circuit will silently pass the shape checks +-- while describing the wrong circuit. Check 1 guards exactly that. + +local RS = game:GetService("ReplicatedStorage") + +local EXPECT_SAMPLES = 823 +local EXPECT_TUNNEL = 200 +local EXPECT_BRIDGE = 474 +local ROAD_HALF = 24 -- ROAD_WIDTH 48 / 2 +local CAR_HALF_W = 5 -- 10-stud car +local CAR_HEIGHT = 5.5 + +local results: { string } = {} +local failures = 0 + +local function check(name: string, ok: boolean, detail: string) + if ok then + table.insert(results, (" PASS %-34s %s"):format(name, detail)) + else + failures += 1 + table.insert(results, (" FAIL %-34s %s"):format(name, detail)) + end +end + +-- Fresh-require the circuit so a cached stale module can't mask a bad build. +local orig = RS.Shared:FindFirstChild("Circuit") +if not orig then + return "FATAL: ReplicatedStorage.Shared.Circuit missing" +end +orig.Name = "_CircuitOld" +local fresh = orig:Clone() +fresh.Name = "Circuit" +fresh.Parent = RS.Shared +local ok, C = pcall(require, fresh) +local function cleanup() + fresh:Destroy() + orig.Name = "Circuit" +end +if not ok then + cleanup() + return "FATAL: Circuit require failed: " .. tostring(C) +end + +local s = C.samples +local n = #s + +-- 1. Source freshness ------------------------------------------------------ +local nTunnel, nBridge = 0, 0 +for i = 1, n do + if s[i].tunnel then + nTunnel += 1 + end + if s[i].bridge then + nBridge += 1 + end +end +check( + "circuit is current source", + n == EXPECT_SAMPLES and nTunnel == EXPECT_TUNNEL and nBridge == EXPECT_BRIDGE, + ("samples=%d tunnel=%d bridge=%d"):format(n, nTunnel, nBridge) +) + +-- 2. Loop closure + spacing ------------------------------------------------ +local minGap, maxGap = math.huge, 0 +for i = 1, n do + local d = (s[(i % n) + 1].pos - s[i].pos).Magnitude + minGap = math.min(minGap, d) + maxGap = math.max(maxGap, d) +end +check("loop closed & evenly spaced", maxGap - minGap < 5, ("gap %.1f..%.1f studs"):format(minGap, maxGap)) + +-- 3. Grade is drivable ----------------------------------------------------- +local maxGrade, gradeAt = 0, 0 +for i = 1, n do + local a, b = s[i].pos, s[(i % n) + 1].pos + local flat = (Vector3.new(b.X, 0, b.Z) - Vector3.new(a.X, 0, a.Z)).Magnitude + if flat > 0.1 then + local g = math.abs(b.Y - a.Y) / flat + if g > maxGrade then + maxGrade, gradeAt = g, i + end + end +end +check("max grade drivable (<15%)", maxGrade < 0.15, ("%.1f%% at sample %d"):format(maxGrade * 100, gradeAt)) + +local map = workspace:FindFirstChild("Map") +if not map then + cleanup() + return "FATAL: workspace.Map missing -- run MapBuilder first" +end +local exp = map:FindFirstChild("Expressway") + +-- 4. Continuous driving surface ------------------------------------------- +local rp = RaycastParams.new() +rp.FilterType = Enum.RaycastFilterType.Include +rp.FilterDescendantsInstances = { exp } +local holes, worstOffset = 0, 0 +for i = 1, n do + local hit = workspace:Raycast(s[i].pos + Vector3.new(0, 12, 0), Vector3.new(0, -40, 0), rp) + if not hit then + holes += 1 + else + worstOffset = math.max(worstOffset, math.abs(s[i].pos.Y - hit.Position.Y)) + end +end +check("no holes in road surface", holes == 0, ("%d gaps, worst offset %.2f studs"):format(holes, worstOffset)) + +-- 5. Tunnel bore count matches flags -------------------------------------- +local walls, ceilings = 0, 0 +for _, d in ipairs(map:GetDescendants()) do + if d.Name == "TunnelWall" then + walls += 1 + elseif d.Name == "TunnelCeiling" then + ceilings += 1 + end +end +check( + "tunnel bore matches flags", + walls == nTunnel * 2 and ceilings == nTunnel, + ("%d walls (want %d), %d ceilings"):format(walls, nTunnel * 2, nTunnel) +) + +-- 6. Nothing intrudes into the car envelope ------------------------------- +-- Probe sits ON the deck: centre lifted by half the car height, so the box +-- never dips below the road and false-positives on the pillars beneath it. +local probe = Instance.new("Part") +probe.Size = Vector3.new(CAR_HALF_W * 2, CAR_HEIGHT, 20) +probe.Anchored = true +probe.CanCollide = false +probe.Transparency = 1 +probe.Parent = workspace + +local op = OverlapParams.new() +op.FilterType = Enum.RaycastFilterType.Exclude +op.FilterDescendantsInstances = { probe, exp } +op.MaxParts = 8 + +local expected = { + KillWall = true, + Guardrail = true, + TunnelCeiling = true, + TunnelLight = true, + RoadLight = true, + BoxGirder = true, + PierCap = true, + Cable = true, + Baseplate = true, +} + +local intrusions = 0 +local firstHit = "" +for i = 1, n do + local a, b = s[i].pos, s[(i % n) + 1].pos + local dir = b - a + if dir.Magnitude > 0.1 then + -- Pillar tops are flush with the deck (0.00 clearance by design), so the + -- probe must sit fully ABOVE the road surface or it clips its own + -- supports. Lift a further 1.5 studs of margin. + local mid = (a + b) / 2 + Vector3.new(0, CAR_HEIGHT / 2 + 2.0, 0) + probe.CFrame = CFrame.lookAt(mid, mid + dir.Unit) + for _, h in ipairs(workspace:GetPartsInPart(probe, op)) do + -- only judge the NEW map; legacy C1/City trees are separate content + local root = h + while root.Parent and root.Parent ~= workspace do + root = root.Parent + end + if root == map and not expected[h.Name] then + intrusions += 1 + if firstHit == "" then + firstHit = h:GetFullName() .. " @sample" .. i + end + break + end + end + end +end +probe:Destroy() +check("car envelope clear", intrusions == 0, ("%d intrusions %s"):format(intrusions, firstHit)) + +-- 7. Supports stay under the deck ----------------------------------------- +local badPillars, minGapUnder = 0, math.huge +for _, d in ipairs(map:GetDescendants()) do + if d.Name == "Pillar" and d:IsA("BasePart") then + local top = d.Position.Y + d.Size.Y / 2 + local best = math.huge + for i = 1, n do + local flat = (Vector3.new(s[i].pos.X, 0, s[i].pos.Z) - Vector3.new(d.Position.X, 0, d.Position.Z)).Magnitude + if flat < best then + best = flat + if flat < ROAD_HALF then + local gap = s[i].pos.Y - top + minGapUnder = math.min(minGapUnder, gap) + if gap < 0 then + badPillars += 1 + end + end + end + end + end +end +check( + "pillars stay below deck", + badPillars == 0, + ("%d protruding, min clearance %.2f studs"):format(badPillars, minGapUnder == math.huge and 0 or minGapUnder) +) + +-- 8. Asset hygiene --------------------------------------------------------- +local badSounds = 0 +for _, svc in ipairs({ game:GetService("ServerStorage"), RS, workspace }) do + for _, d in ipairs(svc:GetDescendants()) do + if d:IsA("Sound") then + local _, count = d.SoundId:gsub("rbxassetid://", "") + if count > 1 then + badSounds += 1 + end + end + end +end +check("no malformed sound ids", badSounds == 0, ("%d double-prefixed"):format(badSounds)) + +cleanup() + +local header = failures == 0 and ("MAP VERIFY: ALL %d CHECKS PASSED"):format(#results) + or ("MAP VERIFY: %d/%d CHECKS FAILED"):format(failures, #results) +return header .. "\n" .. table.concat(results, "\n")