fix(client): level-triggered watchdog arms chassis on cold start
CI / quality (push) Failing after 53s
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.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
+59
-67
@@ -41,6 +41,14 @@ 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 ------------------------------------------------------------
|
-- Collision groups ------------------------------------------------------------
|
||||||
-- The wheels deliberately overlap the chassis: with BODY_SIZE.X = 6 the body
|
-- 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
|
-- edge sits at 3.0, while a wheel at TRACK_HALF 3.35 with WHEEL_WIDTH 1.1 has
|
||||||
@@ -336,86 +344,70 @@ function CarManager.spawn(player: Player, carId: string?)
|
|||||||
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 and the client snaps it back to
|
-- The character is CLIENT-owned, so a server-side PivotTo of it does
|
||||||
-- where it was. Sit() then drags the seat's assembly across the map
|
-- NOT stick -- it holds for one frame, then the client snaps it back.
|
||||||
-- to reach the character -- and because a constraint chassis keeps
|
-- Sit() then drags the seat's assembly across the map to reach the
|
||||||
-- the body, knuckles and wheels as SEPARATE assemblies, only the
|
-- character, and because a constraint chassis keeps the body, knuckles
|
||||||
-- body follows. The knuckles/wheels stay behind at the pad and the
|
-- and wheels as SEPARATE assemblies (constraints don't merge them the
|
||||||
-- car is torn apart (measured ~15,700 studs), later falling out of
|
-- way welds do), only the body follows. The rest stay at the pad and
|
||||||
-- the world.
|
-- the car is torn apart -- measured ~15,700 studs -- then falls out of
|
||||||
--
|
-- the world.
|
||||||
-- Fix: don't fight the client. Record every part's offset from the
|
--
|
||||||
-- body while the model is still intact, let Sit() and the character
|
-- So: never make Sit() do the travelling. Teleport the INTACT car to
|
||||||
-- snap-back fully settle, then REASSEMBLE the car around wherever
|
-- wherever the character already is, part-by-part through the recorded
|
||||||
-- the body actually ended up.
|
-- offsets (PivotTo alone won't carry separate assemblies reliably),
|
||||||
local primary = model.PrimaryPart
|
-- and only then seat the driver. Sit() now moves nobody and nothing
|
||||||
local offsets: { [BasePart]: CFrame } = {}
|
-- tears. Ownership goes to the client last, once the rig is settled.
|
||||||
local movable: { BasePart } = {}
|
local humanoid = character:FindFirstChildOfClass("Humanoid")
|
||||||
if primary then
|
local root = character:FindFirstChild("HumanoidRootPart") :: BasePart?
|
||||||
local inverse = primary.CFrame:Inverse()
|
if humanoid and root then
|
||||||
for _, desc in ipairs(model:GetDescendants()) do
|
-- Face the car the way the character is facing, forced level so it
|
||||||
if desc:IsA("BasePart") and not desc.Anchored then
|
-- can never spawn on its roof.
|
||||||
offsets[desc] = inverse * desc.CFrame
|
local look = root.CFrame.LookVector
|
||||||
table.insert(movable, desc)
|
local flat = Vector3.new(look.X, 0, look.Z)
|
||||||
end
|
if flat.Magnitude < 0.1 then
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
AntiCheat.grant(player)
|
AntiCheat.grant(player)
|
||||||
character:PivotTo(target * CFrame.new(0, 3, 0))
|
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 then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
-- Hold server authority while the rig is in pieces, otherwise
|
|
||||||
-- the client re-asserts the torn layout over our repair.
|
|
||||||
for _, part in ipairs(movable) do
|
|
||||||
pcall(function()
|
|
||||||
part:SetNetworkOwner(nil)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
task.wait(1.2) -- Sit() + client snap-back settle here
|
|
||||||
|
|
||||||
if not primary:IsDescendantOf(Workspace) then
|
if not primary:IsDescendantOf(Workspace) then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- Rebuild every assembly around the body, forced level so the
|
|
||||||
-- car never lands on its roof.
|
|
||||||
local position = primary.Position
|
|
||||||
local look = primary.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 base = CFrame.lookAt(position, position + flat.Unit)
|
|
||||||
for _, part in ipairs(movable) do
|
|
||||||
if part:IsDescendantOf(Workspace) then
|
|
||||||
part.CFrame = base * offsets[part]
|
|
||||||
part.AssemblyLinearVelocity = Vector3.zero
|
|
||||||
part.AssemblyAngularVelocity = Vector3.zero
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
task.wait(0.3) -- let the solver accept the repaired rig
|
|
||||||
|
|
||||||
AntiCheat.grant(player)
|
AntiCheat.grant(player)
|
||||||
for _, part in ipairs(movable) do
|
for _, part in ipairs(movable) do
|
||||||
if part:IsDescendantOf(Workspace) then
|
if part:IsDescendantOf(Workspace) then
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user