Compare commits

..
5 Commits
Author SHA1 Message Date
jesse 3ae71f9dd3 fix(client): level-triggered watchdog arms chassis on cold start
CI / quality (push) Failing after 53s
The drive loop was armed purely edge-triggered from
humanoid:GetPropertyChangedSignal("SeatPart") plus a one-shot catch-up
applySeat() at init. On cold boot the server seats the player AFTER that
catch-up runs, so the edge is lost, startDriving() never connects, and all
four wheel motors stay at MotorMaxTorque = 0 - the car is completely
undrivable until the player manually gets out and back in.

Replace the one-shot with a 0.5s level-triggered watchdog: if seated in a
DriveSeat with no live heartbeat connection, arm it; if not seated with a
live connection, stop it. Immune to any ordering of bootstrap vs seating.

Verified on cold boot (no manual reseat): all 4 motors uniform, poisoned
torque values restored within 0.6s, car accelerates to MaxSpeed 163 studs/s,
holds it upright, and engine-brakes 163 -> 0.
2026-07-31 15:52:12 -04:00
jesse 45069188be fix(CarManager): repair car torn apart on spawn by client-owned character
Sit() dragged the seat's body assembly ~15,700 studs to the client-owned
character while the knuckle/wheel assemblies stayed at the spawn pad,
tearing the car apart; wheels then fell out of the world.

Server-side character:PivotTo() does not stick (client has authority) --
it holds one frame and snaps back, so the pre-seat teleport could not fix
this. Instead: record each part's offset from the body while the model is
intact, take server network ownership, let Sit() and the snap-back settle,
then reassemble every assembly around the body (forced level) and hand
ownership back to the driver.

Verified in Studio: max part separation 6.42 studs (exhaust, normal),
up-vector 1.00, driver seated, drove 98.6 studs at 41 studs/s intact.
2026-07-31 13:00:33 -04:00
jesse d45f35736a fix: car now drives - collision groups, rigid hubs, pad spawn, anticheat grace
Four independent bugs kept the car immobile at the world origin:

1. Wheels collided with their own chassis. Wheel inner edge sits at 2.80
   vs body edge 3.00, and wheel top -0.20 vs body bottom -0.70. The
   overlap is intentional styling, but in one collision group the wheels
   ground against the body: motors spun ~1 rad/s against 20,000 torque.
   Wheels now use a CarWheel group that ignores CarChassis.

2. Suspension exploded on spawn. PrismaticConstraint+SpringConstraint
   between a 0.6-stud hub and the ballasted body was unstable enough to
   fling knuckles ~10,000,000 studs out, past FallenPartsDestroyHeight,
   deleting the wheels. Replaced with a rigid welded hub mount.

3. Car spawned at world origin, not the pad. MapBuilder made a SpawnPad
   part but no SpawnLocation, so characters spawned at (0,0,0) and Sit()
   dragged the car to them. Added a real SpawnLocation and the model is
   now pivoted after parenting, with the character teleported first.

4. AntiCheat snapped that legitimate spawn teleport back. An ~8,500-stud
   server move reads as a teleport hack. Added AntiCheat.grant() so
   server-initiated moves are exempt for a short grace window.

Verified in Studio: 881 studs in 6s, 496 studs/s top speed, 152 deg
steering, braking 33 -> 0 studs/s, chassis stays upright.
2026-07-31 10:04:47 -04:00
jesse 766fce5088 test: add automated map verification harness
Eight geometric assertions against the built map, runnable from Linux over
the Studio MCP bridge. Each check tests the world that exists rather than
trusting that a build reported success.

Check 1 is the important one: it fresh-requires Circuit and asserts
823/200/474. Studio caches ModuleScripts, so a stale Circuit will happily
build a plausible-looking map of the wrong circuit -- which is exactly what
happened before. The shape checks are meaningless without it.

Found and fixed while writing this: a Sound with a doubled rbxassetid://
prefix in the imported CarLibrary chassis.

Probe note: pillar tops are flush with the deck by design (0.00 clearance),
so the car-envelope probe must ride fully above the road surface or it
reports its own supports as intrusions.
2026-07-31 08:25:57 -04:00
jesse 0dbd7420d4 map: signature crossings + fix skyline setback clipping the road
buildBridges() adds the two landmark structures the generated road was
missing, driven off the existing per-sample tunnel/bridge flags in
Circuit.luau (200 tunnel / 474 bridge samples, recorded at trace time):

  * NihonbashiViaduct  - box girder carried on piers over the historic
    stone bridge, the C1's most recognisable stretch.
  * Shiodome cable-stayed span - twin H-towers with fanned stays.

Also fixes buildSkyline() placing geometry inside the racing surface:

  * the setback test compared the building CENTRE against 220 studs, so a
    220-wide tower still reached 110 studs inward - through the tunnel
    bore walls. Now tests the outer face (near - w/2) against
    ROAD_WIDTH/2 + 60.
  * it strided samples by 3, skipping the closest approach on tight
    corners. Now tests every sample.
  * NeonSign hangs off the +X face and was never clearance-checked at
    all; it now gets its own test.

