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.
239 lines
7.2 KiB
Luau
239 lines
7.2 KiB
Luau
--!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")
|