From 6e50f9718d046ab1a6e0a02694ab31f0fe6e1ca5 Mon Sep 17 00:00:00 2001 From: "R3AL.CH1CK3N" Date: Thu, 6 Aug 2026 08:27:47 -0400 Subject: [PATCH] Aux-lane suppression + PA geometry fixes; probe/verify tooling MapBuilder.luau - Suppress the aux lane across samples 525-550 (AUX_SUPPRESS_FROM/TO), honoring the user's hand deletion in Studio so a rebuild no longer regenerates it. - Smoothstep the width ramp over AUX_BLEND=5 samples on each side. A hard cut produced a 26-stud lateral fence jog; the blend removes it. - DO NOT remove this suppression. Re-enabling it resurrects geometry the user deleted by hand. rokit.toml - rojo 7.5.1 -> 7.7.0 to match the auto-updating Studio plugin. Server older than plugin fails the handshake with "attempt to index number with 'protocolVersion'". tools/ - 34 read-only probe/verify/capture scripts used to measure the datamodel instead of reasoning from source. Verified-good baseline at this commit: total parts 16788 PAWestSlip 60 segs, 34.4 deg total bend, worst 1.4, 0 kinks >5 PAEastSlip 60 segs, 30.7 deg total bend, worst 1.3, 0 kinks >5 AuxLane parts 30 (samples 525-550 deliberately absent) barrier coverage 823 samples, 0 unguarded fence jogs >10st 0 --- rokit.toml | 2 +- src/server/MapBuilder.luau | 539 +++++++++++++++++++++++++++++++++ tools/capture_auxlane.py | 126 ++++++++ tools/capture_barrier_fix.py | 121 ++++++++ tools/capture_ramps.py | 125 ++++++++ tools/probe_after.luau | 73 +++++ tools/probe_apron.luau | 55 ++++ tools/probe_barriergap.luau | 134 ++++++++ tools/probe_build.luau | 55 ++++ tools/probe_final.luau | 106 +++++++ tools/probe_fix.luau | 123 ++++++++ tools/probe_fix2.luau | 135 +++++++++ tools/probe_gapreal.luau | 97 ++++++ tools/probe_holes.luau | 93 ++++++ tools/probe_jog.luau | 107 +++++++ tools/probe_kinks.luau | 73 +++++ tools/probe_mainbend.luau | 61 ++++ tools/probe_pa_inventory.luau | 103 +++++++ tools/probe_pa_min.luau | 36 +++ tools/probe_parallel.luau | 80 +++++ tools/probe_place.luau | 40 +++ tools/probe_ramppath.luau | 104 +++++++ tools/probe_rebuild.luau | 62 ++++ tools/probe_regression.luau | 151 +++++++++ tools/probe_revert.luau | 74 +++++ tools/probe_runnable.luau | 61 ++++ tools/probe_sides.luau | 88 ++++++ tools/probe_slip_envelope.luau | 109 +++++++ tools/probe_smoothed.luau | 69 +++++ tools/probe_state.luau | 56 ++++ tools/probe_sync.luau | 39 +++ tools/probe_useredit.luau | 78 +++++ tools/probe_walls.luau | 120 ++++++++ tools/probe_weave.luau | 123 ++++++++ tools/verify_auxlane.luau | 194 ++++++++++++ tools/verify_slips.luau | 112 +++++++ 36 files changed, 3723 insertions(+), 1 deletion(-) create mode 100644 tools/capture_auxlane.py create mode 100644 tools/capture_barrier_fix.py create mode 100644 tools/capture_ramps.py create mode 100644 tools/probe_after.luau create mode 100644 tools/probe_apron.luau create mode 100644 tools/probe_barriergap.luau create mode 100644 tools/probe_build.luau create mode 100644 tools/probe_final.luau create mode 100644 tools/probe_fix.luau create mode 100644 tools/probe_fix2.luau create mode 100644 tools/probe_gapreal.luau create mode 100644 tools/probe_holes.luau create mode 100644 tools/probe_jog.luau create mode 100644 tools/probe_kinks.luau create mode 100644 tools/probe_mainbend.luau create mode 100644 tools/probe_pa_inventory.luau create mode 100644 tools/probe_pa_min.luau create mode 100644 tools/probe_parallel.luau create mode 100644 tools/probe_place.luau create mode 100644 tools/probe_ramppath.luau create mode 100644 tools/probe_rebuild.luau create mode 100644 tools/probe_regression.luau create mode 100644 tools/probe_revert.luau create mode 100644 tools/probe_runnable.luau create mode 100644 tools/probe_sides.luau create mode 100644 tools/probe_slip_envelope.luau create mode 100644 tools/probe_smoothed.luau create mode 100644 tools/probe_state.luau create mode 100644 tools/probe_sync.luau create mode 100644 tools/probe_useredit.luau create mode 100644 tools/probe_walls.luau create mode 100644 tools/probe_weave.luau create mode 100644 tools/verify_auxlane.luau create mode 100644 tools/verify_slips.luau diff --git a/rokit.toml b/rokit.toml index 7deb364..669d821 100644 --- a/rokit.toml +++ b/rokit.toml @@ -1,5 +1,5 @@ [tools] -rojo = "rojo-rbx/rojo@7.5.1" +rojo = "rojo-rbx/rojo@7.7.0" wally = "UpliftGames/wally@0.3.2" selene = "Kampfkarren/selene@0.28.0" stylua = "JohnnyMorganz/stylua@2.1.0" diff --git a/src/server/MapBuilder.luau b/src/server/MapBuilder.luau index c3b503b..873a3f4 100644 --- a/src/server/MapBuilder.luau +++ b/src/server/MapBuilder.luau @@ -95,6 +95,187 @@ local ZEBRA_PITCH = 1.0 * M -- Gore hatching + speed-change-lane boundary lines only make sense where a -- ramp actually diverges. Tied to BUILD_PA_RAMPS in buildPA (2026-08-03). local RAMP_GORE_MARKINGS = false + +-- PA SLIP ROADS (2026-08-03, fifth pass) -------------------------------------- +-- Declared up here rather than next to buildPARamps because buildRoad has to +-- know where the ramps meet the expressway BEFORE it builds the parapets -- +-- otherwise the slip mouth is sealed off by the mainline's own barrier and kill +-- wall and the ramp dead-ends into concrete. +-- +-- `join` = mainline sample where the slip meets the expressway (the mouth). +-- `land` = mainline sample the slip is abreast of when it reaches the apron. +-- The run is |land - join| samples long, ~60 studs each. Both are kept at 12 +-- samples (~720 studs) so the 56-stud drop stays at 7.6% average / 11.4% peak +-- under the eased profile -- steep-ish but well inside what the cars climb. +local PA_SLIP = { + { name = "PAWestSlip", join = 525, land = 537 }, + { name = "PAEastSlip", join = 551, land = 539 }, +} +-- Which edge of the deck the slips leave through. PA_CENTER sits at Z -7469.6 +-- and the mainline crosses the lot's north strip at Z ~ -7142, so the lot is on +-- the -Z side; with the roadway heading east there, right = +Z, hence -1. +local PA_SLIP_SIDE = -1 +-- Samples of bare deck edge at the ramp nose itself, where the ramp deck has +-- taken over and neither the mainline parapet nor the aux-lane parapet belongs. +-- Kept at 1: the ramp leaves at a very shallow angle, so by one sample past the +-- mouth its inner edge is still within a stud of the deck edge. +local PA_SLIP_NOSE = 1 + +-- SPEED-CHANGE (AUXILIARY) LANES --------------------------------------------- +-- FIXED 2026-08-03: the slips used to start at lat = 0 -- dead on the mainline +-- CENTRELINE -- so a 26-stud ramp deck speared straight through the running +-- lanes to reach the deck edge. A real interchange never does that: the ramp +-- gets its OWN lane alongside the mainline so traffic can accelerate to speed +-- before merging (on-ramp) or drop out of the traffic stream before slowing +-- (off-ramp). That lane is what was missing. +-- +-- The aux lane is a full-width lane bolted to the outside of the deck, level +-- with it, tapering back into the deck edge at its far end. Measured from the +-- mouth (sample `join`) along the merge direction: +-- d in [-NOSE .. 0] ramp nose; the ramp deck covers this edge +-- d in ( 0 .. SPAN] full-width speed-change lane +-- d in (SPAN .. SPAN+TAPER] lane tapering away into the deck edge +-- The ramp body runs the OTHER way (join -> land), so ramp and aux lane meet +-- end-to-end AT the mouth at the same offset and width -- one continuous path +-- (lot -> ramp -> aux lane -> merge), never a crossing. +local PA_AUX_W = 26 -- matches the slip deck exactly, so the join is flush + +-- 2026-08-04: mainline sample range where the aux/weaving lane is suppressed, +-- matching the run the user deleted by hand in Studio. Both the lane itself and +-- the parapet opening are keyed off this, so the deck edge stays fenced. +-- +-- 2026-08-04 (2nd pass): widened from 527..548 to the TRUE aux boundary. +-- Measured: the aux lane tapers to zero at 525 and does not resume until 551, +-- but the suppression range started at 527 -- so samples 525,526,549,550 had +-- neither an aux lane to carry the relocated parapet NOR a forced-closed +-- mainline parapet. Result: a 4-sample hole (the user's left oval). Covering +-- 525..550 closes it and, because the parapet now switches back to the +-- mainline edge exactly where the aux lane actually ends, it also removes the +-- 26-stud lateral snap at 527/549 that made the barrier visibly stick out +-- (the user's right circle). +local AUX_SUPPRESS_FROM = 525 +local AUX_SUPPRESS_TO = 550 +-- Samples over which the aux lane (and therefore its outer fence) is faded to +-- zero on each approach to the suppressed range, instead of being cut off in a +-- single sample. A hard cut teleported the fence 26 studs inboard and left the +-- protruding stub the user circled in their screenshot. +local AUX_BLEND = 5 + +-- 1 outside the suppressed range, 0 inside it, smoothstepped across AUX_BLEND +-- samples on each approach so the fence walks in instead of snapping. +local function auxSuppressFactor(i: number): number + if i >= AUX_SUPPRESS_FROM and i <= AUX_SUPPRESS_TO then + return 0 + end + local function ss(u: number): number + u = math.clamp(u, 0, 1) + return u * u * (3 - 2 * u) + end + -- approaching the start of the suppressed run from upstream + local dIn = AUX_SUPPRESS_FROM - i + if dIn > 0 and dIn <= AUX_BLEND then + return ss(dIn / AUX_BLEND) + end + -- leaving the end of the suppressed run downstream + local dOut = i - AUX_SUPPRESS_TO + if dOut > 0 and dOut <= AUX_BLEND then + return ss(dOut / AUX_BLEND) + end + return 1 +end +local PA_AUX_SPAN = 10 -- ~600 studs of full-width lane to get up to speed +local PA_AUX_TAPER = 6 -- ~360 studs of taper back into the deck edge + +-- DECELERATION SIDE (the mirror of the above, added 2026-08-03). +-- The accel lane only serves traffic JOINING the mainline. Traffic LEAVING it +-- had no lane at all: a car had to brake in the running lanes and turn off +-- cold, which is exactly the original defect pointed the other way. So the +-- same mouth also gets a lane on the UPSTREAM side, measured with e = -d - NOSE +-- (distance back up the mainline from the ramp nose): +-- e in [0 .. DEC_SPAN] full-width deceleration lane +-- e in (DEC_SPAN .. +DEC_TAPER] lane tapering out of the deck edge +-- Read in the direction of travel that is the reverse: the lane opens out of +-- the deck edge, runs full width, and delivers the car to the ramp nose having +-- already left the traffic stream. Deceleration needs less room than +-- acceleration (braking beats spooling up), hence the shorter span. +local PA_DEC_SPAN = 8 -- ~480 studs of full-width lane to shed speed in +local PA_DEC_TAPER = 6 -- ~360 studs of taper opening out of the deck edge + +-- Signed distance in samples from a slip mouth, measured along that slip's +-- merge direction. Wrap-safe around the end of the sample array. +local function auxOffsetOf(i: number, slip: { join: number, land: number }): number + local n = #Circuit.samples + local raw = (i - slip.join) % n + if raw > n / 2 then + raw -= n + end + -- The ramp descends join -> land, so the speed-change lane runs the other + -- way; that is what puts them end-to-end instead of on top of each other. + local dir = (slip.land > slip.join) and -1 or 1 + local d = raw * dir + -- OFF-BY-ONE (caught by measurement 2026-08-03): a deck segment with index + -- i spans [i, i+1], so "the segment at sample i" is not symmetric about the + -- mouth. When the aux lane runs toward INCREASING indices the ramp covers + -- segments below the join and the aux must start AT the join; when it runs + -- toward decreasing indices the ramp covers the join itself. Without this + -- shift the east slip left a ~60-stud hole between ramp and aux lane + -- (measured: 88.2 stud mouth->aux gap vs 36.2 on the west). + if dir == 1 then + d += 1 + end + return d +end + +-- Aux lane width at mainline sample i, and whether i is the bare ramp nose. +-- Covers BOTH speed-change lanes at each mouth: the deceleration lane upstream +-- of the ramp nose and the acceleration lane downstream of it. Returns 0 when +-- this sample has no speed-change lane. +-- +-- OVERLAP (measured 2026-08-03): the two mouths are only 26 samples apart, so +-- one mouth's accel taper lands on the same samples as the next mouth's decel +-- lane. Returning the FIRST slip that matches left one-segment holes in the +-- middle of continuous pavement (measured: gaps at samples 540 and 549). Take +-- the WIDEST claim across all slips instead -- where an accel taper and a decel +-- lane overlap the result is a continuous full-width weaving section, which is +-- what the real road does between closely spaced ramps. +local function auxInfoAt(i: number): (number, boolean) + local best, bestNose = 0, false + for _, slip in ipairs(PA_SLIP) do + local d = auxOffsetOf(i, slip) + local w, nose = 0, false + if d >= -PA_SLIP_NOSE and d <= PA_AUX_SPAN + PA_AUX_TAPER then + if d <= 0 then + w, nose = PA_AUX_W, true + elseif d <= PA_AUX_SPAN then + w = PA_AUX_W + else + local u = (d - PA_AUX_SPAN) / PA_AUX_TAPER + w = PA_AUX_W * (1 - u * u * (3 - 2 * u)) + end + else + -- Deceleration lane: upstream of the nose, so d < -NOSE. Measure + -- how far back up the mainline we are and mirror the accel profile. + local e = -d - PA_SLIP_NOSE + if e > 0 and e <= PA_DEC_SPAN + PA_DEC_TAPER then + if e <= PA_DEC_SPAN then + w = PA_AUX_W + else + local u = (e - PA_DEC_SPAN) / PA_DEC_TAPER + w = PA_AUX_W * (1 - u * u * (3 - 2 * u)) + end + end + end + -- A nose claim wins outright: the ramp deck owns that edge, and + -- building aux pavement there would double up on the ramp. + if nose then + return w, true + end + if w > best then + best = w + end + end + return best, bestNose +end local MARK_Y = 0.1 -- sits just proud of the deck to avoid z-fighting local MARK_H = 2.1 -- Heading change per 60-stud sample above which the curve counts as tight @@ -546,6 +727,27 @@ local function planPA() end end end + -- SLIP MOUTHS (2026-08-03). The old polar ramps stay off, but the new + -- linear slips are real and DO need the outer parapet opened where they + -- meet the deck -- otherwise the mouth is walled shut and the ramp is + -- decorative. + -- + -- UPDATED: the opening now tracks the SPEED-CHANGE LANE, not just the + -- mouth. Wherever an aux lane exists the mainline's own parapet and kill + -- wall would sit right in the middle of the running surface (the aux lane + -- is bolted onto the outside of that edge), so the edge has to be open for + -- the whole aux run -- and buildRoad re-fences the OUTER side of the aux + -- lane instead, so there is still never an unguarded drop. + for i = 1, n do + -- Same suppression the deck builder applies, so rampOpen agrees with + -- where the aux lane actually exists. Without this the parapet would be + -- opened across the suppressed run with no aux lane to re-fence it. + local w = auxInfoAt(i) * auxSuppressFactor(i) + if w > 0.5 then + rampOpen[i] = true + rampOpenSide[i] = PA_SLIP_SIDE + end + end PAPlan = { nearIdx = bestIdx, -- Kept for anything still referencing the old two-ramp names; they now @@ -641,6 +843,92 @@ local function buildRoad(map: Folder) fence.Parent = struct end + -- SPEED-CHANGE LANE --------------------------------------------------- + -- Where a slip meets the deck, the ramp does NOT merge straight into + -- the running lanes. It feeds this auxiliary lane, bolted to the + -- outside of the carriageway at deck level, which runs alongside the + -- mainline and tapers away -- so traffic accelerates to speed here and + -- merges laterally, the way a real interchange works. + -- + -- The mainline's own parapet/kill wall is opened along this stretch + -- (planPA marks it), so the aux lane re-fences its OUTER edge below; + -- the edge is never left unguarded. + local auxW, auxIsNose = auxInfoAt(i) + -- 2026-08-04: the user deleted the middle weaving run by hand in Studio. + -- That is the stretch BETWEEN the two mouths where one mouth's accel + -- taper overlaps the next mouth's decel lane, producing a continuous + -- full-width aux lane. They do not want it. Suppress it in source so + -- rebuilds stop resurrecting it, and so the mainline parapet is NOT + -- opened along here (see planPA) -- otherwise removing the lane leaves + -- the deck edge bare, which was the measured barrier gap. + -- + -- 2026-08-04 (3rd pass): a HARD cut here made the OUTER FENCE snap. + -- The aux fence rides at HALF + auxW, so zeroing auxW in one sample + -- teleported the parapet 26 studs inboard, leaving the protruding stub + -- the user circled. Real merges never do that -- they taper. So instead + -- of chopping, ramp auxW down to zero across AUX_BLEND samples on each + -- approach to the suppressed range. The fence then walks in smoothly + -- with pavement under it the whole way, and the full-width lane the + -- user actually deleted still never comes back. + auxW = auxW * auxSuppressFactor(i) + if auxW < 0.5 then + auxW, auxIsNose = 0, false + end + if auxW > 0 and not auxIsNose then + local aoff = right * (PA_SLIP_SIDE * (HALF + auxW / 2)) + local acf = CFrame.lookAt(mid + aoff, mid + aoff + dir, UP) + local auxDeck = part({ + Name = "AuxLane_" .. i, + Size = Vector3.new(auxW, 2, len), + CFrame = acf, + Color = C_ASPHALT, + Material = Enum.Material.Asphalt, + }) + auxDeck.Parent = road + + -- 車道外側線 down the outer edge of the speed-change lane. + local auxEdge = part({ + Name = "EdgeLine", + Size = Vector3.new(EDGE_LINE_W, MARK_H, len), + CFrame = acf * CFrame.new(PA_SLIP_SIDE * (auxW / 2 - 1.6), MARK_Y, 0), + Color = C_PAINT_WHITE, + Material = Enum.Material.SmoothPlastic, + }) + auxEdge.Parent = road + end + + -- Outer fence for the speed-change lane. Follows the taper, so the + -- parapet closes back onto the deck edge at the end of the merge. + if auxW > 0 and not auxIsNose then + local foff = right * (PA_SLIP_SIDE * (HALF + auxW - BARRIER_T / 2)) + local fcf = CFrame.lookAt(mid + foff, mid + foff + dir, UP) + local auxBarrier = part({ + Name = "Barrier", + Size = Vector3.new(BARRIER_T, BARRIER_H, len), + CFrame = fcf * CFrame.new(0, 1 + BARRIER_H / 2, 0), + Color = C_PARAPET, + Material = Enum.Material.Concrete, + }) + auxBarrier.Parent = struct + local auxRail = part({ + Name = "EdgeRail", + Size = Vector3.new(0.6, RAIL_H, len), + CFrame = fcf * CFrame.new(0, 1 + BARRIER_H + RAIL_H / 2, 0), + Color = C_RAIL, + Material = Enum.Material.Metal, + }) + auxRail.Parent = struct + local koff = right * (PA_SLIP_SIDE * (HALF + auxW + 1)) + local auxKill = part({ + Name = "KillWall", + Size = Vector3.new(2, WALL_HEIGHT, len + 6), + CFrame = CFrame.lookAt(mid + koff, mid + koff + dir, UP) * CFrame.new(0, WALL_HEIGHT / 2, 0), + Transparency = 1, + CanCollide = true, + }) + auxKill.Parent = struct + end + -- LANE MARKINGS ----------------------------------------------------- -- Lane count varies by section (see SECTION PROFILE above): most of the -- C1 is 2+2, but 汐留JCT-浜崎橋JCT is 3+3. A 3-lane carriageway needs @@ -734,6 +1022,15 @@ local function buildRoad(map: Folder) -- rather than re-deriving it from geometry that no longer implies it. local junction = plan.rampOpen[i] local paSide = junction and (plan.rampOpenSide[i] or 0) or 0 + -- 2026-08-04: the aux lane is suppressed across AUX_SUPPRESS_FROM..TO + -- (the run the user deleted). The parapet is normally OPENED along a + -- speed-change lane because the aux lane re-fences the outer edge -- + -- but with no aux lane there is nothing to re-fence with, leaving the + -- deck edge bare. That is the measured barrier gap at samples 527..548. + -- Force the parapet CLOSED here so the mainline keeps its own edge. + if i >= AUX_SUPPRESS_FROM and i <= AUX_SUPPRESS_TO then + junction, paSide = nil, 0 + end for _, side in ipairs({ -1, 1 }) do if junction and side == paSide then continue @@ -2905,6 +3202,247 @@ local function buildBridges(map: Folder) end end +-- PA CONNECTOR RAMPS (2026-08-03, FIFTH pass -- ordinary slip roads) --------- +-- +-- WHAT WAS WRONG WITH EVERY EARLIER PASS. All four previous attempts described +-- the ramp in POLAR terms -- a gate on the PA ring at bearing `az`, swept +-- `sweep` degrees at radius `forkR`. That formulation can only ever produce +-- something that wraps around the lot, so each "fix" just slid a big curve to a +-- new place: helix, then spiral, then a 110 deg / R=420 / 806-stud arc. The +-- user's drawing is not polar at all. It is LINEAR: peel off the shoulder at a +-- shallow angle, run PARALLEL to the mainline, drop onto the apron. Confirmed +-- verbatim: "Just like most on and off ramps." +-- +-- So this pass throws the angle ladder away and defines each ramp as an OFFSET +-- CURVE of the mainline itself: at every step, take the mainline centreline at +-- fractional sample `f`, push it `lateral(t)` studs to the PA side, and set the +-- height to `y(t)`. Because `lateral` eases with zero derivative at both ends, +-- the ramp is automatically TANGENT to the expressway at the join (no kink to +-- fix by hand) and lands parallel to it, which is what both drawings show. +-- +-- FOUR MARKS, TWO CORRIDORS. The top-down markup has four red lines: a pair at +-- each end, each pair converging to ONE point on the highway with opposed +-- arrows. Opposed arrows meeting at a single mouth = one two-way ramp, not two +-- one-way ones. So this builds TWO corridors (west and east), each carrying +-- both directions on one deck -- which is exactly how a real PA/rest-area +-- connector is laid out, and reproduces all four drawn lines. +-- +-- Measured anchors (probe_slip_envelope.luau, live Edit DataModel, 823 samples): +-- mainline deck top Y = 103 (Road_* centre 102, 2 thick) +-- PA apron top Y = 47 (PAFloor centre 46, 2 thick) +-- drop 56 (== Circuit.PA_LIFT, so it checks out) +-- mainline is STRAIGHT across the frontage: samples 525..545 all heading -6 +-- PA footprint X[-4538..-3710] Z[-7884..-7056] +-- mainline crosses at Z ~ -7142, i.e. over the lot's north strip +-- +-- The joins sit OUTSIDE the lot footprint (sample 525 is X=-4971, west of the +-- slab; sample 551 is X~-3450, east of it) and the landings sit inside it -- +-- which is what the drawing shows, the marks running past the slab edge before +-- meeting the road. +-- +-- The PA_SLIP table itself lives at the top of the file, because buildRoad has +-- to read it to open the parapet at each mouth before this ever runs. +-- +-- OLD ARC VERSION (kept only as a record of what the drawing is NOT): +-- +-- The two earlier attempts tried to reproduce Daikoku's famous spiral loops and +-- both failed (wrong shape, then wandering off over open water). `BUILD_PA_RAMPS` +-- above stays false; this is a deliberately plain replacement drawn from the +-- user's own markup: ONE ramp down off the mainline into the lot, ONE ramp back +-- up. No helix, no concentric wrapping -- just two constant-radius arcs. +-- +-- Measured anchors (from the live Studio DataModel, not estimated): +-- mainline deck top Y = 103 (Road_* centre 102, 2 thick) +-- PA apron top Y = 47 (PAFloor centre 46, 2 thick) +-- drop 56 (== Circuit.PA_LIFT, so it checks out) +-- mainline crosses the PA north strip at samples 531..546 +-- PA footprint X[-4538..-3710] Z[-7884..-7056] +-- +-- Each ramp is a circular arc of radius R swept 110 degrees. The arc centre is +-- placed R studs off the mainline on the -Z side, so at the mainline end the +-- arc is exactly TANGENT to the road -- the ramp leaves (and rejoins) straight, +-- with no kink at the join. R = 420 gives 806 studs of run for the 56 stud +-- drop: 6.9% average grade, ~10.4% at the steepest point of the eased profile. +local function buildPARamps(map: Folder) + local ramps = Instance.new("Folder") + ramps.Name = "PARamps" + ramps.Parent = map + + local samples = Circuit.samples + local n = #samples + local WIDTH = 26 -- two 11.6-stud lanes plus shoulders + local DECK_TOP = 103 -- mainline surface (Road_* centre 102, 2 thick) + local PA_TOP = 47 -- apron surface (PAFloor centre 46, 2 thick) + local STEPS = 60 -- subdivisions per slip + + -- Lateral offset from the mainline centreline at the far (apron) end. The + -- deck half-width is 30, the slip half-width is 13, so 78 clears the + -- mainline's kill wall (HALF + 1 = 31) by a comfortable 34 studs and puts + -- the slip well clear of traffic before it starts dropping. + local OFFSET = 78 + + -- Smoothstep: zero derivative at t=0 and t=1. Used for BOTH the lateral + -- pull-away and the descent, which is what makes the slip tangent to the + -- mainline at the mouth (leaves straight, no kink) and flush/level with the + -- apron at the bottom (no lip to catch a car). + local function ease(t: number): number + return t * t * (3 - 2 * t) + end + + -- Mainline centreline and heading at a FRACTIONAL sample index, so the slip + -- can be subdivided finer than the 60-stud sample spacing. + local function centreAt(f: number): (Vector3, Vector3) + local i0 = math.floor(f) + local frac = f - i0 + local a = samples[((i0 - 1) % n) + 1] + local b = samples[(i0 % n) + 1] + local pos = a.pos:Lerp(b.pos, frac) + local d = b.pos - a.pos + local flat = Vector3.new(d.X, 0, d.Z) + flat = flat.Magnitude > 0.01 and flat.Unit or Vector3.xAxis + return pos, flat + end + + for _, slip in ipairs(PA_SLIP) do + -- Walk from the mainline join toward the landing sample. `step` is +1 + -- or -1 so the west slip runs east and the east slip runs west, which + -- is what makes them converge on the lot from opposite ends -- the + -- four marks in the drawing. + local span = slip.land - slip.join + local prev: Vector3? = nil + + -- FIXED 2026-08-03: the lateral sweep used to start at 0 -- the + -- mainline CENTRELINE -- so the ramp deck cut clean across the running + -- lanes on its way to the deck edge. It now starts at the centreline + -- of the speed-change lane, i.e. exactly where the aux lane ends, so + -- the ramp is an end-to-end continuation of its own lane and never + -- crosses mainline traffic. + local joinIdx = ((slip.join - 1) % n) + 1 + local INNER = widthAt(joinIdx) / 2 + PA_AUX_W / 2 + + -- 2026-08-04: SMOOTHING PASS. The path used to be laid down segment by + -- segment straight off centreAt(), so it inherited every wobble in the + -- mainline it was tracking. That is fine for the west slip (its span is + -- a gentle 13.1deg) but ruinous for the east one: the mainline turns + -- 34.7deg across samples 539..551, with 9.2/8.0/8.2deg steps right at + -- the mouth, and the slip folded that into 58.2 and 50.1deg hinges -- + -- an earthquake kink. Measured east total heading change: 216deg + -- against the west's 56.5deg, on an almost identical 709 vs 717 stud + -- chord. So: build the raw polyline FIRST, relax it, then lay parts + -- along the relaxed line. Laplacian relaxation on XZ only -- the Y + -- profile is already eased and must keep its parallel-run shape. + local raw = {} + for k = 0, STEPS do + local t = k / STEPS + local f = slip.join + span * t + local c, fwd = centreAt(f) + local right = fwd:Cross(UP) + local latT = math.clamp(t / 0.30, 0, 1) + local lat = INNER + (OFFSET - INNER) * ease(latT) + local dropT = math.clamp((t - 0.30) / (1 - 0.30), 0, 1) + local yy = DECK_TOP - (DECK_TOP - PA_TOP) * ease(dropT) + local p = c + right * (PA_SLIP_SIDE * lat) + raw[k + 1] = Vector3.new(p.X, yy - 1, p.Z) + end + + -- Relax. Only the true endpoints are pinned: the mouth stays welded to + -- the deck edge and the landing stays on the apron. Pinning index 2 as + -- well (first attempt) held the outgoing tangent rigid and produced a + -- 7.5deg kink at segment 2 where the pinned run met the relaxed run -- + -- the pin became its own hinge. Letting index 2 move removes it. + local LAMBDA, PASSES = 0.5, 60 + for _ = 1, PASSES do + local next_ = {} + for j = 1, #raw do + if j == 1 or j == #raw then + next_[j] = raw[j] + else + local avg = (raw[j - 1] + raw[j + 1]) * 0.5 + local mv = (avg - raw[j]) * LAMBDA + -- XZ only: preserve the eased vertical profile exactly. + next_[j] = Vector3.new(raw[j].X + mv.X, raw[j].Y, raw[j].Z + mv.Z) + end + end + raw = next_ + end + + for k = 0, STEPS do + local cur = raw[k + 1] + + if prev then + local delta = cur - prev + local len = delta.Magnitude + if len > 0.5 then + local mid = (prev + cur) / 2 + local segCF = CFrame.lookAt(mid, mid + delta.Unit, UP) + + local deck = part({ + Name = slip.name .. "_Deck_" .. k, + Size = Vector3.new(WIDTH, 2, len + 1.2), + CFrame = segCF, + Color = C_ASPHALT, + Material = Enum.Material.Asphalt, + }) + deck.Parent = ramps + + -- Centre line: this is a two-way connector (the drawing's + -- opposed arrows), so it gets a dividing line, not dashes. + local centreLine = part({ + Name = slip.name .. "_CentreLine", + Size = Vector3.new(LANE_LINE_W, MARK_H, len + 1.2), + CFrame = segCF * CFrame.new(0, 0, 0), + Color = C_PAINT_WHITE, + Material = Enum.Material.SmoothPlastic, + }) + centreLine.Parent = ramps + + -- Parapet + kill wall down both edges. The kill wall is the + -- same invisible backstop the mainline uses -- without it a + -- car at Wangan speed tunnels straight through a 3.2-stud + -- concrete lip and falls 56 studs to the apron. + for _, side in ipairs({ -1, 1 }) do + local wall = part({ + Name = slip.name .. "_Wall_" .. k, + Size = Vector3.new(1.2, 3.2, len + 1.2), + CFrame = segCF * CFrame.new(side * (WIDTH / 2 - 0.6), 2.6, 0), + Color = C_PARAPET, + Material = Enum.Material.Concrete, + }) + wall.Parent = ramps + + local kill = part({ + Name = "KillWall", + Size = Vector3.new(2, WALL_HEIGHT, len + 6), + CFrame = segCF * CFrame.new(side * (WIDTH / 2 + 1), WALL_HEIGHT / 2, 0), + Transparency = 1, + CanCollide = true, + }) + kill.Parent = ramps + end + + -- Pillars from the deck underside down to the apron, every + -- 5th step. Skipped where the gap is small (near the top of + -- the run the slip is still tucked under the mainline). + if k % 5 == 0 then + local h = mid.Y - 1 - PA_TOP + if h > 4 then + local col = part({ + Name = slip.name .. "_Pillar_" .. k, + Size = Vector3.new(7, h, 7), + CFrame = CFrame.new(mid.X, PA_TOP + h / 2, mid.Z), + Color = C_CONCRETE, + Material = Enum.Material.Concrete, + }) + col.Parent = ramps + end + end + end + end + prev = cur + end + end +end + function MapBuilder.build(): Folder local existing = Workspace:FindFirstChild("Map") if existing then @@ -2917,6 +3455,7 @@ function MapBuilder.build(): Folder buildRoad(map) buildJunctionSigns(map) buildPA(map) + buildPARamps(map) buildLandmarks(map) buildSkyline(map) buildBridges(map) diff --git a/tools/capture_auxlane.py b/tools/capture_auxlane.py new file mode 100644 index 0000000..1057551 --- /dev/null +++ b/tools/capture_auxlane.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Capture the speed-change (aux) lanes ANCHORED to the parts themselves. + +Hardcoded camera offsets are how you end up photographing empty air after the +geometry moves. Every shot here is framed from the LIVE position of the aux +lane / slip parts queried out of the DataModel at capture time, so the camera +cannot drift away from the thing under test. +""" +import shutil +import sys +import time + +sys.path.insert(0, "/tmp") +import rbx # noqa: E402 + +OUT = "/home/jesse/roblox/tokyo-wangan-racing/tools/shots" + +# Ask Studio where the aux lanes actually are, then frame from that. +# Returns "x y z | x y z ..." anchors: west aux mid, east aux mid, and the +# two slip mouths. +ANCHOR_CODE = """ +local map = workspace:FindFirstChild("Map") +if not map then return "NOMAP" end +local aux = {} +local mouths = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local ai = d.Name:match("^AuxLane_(%d+)$") + if ai then table.insert(aux, {i = tonumber(ai), p = d.Position}) end + for _, s in ipairs({"PAWestSlip", "PAEastSlip"}) do + local k = d.Name:match("^" .. s .. "_Deck_(%d+)$") + if k and tonumber(k) <= 1 then mouths[s] = d.Position end + end + end +end +if #aux == 0 then return "NOAUX" end +table.sort(aux, function(a, b) return a.i < b.i end) +-- Split the aux segments into two clusters (west run and east run) by index gap. +local groups, cur = {}, {aux[1]} +for k = 2, #aux do + if aux[k].i - aux[k - 1].i > 3 then + table.insert(groups, cur); cur = {} + end + table.insert(cur, aux[k]) +end +table.insert(groups, cur) +local parts = {} +for _, g in ipairs(groups) do + local mid = g[math.max(1, math.floor(#g / 2))].p + table.insert(parts, ("%.1f %.1f %.1f"):format(mid.X, mid.Y, mid.Z)) +end +for _, s in ipairs({"PAWestSlip", "PAEastSlip"}) do + local m = mouths[s] + if m then table.insert(parts, ("%.1f %.1f %.1f"):format(m.X, m.Y, m.Z)) end +end +return table.concat(parts, " | ") +""" + +CAM_TPL = """ +local cam = workspace.CurrentCamera +cam.CameraType = Enum.CameraType.Scriptable +cam.FieldOfView = {fov} +cam.CFrame = CFrame.lookAt( + Vector3.new({px}, {py}, {pz}), + Vector3.new({tx}, {ty}, {tz}) +) +return "cam ok" +""" + + +def grab(name, px, py, pz, tx, ty, tz, fov=55): + code = CAM_TPL.format(fov=fov, px=px, py=py, pz=pz, tx=tx, ty=ty, tz=tz) + r = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": code}) + if isinstance(r, dict) and r.get("isError"): + print(f"{name}: CAM FAIL {r}") + return None + time.sleep(1.5) + cap = rbx.call("screen_capture", {"capture_id": "ScreenCapture_1"}, wait=22, timeout=300) + imgs = cap.get("images") if isinstance(cap, dict) else None + if not imgs: + print(f"{name}: CAPTURE FAIL {str(cap)[:160]}") + return None + dest = f"{OUT}/{name}.jpg" + shutil.copy(imgs[0], dest) + print(f"{name}: OK -> {dest}") + return dest + + +def main(): + a = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": ANCHOR_CODE}, + wait=40, timeout=300) + txt = a if isinstance(a, str) else str(a) + if "NOMAP" in txt or "NOAUX" in txt: + print("ANCHOR FAIL: " + txt[:200]) + return + # Pull the coordinate triples out of whatever wrapper the bridge returns. + import re + trip = re.findall(r"(-?\d+\.\d+) (-?\d+\.\d+) (-?\d+\.\d+)", txt) + if not trip: + print("ANCHOR PARSE FAIL: " + txt[:300]) + return + pts = [tuple(float(v) for v in t) for t in trip] + print("anchors: " + str(pts)) + + made = [] + for n, (x, y, z) in enumerate(pts[:2], start=1): + # Oblique from outside the deck, looking back at the merge. + d = grab(f"aux{n}_oblique", x + 260, y + 150, z + 260, x, y, z, fov=50) + if d: + made.append(d) + # Top-down: the merge taper reads clearest from directly above. + d = grab(f"aux{n}_topdown", x + 3, y + 430, z + 3, x, y, z, fov=42) + if d: + made.append(d) + # Driver's-eye down the lane. + d = grab(f"aux{n}_driver", x - 190, y + 16, z - 60, x + 120, y + 8, z + 40, fov=68) + if d: + made.append(d) + + print("\nMADE " + str(len(made)) + " shots") + for m in made: + print(m) + + +if __name__ == "__main__": + main() diff --git a/tools/capture_barrier_fix.py b/tools/capture_barrier_fix.py new file mode 100644 index 0000000..d6c4462 --- /dev/null +++ b/tools/capture_barrier_fix.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Capture the two spots the user circled in their screenshot. + +Their view looks along the mainline with the ramp descending on the left; the +two marks sit at the suppression boundary (mainline samples ~525 and ~551), +where the aux lane ends and the parapet transitions back to the deck edge. +Anchor the camera to those SAMPLES, live, so the shot cannot drift. + + left oval -> sample 525 area: was a 4-sample hole with no barrier + right circle -> sample 551 area: was a 26-stud protruding fence stub +""" +import re +import shutil +import sys +import time + +sys.path.insert(0, "/tmp") +import rbx # noqa: E402 + +OUT = "/home/jesse/roblox/tokyo-wangan-racing/tools/shots" + +ANCHOR_CODE = """ +local map = workspace:FindFirstChild("Map") +if not map then return "NOMAP" end +local deck = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local k = d.Name:match("^Road_(%d+)$") or d.Name:match("^Deck_(%d+)$") + if k then deck[tonumber(k)] = d end + end +end +local out = {} +for _, i in ipairs({520, 525, 538, 551, 556}) do + local d = deck[i] + if d then + table.insert(out, ("%.1f %.1f %.1f"):format(d.Position.X, d.Position.Y, d.Position.Z)) + end +end +if #out < 5 then return "NOSAMP" end +return table.concat(out, " | ") +""" + +CAM_TPL = """ +local cam = workspace.CurrentCamera +cam.CameraType = Enum.CameraType.Scriptable +cam.FieldOfView = {fov} +cam.CFrame = CFrame.lookAt( + Vector3.new({px}, {py}, {pz}), + Vector3.new({tx}, {ty}, {tz}) +) +return "cam ok" +""" + + +def grab(name, px, py, pz, tx, ty, tz, fov=55): + code = CAM_TPL.format(fov=fov, px=px, py=py, pz=pz, tx=tx, ty=ty, tz=tz) + r = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": code}) + if isinstance(r, dict) and r.get("isError"): + print(f"{name}: CAM FAIL {r}") + return None + time.sleep(1.5) + cap = rbx.call("screen_capture", {"capture_id": "ScreenCapture_1"}, wait=22, timeout=300) + imgs = cap.get("images") if isinstance(cap, dict) else None + if not imgs: + print(f"{name}: CAPTURE FAIL {str(cap)[:160]}") + return None + dest = f"{OUT}/{name}.jpg" + shutil.copy(imgs[0], dest) + print(f"{name}: OK -> {dest}") + return dest + + +def main(): + a = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": ANCHOR_CODE}, + wait=40, timeout=300) + txt = a if isinstance(a, str) else str(a) + if "NOMAP" in txt or "NOSAMP" in txt: + print("ANCHOR FAIL: " + txt[:200]) + return + trip = re.findall(r"(-?\d+\.\d+) (-?\d+\.\d+) (-?\d+\.\d+)", txt) + if len(trip) < 5: + print("ANCHOR PARSE FAIL: " + txt[:300]) + return + p = [tuple(float(v) for v in t) for t in trip] + s520, s525, s538, s551, s556 = p + print("anchors: " + str(p)) + + made = [] + + # LEFT OVAL: the formerly-bare run. Look along the road from upstream so the + # barrier line is seen edge-on -- a gap would read as an obvious break. + made.append(grab("fix_left_oval", + s520[0] - 90, s520[1] + 55, s520[2] - 90, + s538[0], s538[1], s538[2], fov=55)) + + # RIGHT CIRCLE: the transition where the fence used to jut out 26 studs. + made.append(grab("fix_right_circle", + s556[0] + 120, s556[1] + 50, s556[2] + 120, + s551[0], s551[1], s551[2], fov=50)) + + # Top-down over each boundary: a jut shows instantly as a step in the line. + made.append(grab("fix_boundary_525_top", + s525[0], s525[1] + 300, s525[2], + s525[0], s525[1], s525[2], fov=55)) + made.append(grab("fix_boundary_551_top", + s551[0], s551[1] + 300, s551[2], + s551[0], s551[1], s551[2], fov=55)) + + # Wide overview matching the user's framing. + made.append(grab("fix_overview", + s538[0] - 420, s538[1] + 240, s538[2] - 420, + s538[0], s538[1], s538[2], fov=60)) + + made = [m for m in made if m] + print("\nMADE " + str(len(made)) + " shots") + for m in made: + print(m) + + +if __name__ == "__main__": + main() diff --git a/tools/capture_ramps.py b/tools/capture_ramps.py new file mode 100644 index 0000000..a464714 --- /dev/null +++ b/tools/capture_ramps.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Capture the PA slip ramps from the user's own viewpoint, LIVE-ANCHORED. + +The user's screenshot looks along the mainline from above the deck with the +ramp descending on the left. Reproduce that framing from the actual ramp +geometry so the shot cannot drift onto empty air after an edit. +""" +import re +import shutil +import sys +import time + +sys.path.insert(0, "/tmp") +import rbx # noqa: E402 + +OUT = "/home/jesse/roblox/tokyo-wangan-racing/tools/shots" + +# Anchors: for each slip -> mouth (first deck seg), mid-span, landing (last seg). +ANCHOR_CODE = """ +local map = workspace:FindFirstChild("Map") +if not map then return "NOMAP" end +local segs = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + for _, s in ipairs({"PAWestSlip", "PAEastSlip"}) do + local k = d.Name:match("^" .. s .. "_Deck_(%d+)$") + if k then + segs[s] = segs[s] or {} + table.insert(segs[s], {i = tonumber(k), p = d.Position}) + end + end + end +end +local out = {} +for _, s in ipairs({"PAWestSlip", "PAEastSlip"}) do + local g = segs[s] + if g and #g > 2 then + table.sort(g, function(a, b) return a.i < b.i end) + local mouth = g[1].p + local mid = g[math.floor(#g / 2)].p + local land = g[#g].p + table.insert(out, ("%.1f %.1f %.1f"):format(mouth.X, mouth.Y, mouth.Z)) + table.insert(out, ("%.1f %.1f %.1f"):format(mid.X, mid.Y, mid.Z)) + table.insert(out, ("%.1f %.1f %.1f"):format(land.X, land.Y, land.Z)) + end +end +if #out == 0 then return "NOSLIP" end +return table.concat(out, " | ") +""" + +CAM_TPL = """ +local cam = workspace.CurrentCamera +cam.CameraType = Enum.CameraType.Scriptable +cam.FieldOfView = {fov} +cam.CFrame = CFrame.lookAt( + Vector3.new({px}, {py}, {pz}), + Vector3.new({tx}, {ty}, {tz}) +) +return "cam ok" +""" + + +def grab(name, px, py, pz, tx, ty, tz, fov=55): + code = CAM_TPL.format(fov=fov, px=px, py=py, pz=pz, tx=tx, ty=ty, tz=tz) + r = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": code}) + if isinstance(r, dict) and r.get("isError"): + print(f"{name}: CAM FAIL {r}") + return None + time.sleep(1.5) + cap = rbx.call("screen_capture", {"capture_id": "ScreenCapture_1"}, wait=22, timeout=300) + imgs = cap.get("images") if isinstance(cap, dict) else None + if not imgs: + print(f"{name}: CAPTURE FAIL {str(cap)[:160]}") + return None + dest = f"{OUT}/{name}.jpg" + shutil.copy(imgs[0], dest) + print(f"{name}: OK -> {dest}") + return dest + + +def main(): + a = rbx.call("execute_luau", {"datamodel_type": "Edit", "code": ANCHOR_CODE}, + wait=40, timeout=300) + txt = a if isinstance(a, str) else str(a) + if "NOMAP" in txt or "NOSLIP" in txt: + print("ANCHOR FAIL: " + txt[:200]) + return + trip = re.findall(r"(-?\d+\.\d+) (-?\d+\.\d+) (-?\d+\.\d+)", txt) + if len(trip) < 6: + print("ANCHOR PARSE FAIL: " + txt[:300]) + return + pts = [tuple(float(v) for v in t) for t in trip] + print("anchors: " + str(pts)) + + made = [] + for n, base in enumerate((0, 3), start=1): + mouth, mid, land = pts[base], pts[base + 1], pts[base + 2] + tag = ["west", "east"][n - 1] + + # 1. The user's view: high, behind the mouth, looking down the ramp. + made.append(grab( + f"ramp_{tag}_userview", + mouth[0] - 300, mouth[1] + 120, mouth[2] - 300, + land[0], land[1], land[2], fov=60)) + + # 2. Side elevation: shows the descent profile against the deck. + made.append(grab( + f"ramp_{tag}_side", + mid[0] + 420, mid[1] + 90, mid[2], + mid[0], mid[1], mid[2], fov=48)) + + # 3. Top-down over mid-span: is it parallel to the deck or cutting away? + made.append(grab( + f"ramp_{tag}_topdown", + mid[0], mid[1] + 560, mid[2], + mid[0], mid[1], mid[2], fov=55)) + + made = [m for m in made if m] + print("\nMADE " + str(len(made)) + " shots") + for m in made: + print(m) + + +if __name__ == "__main__": + main() diff --git a/tools/probe_after.luau b/tools/probe_after.luau new file mode 100644 index 0000000..f6f71d2 --- /dev/null +++ b/tools/probe_after.luau @@ -0,0 +1,73 @@ +-- The rebuild was interrupted mid-flight; its effect is UNKNOWN. Establish +-- ground truth before touching anything else. +-- (a) Did the build run? part count vs 16714 (user-edited) / 16820 (source). +-- (b) Is the aux lane back? user's edited workspace had 30 aux parts, +-- source produces 52 -- so they deleted ~22. +-- (c) Did the smoothing pass actually land on the east slip? +local out = {} +local function say(s) table.insert(out, s) end + +local map = workspace:FindFirstChild("Map") +if not map then return "workspace.Map MISSING" end + +local parts = 0 +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then parts += 1 end +end +say(("parts=%d (16714=user-edited, 16820=source rebuild)"):format(parts)) + +-- Aux lane inventory, with index range + position so I can tell WHICH lane. +local aux = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local k = d.Name:match("^AuxLane_(%d+)$") + if k then table.insert(aux, { i = tonumber(k), p = d.Position, inst = d }) end + end +end +table.sort(aux, function(a, b) return a.i < b.i end) +say(("AuxLane parts=%d (30=after user deletion, 52=source)"):format(#aux)) +if #aux > 0 then + -- group into contiguous runs so each physical lane shows up separately + local runs, cur = {}, nil + for _, a in ipairs(aux) do + if cur and a.i - cur.last <= 2 then cur.last = a.i; cur.n += 1 + else + if cur then table.insert(runs, cur) end + cur = { first = a.i, last = a.i, n = 1, p = a.p } + end + end + if cur then table.insert(runs, cur) end + for _, r in ipairs(runs) do + say((" aux run idx %d..%d n=%d at (%.0f,%.0f,%.0f)"):format( + r.first, r.last, r.n, r.p.X, r.p.Y, r.p.Z)) + end +end + +-- Slip smoothness: did the relaxation take effect? +for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do + local segs = {} + for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local k = d.Name:match("^" .. s .. "_Deck_(%d+)$") + if k then table.insert(segs, { i = tonumber(k), p = d.Position, cf = d.CFrame }) end + end + end + table.sort(segs, function(a, b) return a.i < b.i end) + if #segs == 0 then say(s .. ": ABSENT") continue end + local total, worst, worstAt, over = 0, 0, -1, 0 + for j = 2, #segs do + local a, b = segs[j - 1].cf.LookVector, segs[j].cf.LookVector + local d = math.deg(math.acos(math.clamp(a:Dot(b), -1, 1))) + total += d + if d > worst then worst = d; worstAt = segs[j].i end + if d > 5 then over += 1 end + end + local topY, level = segs[1].p.Y, 0 + for _, sg in ipairs(segs) do + if math.abs(sg.p.Y - topY) < 0.5 then level += 1 else break end + end + say(("%s: segs=%d total=%.1fdeg worst=%.1f@%d over5=%d level=%d"):format( + s, #segs, total, worst, worstAt, over, level)) +end + +return table.concat(out, "\n") diff --git a/tools/probe_apron.luau b/tools/probe_apron.luau new file mode 100644 index 0000000..9d3bca7 --- /dev/null +++ b/tools/probe_apron.luau @@ -0,0 +1,55 @@ +local HttpService = game:GetService("HttpService") +local RS = game:GetService("ReplicatedStorage") +local Circuit = require(RS:WaitForChild("Shared"):WaitForChild("Circuit")) +local map = workspace:FindFirstChild("Map") +if not map then return "no Map" end + +-- Aggregate PA parts EXCLUDING the slips we just built, so we can see the lot. +local agg = { xMin = math.huge, xMax = -math.huge, zMin = math.huge, zMax = -math.huge } +local names = {} +local biggest, biggestArea = nil, 0 +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") and d.Name:sub(1, 2) == "PA" and not d.Name:find("Slip") then + names[d.Name] = (names[d.Name] or 0) + 1 + agg.xMin = math.min(agg.xMin, d.Position.X - d.Size.X / 2) + agg.xMax = math.max(agg.xMax, d.Position.X + d.Size.X / 2) + agg.zMin = math.min(agg.zMin, d.Position.Z - d.Size.Z / 2) + agg.zMax = math.max(agg.zMax, d.Position.Z + d.Size.Z / 2) + local area = d.Size.X * d.Size.Z + if area > biggestArea then + biggestArea = area + biggest = ("%s size=(%d,%d,%d) pos=(%d,%d,%d) yTop=%.1f"):format( + d.Name, d.Size.X, d.Size.Y, d.Size.Z, + d.Position.X, d.Position.Y, d.Position.Z, + d.Position.Y + d.Size.Y / 2) + end + end +end + +local nameList = {} +for n, c in pairs(names) do + table.insert(nameList, n .. "x" .. c) +end +table.sort(nameList) +-- cap the list so the payload can't truncate +local head = {} +for i = 1, math.min(#nameList, 40) do head[i] = nameList[i] end + +local s = Circuit.samples +local function probe(i) + local a, b = s[i], s[i + 1] + local d = (b.pos - a.pos) + local fwd = Vector3.new(d.X, 0, d.Z).Unit + local right = fwd:Cross(Vector3.yAxis) + return ("s%d pos=(%d,%d) fwd=(%.2f,%.2f) right=(%.2f,%.2f)"):format( + i, a.pos.X, a.pos.Z, fwd.X, fwd.Z, right.X, right.Z) +end + +return HttpService:JSONEncode({ + pa_nonslip_bounds = { x = { math.floor(agg.xMin), math.floor(agg.xMax) }, z = { math.floor(agg.zMin), math.floor(agg.zMax) } }, + biggest_pa_part = biggest, + distinct_pa_names = #nameList, + sample_names = head, + pa_center = tostring(Circuit.PA_CENTER), + joins = { probe(525), probe(537), probe(551), probe(539) }, +}) diff --git a/tools/probe_barriergap.luau b/tools/probe_barriergap.luau new file mode 100644 index 0000000..17c7236 --- /dev/null +++ b/tools/probe_barriergap.luau @@ -0,0 +1,134 @@ +-- Two questions, measured rather than eyeballed: +-- (1) WHERE exactly is the mainline missing barriers? Walk the deck edge and +-- report contiguous runs with no Barrier/EdgeRail within reach. +-- (User deleted ~106 parts by hand, so gaps are wherever they cut.) +-- (2) HOW does the south on-ramp differ from the north off-ramp? Compare the +-- two slips feature-by-feature so "make it look like the other" is a +-- concrete list of deltas, not a guess. +local out = {} +local function say(s) table.insert(out, s) end + +local map = workspace:FindFirstChild("Map") +if not map then return "Map MISSING" end + +-- Collect barrier-ish parts into a coarse spatial hash for fast lookup. +local CELL = 60 +local grid = {} +local function key(x, z) return math.floor(x / CELL) .. ":" .. math.floor(z / CELL) end +local barrierCount = 0 +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") and (d.Name == "Barrier" or d.Name == "EdgeRail") then + barrierCount += 1 + local k = key(d.Position.X, d.Position.Z) + grid[k] = grid[k] or {} + table.insert(grid[k], d) + end +end +say(("indexed %d Barrier/EdgeRail parts"):format(barrierCount)) + +local function nearestBarrier(p) + local best = math.huge + local cx, cz = math.floor(p.X / CELL), math.floor(p.Z / CELL) + for dx = -1, 1 do + for dz = -1, 1 do + local c = grid[(cx + dx) .. ":" .. (cz + dz)] + if c then + for _, b in ipairs(c) do + local d2 = (Vector3.new(b.Position.X, 0, b.Position.Z) - Vector3.new(p.X, 0, p.Z)).Magnitude + -- only count barriers at roughly this height + if math.abs(b.Position.Y - p.Y) < 14 and d2 < best then best = d2 end + end + end + end + end + return best +end + +-- Walk the mainline deck parts to build an ordered centreline. +local deck = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local k = d.Name:match("^Road_(%d+)$") or d.Name:match("^Deck_(%d+)$") + or d.Name:match("^RoadDeck_(%d+)$") + if k then table.insert(deck, { i = tonumber(k), p = d.Position, cf = d.CFrame, sz = d.Size }) end + end +end +table.sort(deck, function(a, b) return a.i < b.i end) +say(("mainline deck parts found: %d"):format(#deck)) + +if #deck > 0 then + local UP = Vector3.new(0, 1, 0) + local runs = {} + local cur = nil + for j, s in ipairs(deck) do + local fwd = s.cf.LookVector + local right = fwd:Cross(UP).Unit + local half = s.sz.X / 2 + -- probe just outboard of each edge + local gapL = nearestBarrier(s.p - right * (half + 2)) > 12 + local gapR = nearestBarrier(s.p + right * (half + 2)) > 12 + local bad = gapL or gapR + if bad then + local side = (gapL and gapR) and "BOTH" or (gapL and "L" or "R") + if cur and cur.side == side and s.i - cur.last <= 2 then + cur.last = s.i; cur.n += 1 + else + if cur then table.insert(runs, cur) end + cur = { first = s.i, last = s.i, n = 1, side = side, pos = s.p } + end + else + if cur then table.insert(runs, cur); cur = nil end + end + end + if cur then table.insert(runs, cur) end + + table.sort(runs, function(a, b) return a.n > b.n end) + say(("UNBARRIERED RUNS: %d total"):format(#runs)) + for j = 1, math.min(#runs, 14) do + local r = runs[j] + say((" samples %d..%d (%d) side=%s at (%.0f,%.0f,%.0f)"):format( + r.first, r.last, r.n, r.side, r.pos.X, r.pos.Y, r.pos.Z)) + end +end + +-- (2) Compare the two slips. +say("--- SLIP COMPARISON ---") +for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do + local segs, walls, lines, other = {}, 0, 0, {} + for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") and d.Name:match("^" .. s) then + local k = d.Name:match("^" .. s .. "_Deck_(%d+)$") + if k then + table.insert(segs, { i = tonumber(k), p = d.Position, sz = d.Size, cf = d.CFrame }) + elseif d.Name:match("_Wall_") then walls += 1 + elseif d.Name:match("[Ll]ine") then lines += 1 + else + local nm = d.Name:gsub("^" .. s .. "_", ""):gsub("_%d+$", "") + other[nm] = (other[nm] or 0) + 1 + end + end + end + table.sort(segs, function(a, b) return a.i < b.i end) + local ol = {} + for k, v in pairs(other) do table.insert(ol, k .. "=" .. v) end + table.sort(ol) + if #segs > 0 then + -- heading of first and last segment: which way does it run? + local h1 = segs[1].cf.LookVector + local hN = segs[#segs].cf.LookVector + -- curvature: total heading change + local turn = 0 + for j = 2, #segs do + local a = segs[j - 1].cf.LookVector + local b = segs[j].cf.LookVector + turn += math.deg(math.acos(math.clamp(a:Dot(b), -1, 1))) + end + say(("%s: segs=%d walls=%d lines=%d startY=%.1f endY=%.1f"):format(s, #segs, walls, lines, segs[1].p.Y, segs[#segs].p.Y)) + say((" heading first=(%.2f,%.2f) last=(%.2f,%.2f) totalTurn=%.1fdeg"):format(h1.X, h1.Z, hN.X, hN.Z, turn)) + say((" start=(%.0f,%.0f,%.0f) end=(%.0f,%.0f,%.0f)"):format( + segs[1].p.X, segs[1].p.Y, segs[1].p.Z, segs[#segs].p.X, segs[#segs].p.Y, segs[#segs].p.Z)) + say(" other parts: " .. (#ol > 0 and table.concat(ol, " ") or "none")) + end +end + +return table.concat(out, "\n") diff --git a/tools/probe_build.luau b/tools/probe_build.luau new file mode 100644 index 0000000..8082d2c --- /dev/null +++ b/tools/probe_build.luau @@ -0,0 +1,55 @@ +-- The require sweep passing only proves the modules LOAD. What runs on Play is +-- MapBuilder actually building. Execute it for real and capture the error. +local SS = game:GetService("ServerScriptService") +local out = {} +local function say(s) table.insert(out, s) end + +local serverScript = SS:FindFirstChild("Server") +local mb +for _, d in ipairs(SS:GetDescendants()) do + if d:IsA("ModuleScript") and d.Name == "MapBuilder" then mb = d end +end +say("MapBuilder module: " .. (mb and mb:GetFullName() or "NOT FOUND")) +if not mb then return table.concat(out, "\n") end + +local ok, mod = pcall(require, mb) +say("require: " .. (ok and "ok" or ("FAIL " .. tostring(mod)))) +if not ok then return table.concat(out, "\n") end + +say("exports: " .. type(mod)) +if type(mod) == "table" then + local keys = {} + for k, v in pairs(mod) do table.insert(keys, ("%s(%s)"):format(tostring(k), type(v))) end + table.sort(keys) + say(" " .. table.concat(keys, ", ")) +end + +-- Clear any existing map so we build from scratch, exactly like a fresh run. +local existing = workspace:FindFirstChild("Map") +say("existing Map in workspace: " .. (existing and "yes" or "no")) + +local buildFn = type(mod) == "table" and (mod.Build or mod.build or mod.BuildMap) +if type(buildFn) ~= "function" then + say("NO BUILD FUNCTION EXPORTED -- cannot test build path") + return table.concat(out, "\n") +end + +if existing then existing:Destroy() end +local t0 = os.clock() +local bok, berr = pcall(buildFn) +local dt = os.clock() - t0 +say(("build pcall: %s (%.2fs)"):format(bok and "OK" or "FAIL", dt)) +if not bok then + say("BUILD ERROR: " .. tostring(berr):sub(1, 600)) + return table.concat(out, "\n") +end + +local m = workspace:FindFirstChild("Map") +say("Map after build: " .. (m and "present" or "MISSING")) +if m then + local n = #m:GetDescendants() + say("descendants: " .. n) + if n == 0 then say("CONTROL FAIL: map built but empty") end +end + +return table.concat(out, "\n") diff --git a/tools/probe_final.luau b/tools/probe_final.luau new file mode 100644 index 0000000..c5109f0 --- /dev/null +++ b/tools/probe_final.luau @@ -0,0 +1,106 @@ +-- Re-verify after un-pinning index 2, and settle the 4 "bare" samples: +-- 525L/526L/549L/550L sit at the ramp MOUTHS (west join=525, east join=551). +-- An open parapet at a mouth is correct -- that is the doorway cars drive +-- through, and the ramp deck's own walls take over. So check whether ramp deck +-- + ramp wall actually cover those samples. If yes: not a hole. If no: a hole. +local SS = game:GetService("ServerScriptService") +local out = {} +local function say(s) table.insert(out, s) end + +local mod = SS:FindFirstChild("MapBuilder", true) +if not mod then return "MapBuilder NOT FOUND" end +local fresh = mod:Clone() +fresh.Name = "MB_fresh_" .. tostring(math.random(1e6)) +fresh.Parent = SS +local ok, MB = pcall(require, fresh) +if not ok then fresh:Destroy() return "require FAILED: " .. tostring(MB) end +local ok2, err = pcall(function() MB.build() end) +fresh:Destroy() +if not ok2 then return "build ERROR: " .. tostring(err) end +say("build OK") + +local map = workspace:FindFirstChild("Map") +local parts = 0 +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then parts += 1 end +end +say(("parts=%d"):format(parts)) + +local deck, bars, slipAll = {}, {}, {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local k = d.Name:match("^Road_(%d+)$") or d.Name:match("^Deck_(%d+)$") + if k then deck[tonumber(k)] = d end + if d.Name == "Barrier" or d.Name == "EdgeRail" then table.insert(bars, d) end + if d.Name:match("^PA[EW][ae]stSlip") then table.insert(slipAll, d) end + end +end + +local UP = Vector3.new(0, 1, 0) +local function flat(v) return Vector3.new(v.X, 0, v.Z) end +local function nearest(list, p, ytol) + local best = math.huge + for _, b in ipairs(list) do + if math.abs(b.Position.Y - p.Y) < (ytol or 14) then + local dd = (flat(b.Position) - flat(p)).Magnitude + if dd < best then best = dd end + end + end + return best +end + +say("--- edge audit 520..555 (L side) ---") +local realHoles = {} +for i = 520, 555 do + local d = deck[i] + if d then + local right = d.CFrame.LookVector:Cross(UP).Unit + local edge = d.Position - right * (d.Size.X / 2 + 2) + local bd = nearest(bars, edge) + local sd = nearest(slipAll, edge, 20) + local covered = bd < 12 or sd < 30 + if not covered then table.insert(realHoles, i) end + if bd >= 12 then + say((" %d: barrier=%.1f slipStruct=%.1f -> %s"):format( + i, bd, sd, covered and "OK (ramp mouth)" or "HOLE")) + end + end +end +say(("REAL holes: %d %s"):format(#realHoles, + #realHoles > 0 and table.concat(realHoles, ",") or "(none)")) + +say("--- smoothness ---") +for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do + local segs = {} + for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local k = d.Name:match("^" .. s .. "_Deck_(%d+)$") + if k then table.insert(segs, { i = tonumber(k), p = d.Position, cf = d.CFrame }) end + end + end + table.sort(segs, function(a, b) return a.i < b.i end) + local total, worst, worstAt, over = 0, 0, -1, 0 + for j = 2, #segs do + local a, b = segs[j - 1].cf.LookVector, segs[j].cf.LookVector + local dth = math.deg(math.acos(math.clamp(a:Dot(b), -1, 1))) + total += dth + if dth > worst then worst = dth; worstAt = segs[j].i end + if dth > 5 then over += 1 end + end + local topY, level = segs[1].p.Y, 0 + for _, sg in ipairs(segs) do + if math.abs(sg.p.Y - topY) < 0.5 then level += 1 else break end + end + say(("%s: total=%.1fdeg worst=%.1f@%d over5=%d level=%d"):format( + s, total, worst, worstAt, over, level)) + say((" start=(%.0f,%.0f,%.0f) end=(%.0f,%.0f,%.0f)"):format( + segs[1].p.X, segs[1].p.Y, segs[1].p.Z, segs[#segs].p.X, segs[#segs].p.Y, segs[#segs].p.Z)) +end + +local aux = 0 +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") and d.Name:match("^AuxLane_%d+$") then aux += 1 end +end +say(("AuxLane total=%d (want 30)"):format(aux)) + +return table.concat(out, "\n") diff --git a/tools/probe_fix.luau b/tools/probe_fix.luau new file mode 100644 index 0000000..3b72f80 --- /dev/null +++ b/tools/probe_fix.luau @@ -0,0 +1,123 @@ +-- Studio caches required modules, which is why the previous rebuild silently +-- ran the OLD code (east slip still measured 216.0deg, unchanged). Force a +-- fresh load: clone the ModuleScript so require() sees a new instance and +-- recompiles from current source. +-- +-- Verifies three things at once: +-- (1) smoothing landed -> east total heading change << 216deg, zero >5deg kinks +-- (2) aux lane stays deleted -> AuxLane run 527..548 ABSENT, total ~30 not 52 +-- (3) barrier gap closed -> parapet present along 527..548 +local SS = game:GetService("ServerScriptService") +local out = {} +local function say(s) table.insert(out, s) end + +local mod = SS:FindFirstChild("MapBuilder", true) +if not mod then return "MapBuilder NOT FOUND" end +if not mod:IsA("ModuleScript") then return "MapBuilder is " .. mod.ClassName end + +local fresh = mod:Clone() +fresh.Name = "MapBuilder_fresh_" .. tostring(math.random(1e6)) +fresh.Parent = SS + +local ok, MB = pcall(require, fresh) +if not ok then + fresh:Destroy() + return "require FAILED: " .. tostring(MB) +end + +local t0 = os.clock() +local ok2, err = pcall(function() MB.build() end) +local dt = os.clock() - t0 +fresh:Destroy() +if not ok2 then return "build ERROR: " .. tostring(err) end +say(("build OK (%.2fs) via fresh module"):format(dt)) + +local map = workspace:FindFirstChild("Map") +if not map then return "Map missing" end + +local parts = 0 +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then parts += 1 end +end +say(("parts=%d"):format(parts)) + +-- (2) aux lane +local aux = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local k = d.Name:match("^AuxLane_(%d+)$") + if k then table.insert(aux, tonumber(k)) end + end +end +table.sort(aux) +local inRange = 0 +for _, i in ipairs(aux) do if i >= 527 and i <= 548 then inRange += 1 end end +say(("AuxLane total=%d inside 527..548 = %d (want 0)"):format(#aux, inRange)) + +-- (3) barrier coverage across the formerly-bare run +local deck, bars = {}, {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local k = d.Name:match("^Road_(%d+)$") or d.Name:match("^Deck_(%d+)$") + if k then deck[tonumber(k)] = d end + if d.Name == "Barrier" or d.Name == "EdgeRail" then table.insert(bars, d) end + end +end +local UP = Vector3.new(0, 1, 0) +local function flat(v) return Vector3.new(v.X, 0, v.Z) end +local bare = {} +for i = 525, 550 do + local d = deck[i] + if d then + local right = d.CFrame.LookVector:Cross(UP).Unit + for _, sgn in ipairs({ -1, 1 }) do + local edge = d.Position + right * sgn * (d.Size.X / 2 + 2) + local best = math.huge + for _, b in ipairs(bars) do + if math.abs(b.Position.Y - edge.Y) < 14 then + local dd = (flat(b.Position) - flat(edge)).Magnitude + if dd < best then best = dd end + end + end + if best > 12 then table.insert(bare, i .. (sgn < 0 and "L" or "R")) end + end + end +end +say(("bare edge samples in 525..550: %d %s"):format(#bare, + #bare > 0 and ("-> " .. table.concat(bare, ",")) or "(want 0)")) + +-- (1) smoothness +for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do + local segs = {} + for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local k = d.Name:match("^" .. s .. "_Deck_(%d+)$") + if k then table.insert(segs, { i = tonumber(k), p = d.Position, cf = d.CFrame }) end + end + end + table.sort(segs, function(a, b) return a.i < b.i end) + if #segs == 0 then say(s .. ": ABSENT") continue end + local total, worst, worstAt, over = 0, 0, -1, 0 + for j = 2, #segs do + local a, b = segs[j - 1].cf.LookVector, segs[j].cf.LookVector + local dth = math.deg(math.acos(math.clamp(a:Dot(b), -1, 1))) + total += dth + if dth > worst then worst = dth; worstAt = segs[j].i end + if dth > 5 then over += 1 end + end + local topY, level = segs[1].p.Y, 0 + for _, sg in ipairs(segs) do + if math.abs(sg.p.Y - topY) < 0.5 then level += 1 else break end + end + local lmin, lmax = math.huge, -math.huge + for j = 2, #segs do + local L = (segs[j].p - segs[j - 1].p).Magnitude + lmin = math.min(lmin, L); lmax = math.max(lmax, L) + end + say(("%s: total=%.1fdeg worst=%.1f@%d over5=%d level=%d spacing=%.1f..%.1f"):format( + s, total, worst, worstAt, over, level, lmin, lmax)) + say((" start=(%.0f,%.0f,%.0f) end=(%.0f,%.0f,%.0f)"):format( + segs[1].p.X, segs[1].p.Y, segs[1].p.Z, segs[#segs].p.X, segs[#segs].p.Y, segs[#segs].p.Z)) +end + +return table.concat(out, "\n") diff --git a/tools/probe_fix2.luau b/tools/probe_fix2.luau new file mode 100644 index 0000000..37003aa --- /dev/null +++ b/tools/probe_fix2.luau @@ -0,0 +1,135 @@ +-- Rebuild with AUX_SUPPRESS widened to 525..550, then verify BOTH marked +-- defects are gone: +-- (A) left oval -> zero samples with no left barrier +-- (B) right circle -> no 26-stud lateral snap at the suppression boundary +-- The 6.3-stud steps at 512/513/563 are the aux lane's own smooth taper +-- (width 26 -> 19 -> 13 -> 7 -> 0) and are CORRECT; the parapet rides the +-- taper. Only steps much larger than that are real juts. +local SS = game:GetService("ServerScriptService") +local out = {} +local function say(s) table.insert(out, s) end + +local mod = SS:FindFirstChild("MapBuilder", true) +if not mod then return "MapBuilder NOT FOUND" end +local fresh = mod:Clone() +fresh.Name = "MB_fresh_" .. tostring(math.random(1e6)) +fresh.Parent = SS +local ok, MB = pcall(require, fresh) +if not ok then fresh:Destroy() return "require FAILED: " .. tostring(MB) end +local ok2, err = pcall(function() MB.build() end) +fresh:Destroy() +if not ok2 then return "build ERROR: " .. tostring(err) end +say("build OK") + +local map = workspace:FindFirstChild("Map") +local parts = 0 +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then parts += 1 end +end +say(("parts=%d"):format(parts)) + +local UP = Vector3.new(0, 1, 0) +local deck, aux, bars = {}, {}, {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local k = d.Name:match("^Road_(%d+)$") or d.Name:match("^Deck_(%d+)$") + if k then deck[tonumber(k)] = d end + local a = d.Name:match("^AuxLane_(%d+)$") + if a then aux[tonumber(a)] = d end + if d.Name == "Barrier" then table.insert(bars, d) end + end +end + +say("sample | auxW | left barrier lat | step") +local prevLat, jogs, gaps = nil, {}, {} +for i = 505, 570 do + local d = deck[i] + if d then + local right = d.CFrame.LookVector:Cross(UP).Unit + local c = d.Position + local best, bestLat = math.huge, nil + for _, b in ipairs(bars) do + if math.abs(b.Position.Y - c.Y) < 14 then + local rel = b.Position - c + local along = math.abs(rel:Dot(d.CFrame.LookVector)) + local lat = rel:Dot(right) + if along < 22 and lat < 0 and along < best then best = along; bestLat = lat end + end + end + local auxW = aux[i] and aux[i].Size.X or 0 + if bestLat then + local step = prevLat and math.abs(math.abs(bestLat) - math.abs(prevLat)) or 0 + if step > 10 then table.insert(jogs, ("%d(%.1f)"):format(i, step)) end + if step > 10 or i % 5 == 0 then + say((" %d | aux=%.0f | lat=%.1f | step=%.1f%s"):format( + i, auxW, bestLat, step, step > 10 and " <<< JOG" or "")) + end + prevLat = bestLat + else + table.insert(gaps, i) + say((" %d | aux=%.0f | NO LEFT BARRIER <<< GAP"):format(i, auxW)) + prevLat = nil + end + end +end +say(("BIG JOGS (>10): %d %s"):format(#jogs, #jogs > 0 and table.concat(jogs, " ") or "(none)")) +say(("GAPS: %d %s"):format(#gaps, #gaps > 0 and table.concat(gaps, ",") or "(none)")) + +-- full-loop unguarded sweep, same rule as before +local function flat(v) return Vector3.new(v.X, 0, v.Z) end +local allBars = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") and (d.Name == "Barrier" or d.Name == "EdgeRail") then + table.insert(allBars, d) + end +end +local slipParts = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") and d.Name:match("Slip") then table.insert(slipParts, d) end +end +local holes, swept = {}, 0 +for i, d in pairs(deck) do + swept += 1 + local right = d.CFrame.LookVector:Cross(UP).Unit + for _, sgn in ipairs({ -1, 1 }) do + local edge = d.Position + right * sgn * (d.Size.X / 2 + 2) + local bd = math.huge + for _, b in ipairs(allBars) do + if math.abs(b.Position.Y - edge.Y) < 14 then + local dd = (flat(b.Position) - flat(edge)).Magnitude + if dd < bd then bd = dd end + end + end + if bd >= 12 then + local ok2b = false + local a = aux[i] + if a then + local outer = a.Position + right * sgn * (a.Size.X / 2 + 2) + local bd2 = math.huge + for _, b in ipairs(allBars) do + if math.abs(b.Position.Y - outer.Y) < 14 then + local dd = (flat(b.Position) - flat(outer)).Magnitude + if dd < bd2 then bd2 = dd end + end + end + if bd2 < 14 then ok2b = true end + end + if not ok2b then + local sd = math.huge + for _, p in ipairs(slipParts) do + local dd = (flat(p.Position) - flat(edge)).Magnitude + if dd < sd then sd = dd end + end + if sd > 30 then table.insert(holes, i .. (sgn < 0 and "L" or "R")) end + end + end + end +end +say(("FULL SWEEP: %d samples, unguarded=%d %s"):format(swept, #holes, + #holes > 0 and table.concat(holes, ",") or "(none)")) + +local auxN = 0 +for _ in pairs(aux) do auxN += 1 end +say(("AuxLane total=%d (want 30)"):format(auxN)) + +return table.concat(out, "\n") diff --git a/tools/probe_gapreal.luau b/tools/probe_gapreal.luau new file mode 100644 index 0000000..7d17b26 --- /dev/null +++ b/tools/probe_gapreal.luau @@ -0,0 +1,97 @@ +-- The 50-sample "unbarriered run" (513..562, LEFT) overlaps the aux-lane region +-- (510..565) exactly. In that stretch the parapet was deliberately RELOCATED +-- outboard, to the aux lane's outer edge -- so a 12-stud probe off the mainline +-- edge would report "missing" when the barrier is simply further out. +-- Do not claim a hole until this is disambiguated. Report ACTUAL distances. +local out = {} +local function say(s) table.insert(out, s) end + +local map = workspace:FindFirstChild("Map") +if not map then return "Map MISSING" end + +local bars = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") and (d.Name == "Barrier" or d.Name == "EdgeRail" + or d.Name == "KillWall" or d.Name:match("Parapet")) then + table.insert(bars, d) + end +end +say(("barrier-ish parts: %d"):format(#bars)) + +local aux = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") and d.Name:match("^AuxLane_(%d+)$") then + table.insert(aux, d) + end +end +say(("aux lane parts: %d"):format(#aux)) + +local deck = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local k = d.Name:match("^Road_(%d+)$") or d.Name:match("^Deck_(%d+)$") + or d.Name:match("^RoadDeck_(%d+)$") + if k then deck[tonumber(k)] = d end + end +end + +local UP = Vector3.new(0, 1, 0) +local function flat(v) return Vector3.new(v.X, 0, v.Z) end + +-- For a probe point, distance to nearest barrier AND nearest aux part. +local function nearest(list, p, ymax) + local best, bi = math.huge, nil + for _, b in ipairs(list) do + if math.abs(b.Position.Y - p.Y) < (ymax or 16) then + local d2 = (flat(b.Position) - flat(p)).Magnitude + if d2 < best then best = d2; bi = b end + end + end + return best, bi +end + +say("sample | edgeDist->barrier | auxHere | verdict") +local realGaps = {} +for i = 505, 570 do + local d = deck[i] + if d then + local right = d.CFrame.LookVector:Cross(UP).Unit + -- LEFT side probe (side = -right) + local edge = d.Position - right * (d.Size.X / 2 + 2) + local bd = nearest(bars, edge) + local ad = nearest(aux, edge) + local hasAux = ad < 20 + -- A barrier is "present" if within 12 of the mainline edge, OR the aux + -- lane is here and a barrier sits within 12 of the aux OUTER edge. + local ok = bd < 12 + if not ok and hasAux then + local auxEdge = d.Position - right * (d.Size.X / 2 + 26) + local bd2 = nearest(bars, auxEdge) + if bd2 < 14 then ok = true end + if i % 6 == 0 then + say((" %d | %.1f | aux=%s | auxEdgeBarrier=%.1f | %s"):format( + i, bd, tostring(hasAux), bd2, ok and "OK(relocated)" or "GAP")) + end + elseif i % 6 == 0 then + say((" %d | %.1f | aux=%s | %s"):format(i, bd, tostring(hasAux), ok and "OK" or "GAP")) + end + if not ok then table.insert(realGaps, i) end + end +end + +say(("REAL gaps after accounting for relocated parapet: %d samples"):format(#realGaps)) +if #realGaps > 0 then + local runs, cur = {}, nil + for _, i in ipairs(realGaps) do + if cur and i - cur.last <= 2 then cur.last = i + else if cur then table.insert(runs, cur) end cur = { first = i, last = i } end + end + if cur then table.insert(runs, cur) end + for _, r in ipairs(runs) do + local d = deck[r.first] + say((" GAP RUN %d..%d at (%.0f,%.0f,%.0f)"):format( + r.first, r.last, d.Position.X, d.Position.Y, d.Position.Z)) + end +end + +return table.concat(out, "\n") diff --git a/tools/probe_holes.luau b/tools/probe_holes.luau new file mode 100644 index 0000000..59a7907 --- /dev/null +++ b/tools/probe_holes.luau @@ -0,0 +1,93 @@ +-- The 10 flagged samples all read barrier=22.9 -- the exact signature of the +-- RELOCATED parapet (aux lane still exists at 510..524 and 551..565, and its +-- outer edge carries the rail). probe_final's audit did not re-probe the aux +-- outer edge, so it mislabelled them. Settle it: for each flagged sample, +-- measure distance from the AUX OUTER edge to the nearest barrier. +local out = {} +local function say(s) table.insert(out, s) end + +local map = workspace:FindFirstChild("Map") +if not map then return "Map MISSING" end + +local deck, bars, aux = {}, {}, {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local k = d.Name:match("^Road_(%d+)$") or d.Name:match("^Deck_(%d+)$") + if k then deck[tonumber(k)] = d end + if d.Name == "Barrier" or d.Name == "EdgeRail" then table.insert(bars, d) end + local a = d.Name:match("^AuxLane_(%d+)$") + if a then aux[tonumber(a)] = d end + end +end + +local UP = Vector3.new(0, 1, 0) +local function flat(v) return Vector3.new(v.X, 0, v.Z) end +local function nearest(p, ytol) + local best = math.huge + for _, b in ipairs(bars) do + if math.abs(b.Position.Y - p.Y) < (ytol or 14) then + local dd = (flat(b.Position) - flat(p)).Magnitude + if dd < best then best = dd end + end + end + return best +end + +local flagged = { 520, 521, 522, 523, 524, 551, 552, 553, 554, 555 } +local trueHoles = {} +say("sample | auxHere | barrier@auxOuterEdge | verdict") +for _, i in ipairs(flagged) do + local d, a = deck[i], aux[i] + if d then + local right = d.CFrame.LookVector:Cross(UP).Unit + local verdict, dist + if a then + -- probe just outboard of the AUX lane, where the rail was moved to + local outer = a.Position - right * (a.Size.X / 2 + 2) + dist = nearest(outer) + verdict = dist < 14 and "OK (parapet relocated to aux edge)" or "HOLE" + else + local edge = d.Position - right * (d.Size.X / 2 + 2) + dist = nearest(edge) + verdict = dist < 12 and "OK" or "HOLE" + end + if verdict == "HOLE" then table.insert(trueHoles, i) end + say((" %d | aux=%s | %.1f | %s"):format(i, a and "yes" or "no", dist, verdict)) + end +end + +say(("TRUE UNGUARDED SAMPLES: %d %s"):format(#trueHoles, + #trueHoles > 0 and table.concat(trueHoles, ",") or "(none)")) + +-- Full-loop sweep so nothing elsewhere is missed. +local swept, holes = 0, {} +for i, d in pairs(deck) do + swept += 1 + local right = d.CFrame.LookVector:Cross(UP).Unit + for _, sgn in ipairs({ -1, 1 }) do + local edge = d.Position + right * sgn * (d.Size.X / 2 + 2) + if nearest(edge) >= 12 then + local a = aux[i] + local ok = false + if a then + local outer = a.Position + right * sgn * (a.Size.X / 2 + 2) + if nearest(outer) < 14 then ok = true end + end + if not ok then + -- ramp mouth? slip structure nearby counts as guarded + local sd = math.huge + for _, p in ipairs(map:GetDescendants()) do + if p:IsA("BasePart") and p.Name:match("Slip") then + local dd = (flat(p.Position) - flat(edge)).Magnitude + if dd < sd then sd = dd end + end + end + if sd > 30 then table.insert(holes, i .. (sgn < 0 and "L" or "R")) end + end + end + end +end +say(("FULL SWEEP: %d deck samples, unguarded=%d %s"):format(swept, #holes, + #holes > 0 and ("-> " .. table.concat(holes, ",")) or "(none)")) + +return table.concat(out, "\n") diff --git a/tools/probe_jog.luau b/tools/probe_jog.luau new file mode 100644 index 0000000..40119dc --- /dev/null +++ b/tools/probe_jog.luau @@ -0,0 +1,107 @@ +-- Slip walls measured CLEAN (0 missing, 0 deviation, 0.00 lateral step), so +-- neither marked defect is on the ramp decks themselves. Prime suspect is the +-- suppression boundary I introduced: across 527..548 the parapet is forced back +-- to the mainline edge, while 510..526 and 549..565 still carry it relocated +-- OUT on the aux lane's outer edge (~26 studs further). At samples 527 and 549 +-- that is a ~26-stud lateral JOG -- a barrier that visibly sticks out and ends +-- in a stub. And the gore between mainline barrier and ramp wall start may be +-- open -- the missing-barrier oval. +-- +-- Measure signed lateral offset of every Barrier from the mainline centreline, +-- walk 505..570, and report steps + gaps. +local out = {} +local function say(s) table.insert(out, s) end + +local map = workspace:FindFirstChild("Map") +if not map then return "Map MISSING" end +local UP = Vector3.new(0, 1, 0) +local function flat(v) return Vector3.new(v.X, 0, v.Z) end + +local deck, aux = {}, {} +local bars = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local k = d.Name:match("^Road_(%d+)$") or d.Name:match("^Deck_(%d+)$") + if k then deck[tonumber(k)] = d end + local a = d.Name:match("^AuxLane_(%d+)$") + if a then aux[tonumber(a)] = d end + if d.Name == "Barrier" then table.insert(bars, d) end + end +end + +-- For each mainline sample, find the barrier nearest to that sample's plane on +-- the LEFT side, and report its lateral offset from the centreline. +say("sample | auxW | barrier lateral offset from centreline | step") +local prevLat = nil +local jogs, gaps = {}, {} +for i = 505, 570 do + local d = deck[i] + if d then + local right = d.CFrame.LookVector:Cross(UP).Unit + local c = d.Position + -- gather barriers within half a sample spacing along the road axis + local best, bestLat = math.huge, nil + for _, b in ipairs(bars) do + if math.abs(b.Position.Y - c.Y) < 14 then + local rel = b.Position - c + local along = math.abs(rel:Dot(d.CFrame.LookVector)) + local lat = rel:Dot(right) + if along < 22 and lat < 0 then -- LEFT side only + if along < best then best = along; bestLat = lat end + end + end + end + local auxW = aux[i] and aux[i].Size.X or 0 + if bestLat then + local step = prevLat and math.abs(math.abs(bestLat) - math.abs(prevLat)) or 0 + if step > 6 then + table.insert(jogs, ("%d(step %.1f)"):format(i, step)) + end + if i % 4 == 0 or step > 6 then + say((" %d | aux=%.0f | lat=%.1f | step=%.1f%s"):format( + i, auxW, bestLat, step, step > 6 and " <<< JOG" or "")) + end + prevLat = bestLat + else + table.insert(gaps, i) + say((" %d | aux=%.0f | NO BARRIER FOUND on left <<< GAP"):format(i, auxW)) + prevLat = nil + end + end +end + +say(("JOGS (lateral step >6): %d %s"):format(#jogs, #jogs > 0 and table.concat(jogs, " ") or "(none)")) +say(("GAPS (no left barrier): %d %s"):format(#gaps, #gaps > 0 and table.concat(gaps, ",") or "(none)")) + +-- Gore check: distance from each slip's FIRST wall to the nearest mainline +-- barrier. A large value means an open mouth corner -- the missing-barrier oval. +say("--- gore / mouth junction ---") +for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do + local first, firstK = nil, math.huge + local last, lastK = nil, -1 + for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local wk = d.Name:match("^" .. s .. "_Wall_(%d+)$") + if wk then + local k = tonumber(wk) + if k < firstK then firstK = k; first = d end + if k > lastK then lastK = k; last = d end + end + end + end + if first then + local bd = math.huge + for _, b in ipairs(bars) do + if math.abs(b.Position.Y - first.Position.Y) < 16 then + local dd = (flat(b.Position) - flat(first.Position)).Magnitude + if dd < bd then bd = dd end + end + end + say(("%s: first wall seg %d at (%.0f,%.0f,%.0f) -> nearest mainline barrier %.1f"):format( + s, firstK, first.Position.X, first.Position.Y, first.Position.Z, bd)) + say((" last wall seg %d at (%.0f,%.0f,%.0f)"):format( + lastK, last.Position.X, last.Position.Y, last.Position.Z)) + end +end + +return table.concat(out, "\n") diff --git a/tools/probe_kinks.luau b/tools/probe_kinks.luau new file mode 100644 index 0000000..5cb1ce9 --- /dev/null +++ b/tools/probe_kinks.luau @@ -0,0 +1,73 @@ +-- PAEastSlip turns 216deg vs PAWestSlip's 56.5deg -- nearly 4x. That is the +-- "looks terrible" the user means. Find WHERE the turn concentrates: is it a +-- smooth long curve, or a few violent kinks? Per-segment heading deltas tell us. +local out = {} +local function say(s) table.insert(out, s) end + +local map = workspace:FindFirstChild("Map") +if not map then return "Map MISSING" end + +for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do + local segs = {} + for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local k = d.Name:match("^" .. s .. "_Deck_(%d+)$") + if k then table.insert(segs, { i = tonumber(k), p = d.Position, cf = d.CFrame, sz = d.Size }) end + end + end + table.sort(segs, function(a, b) return a.i < b.i end) + if #segs == 0 then say(s .. ": absent") continue end + + local deltas = {} + for j = 2, #segs do + local a = segs[j - 1].cf.LookVector + local b = segs[j].cf.LookVector + local dth = math.deg(math.acos(math.clamp(a:Dot(b), -1, 1))) + table.insert(deltas, { i = segs[j].i, d = dth, p = segs[j].p }) + end + + -- worst kinks + local sorted = {} + for _, v in ipairs(deltas) do table.insert(sorted, v) end + table.sort(sorted, function(x, y) return x.d > y.d end) + + local sum = 0 + for _, v in ipairs(deltas) do sum += v.d end + + say(("%s: total=%.1fdeg mean=%.2fdeg/seg n=%d"):format(s, sum, sum / #deltas, #deltas)) + local worst = {} + for j = 1, math.min(8, #sorted) do + table.insert(worst, ("seg%d=%.1f"):format(sorted[j].i, sorted[j].d)) + end + say(" worst kinks: " .. table.concat(worst, " ")) + + -- how many segments exceed a smooth-curve threshold? + local over = 0 + for _, v in ipairs(deltas) do if v.d > 5 then over += 1 end end + say((" segments turning >5deg: %d"):format(over)) + + -- segment length consistency (uneven lengths also read as ugly) + local lmin, lmax, lsum = math.huge, -math.huge, 0 + for j = 2, #segs do + local L = (segs[j].p - segs[j - 1].p).Magnitude + lmin = math.min(lmin, L); lmax = math.max(lmax, L); lsum += L + end + say((" seg spacing: min=%.1f max=%.1f mean=%.1f totalRun=%.0f"):format( + lmin, lmax, lsum / (#segs - 1), lsum)) + + -- vertical profile: where does it drop? + local topY = segs[1].p.Y + local level = 0 + for _, sg in ipairs(segs) do + if math.abs(sg.p.Y - topY) < 0.5 then level += 1 else break end + end + local steep = 0 + for j = 2, #segs do + local dy = math.abs(segs[j].p.Y - segs[j - 1].p.Y) + local dxz = (Vector3.new(segs[j].p.X, 0, segs[j].p.Z) - Vector3.new(segs[j - 1].p.X, 0, segs[j - 1].p.Z)).Magnitude + if dxz > 0.1 and (dy / dxz) > 0.12 then steep += 1 end + end + say((" level-run=%d segments steeper than 12%%: %d"):format(level, steep)) +end + +return table.concat(out, "\n") diff --git a/tools/probe_mainbend.luau b/tools/probe_mainbend.luau new file mode 100644 index 0000000..b7a3d44 --- /dev/null +++ b/tools/probe_mainbend.luau @@ -0,0 +1,61 @@ +-- East slip kinks hard at segs 6,7,11,12 (27.8/17.5/58.2/50.1 deg). The slip +-- follows the mainline via centreAt(f), so either (a) the mainline bends +-- viciously at those samples, or (b) the slip's own path math misbehaves. +-- East runs join=551 -> land=539 (backwards, span=-12); west runs 525 -> 537. +-- Sample the mainline heading across BOTH spans to tell which. +local out = {} +local function say(s) table.insert(out, s) end + +local map = workspace:FindFirstChild("Map") +if not map then return "Map MISSING" end + +local deck = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local k = d.Name:match("^Road_(%d+)$") or d.Name:match("^Deck_(%d+)$") + or d.Name:match("^RoadDeck_(%d+)$") + if k then table.insert(deck, { i = tonumber(k), p = d.Position, cf = d.CFrame }) end + end +end +table.sort(deck, function(a, b) return a.i < b.i end) +local byIdx = {} +for _, d in ipairs(deck) do byIdx[d.i] = d end +say(("mainline parts=%d idx %d..%d"):format(#deck, deck[1].i, deck[#deck].i)) + +local function report(lo, hi, label) + say(("--- mainline heading, samples %d..%d (%s) ---"):format(lo, hi, label)) + local prev, acc = nil, 0 + local worst, worstAt = 0, -1 + for i = lo, hi do + local d = byIdx[i] + if d then + local h = d.cf.LookVector + if prev then + local dth = math.deg(math.acos(math.clamp(prev:Dot(h), -1, 1))) + acc += dth + if dth > worst then worst = dth; worstAt = i end + if dth > 4 then + say((" sample %d: turn %.1fdeg at (%.0f,%.0f,%.0f)"):format(i, dth, d.p.X, d.p.Y, d.p.Z)) + end + end + prev = h + end + end + say((" total=%.1fdeg worst=%.1fdeg@%d"):format(acc, worst, worstAt)) +end + +report(525, 537, "WEST slip span - smooth one") +report(539, 551, "EAST slip span - ugly one") + +-- Also: does the east span cross the sample-wrap boundary or reverse direction? +say("--- endpoint geometry ---") +for _, pair in ipairs({ { 525, 537, "west" }, { 551, 539, "east" } }) do + local a, b = byIdx[pair[1]], byIdx[pair[2]] + if a and b then + local straight = (Vector3.new(a.p.X, 0, a.p.Z) - Vector3.new(b.p.X, 0, b.p.Z)).Magnitude + say(("%s: join@%d=(%.0f,%.0f) land@%d=(%.0f,%.0f) straightDist=%.0f"):format( + pair[3], pair[1], a.p.X, a.p.Z, pair[2], b.p.X, b.p.Z, straight)) + end +end + +return table.concat(out, "\n") diff --git a/tools/probe_pa_inventory.luau b/tools/probe_pa_inventory.luau new file mode 100644 index 0000000..c9b1532 --- /dev/null +++ b/tools/probe_pa_inventory.luau @@ -0,0 +1,103 @@ +-- READ-ONLY inventory of the Daikoku Futo PA area so the capture camera can be +-- anchored to real geometry instead of guessed coordinates. +-- +-- Wanted: +-- * what top-level containers/models exist for the PA +-- * every distinct part-name PREFIX in the PA region, with counts +-- * the PA's world-space bounding box + centre (for framing) +-- * key landmarks: slip ramps, apron/parking deck, any building/structure +local out = {} +local function say(s) table.insert(out, s) end + +local map = workspace:FindFirstChild("Map") +if not map then return "Map MISSING" end + +-- 1. Top-level structure of Map +say("=== Map children ===") +for _, c in ipairs(map:GetChildren()) do + local n = 0 + for _, d in ipairs(c:GetDescendants()) do + if d:IsA("BasePart") then n += 1 end + end + say((" %s (%s) parts=%d"):format(c.Name, c.ClassName, n)) +end + +-- 2. Anything whose name mentions PA / Daikoku / parking +say("=== named PA-ish objects (models/folders) ===") +local seen = 0 +for _, d in ipairs(map:GetDescendants()) do + if not d:IsA("BasePart") then + local nm = d.Name + if nm:match("PA") or nm:lower():match("daikoku") or nm:lower():match("park") + or nm:lower():match("apron") or nm:lower():match("rest") then + local n = 0 + for _, x in ipairs(d:GetDescendants()) do + if x:IsA("BasePart") then n += 1 end + end + say((" %s (%s) parts=%d parent=%s"):format(d.Name, d.ClassName, n, d.Parent.Name)) + seen += 1 + if seen > 40 then say(" ...(truncated)") break end + end + end +end +if seen == 0 then say(" (none found by name)") end + +-- 3. Locate the PA by its slip ramps: the apron is where the ramps land. +local land = {} +for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do + local best, bestK = nil, -1 + for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local k = d.Name:match("^" .. s .. "_Deck_(%d+)$") + if k and tonumber(k) > bestK then bestK = tonumber(k); best = d end + end + end + if best then + land[s] = best.Position + say(("%s landing: (%.0f, %.0f, %.0f)"):format(s, best.Position.X, best.Position.Y, best.Position.Z)) + end +end + +local anchor +if land.PAWestSlip and land.PAEastSlip then + anchor = (land.PAWestSlip + land.PAEastSlip) / 2 +elseif land.PAWestSlip then + anchor = land.PAWestSlip +elseif land.PAEastSlip then + anchor = land.PAEastSlip +end +if not anchor then return table.concat(out, "\n") .. "\nNO SLIP LANDINGS -> cannot anchor" end +say(("PA anchor (between landings): (%.0f, %.0f, %.0f)"):format(anchor.X, anchor.Y, anchor.Z)) + +-- 4. Every part within 1400 studs of the anchor, grouped by name prefix. +local function flat(v) return Vector3.new(v.X, 0, v.Z) end +local counts, mins, maxs = {}, {}, {} +local lo = Vector3.new(math.huge, math.huge, math.huge) +local hi = Vector3.new(-math.huge, -math.huge, -math.huge) +local total = 0 +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + if (flat(d.Position) - flat(anchor)).Magnitude < 1400 then + total += 1 + -- prefix = name with trailing _ stripped + local pre = d.Name:gsub("_%d+$", "") + counts[pre] = (counts[pre] or 0) + 1 + lo = Vector3.new(math.min(lo.X, d.Position.X), math.min(lo.Y, d.Position.Y), math.min(lo.Z, d.Position.Z)) + hi = Vector3.new(math.max(hi.X, d.Position.X), math.max(hi.Y, d.Position.Y), math.max(hi.Z, d.Position.Z)) + end + end +end +say(("=== %d parts within 1400 studs of PA anchor ==="):format(total)) +local keys = {} +for k in pairs(counts) do table.insert(keys, k) end +table.sort(keys, function(a, b) return counts[a] > counts[b] end) +for idx, k in ipairs(keys) do + if idx > 30 then say((" ...and %d more prefixes"):format(#keys - 30)) break end + say((" %-28s %d"):format(k, counts[k])) +end +say(("bbox: (%.0f,%.0f,%.0f) .. (%.0f,%.0f,%.0f)"):format(lo.X, lo.Y, lo.Z, hi.X, hi.Y, hi.Z)) +say(("bbox size: %.0f x %.0f x %.0f"):format(hi.X - lo.X, hi.Y - lo.Y, hi.Z - lo.Z)) +local ctr = (lo + hi) / 2 +say(("bbox centre: (%.0f, %.0f, %.0f)"):format(ctr.X, ctr.Y, ctr.Z)) + +return table.concat(out, "\n") diff --git a/tools/probe_pa_min.luau b/tools/probe_pa_min.luau new file mode 100644 index 0000000..016c3ca --- /dev/null +++ b/tools/probe_pa_min.luau @@ -0,0 +1,36 @@ +-- Minimal PA locator. The full inventory probe returned an empty error, so +-- start small and confirm the basics work before adding anything back. +local out = {} +local function say(s) table.insert(out, s) end + +local map = workspace:FindFirstChild("Map") +if not map then return "Map MISSING" end + +say("=== Map children ===") +for _, c in ipairs(map:GetChildren()) do + local n = 0 + for _, d in ipairs(c:GetDescendants()) do + if d:IsA("BasePart") then n += 1 end + end + say((" %s (%s) parts=%d"):format(c.Name, c.ClassName, n)) +end + +local land = {} +for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do + local best, bestK = nil, -1 + for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local k = d.Name:match("^" .. s .. "_Deck_(%d+)$") + if k and tonumber(k) > bestK then + bestK = tonumber(k) + best = d + end + end + end + if best then + land[s] = best.Position + say(("%s landing: (%.0f, %.0f, %.0f)"):format(s, best.Position.X, best.Position.Y, best.Position.Z)) + end +end + +return table.concat(out, "\n") diff --git a/tools/probe_parallel.luau b/tools/probe_parallel.luau new file mode 100644 index 0000000..e057d68 --- /dev/null +++ b/tools/probe_parallel.luau @@ -0,0 +1,80 @@ +-- The crux of the user's request: does the ramp run PARALLEL to the highway +-- (travelling far along it while climbing), or does it dive off the side? +-- Measures longitudinal progress (in mainline samples) vs vertical drop vs +-- lateral drift, plus the actual grade. Also finds what the ramp passes over. +local ws = game:GetService("Workspace") +local RS = game:GetService("ReplicatedStorage") +local Circuit = require(RS:WaitForChild("Shared"):WaitForChild("Circuit")) +local samples = Circuit.samples +local map = ws:FindFirstChild("Map") +if not map then return "NOMAP" end +local out = {} +local function say(s) table.insert(out, s) end + +local ramps = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local name, idx = d.Name:match("^(PA%a+Slip)_Deck_(%d+)$") + if name then + ramps[name] = ramps[name] or {} + table.insert(ramps[name], { i = tonumber(idx), p = d }) + end + end +end + +local function nearestSample(p) + local bd, bi = math.huge, -1 + for i = 1, #samples do + local s = samples[i] + local dd = (Vector3.new(p.X, 0, p.Z) - Vector3.new(s.pos.X, 0, s.pos.Z)).Magnitude + if dd < bd then bd, bi = dd, i end + end + return bi, bd +end + +for _, name in ipairs({ "PAWestSlip", "PAEastSlip" }) do + local segs = ramps[name] + if not segs then say(name .. ": MISSING") continue end + table.sort(segs, function(x, y) return x.i < y.i end) + local first, last = segs[1].p, segs[#segs].p + local si = nearestSample(first.Position) + local ei = nearestSample(last.Position) + + -- 3D path length along the ramp centreline. + local plen = 0 + for k = 2, #segs do + plen += (segs[k].p.Position - segs[k - 1].p.Position).Magnitude + end + local drop = first.Position.Y - last.Position.Y + local flat = (Vector3.new(first.Position.X, 0, first.Position.Z) + - Vector3.new(last.Position.X, 0, last.Position.Z)).Magnitude + + -- Sample spacing, to convert sample counts into studs. + local spacing = (samples[2].pos - samples[1].pos).Magnitude + local dsamples = math.abs(ei - si) + + say(("=== %s ==="):format(name)) + say((" segments: %d, seg length ~%.0f studs, path length %.0f studs") + :format(#segs, plen / math.max(1, #segs - 1), plen)) + say((" mouth at sample %d -> end near sample %d (travels %d samples = %.0f studs ALONG the highway)") + :format(si, ei, dsamples, dsamples * spacing)) + say((" vertical drop: %.1f studs horizontal run: %.0f studs grade: %.1f%%") + :format(drop, flat, 100 * drop / math.max(1, flat))) + say((" PARALLEL-ness: %.0f studs along-highway per %.0f studs of drop -> %s") + :format(dsamples * spacing, drop, + (dsamples * spacing) > drop * 4 and "runs alongside" or "DIVES OFF THE SIDE")) +end + +-- What is underneath the ramp mid-span? (is it over water/air, or over the lot?) +local names = {} +for _, d in ipairs(ws:GetDescendants()) do + if d:IsA("BasePart") then + names[d.Name:gsub("_%d+$", "")] = true + end +end +local cats = {} +for k in pairs(names) do table.insert(cats, k) end +table.sort(cats) +say("part categories in world: " .. #cats) +say(" " .. table.concat(cats, ", "):sub(1, 900)) +return table.concat(out, "\n") diff --git a/tools/probe_place.luau b/tools/probe_place.luau new file mode 100644 index 0000000..3013b20 --- /dev/null +++ b/tools/probe_place.luau @@ -0,0 +1,40 @@ +-- Which Studio is the bridge actually driving, and can that place be PLAYED? +local out = {} +local function say(s) table.insert(out, s) end + +say("place name: " .. tostring(game.Name)) +say("PlaceId: " .. tostring(game.PlaceId)) +say("GameId: " .. tostring(game.GameId)) +local RS = game:GetService("RunService") +say("IsEdit: " .. tostring(RS:IsEdit()) .. " IsRunning: " .. tostring(RS:IsRunning()) .. + " IsClient: " .. tostring(RS:IsClient()) .. " IsServer: " .. tostring(RS:IsServer())) + +-- Is the map present, and is there anything to actually spawn onto? +local m = workspace:FindFirstChild("Map") +say("workspace.Map: " .. (m and (#m:GetDescendants() .. " descendants") or "MISSING")) +say("SpawnLocations: ") +local spawns = 0 +for _, d in ipairs(workspace:GetDescendants()) do + if d:IsA("SpawnLocation") then + spawns += 1 + say((" %s at %s"):format(d:GetFullName(), tostring(d.Position))) + end +end +if spawns == 0 then say(" NONE -- a playtest has nowhere to spawn the character") end + +-- StarterPlayer/StarterGui presence (a play session needs these to not error) +for _, svc in ipairs({ "StarterPlayer", "StarterGui", "StarterPack", "Players", "Lighting" }) do + local ok, s = pcall(function() return game:GetService(svc) end) + say(("service %s: %s"):format(svc, ok and "ok" or "FAIL")) +end + +-- Rojo connection state: is the synced source actually in this place? +local SS = game:GetService("ServerScriptService") +local srv = SS:FindFirstChild("Server") +say("ServerScriptService.Server: " .. (srv and srv.ClassName or "MISSING")) +if srv then + local mb = srv:FindFirstChild("MapBuilder") + say(" MapBuilder present: " .. tostring(mb ~= nil)) +end + +return table.concat(out, "\n") diff --git a/tools/probe_ramppath.luau b/tools/probe_ramppath.luau new file mode 100644 index 0000000..d3a49dd --- /dev/null +++ b/tools/probe_ramppath.luau @@ -0,0 +1,104 @@ +-- What do the slip ramps ACTUALLY do? Elevation + lateral profile per segment, +-- and do they intersect the mainline deck? +-- The user says: "on and off ramps go to the bottom and through the highway". +-- Measure that claim directly rather than assuming. +local ws = game:GetService("Workspace") +local RS = game:GetService("ReplicatedStorage") +local Circuit = require(RS:WaitForChild("Shared"):WaitForChild("Circuit")) +local samples = Circuit.samples +local map = ws:FindFirstChild("Map") +if not map then return "NOMAP" end + +local out = {} +local function say(s) table.insert(out, s) end + +-- Collect ramp deck segments in build order. +local ramps = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local name, idx = d.Name:match("^(PA%a+Slip)_Deck_(%d+)$") + if name then + ramps[name] = ramps[name] or {} + table.insert(ramps[name], { i = tonumber(idx), p = d }) + end + end +end + +-- Nearest mainline sample + signed lateral offset for a world point. +local function nearestSample(p) + local bd, bi = math.huge, -1 + for i = 1, #samples do + local s = samples[i] + local dd = (Vector3.new(p.X, 0, p.Z) - Vector3.new(s.pos.X, 0, s.pos.Z)).Magnitude + if dd < bd then bd, bi = dd, i end + end + return bi, bd +end +local function lateralAt(i, p) + local n = #samples + local a = samples[((i - 1) % n) + 1] + local b = samples[(i % n) + 1] + local f = b.pos - a.pos + f = Vector3.new(f.X, 0, f.Z).Unit + local r = Vector3.new(-f.Z, 0, f.X) + local rel = Vector3.new(p.X - a.pos.X, 0, p.Z - a.pos.Z) + return rel:Dot(r), a.pos.Y +end + +for _, name in ipairs({ "PAWestSlip", "PAEastSlip" }) do + local segs = ramps[name] + if not segs then + say(name .. ": MISSING") + else + table.sort(segs, function(x, y) return x.i < y.i end) + say(("=== %s (%d segs) ==="):format(name, #segs)) + say("seg | rampY | deckY | dY(ramp-deck) | lateral | note") + local under = 0 + for k, s in ipairs(segs) do + if k == 1 or k % 6 == 0 or k == #segs then + local si = nearestSample(s.p.Position) + local lat, deckY = lateralAt(si, s.p.Position) + local dy = s.p.Position.Y - deckY + local note = "" + -- |lateral| < 30 means it is horizontally UNDER/INSIDE the deck slab. + if math.abs(lat) < 30 then + note = (math.abs(dy) < 8) and "*** THROUGH DECK ***" or "under/over deck footprint" + end + say(("%3d | %6.1f | %6.1f | %+7.1f | %+7.1f | %s") + :format(s.i, s.p.Position.Y, deckY, dy, lat, note)) + end + local si = nearestSample(s.p.Position) + local lat = lateralAt(si, s.p.Position) + if math.abs(lat) < 30 then under += 1 end + end + say(("%s: %d/%d segments sit within the deck footprint (|lateral| < 30)") + :format(name, under, #segs)) + end +end + +-- Does any ramp part actually overlap a Road part in 3D? +local roads = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") and d.Name:match("^Road_%d+$") then + table.insert(roads, d) + end +end +local clashes, tested = {}, 0 +for _, name in ipairs({ "PAWestSlip", "PAEastSlip" }) do + for _, s in ipairs(ramps[name] or {}) do + for _, r in ipairs(roads) do + tested += 1 + local d = s.p.Position - r.Position + local sx = (s.p.Size.X + r.Size.X) / 2 + local sy = (s.p.Size.Y + r.Size.Y) / 2 + local sz = (s.p.Size.Z + r.Size.Z) / 2 + if math.abs(d.X) < sx and math.abs(d.Y) < sy and math.abs(d.Z) < sz then + table.insert(clashes, ("%s x %s (dY=%.1f)"):format(s.p.Name, r.Name, d.Y)) + end + end + end +end +say(("ramp-vs-road AABB tests: %d, overlaps: %d"):format(tested, #clashes)) +for k = 1, math.min(#clashes, 12) do say(" " .. clashes[k]) end +if tested == 0 then say("CONTROL FAIL: nothing tested") end +return table.concat(out, "\n") diff --git a/tools/probe_rebuild.luau b/tools/probe_rebuild.luau new file mode 100644 index 0000000..5906c73 --- /dev/null +++ b/tools/probe_rebuild.luau @@ -0,0 +1,62 @@ +-- Rebuild the map from current source and report what actually landed. +-- The workspace snapshot predates today's edits; this regenerates it. +local SS = game:GetService("ServerScriptService") +local out = {} +local function say(s) table.insert(out, s) end + +local mb +for _, d in ipairs(SS:GetDescendants()) do + if d:IsA("ModuleScript") and d.Name == "MapBuilder" then mb = d end +end +if not mb then return "MapBuilder NOT FOUND" end + +local okReq, M = pcall(require, mb) +if not okReq then return "require failed: " .. tostring(M) end + +local old = workspace:FindFirstChild("Map") +if old then + old:Destroy() + say("destroyed stale Map") +end + +local t0 = os.clock() +local okB, err = pcall(function() return M.build() end) +say(("build: %s (%.2fs)"):format(okB and "OK" or ("FAILED -> " .. tostring(err)), os.clock() - t0)) +if not okB then return table.concat(out, "\n") end + +local map = workspace:FindFirstChild("Map") +if not map then return table.concat(out, "\n") .. "\nMap MISSING after build" end + +local parts, slip, aux = 0, {}, 0 +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + parts += 1 + for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do + local k = d.Name:match("^" .. s .. "_Deck_(%d+)$") + if k then + slip[s] = slip[s] or {} + table.insert(slip[s], { i = tonumber(k), y = d.Position.Y }) + end + end + if d.Name:match("^AuxLane_%d+$") then aux += 1 end + end +end +say(("parts=%d auxLaneSegs=%d"):format(parts, aux)) + +for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do + local g = slip[s] + if not g or #g == 0 then + say(s .. ": ABSENT") + else + table.sort(g, function(a, b) return a.i < b.i end) + local topY, level = g[1].y, 0 + for _, seg in ipairs(g) do + if math.abs(seg.y - topY) < 0.5 then level += 1 else break end + end + say(("%s: segs=%d startY=%.1f endY=%.1f level-run=%d/%d %s"):format( + s, #g, topY, g[#g].y, level, #g, + level >= 12 and "<- PARALLEL ASCENT PRESENT" or "<- old diving profile")) + end +end + +return table.concat(out, "\n") diff --git a/tools/probe_regression.luau b/tools/probe_regression.luau new file mode 100644 index 0000000..eb13621 --- /dev/null +++ b/tools/probe_regression.luau @@ -0,0 +1,151 @@ +-- READ-ONLY REGRESSION CHECK. Does NOT build. Inspects whatever is currently +-- in Studio and compares against every property we had verified as good, so we +-- can tell whether the last edits preserved the good version or damaged it. +-- +-- Known-good baseline (measured earlier this session): +-- ramp PAEastSlip total bend 30.7deg, worst 1.3, over5=0, level run 20 +-- ramp PAWestSlip total bend 34.4deg, worst 1.4, over5=0, level run 20 +-- AuxLane parts = 30 (user's hand-edited count, NOT the source's 52) +-- unguarded deck edges = 0 across all 823 samples +-- parts ~= 16788 +local out = {} +local function say(s) table.insert(out, s) end + +local map = workspace:FindFirstChild("Map") +if not map then return "Map MISSING -- nothing built" end + +local UP = Vector3.new(0, 1, 0) +local function flat(v) return Vector3.new(v.X, 0, v.Z) end + +local parts = 0 +local deck, aux, bars, slipParts = {}, {}, {}, {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + parts += 1 + local k = d.Name:match("^Road_(%d+)$") or d.Name:match("^Deck_(%d+)$") + if k then deck[tonumber(k)] = d end + local a = d.Name:match("^AuxLane_(%d+)$") + if a then aux[tonumber(a)] = d end + if d.Name == "Barrier" or d.Name == "EdgeRail" then table.insert(bars, d) end + if d.Name:match("Slip") then table.insert(slipParts, d) end + end +end +say(("parts = %d (baseline ~16788)"):format(parts)) + +-- 1. RAMP SMOOTHNESS -- the thing the user cares most about right now +local rampOK = true +for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do + local segs = {} + for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local k = d.Name:match("^" .. s .. "_Deck_(%d+)$") + if k then table.insert(segs, { i = tonumber(k), p = d.Position, cf = d.CFrame }) end + end + end + table.sort(segs, function(a, b) return a.i < b.i end) + if #segs == 0 then + say(s .. ": ABSENT <<< RAMP GONE") + rampOK = false + else + local total, worst, worstAt, over = 0, 0, -1, 0 + for j = 2, #segs do + local a, b = segs[j - 1].cf.LookVector, segs[j].cf.LookVector + local dth = math.deg(math.acos(math.clamp(a:Dot(b), -1, 1))) + total += dth + if dth > worst then worst = dth; worstAt = segs[j].i end + if dth > 5 then over += 1 end + end + local topY, level = segs[1].p.Y, 0 + for _, sg in ipairs(segs) do + if math.abs(sg.p.Y - topY) < 0.5 then level += 1 else break end + end + if over > 0 then rampOK = false end + say(("%s: segs=%d total=%.1fdeg worst=%.1f@%d over5=%d level=%d %s"):format( + s, #segs, total, worst, worstAt, over, level, + over == 0 and "OK" or "<<< KINKED")) + end +end +say(rampOK and ">> RAMPS STILL SMOOTH" or ">> RAMP REGRESSION") + +-- 2. USER'S DELETED LANE -- must stay deleted +local auxN, inRange = 0, 0 +for i in pairs(aux) do + auxN += 1 + if i >= 525 and i <= 550 then inRange += 1 end +end +say(("AuxLane = %d (baseline 30) | inside deleted run 525..550 = %d (want 0) %s"):format( + auxN, inRange, (auxN == 30 and inRange == 0) and "OK" or "<<< REGRESSION")) + +-- 3. BARRIER COVERAGE -- full loop +local holes = {} +local swept = 0 +for i, d in pairs(deck) do + swept += 1 + local right = d.CFrame.LookVector:Cross(UP).Unit + for _, sgn in ipairs({ -1, 1 }) do + local edge = d.Position + right * sgn * (d.Size.X / 2 + 2) + local bd = math.huge + for _, b in ipairs(bars) do + if math.abs(b.Position.Y - edge.Y) < 14 then + local dd = (flat(b.Position) - flat(edge)).Magnitude + if dd < bd then bd = dd end + end + end + if bd >= 12 then + local okb = false + local a = aux[i] + if a then + local outer = a.Position + right * sgn * (a.Size.X / 2 + 2) + local bd2 = math.huge + for _, b in ipairs(bars) do + if math.abs(b.Position.Y - outer.Y) < 14 then + local dd = (flat(b.Position) - flat(outer)).Magnitude + if dd < bd2 then bd2 = dd end + end + end + if bd2 < 14 then okb = true end + end + if not okb then + local sd = math.huge + for _, p in ipairs(slipParts) do + local dd = (flat(p.Position) - flat(edge)).Magnitude + if dd < sd then sd = dd end + end + if sd > 30 then table.insert(holes, i .. (sgn < 0 and "L" or "R")) end + end + end + end +end +say(("barrier sweep: %d samples, unguarded=%d %s"):format(swept, #holes, + #holes == 0 and "OK" or ("<<< " .. table.concat(holes, ",")))) + +-- 4. FENCE JOG at the suppression boundary +local prevLat, jogs = nil, {} +for i = 505, 570 do + local d = deck[i] + if d then + local right = d.CFrame.LookVector:Cross(UP).Unit + local best, bestLat = math.huge, nil + for _, b in ipairs(bars) do + if b.Name == "Barrier" and math.abs(b.Position.Y - d.Position.Y) < 14 then + local rel = b.Position - d.Position + local along = math.abs(rel:Dot(d.CFrame.LookVector)) + local lat = rel:Dot(right) + if along < 22 and lat < 0 and along < best then best = along; bestLat = lat end + end + end + if bestLat then + if prevLat then + local step = math.abs(math.abs(bestLat) - math.abs(prevLat)) + if step > 10 then table.insert(jogs, ("%d(%.1f)"):format(i, step)) end + end + prevLat = bestLat + else + prevLat = nil + end + end +end +say(("fence jogs >10 studs: %d %s"):format(#jogs, + #jogs == 0 and "OK" or ("<<< " .. table.concat(jogs, " ")))) + +return table.concat(out, "\n") diff --git a/tools/probe_revert.luau b/tools/probe_revert.luau new file mode 100644 index 0000000..32266de --- /dev/null +++ b/tools/probe_revert.luau @@ -0,0 +1,74 @@ +-- "Changes were reverted" -- WHERE? Distinguish three separate states that can +-- each independently be stale: +-- (a) the MapBuilder SOURCE inside Studio (Rojo sync state) +-- (b) the BUILT geometry sitting in workspace (may predate the source) +-- (c) the place file itself +local out = {} +local function say(s) table.insert(out, s) end + +say("place: " .. tostring(game.Name)) + +-- (a) SOURCE state in Studio +local SS = game:GetService("ServerScriptService") +local mb +for _, d in ipairs(SS:GetDescendants()) do + if d:IsA("ModuleScript") and d.Name == "MapBuilder" then mb = d end +end +if not mb then + say("SOURCE: MapBuilder NOT FOUND in ServerScriptService") +else + local ok, src = pcall(function() return mb.Source end) + if ok and type(src) == "string" then + say(("SOURCE: len=%d PARALLEL_RUN=%s PA_DEC_SPAN=%s PA_SLIP=%s"):format( + #src, + tostring(src:find("PARALLEL_RUN") ~= nil), + tostring(src:find("PA_DEC_SPAN") ~= nil), + tostring(src:find("local PA_SLIP") ~= nil))) + else + say("SOURCE: unreadable " .. tostring(src)) + end +end + +-- (b) BUILT geometry actually in the workspace right now +local map = workspace:FindFirstChild("Map") +if not map then + say("BUILT: workspace.Map MISSING") + return table.concat(out, "\n") +end + +local slipSegs, auxSegs = {}, 0 +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do + local k = d.Name:match("^" .. s .. "_Deck_(%d+)$") + if k then + slipSegs[s] = slipSegs[s] or {} + table.insert(slipSegs[s], { i = tonumber(k), p = d.Position }) + end + end + if d.Name:match("^AuxLane_%d+$") then auxSegs += 1 end + end +end + +say("BUILT: aux lane segments = " .. auxSegs .. " (expect ~52 with decel+accel)") + +for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do + local g = slipSegs[s] + if not g or #g == 0 then + say(("BUILT: %s -- NO DECK SEGMENTS (ramp absent)"):format(s)) + else + table.sort(g, function(a, b) return a.i < b.i end) + -- How many segments hold deck height before the descent starts? + local topY = g[1].p.Y + local level = 0 + for _, seg in ipairs(g) do + if math.abs(seg.p.Y - topY) < 0.5 then level += 1 else break end + end + local drop = topY - g[#g].p.Y + say(("BUILT: %s segs=%d startY=%.1f endY=%.1f drop=%.1f level-run=%d/%d %s"):format( + s, #g, topY, g[#g].p.Y, drop, level, #g, + level >= 12 and "<- PARALLEL FIX PRESENT" or "<- OLD DIVING PROFILE")) + end +end + +return table.concat(out, "\n") diff --git a/tools/probe_runnable.luau b/tools/probe_runnable.luau new file mode 100644 index 0000000..670e2ed --- /dev/null +++ b/tools/probe_runnable.luau @@ -0,0 +1,61 @@ +-- Does the game actually RUN? Exercise the real server startup path and report +-- the first genuine error, rather than guessing from static analysis. +local RS = game:GetService("ReplicatedStorage") +local SS = game:GetService("ServerScriptService") +local out = {} +local function say(s) table.insert(out, s) end + +-- 1. Are the Rojo-synced trees actually present in the DataModel? +local shared = RS:FindFirstChild("Shared") +say("ReplicatedStorage.Shared: " .. (shared and "present" or "MISSING")) +if shared then + local names = {} + for _, c in ipairs(shared:GetChildren()) do table.insert(names, c.Name) end + table.sort(names) + say(" modules: " .. table.concat(names, ", ")) +end + +local server = SS:FindFirstChild("Server") or SS:FindFirstChild("TS") +say("ServerScriptService children:") +for _, c in ipairs(SS:GetChildren()) do + say((" %s (%s)"):format(c.Name, c.ClassName)) +end + +-- 2. Try requiring every shared + server module. Report the FIRST failure with +-- its message -- this is what actually stops a playtest. +local function tryRequire(inst) + local ok, err = pcall(function() return require(inst) end) + return ok, err +end + +say("--- require sweep ---") +local failures = 0 +local tested = 0 +for _, root in ipairs({ shared, server }) do + if root then + for _, m in ipairs(root:GetDescendants()) do + if m:IsA("ModuleScript") then + tested += 1 + local ok, err = tryRequire(m) + if not ok then + failures += 1 + say(("FAIL %s: %s"):format(m:GetFullName(), tostring(err):sub(1, 300))) + end + end + end + end +end +say(("required %d modules, %d failed"):format(tested, failures)) +if tested == 0 then say("CONTROL FAIL: nothing was required -- wrong scope") end + +-- 3. Script instances that would run on play, and whether any are disabled. +say("--- scripts ---") +for _, d in ipairs(game:GetDescendants()) do + if (d:IsA("Script") or d:IsA("LocalScript")) and not d:IsDescendantOf(workspace) then + if d.Name:match("init") or d:IsDescendantOf(SS) then + say((" %s (%s) disabled=%s"):format(d:GetFullName(), d.ClassName, tostring(d.Disabled))) + end + end +end + +return table.concat(out, "\n") diff --git a/tools/probe_sides.luau b/tools/probe_sides.luau new file mode 100644 index 0000000..3d039bd --- /dev/null +++ b/tools/probe_sides.luau @@ -0,0 +1,88 @@ +-- Which side of the deck does each slip + aux lane sit on? +-- Answers: are both existing slips on the SAME carriageway (so "the opposite" +-- means the other carriageway), or is one an entry and one an exit? +local ws = game:GetService("Workspace") +local RS = game:GetService("ReplicatedStorage") +local Circuit = require(RS:WaitForChild("Shared"):WaitForChild("Circuit")) + +local map = ws:FindFirstChild("Map") +if not map then return "NOMAP" end +local samples = Circuit.samples +local out = {} +local function say(s) table.insert(out, s) end + +-- Signed lateral offset of a world point from the mainline at sample i. +-- Positive = one side, negative = the other. Uses the deck's own right vector. +local function sideOf(i, p) + local n = #samples + local a = samples[((i - 1) % n) + 1] + local b = samples[(i % n) + 1] + local fwd = (b.pos - a.pos) + fwd = Vector3.new(fwd.X, 0, fwd.Z).Unit + local right = Vector3.new(-fwd.Z, 0, fwd.X) + local rel = Vector3.new(p.X - a.pos.X, 0, p.Z - a.pos.Z) + return rel:Dot(right) +end + +local SLIPS = { { "PAWestSlip", 525, 537 }, { "PAEastSlip", 551, 539 } } +for _, s in ipairs(SLIPS) do + local name, join, land = s[1], s[2], s[3] + local first, last, nseg = nil, nil, 0 + local maxI = -1 + for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local k = tonumber(d.Name:match("^" .. name .. "_Deck_(%d+)$") or "") + if k then + nseg += 1 + if k <= 1 then first = d end + if k > maxI then maxI, last = k, d end + end + end + end + if not first then + say(name .. ": FAIL no deck parts") + else + local lat = sideOf(join, first.Position) + say(("%s: segs=%d mouth lateral %+.1f (%s side) mouthY=%.1f landY=%.1f -> %s") + :format(name, nseg, lat, lat > 0 and "RIGHT" or "LEFT", + first.Position.Y, last and last.Position.Y or -1, + (last and last.Position.Y < first.Position.Y) and "DESCENDS (exit/off-ramp)" + or "CLIMBS (entry/on-ramp)")) + end +end + +-- Aux lanes: which side, grouped by contiguous sample index. +local aux = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local ai = tonumber(d.Name:match("^AuxLane_(%d+)$") or "") + if ai then table.insert(aux, { i = ai, p = d.Position }) end + end +end +table.sort(aux, function(x, y) return x.i < y.i end) +if #aux == 0 then + say("AUX: FAIL none built") +else + local groups, cur = {}, { aux[1] } + for k = 2, #aux do + if aux[k].i - aux[k - 1].i > 3 then + table.insert(groups, cur); cur = {} + end + table.insert(cur, aux[k]) + end + table.insert(groups, cur) + for gi, g in ipairs(groups) do + local mid = g[math.max(1, math.floor(#g / 2))] + local lat = sideOf(mid.i, mid.p) + say(("AUX group %d: samples %d..%d (%d segs) lateral %+.1f (%s side)") + :format(gi, g[1].i, g[#g].i, #g, lat, lat > 0 and "RIGHT" or "LEFT")) + end +end + +-- Does the deck carry traffic both ways, and is there a median? +local med = 0 +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") and d.Name:match("^Median") then med += 1 end +end +say(("median parts: %d (deck is one slab split into two carriageways)"):format(med)) +return table.concat(out, "\n") diff --git a/tools/probe_slip_envelope.luau b/tools/probe_slip_envelope.luau new file mode 100644 index 0000000..b632fb9 --- /dev/null +++ b/tools/probe_slip_envelope.luau @@ -0,0 +1,109 @@ +-- Measure the envelope a SIMPLE straight slip-ramp must live in. +-- User's drawing: short stubs peeling off the mainline at a shallow angle, +-- running roughly PARALLEL to the highway in the strip between the mainline +-- edge and the PA lot's north edge, descending an S onto the apron. +-- +-- We need to know, in real numbers: +-- 1. Where the mainline actually runs across the PA frontage (samples, +-- heading, deck Y) -- is it straight enough for a parallel slip? +-- 2. Where the lot's north edge is, and how wide the clear strip is +-- between the mainline's south edge and that lot edge. +-- 3. Deck Y vs apron Y (the height the S-curve must lose). +-- 4. What is already sitting in that strip that a ramp would hit. + +local RS = game:GetService("ReplicatedStorage") +local HttpService = game:GetService("HttpService") +local Circuit = require(RS:WaitForChild("Shared"):WaitForChild("Circuit")) + +local samples = Circuit.samples +local n = #samples +local c = Circuit.PA_CENTER + +-- Nearest mainline sample to the PA centre. +local bestIdx, bestD = 1, math.huge +for i = 1, n do + local p = samples[i].pos + local d = (Vector3.new(p.X, 0, p.Z) - Vector3.new(c.X, 0, c.Z)).Magnitude + if d < bestD then bestD, bestIdx = d, i end +end + +-- Walk a band around the PA frontage and report heading + deck Y, so we can +-- see how straight the mainline is and where a parallel slip could sit. +local band = {} +for k = -40, 40, 5 do + local i = ((bestIdx - 1 + k) % n) + 1 + local s = samples[i].pos + local nx = samples[(i % n) + 1].pos + local hdg = math.deg(math.atan2(nx.Z - s.Z, nx.X - s.X)) + local d = (Vector3.new(s.X, 0, s.Z) - Vector3.new(c.X, 0, c.Z)).Magnitude + table.insert(band, { + k = k, idx = i, + x = math.floor(s.X), y = math.floor(s.Y + 0.5), z = math.floor(s.Z), + hdg = math.floor(hdg + 0.5), + distPA = math.floor(d), + }) +end + +-- The apron / lot floor: bounds and surface height. +local map = workspace:FindFirstChild("Map") +local floor = map and map:FindFirstChild("PAFloor", true) +local lot = nil +if floor then + lot = { + name = floor.Name, + surfaceY = floor.Position.Y + floor.Size.Y / 2, + x_min = math.floor(floor.Position.X - floor.Size.X / 2), + x_max = math.floor(floor.Position.X + floor.Size.X / 2), + z_min = math.floor(floor.Position.Z - floor.Size.Z / 2), + z_max = math.floor(floor.Position.Z + floor.Size.Z / 2), + } +end + +-- What already occupies the strip between the mainline and the lot's north +-- edge? Anything here is a collision candidate for a parallel slip ramp. +-- Strip = north third of the lot bbox, i.e. Z above (z_max - 220). +local obstacles = {} +if lot then + local stripZ = lot.z_max - 220 + for _, d in ipairs(workspace:GetDescendants()) do + if d:IsA("BasePart") then + local p = d.Position + if p.Z >= stripZ and p.Z <= lot.z_max + 160 + and p.X >= lot.x_min - 60 and p.X <= lot.x_max + 60 then + local key = d.Name + local e = obstacles[key] + if not e then + e = { n = 0, x_min = 1e9, x_max = -1e9, z_min = 1e9, + z_max = -1e9, y_max = -1e9 } + obstacles[key] = e + end + e.n += 1 + e.x_min = math.min(e.x_min, p.X); e.x_max = math.max(e.x_max, p.X) + e.z_min = math.min(e.z_min, p.Z); e.z_max = math.max(e.z_max, p.Z) + e.y_max = math.max(e.y_max, p.Y + d.Size.Y / 2) + end + end + end +end + +local obsOut = {} +for k, e in pairs(obstacles) do + table.insert(obsOut, { + name = k, n = e.n, + x = math.floor(e.x_min) .. ".." .. math.floor(e.x_max), + z = math.floor(e.z_min) .. ".." .. math.floor(e.z_max), + ytop = math.floor(e.y_max), + }) +end +table.sort(obsOut, function(a, b) return a.n > b.n end) +while #obsOut > 25 do table.remove(obsOut) end + +return HttpService:JSONEncode({ + bestIdx = bestIdx, + nearestDist = math.floor(bestD), + pa_center = { math.floor(c.X), math.floor(c.Y), math.floor(c.Z) }, + pa_radius = Circuit.PA_RADIUS, + band = band, + lot = lot, + strip_obstacles = obsOut, +}) diff --git a/tools/probe_smoothed.luau b/tools/probe_smoothed.luau new file mode 100644 index 0000000..76e6c09 --- /dev/null +++ b/tools/probe_smoothed.luau @@ -0,0 +1,69 @@ +-- Rebuild with the smoothing pass, then re-measure the SAME metrics as before +-- so the before/after is directly comparable. Target: east total heading change +-- down from 216deg toward the west's ~56deg, and ZERO segments turning >5deg +-- (the 58.2/50.1deg hinges must be gone). Also verify the parallel run and +-- the endpoints did not move -- smoothing must not detach the mouth. +local SS = game:GetService("ServerScriptService") +local out = {} +local function say(s) table.insert(out, s) end + +local mod = SS:FindFirstChild("MapBuilder", true) +if not mod then return "MapBuilder module NOT FOUND" end + +local ok, MB = pcall(require, mod) +if not ok then return "require failed: " .. tostring(MB) end + +local t0 = os.clock() +local ok2, err = pcall(function() MB.build() end) +if not ok2 then return "build ERROR: " .. tostring(err) end +say(("build OK (%.2fs)"):format(os.clock() - t0)) + +local map = workspace:FindFirstChild("Map") +if not map then return "Map missing after build" end + +local parts = 0 +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then parts += 1 end +end +say(("parts=%d"):format(parts)) + +for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do + local segs = {} + for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local k = d.Name:match("^" .. s .. "_Deck_(%d+)$") + if k then table.insert(segs, { i = tonumber(k), p = d.Position, cf = d.CFrame }) end + end + end + table.sort(segs, function(a, b) return a.i < b.i end) + if #segs == 0 then say(s .. ": ABSENT") continue end + + local total, worst, worstAt, over = 0, 0, -1, 0 + for j = 2, #segs do + local a, b = segs[j - 1].cf.LookVector, segs[j].cf.LookVector + local d = math.deg(math.acos(math.clamp(a:Dot(b), -1, 1))) + total += d + if d > worst then worst = d; worstAt = segs[j].i end + if d > 5 then over += 1 end + end + + local topY, level = segs[1].p.Y, 0 + for _, sg in ipairs(segs) do + if math.abs(sg.p.Y - topY) < 0.5 then level += 1 else break end + end + + local lmin, lmax = math.huge, -math.huge + for j = 2, #segs do + local L = (segs[j].p - segs[j - 1].p).Magnitude + lmin = math.min(lmin, L); lmax = math.max(lmax, L) + end + + say(("%s: segs=%d total=%.1fdeg mean=%.2f worst=%.1f@%d over5=%d"):format( + s, #segs, total, total / (#segs - 1), worst, worstAt, over)) + say((" level-run=%d startY=%.1f endY=%.1f spacing=%.1f..%.1f"):format( + level, segs[1].p.Y, segs[#segs].p.Y, lmin, lmax)) + say((" start=(%.0f,%.0f,%.0f) end=(%.0f,%.0f,%.0f)"):format( + segs[1].p.X, segs[1].p.Y, segs[1].p.Z, segs[#segs].p.X, segs[#segs].p.Y, segs[#segs].p.Z)) +end + +return table.concat(out, "\n") diff --git a/tools/probe_state.luau b/tools/probe_state.luau new file mode 100644 index 0000000..1cdd7b3 --- /dev/null +++ b/tools/probe_state.luau @@ -0,0 +1,56 @@ +-- What is actually on screen right now? Don't assume "missing" means empty -- +-- distinguish (a) no Map at all, (b) Map present but stripped, (c) Map fine. +local out = {} +local function say(s) table.insert(out, s) end + +say("place: " .. tostring(game.Name)) + +local map = workspace:FindFirstChild("Map") +if not map then + say("workspace.Map: MISSING ENTIRELY") +else + local parts, total = 0, 0 + for _, d in ipairs(map:GetDescendants()) do + total += 1 + if d:IsA("BasePart") then parts += 1 end + end + say(("workspace.Map: present descendants=%d parts=%d"):format(total, parts)) + + local kids = {} + for _, c in ipairs(map:GetChildren()) do + local n = 0 + for _, _d in ipairs(c:GetDescendants()) do n += 1 end + table.insert(kids, ("%s(%d)"):format(c.Name, n)) + end + say("Map children: " .. (#kids > 0 and table.concat(kids, ", ") or "NONE")) +end + +-- Is anything else sitting loose in workspace? +local loose = {} +for _, c in ipairs(workspace:GetChildren()) do + if c:IsA("BasePart") or c:IsA("Model") or c:IsA("Folder") then + table.insert(loose, c.Name) + end + if #loose > 15 then break end +end +say("workspace top-level: " .. table.concat(loose, ", ")) + +-- Where is the camera looking? If the map exists but the camera is parked in +-- empty space, "gone from my screen" means a camera problem, not a build one. +local cam = workspace.CurrentCamera +if cam then + say(("camera pos=(%.0f, %.0f, %.0f)"):format(cam.CFrame.Position.X, cam.CFrame.Position.Y, cam.CFrame.Position.Z)) +end + +-- Spawn / reference point so we know where the map SHOULD be. +local ref +for _, d in ipairs(workspace:GetDescendants()) do + if d:IsA("BasePart") and d.Name:match("Deck") then ref = d break end +end +if ref then + say(("sample deck part '%s' at (%.0f, %.0f, %.0f)"):format(ref.Name, ref.Position.X, ref.Position.Y, ref.Position.Z)) +else + say("no Deck part found anywhere in workspace") +end + +return table.concat(out, "\n") diff --git a/tools/probe_sync.luau b/tools/probe_sync.luau new file mode 100644 index 0000000..b3ba2be --- /dev/null +++ b/tools/probe_sync.luau @@ -0,0 +1,39 @@ +-- Is Studio actually CONNECTED to the Rojo server, or is it showing a stale +-- snapshot? A place can contain MapBuilder from an old sync and still be +-- disconnected, which is how you end up testing yesterday's geometry. +local out = {} +local function say(s) table.insert(out, s) end + +-- Rojo's plugin leaves session state in the DataModel; look for its markers. +local found = {} +for _, d in ipairs(game:GetDescendants()) do + local n = d.Name + if n:match("Rojo") or n:match("ROJO") then + table.insert(found, ("%s (%s)"):format(d:GetFullName(), d.ClassName)) + end + if #found > 12 then break end +end +say("Rojo-named instances: " .. (#found > 0 and table.concat(found, ", ") or "NONE FOUND")) + +-- Does the in-place MapBuilder source contain the fix I just made? This is the +-- real test of whether the running place is current. +local SS = game:GetService("ServerScriptService") +local mb +for _, d in ipairs(SS:GetDescendants()) do + if d:IsA("ModuleScript") and d.Name == "MapBuilder" then mb = d end +end +if mb then + local ok, src = pcall(function() return mb.Source end) + if ok and type(src) == "string" then + say("MapBuilder source length: " .. #src) + say("has PARALLEL_RUN (today's ramp fix): " .. tostring(src:find("PARALLEL_RUN") ~= nil)) + say("has PA_AUX_DECEL (decel lanes): " .. tostring(src:find("PA_AUX_DECEL") ~= nil)) + else + say("could not read MapBuilder.Source (permission): " .. tostring(src)) + end +else + say("MapBuilder NOT FOUND") +end + +say("place: " .. tostring(game.Name)) +return table.concat(out, "\n") diff --git a/tools/probe_useredit.luau b/tools/probe_useredit.luau new file mode 100644 index 0000000..9e3b0b3 --- /dev/null +++ b/tools/probe_useredit.luau @@ -0,0 +1,78 @@ +-- The user hand-edited ramps in Studio. Their work exists ONLY in the live +-- workspace, not in MapBuilder source. Before touching anything, measure what +-- is actually there now and how it differs from what source would generate. +local out = {} +local function say(s) table.insert(out, s) end + +say("place: " .. tostring(game.Name)) + +local map = workspace:FindFirstChild("Map") +if not map then return "workspace.Map MISSING" end + +local parts = 0 +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then parts += 1 end +end +say(("Map parts=%d (source rebuild produced 16820)"):format(parts)) + +-- Slip deck geometry as it stands RIGHT NOW. +local slip = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do + local k = d.Name:match("^" .. s .. "_Deck_(%d+)$") + if k then + slip[s] = slip[s] or {} + table.insert(slip[s], { + i = tonumber(k), pos = d.Position, size = d.Size, + cf = d.CFrame, inst = d, + }) + end + end + end +end + +for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do + local g = slip[s] + if not g or #g == 0 then + say(s .. ": ABSENT") + else + table.sort(g, function(a, b) return a.i < b.i end) + local topY, level = g[1].pos.Y, 0 + for _, seg in ipairs(g) do + if math.abs(seg.pos.Y - topY) < 0.5 then level += 1 else break end + end + -- widths and gaps tell us if the user reshaped it + local wmin, wmax = math.huge, -math.huge + local maxGap, gapAt = 0, -1 + for j, seg in ipairs(g) do + wmin = math.min(wmin, seg.size.X); wmax = math.max(wmax, seg.size.X) + if j > 1 then + local d2 = (seg.pos - g[j - 1].pos).Magnitude + if d2 > maxGap then maxGap = d2; gapAt = seg.i end + end + end + say(("%s: segs=%d idx[%d..%d] startY=%.1f endY=%.1f level=%d width=%.1f..%.1f maxGap=%.1f@%d"):format( + s, #g, g[1].i, g[#g].i, topY, g[#g].pos.Y, level, wmin, wmax, maxGap, gapAt)) + end +end + +-- What barrier-ish parts exist, and on what? Name survey first so I know the +-- real naming scheme before I go hunting for "missing" ones. +local kinds = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local n = d.Name:gsub("_%d+$", "_#") + if n:lower():match("barrier") or n:lower():match("parapet") or n:lower():match("rail") + or n:lower():match("wall") or n:lower():match("guard") or n:lower():match("kerb") + or n:lower():match("curb") or n:lower():match("fence") then + kinds[n] = (kinds[n] or 0) + 1 + end + end +end +local list = {} +for k, v in pairs(kinds) do table.insert(list, ("%s=%d"):format(k, v)) end +table.sort(list) +say("BARRIER-LIKE PART NAMES: " .. (#list > 0 and table.concat(list, " ") or "NONE")) + +return table.concat(out, "\n") diff --git a/tools/probe_walls.luau b/tools/probe_walls.luau new file mode 100644 index 0000000..f18a40c --- /dev/null +++ b/tools/probe_walls.luau @@ -0,0 +1,120 @@ +-- User's picture marks TWO defects on the slip ramps: +-- (A) left tall oval -> a stretch of ramp edge with NO barrier at all +-- (B) right circle -> the OUTER barrier "sticks out" / juts, not smooth +-- Earlier measurement hinted at (A): PAEastSlip had walls=119 where west had +-- 120, i.e. one wall segment short. Find the truth for both slips now. +-- +-- Method: +-- 1. Index every _Wall_ by k and by side (left/right of centreline). +-- Missing k on a side = a hole in that parapet. +-- 2. For each wall, compute SIGNED lateral offset from its own deck segment +-- centre. Walls are built at +/-(WIDTH/2 - 0.6); any outlier is a jut. +-- 3. Measure end-to-end gaps between consecutive walls on the same side -- +-- a protruding corner shows as a large lateral step between neighbours. +local out = {} +local function say(s) table.insert(out, s) end + +local map = workspace:FindFirstChild("Map") +if not map then return "Map MISSING" end + +local UP = Vector3.new(0, 1, 0) + +for _, s in ipairs({ "PAWestSlip", "PAEastSlip" }) do + local decks, walls = {}, {} + for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local dk = d.Name:match("^" .. s .. "_Deck_(%d+)$") + if dk then decks[tonumber(dk)] = d end + local wk = d.Name:match("^" .. s .. "_Wall_(%d+)$") + if wk then + local k = tonumber(wk) + walls[k] = walls[k] or {} + table.insert(walls[k], d) + end + end + end + + local idx = {} + for k in pairs(decks) do table.insert(idx, k) end + table.sort(idx) + if #idx == 0 then say(s .. ": no decks") continue end + + say(("=== %s: decks=%d (idx %d..%d) ==="):format(s, #idx, idx[1], idx[#idx])) + + -- (1) missing walls + local missing, oneSided = {}, {} + for _, k in ipairs(idx) do + local w = walls[k] + if not w then + table.insert(missing, k) + elseif #w < 2 then + table.insert(oneSided, k) + end + end + say((" walls: missing-both=%d %s | only-one-side=%d %s"):format( + #missing, #missing > 0 and table.concat(missing, ",") or "-", + #oneSided, #oneSided > 0 and table.concat(oneSided, ",") or "-")) + + -- (2) signed lateral offset of each wall from its deck centre + local offs = {} + local worstOut, worstAt, worstSide = 0, -1, "" + for _, k in ipairs(idx) do + local dk = decks[k] + local w = walls[k] + if dk and w then + local right = dk.CFrame.LookVector:Cross(UP).Unit + for _, wp in ipairs(w) do + local rel = wp.Position - dk.Position + local lat = rel:Dot(right) + table.insert(offs, { k = k, lat = lat, side = lat < 0 and "L" or "R" }) + end + end + end + -- expected magnitude = median |lat| + local mags = {} + for _, o in ipairs(offs) do table.insert(mags, math.abs(o.lat)) end + table.sort(mags) + local med = mags[math.max(1, math.floor(#mags / 2))] or 0 + say((" median |lateral offset| = %.2f (walls sit this far off centre)"):format(med)) + local juts = {} + for _, o in ipairs(offs) do + local dev = math.abs(o.lat) - med + if dev > 1.5 then + table.insert(juts, ("%d%s+%.1f"):format(o.k, o.side, dev)) + if dev > worstOut then worstOut = dev; worstAt = o.k; worstSide = o.side end + end + end + say((" walls deviating >1.5 from median: %d %s"):format(#juts, + #juts > 0 and table.concat(juts, " ") or "(none)")) + if worstAt > 0 then + say((" worst jut: seg %d side %s, +%.1f studs outboard"):format(worstAt, worstSide, worstOut)) + end + + -- (3) lateral step between consecutive walls on the SAME side + local bySide = { L = {}, R = {} } + for _, o in ipairs(offs) do table.insert(bySide[o.side], o) end + for _, sd in ipairs({ "L", "R" }) do + local a = bySide[sd] + table.sort(a, function(x, y) return x.k < y.k end) + local bigStep, stepAt = 0, -1 + for j = 2, #a do + local st = math.abs(math.abs(a[j].lat) - math.abs(a[j - 1].lat)) + if st > bigStep then bigStep = st; stepAt = a[j].k end + end + say((" side %s: n=%d largest lateral step=%.2f at seg %d"):format(sd, #a, bigStep, stepAt)) + end + + -- world position of any missing/one-sided spot so I can match it to the pic + for _, k in ipairs(oneSided) do + local dk = decks[k] + if dk then + local w = walls[k][1] + local right = dk.CFrame.LookVector:Cross(UP).Unit + local sd = (w.Position - dk.Position):Dot(right) < 0 and "L" or "R" + say((" >> seg %d has ONLY side %s, at (%.0f,%.0f,%.0f)"):format( + k, sd, dk.Position.X, dk.Position.Y, dk.Position.Z)) + end + end +end + +return table.concat(out, "\n") diff --git a/tools/probe_weave.luau b/tools/probe_weave.luau new file mode 100644 index 0000000..70860f4 --- /dev/null +++ b/tools/probe_weave.luau @@ -0,0 +1,123 @@ +-- Is the fused aux run genuine continuous pavement (a weaving section), or +-- overlapping/duplicated parts? Checks per-sample coverage, duplicate indices, +-- and the width profile across both mouths. +local ws = game:GetService("Workspace") +local map = ws:FindFirstChild("Map") +if not map then return "NOMAP" end + +local seen, dupes = {}, 0 +local byIdx = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local i = tonumber(d.Name:match("^AuxLane_(%d+)$") or "") + if i then + if seen[i] then dupes += 1 end + seen[i] = true + byIdx[i] = d + end + end +end + +local out = {} +local function say(s) table.insert(out, s) end +say(("duplicate AuxLane indices: %d -> %s"):format(dupes, dupes == 0 and "PASS" or "FAIL")) + +-- Walk 505..570 and report which samples have lane and how wide. +local missing = {} +local prof = {} +for i = 505, 570 do + local p = byIdx[i] + if p then + table.insert(prof, ("%d:%.0f"):format(i, p.Size.X)) + else + table.insert(missing, i) + table.insert(prof, ("%d:--"):format(i)) + end +end +say("width profile (sample:width, -- = no lane):") +say(" " .. table.concat(prof, " ")) +say("gaps in 505..570: " .. (#missing > 0 and table.concat(missing, ",") or "none")) + +-- The only gaps should be the ramp noses (the ramp deck covers those). +-- Derive coverage from the ACTUAL ramp deck parts, but test the point where +-- the AUX LANE would sit (deck edge + half lane width) -- NOT the mainline +-- centreline. Testing proximity to the centreline marked 26 samples as "nose" +-- because the ramp spirals past them at other heights, which would mask any +-- real hole and make this check vacuously pass. The nose test must also match +-- in HEIGHT: a ramp 50 studs below the deck does not cover the deck's edge. +local samples = require(game:GetService("ReplicatedStorage") + :WaitForChild("Shared"):WaitForChild("Circuit")).samples +local decks = {} +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") and d.Name:match("Slip_Deck_") then + table.insert(decks, d) + end +end + +local HALF, AUXW, SIDE = 30, 26, -1 +local function auxPointAt(i) + local n = #samples + local a = samples[((i - 1) % n) + 1] + local b = samples[(i % n) + 1] + local f = b.pos - a.pos + f = Vector3.new(f.X, 0, f.Z).Unit + local r = Vector3.new(-f.Z, 0, f.X) + return a.pos + r * (SIDE * (HALF + AUXW / 2)), a.pos.Y +end + +local NOSE, noseWhy = {}, {} +for _, i in ipairs(missing) do + local p, deckY = auxPointAt(i) + for _, d in ipairs(decks) do + local flat = (Vector3.new(d.Position.X, 0, d.Position.Z) + - Vector3.new(p.X, 0, p.Z)).Magnitude + local dy = math.abs(d.Position.Y - deckY) + -- Must overlap the lane footprint horizontally AND be at deck level. + if flat <= AUXW and dy <= 6 then + NOSE[i] = true + noseWhy[i] = ("%s flat=%.0f dy=%.0f"):format(d.Name, flat, dy) + break + end + end +end +local noseList = {} +for i in pairs(NOSE) do table.insert(noseList, i) end +table.sort(noseList) +say(("ramp-nose-covered gaps (%d): %s"):format(#noseList, table.concat(noseList, ","))) +for _, i in ipairs(noseList) do + say((" %d covered by %s"):format(i, noseWhy[i])) +end + +local lo, hi = math.huge, -1 +for k in pairs(byIdx) do + lo = math.min(lo, k); hi = math.max(hi, k) +end +local bad = {} +for _, i in ipairs(missing) do + -- Samples outside the lane's own extent are not holes; only flag gaps that + -- sit strictly between the first and last aux segment. + if i > lo and i < hi and not NOSE[i] then table.insert(bad, i) end +end +say(("lane run extent: %d..%d"):format(lo, hi)) +say(("non-nose gaps inside the lane run: %s -> %s"):format(#bad > 0 and table.concat(bad, ",") or "none", + #bad == 0 and "PASS (only ramp noses uncovered)" or "FAIL (hole in pavement)")) + +-- Between the two mouths, is the lane continuously full width (a real weaving +-- section) rather than tapering out and back in? +local weaveMin = math.huge +local weaveTested = 0 +for i = 528, 548 do + local p = byIdx[i] + if p then + weaveTested += 1 + weaveMin = math.min(weaveMin, p.Size.X) + end +end +if weaveTested == 0 then + say("weave section: FAIL nothing tested") +else + say(("weave section 528..548: %d segs, min width %.1f -> %s") + :format(weaveTested, weaveMin, + weaveMin >= 24 and "PASS (continuous full-width weaving lane)" or "TAPERS")) +end +return table.concat(out, "\n") diff --git a/tools/verify_auxlane.luau b/tools/verify_auxlane.luau new file mode 100644 index 0000000..093135f --- /dev/null +++ b/tools/verify_auxlane.luau @@ -0,0 +1,194 @@ +-- Verify the speed-change (auxiliary) lanes and that the slips no longer cut +-- across mainline traffic. +-- +-- Rebuilds from a CLONED module first (require() cache bypass), then measures. +-- Every check below can FAIL -- and asserts it actually tested something, so a +-- lookup returning nothing reports FAIL rather than vacuously passing. +local sss = game:GetService("ServerScriptService") +local ss = game:GetService("ServerStorage") +local ws = game:GetService("Workspace") +local RS = game:GetService("ReplicatedStorage") + +local realMod = sss:FindFirstChild("MapBuilder", true) +if not realMod then return "MapBuilder not found" end + +local bust = ss:FindFirstChild("_cachebust") +if not bust then + bust = Instance.new("Folder") + bust.Name = "_cachebust" + bust.Parent = ss +end +for _, ch in ipairs(bust:GetChildren()) do + ch:Destroy() +end +local clone = realMod:Clone() +clone.Name = "MapBuilder_aux_" .. tostring(math.random(1e6, 9e6)) +clone.Parent = bust + +local mb = require(clone) +local old = ws:FindFirstChild("Map") +if old then old:Destroy() end +local ok, err = pcall(function() mb.build() end) +if not ok then return "BUILD ERROR: " .. tostring(err) end + +local map = ws:FindFirstChild("Map") +if not map then return "no Map after build" end + +local Circuit = require(RS:WaitForChild("Shared"):WaitForChild("Circuit")) +local out = {} +local function say(s) table.insert(out, s) end + +-- Collect the aux lane decks and the slip decks. +local aux = {} +local decks = { PAWestSlip = {}, PAEastSlip = {} } +for _, d in ipairs(map:GetDescendants()) do + if d:IsA("BasePart") then + local ai = d.Name:match("^AuxLane_(%d+)$") + if ai then + table.insert(aux, { i = tonumber(ai), p = d.Position, s = d.Size, cf = d.CFrame }) + end + for slipName, list in pairs(decks) do + local idx = d.Name:match("^" .. slipName .. "_Deck_(%d+)$") + if idx then + table.insert(list, { i = tonumber(idx), p = d.Position, s = d.Size, cf = d.CFrame }) + end + end + end +end + +-- 1. The aux lanes must exist at all. +if #aux == 0 then + say("FAIL: no AuxLane_* parts were built") + return table.concat(out, "\n") +end +say(("aux lane segments built: %d"):format(#aux)) + +-- 2. Every aux segment must sit OUTSIDE the mainline deck, not on top of it. +-- Measure the gap between the mainline centreline and the aux centreline, +-- and compare against that sample's own half-width. +local samples = Circuit.samples +local minClear, minAt = math.huge, -1 +local tested = 0 +for _, a in ipairs(aux) do + local s = samples[a.i] + if s then + tested += 1 + local d = (Vector3.new(a.p.X, 0, a.p.Z) - Vector3.new(s.pos.X, 0, s.pos.Z)).Magnitude + -- inner edge of aux lane relative to mainline centre + local innerEdge = d - a.s.X / 2 + if innerEdge < minClear then + minClear, minAt = innerEdge, a.i + end + end +end +if tested == 0 then + say("FAIL: no aux segment matched a circuit sample (harness looked up nothing)") +else + say(("aux inner edge vs mainline centre: min %.1f studs at sample %d (tested %d)") + :format(minClear, minAt, tested)) +end + +-- 3. The slip decks must START at the aux lane offset, not on the centreline. +-- This is the actual bug the user reported: lat began at 0. +for slipName, list in pairs(decks) do + table.sort(list, function(x, y) return x.i < y.i end) + if #list < 50 then + say(("%s FAIL: only %d deck segments"):format(slipName, #list)) + else + local first = list[1] + -- Find the nearest mainline sample to the slip's first segment. + local bestD, bestI = math.huge, -1 + for i = 1, #samples do + local d = (Vector3.new(first.p.X, 0, first.p.Z) - Vector3.new(samples[i].pos.X, 0, samples[i].pos.Z)).Magnitude + if d < bestD then bestD, bestI = d, i end + end + local halfW = 0 + do + -- Deck half-width at that sample, read off the built road itself. + local road = map:FindFirstChild("Road_" .. bestI, true) + halfW = road and road.Size.X / 2 or 0 + end + local innerEdge = bestD - first.s.X / 2 + local verdict = (halfW > 0 and innerEdge >= halfW - 1.5) and "PASS" or "FAIL" + say(("%s start: centre %.1f from mainline centre, inner edge %.1f, deck half-width %.1f -> %s") + :format(slipName, bestD, innerEdge, halfW, verdict)) + end +end + +-- 4. Continuity: the slip mouth and the nearest aux segment must be adjacent +-- (end-to-end), not overlapping and not separated by a hole. +for slipName, list in pairs(decks) do + if #list >= 50 then + local first = list[1] + local bestGap, bestI = math.huge, -1 + for _, a in ipairs(aux) do + local g = (a.p - first.p).Magnitude + if g < bestGap then bestGap, bestI = g, a.i end + end + local verdict = bestGap <= 90 and "PASS" or "FAIL" + say(("%s mouth -> nearest aux segment: %.1f studs (sample %d) -> %s") + :format(slipName, bestGap, bestI, verdict)) + end +end + +-- 5. The aux lane must be level with the mainline deck at the mouth. +local maxStep, stepAt = 0, -1 +for _, a in ipairs(aux) do + local road = map:FindFirstChild("Road_" .. a.i, true) + if road then + local step = math.abs((a.p.Y + a.s.Y / 2) - (road.Position.Y + road.Size.Y / 2)) + if step > maxStep then maxStep, stepAt = step, a.i end + end +end +say(("aux/mainline surface step: max %.2f studs at sample %d -> %s") + :format(maxStep, stepAt, maxStep <= 0.05 and "PASS" or "FAIL")) + +-- 6. The taper must actually taper: widths should fall to near zero at the end. +local wmin, wmax = math.huge, 0 +for _, a in ipairs(aux) do + wmin = math.min(wmin, a.s.X) + wmax = math.max(wmax, a.s.X) +end +say(("aux width range: %.1f .. %.1f studs -> %s") + :format(wmin, wmax, (wmax >= 25 and wmin <= 8) and "PASS (full lane + taper)" or "CHECK")) + +-- 7. DECELERATION LANES. Each mouth must now have lane on BOTH sides: one +-- upstream (decel, for traffic leaving) and one downstream (accel, for +-- traffic joining). Group the aux segments into runs and check each slip +-- mouth has a run on either side of it. +table.sort(aux, function(x, y) return x.i < y.i end) +local runs, cur = {}, { aux[1] } +for k = 2, #aux do + if aux[k].i - aux[k - 1].i > 3 then + table.insert(runs, cur); cur = {} + end + table.insert(cur, aux[k]) +end +table.insert(runs, cur) +say(("aux runs: %d (expect 4 = 2 mouths x {decel, accel})"):format(#runs)) +for ri, r in ipairs(runs) do + local wlo, whi = math.huge, 0 + for _, a in ipairs(r) do + wlo = math.min(wlo, a.s.X); whi = math.max(whi, a.s.X) + end + say((" run %d: samples %d..%d (%d segs) width %.1f..%.1f") + :format(ri, r[1].i, r[#r].i, #r, wlo, whi)) +end +-- Each slip join must sit between a decel run and an accel run. +for _, slip in ipairs({ { "PAWestSlip", 525 }, { "PAEastSlip", 551 } }) do + local name, join = slip[1], slip[2] + local before, after = false, false + for _, r in ipairs(runs) do + if r[#r].i < join and join - r[#r].i <= 6 then before = true end + if r[1].i > join and r[1].i - join <= 6 then after = true end + if r[1].i <= join and r[#r].i >= join then + -- run spans the join: counts as adjacent on both sides + before = true; after = true + end + end + say(("%s join=%d: decel-side run %s, accel-side run %s -> %s") + :format(name, join, tostring(before), tostring(after), + (before and after) and "PASS" or "FAIL")) +end + +return table.concat(out, "\n") diff --git a/tools/verify_slips.luau b/tools/verify_slips.luau new file mode 100644 index 0000000..0aa9b01 --- /dev/null +++ b/tools/verify_slips.luau @@ -0,0 +1,112 @@ +-- Verify the linear slips against assertions that can FAIL. +-- Fixes the earlier harness bug: FindFirstChild must be RECURSIVE (PAFloor is +-- nested), otherwise `floor` is nil and every apron test vacuously reports +-- "does not reach" regardless of the real geometry. +local HttpService = game:GetService("HttpService") +local map = workspace:FindFirstChild("Map") +if not map then return "no Map -- run the rebuild first" end + +local ramps = map:FindFirstChild("PARamps", true) +if not ramps then return "PARamps folder missing" end +local floor = map:FindFirstChild("PAFloor", true) -- RECURSIVE +if not floor then return "PAFloor missing" end + +local fTop = floor.Position.Y + floor.Size.Y / 2 +local fx0, fx1 = floor.Position.X - floor.Size.X / 2, floor.Position.X + floor.Size.X / 2 +local fz0, fz1 = floor.Position.Z - floor.Size.Z / 2, floor.Position.Z + floor.Size.Z / 2 + +-- Collect deck segments per slip, ordered by their index suffix so we can find +-- the true LOW end rather than guessing from a bounding box. +local decks = { PAWestSlip = {}, PAEastSlip = {} } +local counts = {} +for _, d in ipairs(ramps:GetDescendants()) do + if d:IsA("BasePart") then + counts[d.Name:gsub("_%d+$", "")] = (counts[d.Name:gsub("_%d+$", "")] or 0) + 1 + for slipName, list in pairs(decks) do + local idx = d.Name:match("^" .. slipName .. "_Deck_(%d+)$") + if idx then + table.insert(list, { i = tonumber(idx), p = d.Position, s = d.Size, cf = d.CFrame }) + end + end + end +end + +local verdict, report = {}, {} +for slipName, list in pairs(decks) do + table.sort(list, function(a, b) return a.i < b.i end) + if #list < 50 then + table.insert(verdict, slipName .. " FAIL: only " .. #list .. " deck segments") + else + local first, last = list[1], list[#list] + local yTopFirst = first.p.Y + first.s.Y / 2 + local yTopLast = last.p.Y + last.s.Y / 2 + + -- 1. Does the LOW end sit inside the apron footprint? + local inX = last.p.X >= fx0 and last.p.X <= fx1 + local inZ = last.p.Z >= fz0 and last.p.Z <= fz1 + local lands = inX and inZ + + -- 2. Is the low end FLUSH with the apron surface? A lip catches cars. + local lip = yTopLast - fTop + + -- 3. Is the high end flush with the mainline deck (Y 103)? + local mouthErr = yTopFirst - 103 + + -- 4. Peak grade over any single segment. + local maxGrade = 0 + for k = 2, #list do + local a, b = list[k - 1], list[k] + local dy = math.abs((b.p.Y) - (a.p.Y)) + local dxz = (Vector3.new(b.p.X, 0, b.p.Z) - Vector3.new(a.p.X, 0, a.p.Z)).Magnitude + if dxz > 0.5 then maxGrade = math.max(maxGrade, dy / dxz) end + end + + report[slipName] = { + segs = #list, + mouth = { x = math.floor(first.p.X), z = math.floor(first.p.Z), yTop = math.floor(yTopFirst * 10) / 10 }, + landing = { x = math.floor(last.p.X), z = math.floor(last.p.Z), yTop = math.floor(yTopLast * 10) / 10 }, + landsInApron = lands, + lipStuds = math.floor(lip * 100) / 100, + mouthErrStuds = math.floor(mouthErr * 100) / 100, + peakGradePct = math.floor(maxGrade * 1000) / 10, + } + + if not lands then + table.insert(verdict, ("%s FAIL: landing (%d,%d) outside apron x[%d..%d] z[%d..%d]") + :format(slipName, last.p.X, last.p.Z, fx0, fx1, fz0, fz1)) + end + if math.abs(lip) > 1.5 then + table.insert(verdict, ("%s FAIL: %.2f stud lip at apron"):format(slipName, lip)) + end + if math.abs(mouthErr) > 1.5 then + table.insert(verdict, ("%s FAIL: %.2f stud step at mainline mouth"):format(slipName, mouthErr)) + end + if maxGrade > 0.16 then + table.insert(verdict, ("%s FAIL: peak grade %.1f%%"):format(slipName, maxGrade * 100)) + end + end +end + +-- 5. Does the mainline parapet actually open at each mouth? Count Barrier parts +-- within 160 studs of each mouth on the lot side; a sealed mouth = ramp is +-- decorative. +local road = map +local mouthBlocked = {} +for slipName, r in pairs(report) do + local mp = Vector3.new(r.mouth.x, 103, r.mouth.z) + local n = 0 + for _, d in ipairs(road:GetDescendants()) do + if d:IsA("BasePart") and (d.Name == "Barrier" or d.Name == "Parapet") then + if (Vector3.new(d.Position.X, 103, d.Position.Z) - mp).Magnitude < 150 then n += 1 end + end + end + mouthBlocked[slipName] = n +end + +return HttpService:JSONEncode({ + apron = { top = fTop, x = { math.floor(fx0), math.floor(fx1) }, z = { math.floor(fz0), math.floor(fz1) } }, + slips = report, + partCounts = counts, + barriersNearMouth = mouthBlocked, + verdict = #verdict == 0 and "ALL PASS" or table.concat(verdict, " | "), +})