diff --git a/src/client/ChassisController.luau b/src/client/ChassisController.luau index ca56081..abd3a5d 100644 --- a/src/client/ChassisController.luau +++ b/src/client/ChassisController.luau @@ -115,13 +115,49 @@ function ChassisController.init() if seatedConn then seatedConn:Disconnect() end - seatedConn = humanoid:GetPropertyChangedSignal("SeatPart"):Connect(function() + local function applySeat() stopDriving() local seatPart = humanoid.SeatPart if seatPart and seatPart:IsA("VehicleSeat") and seatPart.Name == Chassis.SEAT_NAME then startDriving(seatPart) 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) + + -- 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 if player.Character then diff --git a/src/server/CarManager.luau b/src/server/CarManager.luau index 8d55b10..a1f6275 100644 --- a/src/server/CarManager.luau +++ b/src/server/CarManager.luau @@ -41,6 +41,14 @@ local CarManager = {} local activeCars: { [Player]: Model } = {} 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 @@ -336,86 +344,70 @@ function CarManager.spawn(player: Player, carId: string?) model:SetAttribute("OwnerUserId", player.UserId) model:SetAttribute("CarId", id) - -- Give the driver network ownership of EVERY assembly in the car. With a - -- constraint chassis the body, hubs, knuckles, and wheels are SEPARATE - -- assemblies (constraints don't merge them like welds do) — missing one - -- causes stuttery mixed-authority physics. + -- Record every movable part's offset from the body while the model is + -- guaranteed intact (freshly built, nothing has simulated yet), and hold + -- SERVER authority for now. Handing ownership to the client before the + -- 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 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 local character = player.Character - if character then - local seat = model:FindFirstChild(Chassis.SEAT_NAME) :: VehicleSeat - if seat then - -- 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 - -- where it was. 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, only the - -- body follows. The knuckles/wheels stay behind at the pad and the - -- car is torn apart (measured ~15,700 studs), later falling 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 - -- snap-back fully settle, then REASSEMBLE the car around wherever - -- the body actually ended up. - local primary = model.PrimaryPart - local offsets: { [BasePart]: CFrame } = {} - local movable: { BasePart } = {} - if primary then - local inverse = primary.CFrame:Inverse() - for _, desc in ipairs(model:GetDescendants()) do - if desc:IsA("BasePart") and not desc.Anchored then - offsets[desc] = inverse * desc.CFrame - table.insert(movable, desc) - end - end + local seat = model:FindFirstChild(Chassis.SEAT_NAME) :: VehicleSeat? + if character and seat and primary 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) - character:PivotTo(target * CFrame.new(0, 3, 0)) + seat:Sit(humanoid) + task.defer(function() - seat:Sit(character:FindFirstChildOfClass("Humanoid")) - - 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 - + task.wait(SETTLE_TIME) -- let Sit() + the solver settle if not primary:IsDescendantOf(Workspace) then return 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) for _, part in ipairs(movable) do if part:IsDescendantOf(Workspace) then diff --git a/src/server/DataManager.luau b/src/server/DataManager.luau index a1e71e6..f23434e 100644 --- a/src/server/DataManager.luau +++ b/src/server/DataManager.luau @@ -34,6 +34,8 @@ local DataManager = {} -- Profiles keyed by player. Value is the ProfileStore profile object. local profiles: { [Player]: any } = {} +-- Players whose profile session is mid-flight (StartSessionAsync yields). +local loading: { [Player]: boolean } = {} -- Callbacks queued before a profile finished loading. local readyQueue: { [Player]: { (ProfileData) -> () } } = {} @@ -55,6 +57,16 @@ local function flushReady(player: Player, data: ProfileData) end 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), { Cancel = function() -- Give up starting the session if the player already left. @@ -64,6 +76,7 @@ local function onPlayerAdded(player: Player) if profile == nil then -- 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.") return end @@ -84,6 +97,7 @@ local function onPlayerAdded(player: Player) profile.OnSessionEnd:Connect(function() -- The session was lost (another server stole it, or we released it). profiles[player] = nil + loading[player] = nil if player.Parent == Players then player:Kick("[Wangan] Your session ended (data loaded elsewhere). Please rejoin.") end @@ -91,11 +105,13 @@ local function onPlayerAdded(player: Player) if player.Parent ~= Players then -- Player left during the yield above; release immediately. + loading[player] = nil profile:EndSession() return end profiles[player] = profile + loading[player] = nil flushReady(player, profile.Data) end @@ -105,6 +121,7 @@ local function onPlayerRemoving(player: Player) profile:EndSession() -- saves + releases the lock end profiles[player] = nil + loading[player] = nil readyQueue[player] = nil end @@ -143,11 +160,20 @@ function DataManager.init() store = base 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 task.spawn(onPlayerAdded, player) end - Players.PlayerAdded:Connect(onPlayerAdded) - Players.PlayerRemoving:Connect(onPlayerRemoving) -- Ensure clean release on shutdown (BindToClose is handled by ProfileStore too, -- but ending sessions explicitly is tidy for the last frame).