-- PROTOTYPE (tickets "Probe vehicle data and native passing in game", "Can rebuilding a road segment move a passer -- past its blocker?"): throwaway, lives only on branch prototype/vehicle-probe. -- Every INTERVAL it dumps every player-owned road vehicle and groups them by street segment. -- EXPERIMENT: when a player vehicle is within MAX_GAP behind another in the same lane of a two-way segment, -- rebuild that segment (remove + re-add an identical BaseEdge) and log where both vehicles end up. -- ponytail: module-local timer, not GameScriptState; fine for a probe (state resets between calls anyway). local TAG = "[overtake_probe] " local INTERVAL = 2.0 local MAX_GAP = 40.0 local MAX_REBUILDS = 0 -- off while testing the GUI-thread position flip (replaceSegment works, see vehicle-probe.md) local MAX_SETLINES = 0 -- set-line disabled: lanes ahead get re-planned by the engine anyway, no forced lane change local MAX_JUMPS = 0 -- dead: sendCommand(makeComponentExchangeCmd(...)) fails in engine state even without a callback local JUMP_AHEAD = 15.0 local WATCH_TICKS = 150 -- ~30 s of game time at dt=0.2: log watched vehicles every update after a rebuild local elapsed = 0.0 local ticks = 0 local function p(s : string) print(TAG .. s) end local function edgeAt(mp : Engine.Component.MovePath) : EdgeId, boolean -- path.edges is 1-based, elements are EdgeIdDir userdata (tealdef says {EdgeId, boolean}) local e = (mp.path.edges as {any})[mp.dyn.pathPos.edgeIndex + 1] as UtilPathfinding.EdgeIdDir if e == nil then return nil, nil end return e.edgeId, e.dir end local function turnaround(edge : EdgeId) : integer local tn = api.engine.getComponent(edge.entity, api.type.ComponentType.TRANSPORT_NETWORK) as Engine.Component.TransportNetwork if tn == nil or tn.turnaroundEdges == nil then return nil end return tn.turnaroundEdges[edge.index + 1] end local record Car v : integer pos : number pos01 : number speed : number dir : boolean end local record ProbeState rebuilds : integer setlines : integer jumps : integer watch : {integer} -- vehicles involved in the last rebuild, flagged in later dumps watchLeft : integer -- updates left for per-tick logging of the watched vehicles end -- No edge objects (stops, signals), -- because re-adding them is out of scope for this probe. local function rebuildable(seg : integer) : boolean, string local be = api.engine.getComponent(seg, api.type.ComponentType.BASE_EDGE) as Engine.Component.BaseEdge if be == nil then return false, "no BaseEdge" end if #be.objects > 0 then return false, "has edge objects" end -- count BaseEdge lanes, not TransportNetwork edges (town roads split each lane into several edges). -- Sidewalks carry modes 0/1 only; driving lanes carry 2+ (observed: [2,3,4]). local driving = 0 for _, lc in ipairs(be.laneConfigs) do for k, on in pairs(lc.transportModes) do if on and (tonumber(tostring(k)) or 0) >= 2 then driving = driving + 1 break end end end -- one lane per direction: 3 rebuilds there changed nothing (same gap, same lane), so trace multilane only if driving < 4 then return false, "driving lanes=" .. driving .. " (single-lane rebuilds showed no effect)" end return true, string.format("drivingLanes=%d template=%s length=%.1f", driving, tostring(be.roadTemplate), be.distance) end local function rebuild(seg : integer) -- same call the official campaign (mission 03 init.script.tl) uses to refresh a road segment in place local proposal = api.engine.util.proposal.replaceSegment(seg) api.cmd.sendCommand(api.cmd.makeWorldBuildProposalCmd(proposal, nil, true, true)) end -- lanes of the next few path edges, to see whether a re-path changes the lane choice local function lanesAhead(mp : Engine.Component.MovePath) : string local out : {string} = {} local edges = mp.path.edges as {any} for k = mp.dyn.pathPos.edgeIndex + 1, math.min(#edges, mp.dyn.pathPos.edgeIndex + 4) do local e = edges[k] as UtilPathfinding.EdgeIdDir table.insert(out, e.edgeId.entity .. ":" .. e.edgeId.index) end return table.concat(out, " ") end local function setLine(v : integer) local tv = api.engine.getComponent(v, api.type.ComponentType.TRANSPORT_VEHICLE) as Engine.Component.TransportVehicle p(string.format("SETLINE_TEST send veh=%d line=%s stopIndex=%s", v, tostring(tv.line), tostring(tv.stopIndex))) api.cmd.sendCommand(api.cmd.makeVehicleSetLineCmd(v, tv.line, tv.stopIndex)) end -- returns false when the target doesn't fit on the current edge (we don't jump across edges) local function jumpPast(follower : Car, leader : Car) : boolean, string if leader.pos01 <= 0 then return false, "leader pos01=0" end local len = leader.pos / leader.pos01 local target = leader.pos + JUMP_AHEAD if target >= len then return false, string.format("target %.1f beyond edge length %.1f", target, len) end local mp = api.engine.getComponent(follower.v, api.type.ComponentType.MOVE_PATH) as Engine.Component.MovePath mp.dyn.pathPos.pos = target mp.dyn.pathPos.pos01 = target / len api.cmd.sendCommand(api.cmd.debug.makeComponentExchangeCmd(follower.v, mp as Engine.Component)) return true, string.format("pos %.1f -> %.1f (leader %.1f, edge length %.1f)", follower.pos, target, leader.pos, len) end local function dump(dt : number, state : GameScriptState) local st = state:get() st.rebuilds = st.rebuilds or 0 st.setlines = st.setlines or 0 st.jumps = st.jumps or 0 st.watch = st.watch or {} local watched : {integer : boolean} = {} for _, w in ipairs(st.watch) do watched[w] = true end local player = api.engine.util.getPlayer() local vehicles = api.engine.getEntitiesWithComponent(api.type.ComponentType.TRANSPORT_VEHICLE, { requireOwnedByPlayer = player }) p(string.format("tick=%d dt=%s playerVehicles=%d rebuilds=%d setlines=%d jumps=%d", ticks, tostring(dt), #vehicles, st.rebuilds, st.setlines, st.jumps)) local byLane : {string : {Car}} = {} local laneSeg : {string : integer} = {} for _, v in ipairs(vehicles) do local lv = api.engine.getComponent(v, api.type.ComponentType.LAND_VEHICLE) as Engine.Component.LandVehicle local mp = api.engine.getComponent(v, api.type.ComponentType.MOVE_PATH) as Engine.Component.MovePath local tv = api.engine.getComponent(v, api.type.ComponentType.TRANSPORT_VEHICLE) as Engine.Component.TransportVehicle if lv ~= nil and not lv.isTram and mp ~= nil then local edge, dir = edgeAt(mp) local seg = edge and edge.entity or -1 p(string.format( "%sveh=%d seg=%d lane=%s dir=%s turnaround=%s edgeIndex=%d/%d pos=%.1f speed=%.2f state=%s tvState=%s line=%s standing=%.1f", watched[v] and "WATCH " or "", v, seg, tostring(edge and edge.index), tostring(dir), tostring(edge and turnaround(edge)), mp.dyn.pathPos.edgeIndex, #(mp.path.edges as {any}), mp.dyn.pathPos.pos, mp.dyn.speed, tostring(mp.state), tostring(tv and tv.state), tostring(tv and tv.line), mp.dyn.timeStanding)) if watched[v] then p("WATCH lanesAhead veh=" .. v .. " " .. lanesAhead(mp)) end if edge ~= nil then local key = seg .. ":" .. edge.index byLane[key] = byLane[key] or {} laneSeg[key] = seg table.insert(byLane[key], { v = v, pos = mp.dyn.pathPos.pos, pos01 = mp.dyn.pathPos.pos01, speed = mp.dyn.speed, dir = dir }) end end end if st.rebuilds >= MAX_REBUILDS and st.setlines >= MAX_SETLINES and st.jumps >= MAX_JUMPS then return end for key, cars in pairs(byLane) do for i = 1, #cars do for j = 1, #cars do local a, b = cars[i], cars[j] -- ponytail: assumes pos grows in driving direction when dir=true (held so far in the logs) local ahead = (a.dir and b.pos > a.pos) or (not a.dir and b.pos < a.pos) -- leader must be moving, otherwise it's a queue at a stop or junction, not a slow vehicle if i ~= j and ahead and math.abs(b.pos - a.pos) < MAX_GAP and b.speed > 1.0 then local seg = laneSeg[key] if st.jumps < MAX_JUMPS then local ok, why = jumpPast(a, b) p(string.format("JUMP_TEST seg=%d follower=%d leader=%d sent=%s %s", seg, a.v, b.v, tostring(ok), why)) if ok then st.jumps = st.jumps + 1 st.watch = { a.v, b.v } state:set(st) return -- one experiment per dump end end if st.setlines < MAX_SETLINES then local amp = api.engine.getComponent(a.v, api.type.ComponentType.MOVE_PATH) as Engine.Component.MovePath p(string.format("SETLINE_TEST candidate seg=%d follower=%d (v=%.2f) leader=%d (v=%.2f) lanesAhead before: %s", seg, a.v, a.speed, b.v, b.speed, lanesAhead(amp))) setLine(a.v) st.setlines = st.setlines + 1 st.watch = { a.v, b.v } state:set(st) return -- one experiment per dump end if st.rebuilds >= MAX_REBUILDS then return end local ok, why = rebuildable(seg) p(string.format("REBUILD_TEST candidate seg=%d follower=%d (pos=%.1f v=%.2f) leader=%d (pos=%.1f v=%.2f) ok=%s %s", seg, a.v, a.pos, a.speed, b.v, b.pos, b.speed, tostring(ok), why)) if ok then rebuild(seg) st.rebuilds = st.rebuilds + 1 st.watch = { a.v, b.v } st.watchLeft = WATCH_TICKS state:set(st) p(string.format("REBUILD_TEST sent #%d seg=%d", st.rebuilds, seg)) return -- one rebuild per dump end end end end end end -- per-update trace of the watched vehicles, to catch the moment a lane changes local function trace(state : GameScriptState) local st = state:get() if (st.watchLeft or 0) <= 0 or st.watch == nil then return end for _, v in ipairs(st.watch) do local mp = api.engine.getComponent(v, api.type.ComponentType.MOVE_PATH) as Engine.Component.MovePath if mp ~= nil then local edge, _dir = edgeAt(mp) p(string.format("TRACE t=%d veh=%d seg=%s lane=%s pos=%.1f v=%.2f ahead=%s", WATCH_TICKS - st.watchLeft, v, tostring(edge and edge.entity), tostring(edge and edge.index), mp.dyn.pathPos.pos, mp.dyn.speed, lanesAhead(mp))) end end st.watchLeft = st.watchLeft - 1 state:set(st) end -- EXPERIMENT (GUI thread, where sendCommand callbacks work): flip the positions of a follower and its leader by -- exchanging their MovePath components (route + position) with api.cmd.debug.makeComponentExchangeCmd. local MAX_FLIPS = 2 local FLIP_SCAN_FRAMES = 60 -- scan for a pair about once per second local FLIP_TRACE_FRAMES = 600 -- then log both vehicles every 10 frames for ~10 s local record GuiState frames : integer flips : integer traceLeft : integer a : integer b : integer end local function movePathOf(v : integer) : Engine.Component.MovePath return api.engine.getComponent(v, api.type.ComponentType.MOVE_PATH) as Engine.Component.MovePath end local function flipLine(v : integer) : string local mp = movePathOf(v) if mp == nil then return "veh=" .. v .. " no MovePath" end local edge, _dir = edgeAt(mp) return string.format("veh=%d seg=%s lane=%s pos=%.1f v=%.2f", v, tostring(edge and edge.entity), tostring(edge and edge.index), mp.dyn.pathPos.pos, mp.dyn.speed) end local function sendFlip(gs : GuiState, follower : integer, leader : integer) local mpF, mpL = movePathOf(follower), movePathOf(leader) p("FLIP_TEST before " .. flipLine(follower) .. " | " .. flipLine(leader)) local function done(label : string) : function(any, boolean) return function(res : any, success : boolean) p(string.format("FLIP_TEST result %s success=%s res=%s", label, tostring(success), tostring(res))) end end api.cmd.sendCommand(api.cmd.debug.makeComponentExchangeCmd(follower, mpL as Engine.Component), done("follower<-leader")) api.cmd.sendCommand(api.cmd.debug.makeComponentExchangeCmd(leader, mpF as Engine.Component), done("leader<-follower")) gs.flips = gs.flips + 1 gs.traceLeft = FLIP_TRACE_FRAMES gs.a, gs.b = follower, leader end -- same pair rule as the engine-side probe: same segment + lane, follower < MAX_GAP behind a moving leader local function findPair() : integer, integer local player = api.engine.util.getPlayer() local byLane : {string : {Car}} = {} for _, v in ipairs(api.engine.getEntitiesWithComponent(api.type.ComponentType.TRANSPORT_VEHICLE, { requireOwnedByPlayer = player })) do local lv = api.engine.getComponent(v, api.type.ComponentType.LAND_VEHICLE) as Engine.Component.LandVehicle local mp = movePathOf(v) if lv ~= nil and not lv.isTram and mp ~= nil then local edge, dir = edgeAt(mp) if edge ~= nil then local key = edge.entity .. ":" .. edge.index byLane[key] = byLane[key] or {} table.insert(byLane[key], { v = v, pos = mp.dyn.pathPos.pos, pos01 = mp.dyn.pathPos.pos01, speed = mp.dyn.speed, dir = dir }) end end end for _, cars in pairs(byLane) do for i = 1, #cars do for j = 1, #cars do local a, b = cars[i], cars[j] local ahead = (a.dir and b.pos > a.pos) or (not a.dir and b.pos < a.pos) if i ~= j and ahead and math.abs(b.pos - a.pos) < MAX_GAP and b.speed > 1.0 then return a.v, b.v end end end end return nil, nil end local function guiStep(gsState : GameScriptState) local gs = gsState:get() gs.frames = (gs.frames or 0) + 1 gs.flips = gs.flips or 0 gs.traceLeft = gs.traceLeft or 0 if gs.traceLeft > 0 then if gs.traceLeft % 10 == 0 then p(string.format("FLIP_TRACE f=%d %s | %s", FLIP_TRACE_FRAMES - gs.traceLeft, flipLine(gs.a), flipLine(gs.b))) end gs.traceLeft = gs.traceLeft - 1 elseif gs.flips < MAX_FLIPS and gs.frames % FLIP_SCAN_FRAMES == 0 then local follower, leader = findPair() if follower ~= nil then sendFlip(gs, follower, leader) end end gsState:set(gs) end local ret : GameScriptWithGui = { update = function(_userParams : table, state : GameScriptState, dt : number) : nil ticks = ticks + 1 elapsed = elapsed + dt if dt > 0 then xpcall(trace, function(err : any) p("ERROR trace " .. tostring(err)) end, state) end if ticks == 1 then p("update running, first dt=" .. tostring(dt)) end if elapsed < INTERVAL then return end elapsed = 0.0 xpcall(dump, function(err : any) p("ERROR " .. tostring(err)) end, dt, state) end, guiUpdate = function(_userParams : table, _state : GameScriptStateReadOnly, guiState : GameScriptState) xpcall(guiStep, function(err : any) p("ERROR gui " .. tostring(err)) end, guiState) end, } return ret