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")