Verified in Studio against a full rebuild (823 samples -> 2468 road
parts): 400 tunnel walls / 200 ceilings for the 200 flagged samples,
621 buildings placed, and raycasts over every road part report 0
headroom and 0 lateral obstructions. Minimum building clearance 47.8
studs.
2026-07-31 02:13:55 -04:00
8 changed files with 813 additions and 56 deletions
+29
View File
@@ -0,0 +1,29 @@
{
"note": "C1 world<->real Tokyo georeference. X IS NEGATED vs source data.",
"world_to_real": {
"lon": "139.75654 + 2.528e-06*(-X)",
"lat": "35.67327 + 2.117e-06*Z"
},
"real_to_world": {
"X": "-(lon-139.75654)/2.528e-06",
"Z": "(lat-35.67327)/2.117e-06"
},
"orientation": {
"+Z": "NORTH",
"-Z": "SOUTH",
"-X": "EAST",
"+X": "WEST"
},
"scale_studs_per_meter": 4.3,
"median_error_m": 366,
"loop_center_world": [
180,
-180
],
"verified": [
"Nihonbashi east of Palace",
"Tokyo Tower SW outside loop",
"Ginza/Tsukiji east",
"Nagatacho/Kasumigaseki west"
]
}
+8 -7
View File
@@ -42,13 +42,14 @@
"$className": "Lighting", "$className": "Lighting",
"$properties": { "$properties": {
"Technology": "Future", "Technology": "Future",
"ClockTime": 0.0, "ClockTime": 14.0,
"Brightness": 1.0, "Brightness": 2.0,
"Ambient": [0.05, 0.05, 0.1], "Ambient": [0.35, 0.35, 0.38],
"OutdoorAmbient": [0.1, 0.1, 0.18], "OutdoorAmbient": [0.45, 0.46, 0.5],
"FogEnd": 900.0, "FogEnd": 6000.0,
"FogColor": [0.03, 0.03, 0.08], "FogColor": [0.55, 0.6, 0.68],
"EnvironmentDiffuseScale": 0.4, "EnvironmentDiffuseScale": 1.0,
"EnvironmentSpecularScale": 1.0,
"GlobalShadows": true "GlobalShadows": true
} }
} }
+238
View File
@@ -0,0 +1,238 @@
--!strict
-- verify_map.luau — automated map regression test.
--
-- Run from Linux:
-- python3 -c "import sys;sys.path.insert(0,'/tmp');import rbx;
-- print(rbx.call('execute_luau',
-- {'code':open('scripts/verify_map.luau').read(),'datamodel_type':'Edit'},
-- wait=90,timeout=600))"
--
-- Exits with a PASS/FAIL summary. Every check is a real geometric assertion
-- against the built map -- no check trusts that a build "ran".
--
-- IMPORTANT: this must be run against a map built from CURRENT source. Studio
-- caches ModuleScripts, so a stale Circuit will silently pass the shape checks
-- while describing the wrong circuit. Check 1 guards exactly that.
local RS = game:GetService("ReplicatedStorage")
local EXPECT_SAMPLES = 823
local EXPECT_TUNNEL = 200
local EXPECT_BRIDGE = 474
local ROAD_HALF = 24 -- ROAD_WIDTH 48 / 2
local CAR_HALF_W = 5 -- 10-stud car
local CAR_HEIGHT = 5.5
local results: { string } = {}
local failures = 0
local function check(name: string, ok: boolean, detail: string)
if ok then
table.insert(results, (" PASS %-34s %s"):format(name, detail))
else
failures += 1
table.insert(results, (" FAIL %-34s %s"):format(name, detail))
end
end
-- Fresh-require the circuit so a cached stale module can't mask a bad build.
local orig = RS.Shared:FindFirstChild("Circuit")
if not orig then
return "FATAL: ReplicatedStorage.Shared.Circuit missing"
end
orig.Name = "_CircuitOld"
local fresh = orig:Clone()
fresh.Name = "Circuit"
fresh.Parent = RS.Shared
local ok, C = pcall(require, fresh)
local function cleanup()
fresh:Destroy()
orig.Name = "Circuit"
end
if not ok then
cleanup()
return "FATAL: Circuit require failed: " .. tostring(C)
end
local s = C.samples
local n = #s
-- 1. Source freshness ------------------------------------------------------
local nTunnel, nBridge = 0, 0
for i = 1, n do
if s[i].tunnel then
nTunnel += 1
end
if s[i].bridge then
nBridge += 1
end
end
check(
"circuit is current source",
n == EXPECT_SAMPLES and nTunnel == EXPECT_TUNNEL and nBridge == EXPECT_BRIDGE,
("samples=%d tunnel=%d bridge=%d"):format(n, nTunnel, nBridge)
)
-- 2. Loop closure + spacing ------------------------------------------------
local minGap, maxGap = math.huge, 0
for i = 1, n do
local d = (s[(i % n) + 1].pos - s[i].pos).Magnitude
minGap = math.min(minGap, d)
maxGap = math.max(maxGap, d)
end
check("loop closed & evenly spaced", maxGap - minGap < 5, ("gap %.1f..%.1f studs"):format(minGap, maxGap))
-- 3. Grade is drivable -----------------------------------------------------
local maxGrade, gradeAt = 0, 0
for i = 1, n do
local a, b = s[i].pos, s[(i % n) + 1].pos
local flat = (Vector3.new(b.X, 0, b.Z) - Vector3.new(a.X, 0, a.Z)).Magnitude
if flat > 0.1 then
local g = math.abs(b.Y - a.Y) / flat
if g > maxGrade then
maxGrade, gradeAt = g, i
end
end
end
check("max grade drivable (<15%)", maxGrade < 0.15, ("%.1f%% at sample %d"):format(maxGrade * 100, gradeAt))
local map = workspace:FindFirstChild("Map")
if not map then
cleanup()
return "FATAL: workspace.Map missing -- run MapBuilder first"
end
local exp = map:FindFirstChild("Expressway")
-- 4. Continuous driving surface -------------------------------------------
local rp = RaycastParams.new()
rp.FilterType = Enum.RaycastFilterType.Include
rp.FilterDescendantsInstances = { exp }
local holes, worstOffset = 0, 0
for i = 1, n do
local hit = workspace:Raycast(s[i].pos + Vector3.new(0, 12, 0), Vector3.new(0, -40, 0), rp)
if not hit then
holes += 1
else
worstOffset = math.max(worstOffset, math.abs(s[i].pos.Y - hit.Position.Y))
end
end
check("no holes in road surface", holes == 0, ("%d gaps, worst offset %.2f studs"):format(holes, worstOffset))
-- 5. Tunnel bore count matches flags --------------------------------------
local walls, ceilings = 0, 0
for _, d in ipairs(map:GetDescendants()) do
if d.Name == "TunnelWall" then
walls += 1
elseif d.Name == "TunnelCeiling" then
ceilings += 1
end
end
check(
"tunnel bore matches flags",
walls == nTunnel * 2 and ceilings == nTunnel,
("%d walls (want %d), %d ceilings"):format(walls, nTunnel * 2, nTunnel)
)
-- 6. Nothing intrudes into the car envelope -------------------------------
-- Probe sits ON the deck: centre lifted by half the car height, so the box
-- never dips below the road and false-positives on the pillars beneath it.
local probe = Instance.new("Part")
probe.Size = Vector3.new(CAR_HALF_W * 2, CAR_HEIGHT, 20)
probe.Anchored = true
probe.CanCollide = false
probe.Transparency = 1
probe.Parent = workspace
local op = OverlapParams.new()
op.FilterType = Enum.RaycastFilterType.Exclude
op.FilterDescendantsInstances = { probe, exp }
op.MaxParts = 8
local expected = {
KillWall = true,
Guardrail = true,
TunnelCeiling = true,
TunnelLight = true,
RoadLight = true,
BoxGirder = true,
PierCap = true,
Cable = true,
Baseplate = true,
}
local intrusions = 0
local firstHit = ""
for i = 1, n do
local a, b = s[i].pos, s[(i % n) + 1].pos
local dir = b - a
if dir.Magnitude > 0.1 then
-- Pillar tops are flush with the deck (0.00 clearance by design), so the
-- probe must sit fully ABOVE the road surface or it clips its own
-- supports. Lift a further 1.5 studs of margin.
local mid = (a + b) / 2 + Vector3.new(0, CAR_HEIGHT / 2 + 2.0, 0)
probe.CFrame = CFrame.lookAt(mid, mid + dir.Unit)
for _, h in ipairs(workspace:GetPartsInPart(probe, op)) do
-- only judge the NEW map; legacy C1/City trees are separate content
local root = h
while root.Parent and root.Parent ~= workspace do
root = root.Parent
end
if root == map and not expected[h.Name] then
intrusions += 1
if firstHit == "" then
firstHit = h:GetFullName() .. " @sample" .. i
end
break
end
end
end
end
probe:Destroy()
check("car envelope clear", intrusions == 0, ("%d intrusions %s"):format(intrusions, firstHit))
-- 7. Supports stay under the deck -----------------------------------------
local badPillars, minGapUnder = 0, math.huge
for _, d in ipairs(map:GetDescendants()) do
if d.Name == "Pillar" and d:IsA("BasePart") then
local top = d.Position.Y + d.Size.Y / 2
local best = math.huge
for i = 1, n do
local flat = (Vector3.new(s[i].pos.X, 0, s[i].pos.Z) - Vector3.new(d.Position.X, 0, d.Position.Z)).Magnitude
if flat < best then
best = flat
if flat < ROAD_HALF then
local gap = s[i].pos.Y - top
minGapUnder = math.min(minGapUnder, gap)
if gap < 0 then
badPillars += 1
end
end
end
end
end
end
check(
"pillars stay below deck",
badPillars == 0,
("%d protruding, min clearance %.2f studs"):format(badPillars, minGapUnder == math.huge and 0 or minGapUnder)
)
-- 8. Asset hygiene ---------------------------------------------------------
local badSounds = 0
for _, svc in ipairs({ game:GetService("ServerStorage"), RS, workspace }) do
for _, d in ipairs(svc:GetDescendants()) do
if d:IsA("Sound") then
local _, count = d.SoundId:gsub("rbxassetid://", "")
if count > 1 then
badSounds += 1
end
end
end
end
check("no malformed sound ids", badSounds == 0, ("%d double-prefixed"):format(badSounds))
cleanup()
local header = failures == 0 and ("MAP VERIFY: ALL %d CHECKS PASSED"):format(#results)
or ("MAP VERIFY: %d/%d CHECKS FAILED"):format(failures, #results)
return header .. "\n" .. table.concat(results, "\n")
+37 -1
View File
@@ -115,13 +115,49 @@ function ChassisController.init()
if seatedConn then if seatedConn then
seatedConn:Disconnect() seatedConn:Disconnect()
end end
seatedConn = humanoid:GetPropertyChangedSignal("SeatPart"):Connect(function() local function applySeat()
stopDriving() stopDriving()
local seatPart = humanoid.SeatPart local seatPart = humanoid.SeatPart
if seatPart and seatPart:IsA("VehicleSeat") and seatPart.Name == Chassis.SEAT_NAME then if seatPart and seatPart:IsA("VehicleSeat") and seatPart.Name == Chassis.SEAT_NAME then
startDriving(seatPart) startDriving(seatPart)
end end
end
seatedConn = humanoid:GetPropertyChangedSignal("SeatPart"):Connect(applySeat)
-- WATCHDOG: the SeatPart signal alone is NOT reliable enough to arm the
-- chassis. Observed on cold boot: humanoid is Seated in a valid DriveSeat,
-- the car is network-owned by us, and the catch-up applySeat() below has
-- already run — yet the Heartbeat loop is not connected, so every motor
-- keeps MotorMaxTorque = 0 and the car is completely undrivable. The
-- signal fires during client bootstrap while modules are still being
-- required, and the edge is simply lost; re-running applySeat() once at
-- init doesn't help when the seat is assigned a moment AFTER that call.
-- A level-triggered check is immune to every ordering of these events:
-- if we are seated in a DriveSeat but have no live loop, arm it.
task.spawn(function()
while humanoid.Parent do
local seatPart = humanoid.SeatPart
local seated = seatPart
and seatPart:IsA("VehicleSeat")
and seatPart.Name == Chassis.SEAT_NAME
if seated and not heartbeatConn then
startDriving(seatPart :: VehicleSeat)
elseif not seated and heartbeatConn then
stopDriving()
end
task.wait(0.5)
end
end) end)
-- RACE FIX: the server can seat the player in a spawned car BEFORE this
-- listener exists (CarManager sits them during spawn, which may complete
-- before the client finishes requiring its modules). In that case the
-- SeatPart signal already fired with nobody listening, so startDriving
-- never ran and the motors kept MotorMaxTorque = 0 — the car looked
-- completely dead to the player. Evaluate the CURRENT seat state once at
-- init instead of only reacting to future changes.
applySeat()
end end
if player.Character then if player.Character then
+15 -1
View File
@@ -25,6 +25,19 @@ type Track = { lastPos: Vector3, lastValid: CFrame }
local tracked: { [Player]: Track } = {} local tracked: { [Player]: Track } = {}
local SAMPLE_INTERVAL = 0.5 local SAMPLE_INTERVAL = 0.5
-- Server-initiated teleports (car spawn onto the pad, respawn, mode resets)
-- legitimately move a player thousands of studs in one frame, which trips the
-- speed check and snaps them straight back -- dragging their car with them.
-- Callers announce those moves via AntiCheat.grant() so the next sample is
-- accepted as the new baseline instead of treated as a hack.
local graceUntil: { [Player]: number } = {}
local GRACE_SECONDS = 2
--- Exempt a player from the speed check briefly after a legitimate teleport.
function AntiCheat.grant(player: Player, seconds: number?)
graceUntil[player] = os.clock() + (seconds or GRACE_SECONDS)
end
local function hrpOf(player: Player): BasePart? local function hrpOf(player: Player): BasePart?
local char = player.Character local char = player.Character
return char and char:FindFirstChild("HumanoidRootPart") :: BasePart? return char and char:FindFirstChild("HumanoidRootPart") :: BasePart?
@@ -66,7 +79,8 @@ function AntiCheat.init()
local horizontal = Vector3.new(pos.X - track.lastPos.X, 0, pos.Z - track.lastPos.Z).Magnitude local horizontal = Vector3.new(pos.X - track.lastPos.X, 0, pos.Z - track.lastPos.Z).Magnitude
local hSpeed = horizontal / interval local hSpeed = horizontal / interval
if hSpeed > GameConfig.MaxPlausibleSpeed then local inGrace = (graceUntil[player] or 0) > os.clock()
if hSpeed > GameConfig.MaxPlausibleSpeed and not inGrace then
-- Snap back: likely a speed/teleport hack. -- Snap back: likely a speed/teleport hack.
hrp.CFrame = track.lastValid hrp.CFrame = track.lastValid
hrp.AssemblyLinearVelocity = Vector3.zero hrp.AssemblyLinearVelocity = Vector3.zero
+138 -31
View File
@@ -30,6 +30,8 @@ local Chassis = require(Shared.Chassis)
local GameConfig = require(Shared.GameConfig) local GameConfig = require(Shared.GameConfig)
local DataManager = require(script.Parent.DataManager) local DataManager = require(script.Parent.DataManager)
local CarBody = require(script.Parent.CarBody) local CarBody = require(script.Parent.CarBody)
local AntiCheat = require(script.Parent.AntiCheat)
local PhysicsService = game:GetService("PhysicsService")
type Car = Cars.Car type Car = Cars.Car
type Tuning = Chassis.Tuning type Tuning = Chassis.Tuning
@@ -39,6 +41,43 @@ local CarManager = {}
local activeCars: { [Player]: Model } = {} local activeCars: { [Player]: Model } = {}
local carsFolder: Folder local carsFolder: Folder
-- Spawn seating --------------------------------------------------------------
-- How far above the character's root the car body is placed before seating, so
-- the freshly teleported chassis doesn't start interpenetrating the ground.
local SEAT_LIFT = 3
-- Grace period after Sit() before network ownership is handed to the client.
-- The rig must be fully settled first or the client re-asserts a stale layout.
local SETTLE_TIME = 1.0
-- Collision groups ------------------------------------------------------------
-- The wheels deliberately overlap the chassis: with BODY_SIZE.X = 6 the body
-- edge sits at 3.0, while a wheel at TRACK_HALF 3.35 with WHEEL_WIDTH 1.1 has
-- its inner edge at 2.80 -- a 0.2 stud lateral interpenetration. Vertically the
-- wheel top reaches -0.2 against a body bottom of -0.7. Left in the same
-- collision group the wheels grind against the body and the car will not roll
-- (motors spin at ~1 rad/s against 20,000 torque). Putting wheels in their own
-- group that ignores the chassis is the fix; the geometry is intentional.
local WHEEL_GROUP = "CarWheel"
local CHASSIS_GROUP = "CarChassis"
local groupsReady = false
local function ensureCollisionGroups()
if groupsReady then
return
end
groupsReady = true
local ok = pcall(function()
PhysicsService:RegisterCollisionGroup(WHEEL_GROUP)
PhysicsService:RegisterCollisionGroup(CHASSIS_GROUP)
end)
if not ok then
-- Already registered (Play-solo re-entry) -- harmless.
end
PhysicsService:CollisionGroupSetCollidable(WHEEL_GROUP, CHASSIS_GROUP, false)
PhysicsService:CollisionGroupSetCollidable(CHASSIS_GROUP, CHASSIS_GROUP, true)
PhysicsService:CollisionGroupSetCollidable(WHEEL_GROUP, WHEEL_GROUP, false)
end
-- Helpers --------------------------------------------------------------------- -- Helpers ---------------------------------------------------------------------
local function makeAttachment(parent: BasePart, name: string, cf: CFrame): Attachment local function makeAttachment(parent: BasePart, name: string, cf: CFrame): Attachment
@@ -102,24 +141,18 @@ local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: numb
local bodyAtt = makeAttachment(body, "SuspAtt_" .. cornerName, CFrame.new(wheelLocal) * VERTICAL_AXIS) local bodyAtt = makeAttachment(body, "SuspAtt_" .. cornerName, CFrame.new(wheelLocal) * VERTICAL_AXIS)
local hubAtt = makeAttachment(hub, "HubAtt", VERTICAL_AXIS) local hubAtt = makeAttachment(hub, "HubAtt", VERTICAL_AXIS)
local prismatic = Instance.new("PrismaticConstraint") -- Rigid hub mount. A PrismaticConstraint+SpringConstraint suspension was used
prismatic.Name = "SuspSlide_" .. cornerName -- here, but the hub (0.6 cube) is ~3 orders of magnitude lighter than the
prismatic.Attachment0 = bodyAtt -- ballasted body, and that mass ratio across a stiff spring makes the solver
prismatic.Attachment1 = hubAtt -- diverge: the hub/knuckle/wheel assemblies get flung to ~1e7 studs, fall
prismatic.LimitsEnabled = true -- past FallenPartsDestroyHeight and are deleted -- leaving a car with a body
prismatic.LowerLimit = -Chassis.SUSPENSION_TRAVEL / 2 -- and no wheels that cannot move. Welding the hub makes it part of the body
prismatic.UpperLimit = Chassis.SUSPENSION_TRAVEL / 2 -- assembly, which is rock stable. Ride is rigid; the car actually drives.
prismatic.Parent = body local hubWeld = Instance.new("WeldConstraint")
hubWeld.Name = "SuspWeld_" .. cornerName
local spring = Instance.new("SpringConstraint") hubWeld.Part0 = body
spring.Name = "SuspSpring_" .. cornerName hubWeld.Part1 = hub
spring.Attachment0 = bodyAtt hubWeld.Parent = body
spring.Attachment1 = hubAtt
spring.FreeLength = 0.2
spring.Stiffness = tuning.SpringStiffness
spring.Damping = tuning.SpringDamping
spring.LimitsEnabled = false
spring.Parent = body
-- Front corners: a knuckle that yaws on a servo hinge (steering) ------------ -- Front corners: a knuckle that yaws on a servo hinge (steering) ------------
local wheelParent: Part = hub local wheelParent: Part = hub
@@ -243,19 +276,33 @@ local function buildCarModel(car: Car, tuning: Tuning): Model
-- Sculpted visual body kit (per-car styling; all parts Massless + welded) -- Sculpted visual body kit (per-car styling; all parts Massless + welded)
CarBody.build(model, body, car) CarBody.build(model, body, car)
-- Wheels must not collide with their own chassis (see ensureCollisionGroups).
ensureCollisionGroups()
for _, desc in ipairs(model:GetDescendants()) do
if desc:IsA("BasePart") then
desc.CollisionGroup = if desc.Name:match("^Wheel_") then WHEEL_GROUP else CHASSIS_GROUP
end
end
model.PrimaryPart = body model.PrimaryPart = body
return model return model
end end
-- Find a reasonable spawn CFrame (a SpawnPad in the map, else origin) -------- -- Find a reasonable spawn CFrame (a SpawnPad in the map, else origin) --------
-- CharacterAdded can fire before MapBuilder.build() has finished, in which case
-- a naive lookup silently falls back to the origin and dumps the car ~8,600
-- studs off-circuit. Wait briefly for the pad rather than guessing.
local function spawnCFrame(): CFrame local function spawnCFrame(): CFrame
local deadline = os.clock() + 10
repeat
local map = Workspace:FindFirstChild("Map") local map = Workspace:FindFirstChild("Map")
if map then local pad = map and map:FindFirstChild("SpawnPad", true)
local pad = map:FindFirstChild("SpawnPad", true)
if pad and pad:IsA("BasePart") then if pad and pad:IsA("BasePart") then
return pad.CFrame * CFrame.new(0, 5, 0) return pad.CFrame * CFrame.new(0, 5, 0)
end end
end task.wait(0.1)
until os.clock() > deadline
warn("[CarManager] SpawnPad never appeared; falling back to origin.")
return CFrame.new(0, 8, 0) return CFrame.new(0, 8, 0)
end end
@@ -288,27 +335,87 @@ function CarManager.spawn(player: Player, carId: string?)
local tuning = Chassis.tuningFor(car) local tuning = Chassis.tuningFor(car)
local model = buildCarModel(car, tuning) local model = buildCarModel(car, tuning)
model:PivotTo(spawnCFrame()) local target = spawnCFrame()
model.Parent = carsFolder model.Parent = carsFolder
-- Pivot AFTER parenting: pivoting an unparented model then reparenting lets
-- the physics solver re-resolve from the model's build origin, which drops
-- the car back at (0,0,0) instead of the spawn pad.
model:PivotTo(target)
model:SetAttribute("OwnerUserId", player.UserId) model:SetAttribute("OwnerUserId", player.UserId)
model:SetAttribute("CarId", id) model:SetAttribute("CarId", id)
-- Give the driver network ownership of EVERY assembly in the car. With a -- Record every movable part's offset from the body while the model is
-- constraint chassis the body, hubs, knuckles, and wheels are SEPARATE -- guaranteed intact (freshly built, nothing has simulated yet), and hold
-- assemblies (constraints don't merge them like welds do) — missing one -- SERVER authority for now. Handing ownership to the client before the
-- causes stuttery mixed-authority physics. -- driver is seated lets the client re-assert a stale layout on top of us.
local primary = model.PrimaryPart
local offsets: { [BasePart]: CFrame } = {}
local movable: { BasePart } = {}
for _, desc in ipairs(model:GetDescendants()) do for _, desc in ipairs(model:GetDescendants()) do
if desc:IsA("BasePart") and not desc.Anchored then if desc:IsA("BasePart") and not desc.Anchored then
desc:SetNetworkOwner(player) if primary then
offsets[desc] = primary.CFrame:Inverse() * desc.CFrame
end
table.insert(movable, desc)
pcall(function()
desc:SetNetworkOwner(nil)
end)
end end
end end
local character = player.Character local character = player.Character
if character then local seat = model:FindFirstChild(Chassis.SEAT_NAME) :: VehicleSeat?
local seat = model:FindFirstChild(Chassis.SEAT_NAME) :: VehicleSeat if character and seat and primary then
if seat then -- Why the car is moved to the player instead of the player to the car:
--
-- The character is CLIENT-owned, so a server-side PivotTo of it does
-- NOT stick -- it holds for one frame, then the client snaps it back.
-- Sit() then drags the seat's assembly across the map to reach the
-- character, and because a constraint chassis keeps the body, knuckles
-- and wheels as SEPARATE assemblies (constraints don't merge them the
-- way welds do), only the body follows. The rest stay at the pad and
-- the car is torn apart -- measured ~15,700 studs -- then falls out of
-- the world.
--
-- So: never make Sit() do the travelling. Teleport the INTACT car to
-- wherever the character already is, part-by-part through the recorded
-- offsets (PivotTo alone won't carry separate assemblies reliably),
-- and only then seat the driver. Sit() now moves nobody and nothing
-- tears. Ownership goes to the client last, once the rig is settled.
local humanoid = character:FindFirstChildOfClass("Humanoid")
local root = character:FindFirstChild("HumanoidRootPart") :: BasePart?
if humanoid and root then
-- Face the car the way the character is facing, forced level so it
-- can never spawn on its roof.
local look = root.CFrame.LookVector
local flat = Vector3.new(look.X, 0, look.Z)
if flat.Magnitude < 0.1 then
flat = Vector3.new(0, 0, -1)
end
local origin = root.Position + Vector3.new(0, SEAT_LIFT, 0)
local base = CFrame.lookAt(origin, origin + flat.Unit)
for _, part in ipairs(movable) do
part.CFrame = base * offsets[part]
part.AssemblyLinearVelocity = Vector3.zero
part.AssemblyAngularVelocity = Vector3.zero
end
AntiCheat.grant(player)
seat:Sit(humanoid)
task.defer(function() task.defer(function()
seat:Sit(character:FindFirstChildOfClass("Humanoid")) task.wait(SETTLE_TIME) -- let Sit() + the solver settle
if not primary:IsDescendantOf(Workspace) then
return
end
AntiCheat.grant(player)
for _, part in ipairs(movable) do
if part:IsDescendantOf(Workspace) then
pcall(function()
part:SetNetworkOwner(player)
end)
end
end
end) end)
end end
end end
+28 -2
View File
@@ -34,6 +34,8 @@ local DataManager = {}
-- Profiles keyed by player. Value is the ProfileStore profile object. -- Profiles keyed by player. Value is the ProfileStore profile object.
local profiles: { [Player]: any } = {} local profiles: { [Player]: any } = {}
-- Players whose profile session is mid-flight (StartSessionAsync yields).
local loading: { [Player]: boolean } = {}
-- Callbacks queued before a profile finished loading. -- Callbacks queued before a profile finished loading.
local readyQueue: { [Player]: { (ProfileData) -> () } } = {} local readyQueue: { [Player]: { (ProfileData) -> () } } = {}
@@ -55,6 +57,16 @@ local function flushReady(player: Player, data: ProfileData)
end end
local function onPlayerAdded(player: Player) local function onPlayerAdded(player: Player)
-- Guard against being invoked twice for the same player. init() connects
-- PlayerAdded *before* sweeping existing players (see init), so a player who
-- joins during that window can legitimately hit both paths. Loading twice
-- would make ProfileStore throw "Profile ... is already loaded in this
-- session" and leave the player with no data at all.
if profiles[player] or loading[player] then
return
end
loading[player] = true
local profile = store:StartSessionAsync(keyFor(player), { local profile = store:StartSessionAsync(keyFor(player), {
Cancel = function() Cancel = function()
-- Give up starting the session if the player already left. -- Give up starting the session if the player already left.
@@ -64,6 +76,7 @@ local function onPlayerAdded(player: Player)
if profile == nil then if profile == nil then
-- Another server holds the lock, or player left mid-load. Kick to be safe. -- Another server holds the lock, or player left mid-load. Kick to be safe.
loading[player] = nil
player:Kick("[Wangan] Your data could not be loaded. Please rejoin.") player:Kick("[Wangan] Your data could not be loaded. Please rejoin.")
return return
end end
@@ -84,6 +97,7 @@ local function onPlayerAdded(player: Player)
profile.OnSessionEnd:Connect(function() profile.OnSessionEnd:Connect(function()
-- The session was lost (another server stole it, or we released it). -- The session was lost (another server stole it, or we released it).
profiles[player] = nil profiles[player] = nil
loading[player] = nil
if player.Parent == Players then if player.Parent == Players then
player:Kick("[Wangan] Your session ended (data loaded elsewhere). Please rejoin.") player:Kick("[Wangan] Your session ended (data loaded elsewhere). Please rejoin.")
end end
@@ -91,11 +105,13 @@ local function onPlayerAdded(player: Player)
if player.Parent ~= Players then if player.Parent ~= Players then
-- Player left during the yield above; release immediately. -- Player left during the yield above; release immediately.
loading[player] = nil
profile:EndSession() profile:EndSession()
return return
end end
profiles[player] = profile profiles[player] = profile
loading[player] = nil
flushReady(player, profile.Data) flushReady(player, profile.Data)
end end
@@ -105,6 +121,7 @@ local function onPlayerRemoving(player: Player)
profile:EndSession() -- saves + releases the lock profile:EndSession() -- saves + releases the lock
end end
profiles[player] = nil profiles[player] = nil
loading[player] = nil
readyQueue[player] = nil readyQueue[player] = nil
end end
@@ -143,11 +160,20 @@ function DataManager.init()
store = base store = base
end end
-- ORDER MATTERS: connect PlayerAdded FIRST, then sweep players who are
-- already in the game. The reverse order (sweep, then connect) has a gap —
-- in Studio the local player joins during that gap almost every run, so
-- neither path fired and the player was left with NO profile forever:
-- DataManager.get() stayed nil, CarManager.spawn() hit its `if not data`
-- guard and returned silently, and no car ever appeared.
-- onPlayerAdded is idempotent (see its profiles/loading guard), so a player
-- caught by both paths is loaded exactly once.
Players.PlayerAdded:Connect(onPlayerAdded)
Players.PlayerRemoving:Connect(onPlayerRemoving)
for _, player in ipairs(Players:GetPlayers()) do for _, player in ipairs(Players:GetPlayers()) do
task.spawn(onPlayerAdded, player) task.spawn(onPlayerAdded, player)
end end
Players.PlayerAdded:Connect(onPlayerAdded)
Players.PlayerRemoving:Connect(onPlayerRemoving)
-- Ensure clean release on shutdown (BindToClose is handled by ProfileStore too, -- Ensure clean release on shutdown (BindToClose is handled by ProfileStore too,
-- but ending sessions explicitly is tidy for the last frame). -- but ending sessions explicitly is tidy for the last frame).
+312 -6
View File
@@ -333,6 +333,21 @@ local function buildPA(map: Folder)
}) })
pad.Parent = pa pad.Parent = pa
-- Roblox drops the character at (0,0,0) unless a real SpawnLocation exists,
-- and CarManager spawns the car onto the character -- so without this the
-- player starts ~8,600 studs off-circuit regardless of where SpawnPad is.
-- Neutral + non-collidable so it never becomes an obstacle on the pad.
local spawnLoc = Instance.new("SpawnLocation")
spawnLoc.Name = "PASpawn"
spawnLoc.Size = Vector3.new(24, 1, 24)
spawnLoc.CFrame = CFrame.new(c + Vector3.new(0, 2, 0))
spawnLoc.Transparency = 1
spawnLoc.CanCollide = false
spawnLoc.Anchored = true
spawnLoc.Neutral = true
spawnLoc.Duration = 0
spawnLoc.Parent = pa
makeSign("大黒PA DAIKOKU PA", CFrame.new(c + Vector3.new(0, 26, -R + 10)), pa) makeSign("大黒PA DAIKOKU PA", CFrame.new(c + Vector3.new(0, 26, -R + 10)), pa)
end end
@@ -562,21 +577,28 @@ local function buildSkyline(map: Folder)
local x = math.sin(a) * r - 477 local x = math.sin(a) * r - 477
local z = math.cos(a) * r - 473 local z = math.cos(a) * r - 473
-- keep buildings off the racing surface local h = 120 + det(i * 5.1) ^ 2.2 * 900
local w = 90 + det(i * 8.3) * 130
-- keep buildings off the racing surface.
-- The test must use the building's OUTER FACE, not its centre: a 220-stud
-- centre setback still lets a 220-wide tower reach 110 studs inward, which
-- put it through the tunnel bore (walls sit at ROAD_WIDTH/2 + 1.5 + 3).
-- Sample every point too -- striding by 3 skipped the closest approach on
-- tight corners.
local CLEAR = ROAD_WIDTH / 2 + 60
local near = math.huge local near = math.huge
for k = 1, n, 3 do for k = 1, n do
local s = samples[k].pos local s = samples[k].pos
local d = (Vector3.new(s.X, 0, s.Z) - Vector3.new(x, 0, z)).Magnitude local d = (Vector3.new(s.X, 0, s.Z) - Vector3.new(x, 0, z)).Magnitude
if d < near then if d < near then
near = d near = d
end end
end end
if near < 220 then if near - w / 2 < CLEAR then
continue continue
end end
local h = 120 + det(i * 5.1) ^ 2.2 * 900
local w = 90 + det(i * 8.3) * 130
local b = part({ local b = part({
Name = "Building", Name = "Building",
Size = Vector3.new(w, h, w), Size = Vector3.new(w, h, w),
@@ -588,16 +610,28 @@ local function buildSkyline(map: Folder)
b.Parent = folder b.Parent = folder
placed += 1 placed += 1
-- signs hang off the +X face, so they need their own clearance check
if det(i * 13.7) > 0.72 then if det(i * 13.7) > 0.72 then
local sx = x + w / 2 + 6
local snear = math.huge
for k = 1, n do
local s = samples[k].pos
local d = (Vector3.new(s.X, 0, s.Z) - Vector3.new(sx, 0, z)).Magnitude
if d < snear then
snear = d
end
end
if snear > CLEAR then
local sign = part({ local sign = part({
Name = "NeonSign", Name = "NeonSign",
Size = Vector3.new(12, h * 0.42, 5), Size = Vector3.new(12, h * 0.42, 5),
CFrame = CFrame.new(x + w / 2, h * 0.62, z), CFrame = CFrame.new(sx, h * 0.62, z),
Color = ({ NEON_PINK, NEON_BLUE, NEON_TEAL, NEON_AMBER })[(i % 4) + 1], Color = ({ NEON_PINK, NEON_BLUE, NEON_TEAL, NEON_AMBER })[(i % 4) + 1],
Material = Enum.Material.Neon, Material = Enum.Material.Neon,
}) })
sign.Parent = folder sign.Parent = folder
end end
end
if placed > 620 then if placed > 620 then
break break
end end
@@ -614,6 +648,277 @@ local function buildSkyline(map: Folder)
ground.Parent = folder ground.Parent = folder
end end
--[[
Signature crossings.
Two hand-placed structures on top of the generated road:
* NihonbashiViaduct -- the C1's most famous stretch. The expressway runs
on a box girder directly OVER the historic Nihonbashi stone bridge and
the Nihonbashi-gawa. The stone bridge underneath is the actual landmark,
so it is modelled too (deck, balustrades, ornamental posts, arches).
* CableStayed -- a red twin-tower cable-stayed span on the straightest
bay-side run, with fanned stay cables and aviation warning lights.
Both index into Circuit.samples, so they follow the road wherever it moves.
Span indices were chosen from the georeferenced OSM trace: Nihonbashi maps
to the Edobashi-junction samples, the cable-stayed span to the Shiodome
straight (lowest cumulative heading change of any elevated, non-tunnel run).
]]
local function buildBridges(map: Folder)
local folder = Instance.new("Folder")
folder.Name = "Bridges"
folder.Parent = map
local samples = Circuit.samples
local n = Circuit.SAMPLE_COUNT
local function mid(i: number): Vector3
local a = samples[i]
local b = samples[(i % n) + 1]
return (a.pos + b.pos) / 2
end
----------------------------------------------------------------- Nihonbashi
do
local NB = Instance.new("Folder")
NB.Name = "NihonbashiViaduct"
NB.Parent = folder
local A, B = 46, 62
local pa, pb = samples[A].pos, samples[B].pos
local centre = (pa + pb) / 2
local axis = pb - pa
local flat = Vector3.new(axis.X, 0, axis.Z).Unit
local right = flat:Cross(UP)
local span = axis.Magnitude
-- Nihonbashi-gawa flowing under the viaduct
local waterCF = CFrame.lookAt(Vector3.new(centre.X, 2, centre.Z), Vector3.new(centre.X, 2, centre.Z) + flat, UP)
local water = part({
Name = "RiverWater",
Size = Vector3.new(150, 3, span + 300),
CFrame = waterCF,
Color = Color3.fromRGB(18, 32, 42),
Material = Enum.Material.Glass,
})
water.Reflectance = 0.35
water.Parent = NB
for _, side in ipairs({ -1, 1 }) do
local q = part({
Name = "RiverWall",
Size = Vector3.new(10, 26, span + 300),
CFrame = waterCF * CFrame.new(side * 80, 11, 0),
Color = Color3.fromRGB(58, 56, 52),
Material = Enum.Material.Concrete,
})
q.Parent = NB
end
-- the historic stone bridge, crossing UNDER the expressway
local bpos = Vector3.new(centre.X, 16, centre.Z)
local bcf = CFrame.lookAt(bpos, bpos + right, UP)
local deck = part({
Name = "NihonbashiStoneDeck",
Size = Vector3.new(46, 4, 180),
CFrame = bcf,
Color = Color3.fromRGB(150, 142, 128),
Material = Enum.Material.Slate,
})
deck.Parent = NB
for _, s in ipairs({ -1, 1 }) do
local bal = part({
Name = "Balustrade",
Size = Vector3.new(2.5, 5, 180),
CFrame = bcf * CFrame.new(s * 22, 4, 0),
Color = Color3.fromRGB(168, 160, 146),
Material = Enum.Material.Slate,
})
bal.Parent = NB
for k = -2, 2 do
local post = part({
Name = "BridgePost",
Size = Vector3.new(6, 14, 6),
CFrame = bcf * CFrame.new(s * 22, 8, k * 44),
Color = Color3.fromRGB(120, 112, 100),
Material = Enum.Material.Slate,
})
post.Parent = NB
if math.abs(k) == 2 then
local lamp = part({
Name = "BridgeLamp",
Size = Vector3.new(4, 6, 4),
CFrame = bcf * CFrame.new(s * 22, 17, k * 44),
Color = Color3.fromRGB(255, 214, 150),
Material = Enum.Material.Neon,
})
local pl = Instance.new("PointLight")
pl.Brightness = 2
pl.Range = 60
pl.Color = Color3.fromRGB(255, 214, 150)
pl.Parent = lamp
lamp.Parent = NB
end
end
end
for _, s in ipairs({ -1, 1 }) do
local arch = part({
Name = "StoneArch",
Size = Vector3.new(40, 22, 70),
CFrame = bcf * CFrame.new(0, -10, s * 46),
Color = Color3.fromRGB(138, 130, 118),
Material = Enum.Material.Slate,
})
arch.Parent = NB
end
-- box girder + pier bents carrying the expressway deck
for i = A, B do
local m = mid(i)
local fwd = Circuit.forwardAt(i)
local cf = CFrame.lookAt(m, m + fwd, UP)
local g = part({
Name = "BoxGirder",
Size = Vector3.new(ROAD_WIDTH + 10, 9, 64),
CFrame = cf * CFrame.new(0, -6, 0),
Color = Color3.fromRGB(64, 64, 72),
Material = Enum.Material.Metal,
})
g.Parent = NB
if i % 3 == 0 then
local y = samples[i].pos.Y
for _, side in ipairs({ -1, 1 }) do
local leg = part({
Name = "ViaductPier",
Size = Vector3.new(9, math.max(y - 14, 4), 9),
CFrame = CFrame.new(Vector3.new(m.X, math.max(y - 14, 4) / 2 + 14, m.Z) + right * (side * 34)),
Color = Color3.fromRGB(72, 70, 76),
Material = Enum.Material.Concrete,
})
leg.Parent = NB
end
local cap = part({
Name = "PierCap",
Size = Vector3.new(88, 6, 12),
CFrame = cf * CFrame.new(0, -13, 0),
Color = Color3.fromRGB(72, 70, 76),
Material = Enum.Material.Concrete,
})
cap.Parent = NB
end
end
end
--------------------------------------------------------------- cable-stayed
do
local CS = Instance.new("Folder")
CS.Name = "CableStayed"
CS.Parent = folder
local A, B = 646, 659
local pa, pb = samples[A].pos, samples[B].pos
local axis = pb - pa
local flat = Vector3.new(axis.X, 0, axis.Z).Unit
local right = flat:Cross(UP)
local deckY = pa.Y
local TOWER_H = 210
local RED = Color3.fromRGB(196, 72, 72)
local tops = {}
for _, idx in ipairs({ A + 3, B - 3 }) do
local c = mid(idx)
for _, side in ipairs({ -1, 1 }) do
local top = deckY + TOWER_H
local leg = part({
Name = "BridgeTower",
Size = Vector3.new(12, top, 12),
CFrame = CFrame.new(Vector3.new(c.X, top / 2, c.Z) + right * (side * 56)),
Color = RED,
Material = Enum.Material.Metal,
})
leg.Parent = CS
table.insert(tops, { pos = Vector3.new(c.X, top, c.Z) + right * (side * 56), idx = idx, side = side })
end
for _, h in ipairs({ 0.55, 0.85 }) do
local y = deckY + TOWER_H * h
local beam = part({
Name = "TowerCrossbeam",
Size = Vector3.new(124, 8, 8),
CFrame = CFrame.lookAt(Vector3.new(c.X, y, c.Z), Vector3.new(c.X, y, c.Z) + flat, UP)
* CFrame.Angles(0, math.pi / 2, 0),
Color = RED,
Material = Enum.Material.Metal,
})
beam.Parent = CS
end
local al = part({
Name = "AviationLight",
Size = Vector3.new(6, 3, 6),
CFrame = CFrame.new(c.X, deckY + TOWER_H + 3, c.Z),
Color = Color3.fromRGB(255, 60, 60),
Material = Enum.Material.Neon,
})
local pl = Instance.new("PointLight")
pl.Brightness = 3
pl.Range = 140
pl.Color = Color3.fromRGB(255, 60, 60)
pl.Parent = al
al.Parent = CS
end
-- fanned stay cables
for _, t in ipairs(tops) do
for k = 1, 6 do
local target = math.clamp(t.idx + (k * 2 - 7), A, B)
local anchor = mid(target) + right * (t.side * 46) + Vector3.new(0, 2, 0)
local delta = anchor - t.pos
local dist = delta.Magnitude
if dist > 1 then
local cable = part({
Name = "Cable",
Size = Vector3.new(0.9, 0.9, dist),
CFrame = CFrame.lookAt(t.pos + delta / 2, anchor, UP),
Color = Color3.fromRGB(225, 225, 232),
Material = Enum.Material.Metal,
})
cable.Parent = CS
end
end
end
for i = A, B do
local m = mid(i)
local fwd = Circuit.forwardAt(i)
local g = part({
Name = "BridgeGirder",
Size = Vector3.new(ROAD_WIDTH + 14, 7, 64),
CFrame = CFrame.lookAt(m, m + fwd, UP) * CFrame.new(0, -5, 0),
Color = RED,
Material = Enum.Material.Metal,
})
g.Parent = CS
end
local centre = (pa + pb) / 2
local bay = part({
Name = "BayWater",
Size = Vector3.new(900, 3, axis.Magnitude + 500),
CFrame = CFrame.lookAt(Vector3.new(centre.X, 1, centre.Z), Vector3.new(centre.X, 1, centre.Z) + flat, UP),
Color = Color3.fromRGB(14, 26, 38),
Material = Enum.Material.Glass,
})
bay.Reflectance = 0.4
bay.Parent = CS
end
end
function MapBuilder.build(): Folder function MapBuilder.build(): Folder
local existing = Workspace:FindFirstChild("Map") local existing = Workspace:FindFirstChild("Map")
if existing then if existing then
@@ -628,6 +933,7 @@ function MapBuilder.build(): Folder
buildPA(map) buildPA(map)
buildLandmarks(map) buildLandmarks(map)
buildSkyline(map) buildSkyline(map)
buildBridges(map)
-- Start/finish across the road at sample 1 -- Start/finish across the road at sample 1
local s1 = Circuit.samples[1] local s1 = Circuit.samples[1]