Rework the PA junction to match the reference drawing: yellow = PA -> highway (entry), red = highway -> PA (exit). Entry and exit swap sides (entry now EAST, exit WEST) and both trunks hug the PA perimeter instead of running concentric spirals. The simplification: both carriageways sit at the same deck level, adjacent, so the median opening IS the far-side access path. The under-deck crossing and merge runs are deleted outright -- 8 runs collapse to 4, and the girder-soffit ceiling logic that constrained the crossing goes with them. Part count 24,210 -> 21,691. The median opening is now load-bearing rather than cosmetic (it is the only route from the far carriageway to the ramps), so it widens from 6 to 9 samples per junction. Orientation is a single flip-able constant (ORIENT) so a mirrored result is a one-line correction, not a rewrite. Measured on the built map (not asserted): - 600 entry + 600 exit ramp parts - max grade 8.00% / 8.27%, under the 9% limit - 0 collisions with piers/masts/girders (558 structural parts) - segment continuity <= 2.5 studs; trunk->merge handoff <= 1.7 studs - merge lands at dY 0.0 (exact deck level), 33 studs lateral - median open at junctions: 0 parts vs. control ~7 on plain mainline Two traps worth recording, both caught by controls: 1. Studio's require() cache silently rebuilt STALE code. The first "passing" build reported 17,575 parts and zero ramps, and the map contained PAOffRamp/PAOnRamp parts whose names exist nowhere in the current source. Every measurement taken before this was describing an old map. Added tools/rebuild_fresh.luau, which clones the module to bust the cache; use it rather than requiring MapBuilder directly. 2. A reported "median never opens" bug was a bad probe, not a defect. It counted `Barrier` (the outer edge parapet, which must NEVER open) instead of `Median_<i>`. Re-run against the right name: control 7, junctions 0. Both were only visible because the probes carry controls -- a 0-hit result with no control is vacuous. The verification harnesses are kept in tools/ with their controls built in.
304 lines
8.8 KiB
Luau
304 lines
8.8 KiB
Luau
-- Verify the SHARED-TRUNK PA ramps as actually BUILT (not as solved offline).
|
|
--
|
|
-- The offline solver + /tmp/verify_shared.py agreed on the numbers, but they
|
|
-- both worked from the parameter table. This measures the real Roblox parts
|
|
-- that MapBuilder emits, which is the only thing the player can hit.
|
|
--
|
|
-- Runs on a CLONE parented to workspace so spatial queries work, with an
|
|
-- Include filter so the LIVE map can never contribute a false collision.
|
|
-- SAFETY (learned the hard way, 2026-08-03): MapBuilder.build() begins by
|
|
-- destroying Workspace.Map. A verifier that calls build() therefore DELETES
|
|
-- THE LIVE MAP as a side effect, and its own map:Destroy() at the end then
|
|
-- leaves the workspace with no map at all. Snapshot the live map aside first
|
|
-- and restore it in a finally-style block so the user's session survives even
|
|
-- if this script errors halfway through.
|
|
local ServerScriptService = game:GetService("ServerScriptService")
|
|
local live = workspace:FindFirstChild("Map")
|
|
local stash = nil
|
|
if live then
|
|
stash = Instance.new("Folder")
|
|
stash.Name = "__MAP_STASH__"
|
|
stash.Parent = ServerScriptService
|
|
live.Parent = stash -- reparent, do NOT clone: 24k parts
|
|
end
|
|
local function restoreLive()
|
|
if stash then
|
|
local held = stash:FindFirstChild("Map")
|
|
local rebuilt = workspace:FindFirstChild("Map")
|
|
if rebuilt then
|
|
rebuilt:Destroy()
|
|
end
|
|
if held then
|
|
held.Parent = workspace
|
|
end
|
|
stash:Destroy()
|
|
end
|
|
end
|
|
|
|
local out = {}
|
|
local function log(...)
|
|
table.insert(out, table.concat({ ... }, " "))
|
|
end
|
|
local pass, fail = 0, 0
|
|
local function check(name, ok, detail)
|
|
if ok then
|
|
pass += 1
|
|
log("PASS", name, detail or "")
|
|
else
|
|
fail += 1
|
|
log("FAIL", name, detail or "")
|
|
end
|
|
end
|
|
|
|
local src = ServerScriptService:FindFirstChild("Server")
|
|
src = src and src:FindFirstChild("MapBuilder") or ServerScriptService:FindFirstChild("MapBuilder", true)
|
|
if not src then
|
|
return "FATAL: MapBuilder module not found"
|
|
end
|
|
local MapBuilder = require(src:Clone())
|
|
local map = MapBuilder.build and MapBuilder.build() or MapBuilder.Build()
|
|
map.Name = "VERIFY_CLONE"
|
|
map.Parent = workspace
|
|
|
|
local RUNS = {
|
|
"PAEntryRampTrunk",
|
|
"PAEntryRampNear",
|
|
"PAEntryRampCross",
|
|
"PAEntryRampMerge",
|
|
"PAExitRampTrunk",
|
|
"PAExitRampNear",
|
|
"PAExitRampCross",
|
|
"PAExitRampMerge",
|
|
}
|
|
|
|
-- Collect the deck parts of each run, in segment order.
|
|
local deckOf = {}
|
|
for _, r in ipairs(RUNS) do
|
|
deckOf[r] = {}
|
|
end
|
|
local everything = map:GetDescendants()
|
|
for _, d in ipairs(everything) do
|
|
if d:IsA("BasePart") then
|
|
for _, r in ipairs(RUNS) do
|
|
local idx = d.Name:match("^" .. r .. "_(%d+)$")
|
|
if idx then
|
|
table.insert(deckOf[r], { i = tonumber(idx), p = d })
|
|
end
|
|
end
|
|
end
|
|
end
|
|
for _, r in ipairs(RUNS) do
|
|
table.sort(deckOf[r], function(a, b)
|
|
return a.i < b.i
|
|
end)
|
|
end
|
|
|
|
log("=== 0. RUNS BUILT ===")
|
|
local allBuilt = true
|
|
for _, r in ipairs(RUNS) do
|
|
local n = #deckOf[r]
|
|
if n == 0 then
|
|
allBuilt = false
|
|
end
|
|
log(string.format(" %-20s %d segments", r, n))
|
|
end
|
|
check("all 8 runs emitted", allBuilt)
|
|
|
|
-- What counts as "our own structure" rather than an obstacle.
|
|
local function isRampPart(p)
|
|
local n = p.Name
|
|
if n:find("PARamp") or n:find("PARing") then
|
|
return true
|
|
end
|
|
for _, r in ipairs(RUNS) do
|
|
if n:sub(1, #r) == r then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
log("")
|
|
log("=== 1. SOLID-OBSTACLE HITS ===")
|
|
-- A run's first and last few segments are MOUTHS: they are supposed to touch
|
|
-- the ring deck / the mainline carriageway. Everything in between must be in
|
|
-- clear air.
|
|
local MOUTH = 6
|
|
local totalHits = 0
|
|
for _, r in ipairs(RUNS) do
|
|
local segs = deckOf[r]
|
|
local hits, sample = 0, nil
|
|
for k, e in ipairs(segs) do
|
|
if k > MOUTH and k <= #segs - MOUTH then
|
|
local params = OverlapParams.new()
|
|
params.FilterType = Enum.RaycastFilterType.Include
|
|
params.FilterDescendantsInstances = { map }
|
|
params.MaxParts = 40
|
|
for _, o in ipairs(workspace:GetPartsInPart(e.p, params)) do
|
|
if o ~= e.p and not isRampPart(o) then
|
|
hits += 1
|
|
if not sample then
|
|
sample = string.format("seg %d vs %s", k, o.Name)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
totalHits += hits
|
|
check(string.format("%s clear of solid obstacles", r), hits == 0, sample or "0 hits")
|
|
end
|
|
|
|
log("")
|
|
log("=== 2. GRADE ===")
|
|
-- Limit is 9%; the solve claimed <= 8.28% entry, <= 5.29% exit.
|
|
local LIMIT = 9.0
|
|
for _, r in ipairs(RUNS) do
|
|
local segs = deckOf[r]
|
|
local worst, atK = 0, 0
|
|
for k = 1, #segs - 1 do
|
|
local a, b = segs[k].p.Position, segs[k + 1].p.Position
|
|
local horiz = Vector3.new(b.X - a.X, 0, b.Z - a.Z).Magnitude
|
|
if horiz > 0.5 then
|
|
local g = math.abs(b.Y - a.Y) / horiz * 100
|
|
if g > worst then
|
|
worst, atK = g, k
|
|
end
|
|
end
|
|
end
|
|
check(
|
|
string.format("%s grade <= %.0f%%", r, LIMIT),
|
|
worst <= LIMIT,
|
|
string.format("max %.2f%% at seg %d", worst, atK)
|
|
)
|
|
end
|
|
|
|
log("")
|
|
log("=== 3. UNDER-DECK CLEARANCE (cross runs) ===")
|
|
-- The crossing must fit between the ground and the girder soffit, with a car
|
|
-- on it. Measured against the ACTUAL mainline deck parts overhead, not against
|
|
-- the assumed profile.
|
|
local roadParts = {}
|
|
for _, d in ipairs(everything) do
|
|
if d:IsA("BasePart") and (d.Name:sub(1, 5) == "Road_" or d.Name:sub(1, 4) == "Deck") then
|
|
table.insert(roadParts, d)
|
|
end
|
|
end
|
|
local GIRDER = 8
|
|
local CAR = 9
|
|
for _, r in ipairs({ "PAEntryRampCross", "PAExitRampCross" }) do
|
|
local segs = deckOf[r]
|
|
local worst, overhead = math.huge, 0
|
|
for _, e in ipairs(segs) do
|
|
local p = e.p.Position
|
|
for _, rp in ipairs(roadParts) do
|
|
local dx = math.abs(p.X - rp.Position.X) - rp.Size.X / 2
|
|
local dz = math.abs(p.Z - rp.Position.Z) - rp.Size.Z / 2
|
|
if dx < 20 and dz < 20 and rp.Position.Y > p.Y then
|
|
overhead += 1
|
|
local gap = (rp.Position.Y - GIRDER) - (p.Y + 1)
|
|
worst = math.min(worst, gap)
|
|
end
|
|
end
|
|
end
|
|
if overhead == 0 then
|
|
check(
|
|
string.format("%s passes under the deck", r),
|
|
false,
|
|
"NO overhead deck found -- crossing may miss the mainline"
|
|
)
|
|
else
|
|
check(
|
|
string.format("%s clears the soffit with a car (%d st)", r, CAR),
|
|
worst >= CAR,
|
|
string.format("min gap %.1f st over %d overhead samples", worst, overhead)
|
|
)
|
|
end
|
|
end
|
|
|
|
log("")
|
|
log("=== 4. MERGE ACCURACY ===")
|
|
-- The last segment of each slip must land ON its carriageway, not beside it.
|
|
local samples = {}
|
|
for _, d in ipairs(everything) do
|
|
if d:IsA("BasePart") and d.Name:sub(1, 5) == "Road_" then
|
|
table.insert(samples, d)
|
|
end
|
|
end
|
|
for _, r in ipairs({ "PAEntryRampNear", "PAEntryRampMerge", "PAExitRampNear", "PAExitRampMerge" }) do
|
|
local segs = deckOf[r]
|
|
if #segs > 0 then
|
|
local tip = segs[#segs].p.Position
|
|
local best, bestName = math.huge, "?"
|
|
for _, rp in ipairs(samples) do
|
|
local d = (Vector3.new(rp.Position.X, 0, rp.Position.Z) - Vector3.new(tip.X, 0, tip.Z)).Magnitude
|
|
if d < best then
|
|
best, bestName = d, rp.Name
|
|
end
|
|
end
|
|
-- Must be within half a carriageway of a road sample, and at its height.
|
|
local dy = math.huge
|
|
for _, rp in ipairs(samples) do
|
|
if rp.Name == bestName then
|
|
dy = math.abs(rp.Position.Y - tip.Y)
|
|
end
|
|
end
|
|
check(
|
|
string.format("%s lands on the carriageway", r),
|
|
best <= 40 and dy <= 6,
|
|
string.format("%.1f st from %s, dY %.1f", best, bestName, dy)
|
|
)
|
|
end
|
|
end
|
|
|
|
log("")
|
|
log("=== 5. TRUNK SEPARATION ===")
|
|
-- The two trunks spiral concentrically about the same centre; where they pass
|
|
-- close in plan they must be well apart vertically (32-stud decks).
|
|
local a, b = deckOf.PAEntryRampTrunk, deckOf.PAExitRampTrunk
|
|
local worstSep, closePairs = math.huge, 0
|
|
for _, ea in ipairs(a) do
|
|
for _, eb in ipairs(b) do
|
|
local pa2, pb2 = ea.p.Position, eb.p.Position
|
|
local plan = Vector3.new(pa2.X - pb2.X, 0, pa2.Z - pb2.Z).Magnitude
|
|
if plan < 40 then
|
|
closePairs += 1
|
|
worstSep = math.min(worstSep, math.abs(pa2.Y - pb2.Y))
|
|
end
|
|
end
|
|
end
|
|
if closePairs == 0 then
|
|
check("trunks never pass within 40 st in plan", true, "0 close pairs -- no conflict")
|
|
else
|
|
check(
|
|
"trunks vertically separated where they pass close",
|
|
worstSep >= 20,
|
|
string.format("min %.1f st vertical over %d close pairs", worstSep, closePairs)
|
|
)
|
|
end
|
|
|
|
log("")
|
|
log("=== 6. PILLARS NOT IN THE EXPRESSWAY ===")
|
|
local badPillars, worstPil = 0, nil
|
|
for _, d in ipairs(everything) do
|
|
if d:IsA("BasePart") and d.Name == "PARampPillar" then
|
|
for _, rp in ipairs(roadParts) do
|
|
local dx = math.abs(d.Position.X - rp.Position.X) - rp.Size.X / 2
|
|
local dz = math.abs(d.Position.Z - rp.Position.Z) - rp.Size.Z / 2
|
|
if dx < 0 and dz < 0 then
|
|
badPillars += 1
|
|
worstPil = worstPil
|
|
or string.format("pillar at %.0f,%.0f under %s", d.Position.X, d.Position.Z, rp.Name)
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
check("no ramp pillar planted in the mainline footprint", badPillars == 0, worstPil or "0")
|
|
|
|
map:Destroy()
|
|
restoreLive()
|
|
log("")
|
|
log(string.format("RESULT %d passed, %d failed (obstacle hits total: %d)", pass, fail, totalHits))
|
|
log(string.format("live map restored: %s", tostring(workspace:FindFirstChild("Map") ~= nil)))
|
|
return table.concat(out, "\n")
|