# Maskinpulje etape 1 (v1.48.0.0) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Markarbejdet kan sende en ledig solo-traktor, der henter et frakoblet puljeredskab (plov, kultivator, såmaskine …), udfører arbejdet med Courseplay, sætter redskabet af på dets AD-parkering og kører hjem. **Architecture:** Nyt modul `adPool.lua` (ren planner + tynd spil-adapter) finder puljeredskaber (frakoblet + AD-parkering "X" + markør "Connect X") og puljetraktorer og parrer dem. `ADFieldWork.dispatch` bruger puljen som fallback, når ingen færdig kombination er ledig; en ny fase `fetching` kører `ADHitch.fetch` og fortsætter så i den eksisterende `driving`-fase. Når en opgave slutter, kører `ADHitch.returnHome` redskabet til dets parkering (afkobling) og traktoren hjem. Marker, der venter, betjenes længst-ventende først. **Tech Stack:** Lua 5.1 (FS25-scripting), luajit til tests, AutoDrive 3.0.1.2 og Courseplay 8.1.0.3 API'er. **Spec:** `docs/superpowers/specs/2026-09-28-maskinpulje-design.md` ## Global Constraints - Kun etape 1 (markarbejde). Tømmevogne ved høst (`decision.helper == true`) og marksilo-flowet bruger IKKE puljen i denne version. - Puljeredskab = frakoblet + AD-parkering "X" (`advd.parkDestination >= 1`) + markør "Connect X" (store/små bogstaver ligegyldigt). - Puljetraktor = motoriseret, intet koblet bag på, ikke i et flow (`ADRunsController.getRunForVehicle == nil`), ikke optaget, ikke styret af spilleren, egen AD-parkering, flueben i markflåden (`ADFieldFleet.isEnabled`). - Færdige kombinationer foretrækkes altid; puljen kun når `pick`/`pickMany` intet giver. - Redskab nærmest marken, derefter nærmeste traktor til redskabet med nok hk; manglende hk-data = tilladt. - Karantæne for et redskab, der ikke kunne kobles på: 10 min (`ADPool.QUARANTINE_MS = 600000`). - En traktor køres kun hjem, når et af mod'ens egne jobs slutter — aldrig af sig selv. - Flows (runs) rører aldrig puljen. - Kommentarer og logtekster på dansk, som resten af koden. Ingen Co-Authored-By i commits. Push kun til remote `gitea`. - Tests køres fra repo-roden: `luajit tests/test_.lua` — output slutter med `ALLE TESTS OK`. - Build: `cd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip .`; deploy mens spillet kører: `cp` til `~/FS25-data/mods/.FS25_ADSmartPickup.new` og `mv` på plads. ## Review Focus - **To marker vil have samme plov samtidig** → kun den første får den; den anden venter (reservation) — testet i Task 1 (`pairFor` springer reserverede over). - **Redskab uden hk-data eller traktor uden motor-data** → tillades (intet går i stå) — testet i Task 1 (`powerOk` nil-tilfælde, `numberOf` af tabel-specs). - **Traktor på hjemtur bliver valgt til et nyt job** → må ikke ske; den er optaget til den er hjemme — testet i Task 3 (`ADPool.isReturning`) og brugt i `getRigs`/`poolTractors`. - **Spilleren kobler selv et puljeredskab på og kører** → traktoren køres ikke hjem af mod'en — dækket af, at `returnHome` kun kaldes fra `finishAssignment`/fejlveje (Task 5); ingen tick-regel. - **Kobling fejler (trækøje står skævt)** → traktor hjem, redskab i karantæne, marken prøver igen — testet i Task 1 (karantæne i `pairFor`) og Task 4 (fejlvej). --- ### Task 1: `adPool.lua` — ren planlægning **Files:** - Create: `FS25_ADSmartPickup/adPool.lua` - Test: `tests/test_adPool.lua` **Interfaces:** - Consumes: `ADFieldFleet.roleFromSpecs(specs)` (adFieldFleet.lua:27), `ADFieldModel.checkWidth(role, width, requiredWidth)` (bruges via `accept`), `ADHitch.parseMarker(name)` (adHitch.lua). - Produces: - `ADPool.roleOf(specs) -> role|nil` (specs = `ADCpBridge.getSpecs`-tabel; `unloader` når `specs.spec_trailer and specs.spec_dischargeable` og ingen anden rolle). - `ADPool.numberOf(value) -> number|nil` - `ADPool.powerOk(tractorHp, neededHp) -> bool` - `ADPool.connectFor(parkName, markers) -> marker|nil` (markers = `{{name, id}}`) - `ADPool.pairFor(role, fieldX, fieldZ, implements, tractors, blocked, accept) -> pair | nil, why, args` - implement = `{id, name, role, roles?, x, z, width, neededHp, connectId, parkId}` - tractor = `{id, name, x, z, hp, parkId}` - blocked = `{[id] = true}` (reserverede + karantæne, både redskab- og traktor-id) - pair = `{implement = implement, tractor = tractor}` - why ∈ `noPoolImplement` / `noPoolTractor` / `tooWeak` (args = `{neededHp}`) - `ADPool.queueOrder(infos, waitingSince) -> infos (ny tabel)` - `ADPool.reserve(owner, ids)`, `ADPool.release(owner)`, `ADPool.quarantine(id, nowMs)`, `ADPool.blocked(nowMs) -> {[id]=true}` - [ ] **Step 1: Write the failing test** Create `tests/test_adPool.lua`: ```lua -- Kør: luajit tests/test_adPool.lua (fra repo-roden) dofile("FS25_ADSmartPickup/adFieldFleet.lua") dofile("FS25_ADSmartPickup/adHitch.lua") dofile("FS25_ADSmartPickup/adPool.lua") local P = ADPool local failures = 0 local function check(name, actual, expected) if actual == expected then print("OK " .. name) else failures = failures + 1 print(string.format("FAIL %s: forventede %s, fik %s", name, tostring(expected), tostring(actual))) end end -- rolle ud fra redskabets egne specs check("rolle: plov", P.roleOf({spec_plow = true}), "plow") check("rolle: kultivator", P.roleOf({spec_cultivator = true}), "cultivator") check("rolle: vogn med tip", P.roleOf({spec_trailer = true, spec_dischargeable = true}), "unloader") check("rolle: vogn der også er læssevogn er læssevogn", P.roleOf({spec_trailer = true, spec_dischargeable = true, spec_forageWagon = true}), "collector") check("rolle: intet", P.roleOf({}), nil) -- hk fra butiksdata: tal eller tabel (konfigurationer) check("tal", P.numberOf(180), 180) check("tabel: største", P.numberOf({base = 120, config = 150}), 150) check("tom tabel", P.numberOf({}), nil) check("nil", P.numberOf(nil), nil) check("hk: nok", P.powerOk(200, 180), true) check("hk: for lidt", P.powerOk(150, 180), false) check("hk: redskab uden data", P.powerOk(150, nil), true) check("hk: traktor uden data", P.powerOk(nil, 180), true) -- Connect-markør til en parkering local markers = {{name = "Start", id = 1}, {name = "Bredal", id = 4}, {name = "Connect Bredal", id = 9}, {name = "connect plov 1", id = 12}} check("connect: Bredal", P.connectFor("Bredal", markers).id, 9) check("connect: små bogstaver", P.connectFor("Plov 1", markers).id, 12) check("connect: ingen", P.connectFor("Start", markers), nil) check("connect: uden parkering", P.connectFor(nil, markers), nil) -- parring: redskab nærmest marken, så traktor nærmest redskabet local implements = { {id = "plovFar", name = "Plov far", role = "plow", x = 500, z = 0, connectId = 20, parkId = 21}, {id = "plovNear", name = "Plov nær", role = "plow", x = 100, z = 0, neededHp = 180, connectId = 30, parkId = 31}, {id = "kult", name = "Kultivator", role = "cultivator", x = 0, z = 0, connectId = 40, parkId = 41}, } local tractors = { {id = "small", name = "Lille", x = 110, z = 0, hp = 120, parkId = 2}, {id = "big", name = "Stor", x = 300, z = 0, hp = 250, parkId = 3}, {id = "mid", name = "Mellem", x = 150, z = 0, hp = 190, parkId = 5}, } local pair = P.pairFor("plow", 0, 0, implements, tractors, {}) check("par: nærmeste plov til marken", pair.implement.id, "plovNear") check("par: nærmeste stærke traktor til ploven", pair.tractor.id, "mid") local blockedPair = P.pairFor("plow", 0, 0, implements, tractors, {plovNear = true}) check("par: reserveret plov springes over", blockedPair.implement.id, "plovFar") check("par: plov uden hk-krav tager nærmeste traktor", blockedPair.tractor.id, "big") local none, why = P.pairFor("seeder", 0, 0, implements, tractors, {}) check("par: ingen såmaskine", none, nil); check("par: grund", why, "noPoolImplement") local weak, weakWhy, weakArgs = P.pairFor("plow", 0, 0, {implements[2]}, {tractors[1]}, {}) check("par: for svag", weak, nil); check("par: grund tooWeak", weakWhy, "tooWeak"); check("par: hk i args", weakArgs[1], 180) local noTractor, noTractorWhy = P.pairFor("plow", 0, 0, implements, {}, {}) check("par: ingen traktor", noTractor, nil); check("par: grund noPoolTractor", noTractorWhy, "noPoolTractor") local rejected, rejectedWhy = P.pairFor("plow", 0, 0, implements, tractors, {}, function() return false end) check("par: accept afviser alle", rejected, nil); check("par: grund", rejectedWhy, "noPoolImplement") local multi = P.pairFor("fertilizer", 0, 0, {{id = "spreder", role = "fertilizer", roles = {fertilizer = true, liming = true}, x = 0, z = 0}}, tractors, {}) check("par: redskab med flere roller", multi.implement.id, "spreder") -- reservationer og karantæne P.reserve("F12", {"plovNear", "mid"}) check("reserveret: redskab", P.blocked(0).plovNear, true) check("reserveret: traktor", P.blocked(0).mid, true) P.release("F12") check("frigivet", P.blocked(0).plovNear, nil) P.quarantine("plovNear", 1000) check("karantæne: aktiv", P.blocked(1000 + P.QUARANTINE_MS - 1).plovNear, true) check("karantæne: udløbet", P.blocked(1000 + P.QUARANTINE_MS).plovNear, nil) -- kø: længst ventende først, lige -> lavest mark-id, ikke-ventende bagerst i oprindelig rækkefølge local infos = {{id = 5}, {id = 3}, {id = 9}, {id = 1}} local ordered = P.queueOrder(infos, {[9] = 100, [3] = 50, [1] = 50}) check("kø: 1.", ordered[1].id, 1); check("kø: 2.", ordered[2].id, 3); check("kø: 3.", ordered[3].id, 9); check("kø: 4.", ordered[4].id, 5) check("kø: rører ikke input", infos[1].id, 5) print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) os.exit(failures == 0 and 0 or 1) ``` - [ ] **Step 2: Run test to verify it fails** Run: `luajit tests/test_adPool.lua` Expected: fejl `cannot open FS25_ADSmartPickup/adPool.lua` - [ ] **Step 3: Write minimal implementation** Create `FS25_ADSmartPickup/adPool.lua`: ```lua -- AD Pool -- Maskinpulje: plove, kultivatorer, såmaskiner … holder frakoblet på deres AD-parkering "X", og en ledig -- solo-traktor henter dem via markøren "Connect X", når markarbejdet mangler en færdig kombination. -- Ren logik øverst (testes med luajit), spil-adapter nederst. ADPool = {} ADPool.LOG_PREFIX = "ADSmartPickup: pulje: " ADPool.QUARANTINE_MS = 600000 -- redskab der ikke kunne kobles på, springes over så længe -- ---- ren logik ---- -- Rolle for et frakoblet redskab ud fra dets egne specs (samme prioritet som markflåden); en ren vogn med -- tip/snegl er tømmevogn. function ADPool.roleOf(specs) local role = ADFieldFleet.roleFromSpecs(specs) if role ~= nil and role ~= "unloader" then return role end if specs.spec_trailer and specs.spec_dischargeable then return "unloader" end return role end -- Butiksdata er enten et tal eller en tabel pr. konfiguration: største tal, ellers nil. function ADPool.numberOf(value) if type(value) == "number" then return value end if type(value) ~= "table" then return nil end local best = nil for _, entry in pairs(value) do if type(entry) == "number" and (best == nil or entry > best) then best = entry end end return best end -- Mangler et af tallene, tillades det (intet må gå i stå på manglende data). function ADPool.powerOk(tractorHp, neededHp) if tractorHp == nil or neededHp == nil or tractorHp <= 0 or neededHp <= 0 then return true end return tractorHp >= neededHp end -- Markøren "Connect " for en parkering (navneregel). function ADPool.connectFor(parkName, markers) if type(parkName) ~= "string" then return nil end local wanted = parkName:lower() for _, marker in pairs(markers or {}) do local action, target = ADHitch.parseMarker(marker.name) if action == "attach" and target ~= nil and target:lower() == wanted then return marker end end return nil end local function hasRole(item, role) return item.role == role or (item.roles ~= nil and item.roles[role] == true) end local function distanceSq(ax, az, bx, bz) return (ax - bx) ^ 2 + (az - bz) ^ 2 end local function nearest(items, x, z, keep) local best, bestDistance = nil, math.huge for _, item in ipairs(items) do if keep(item) then local distance = distanceSq(item.x, item.z, x, z) if distance < bestDistance then best, bestDistance = item, distance end end end return best end -- Redskab med rollen nærmest marken, derefter nærmeste ledige traktor med nok hk til redskabet. -- blocked = {[id] = true} (reserverede og redskaber i karantæne). accept (valgfri): implement -> bool. function ADPool.pairFor(role, fieldX, fieldZ, implements, tractors, blocked, accept) blocked = blocked or {} local free = {} for _, tractor in ipairs(tractors or {}) do if not blocked[tractor.id] then table.insert(free, tractor) end end local candidates = {} for _, implement in ipairs(implements or {}) do if hasRole(implement, role) and not blocked[implement.id] and (accept == nil or accept(implement)) then table.insert(candidates, implement) end end if #candidates == 0 then return nil, "noPoolImplement" end if #free == 0 then return nil, "noPoolTractor" end table.sort(candidates, function(a, b) return distanceSq(a.x, a.z, fieldX, fieldZ) < distanceSq(b.x, b.z, fieldX, fieldZ) end) local weakest = nil for _, implement in ipairs(candidates) do local tractor = nearest(free, implement.x, implement.z, function(t) return ADPool.powerOk(t.hp, implement.neededHp) end) if tractor ~= nil then return {implement = implement, tractor = tractor} end if weakest == nil or (implement.neededHp or 0) < weakest then weakest = implement.neededHp end end return nil, "tooWeak", {weakest or 0} end -- Ventende marker først (længst ventende først, lige -> lavest id); resten bagefter i oprindelig rækkefølge. function ADPool.queueOrder(infos, waitingSince) local waiting, rest = {}, {} for _, info in ipairs(infos or {}) do if waitingSince ~= nil and waitingSince[info.id] ~= nil then table.insert(waiting, info) else table.insert(rest, info) end end table.sort(waiting, function(a, b) local wa, wb = waitingSince[a.id], waitingSince[b.id] if wa ~= wb then return wa < wb end return a.id < b.id end) for _, info in ipairs(rest) do table.insert(waiting, info) end return waiting end -- Reservationer (i hukommelsen): owner -> {id, ...}. Karantæne: id -> udløbstid. local reservations, quarantined = {}, {} function ADPool.reserve(owner, ids) reservations[owner] = ids end function ADPool.release(owner) reservations[owner] = nil end function ADPool.quarantine(id, nowMs) quarantined[id] = nowMs + ADPool.QUARANTINE_MS end function ADPool.blocked(nowMs) local result = {} for _, ids in pairs(reservations) do for _, id in ipairs(ids) do result[id] = true end end for id, untilMs in pairs(quarantined) do if nowMs < untilMs then result[id] = true end end return result end ``` - [ ] **Step 4: Run test to verify it passes** Run: `luajit tests/test_adPool.lua` Expected: sidste linje `ALLE TESTS OK` - [ ] **Step 5: Commit** ```bash git add FS25_ADSmartPickup/adPool.lua tests/test_adPool.lua git commit -m "feat(pulje): ren planlægning — rolle, hk-tjek, Connect-navneregel, parring, kø, reservation/karantæne" ``` --- ### Task 2: `adPool.lua` — spil-adapter (indsamling af puljen) **Files:** - Modify: `FS25_ADSmartPickup/adPool.lua` (tilføj nederst) - Modify: `FS25_ADSmartPickup/modDesc.xml` (registrér `adPool.lua` efter `adHitch.lua`) **Interfaces:** - Consumes: `ADCpBridge.getSpecs(vehicle)` (adCpBridge.lua:153), `ADCpBridge.getWorkWidth(vehicle)` (:363), `ADRunsController.getFarmVehicles(adEnv)`, `ADRunsController.getRunForVehicle(vehicle)`, `ADRunsController.getVehicleId(vehicle)`, `ADRuns.vehicleLabel(vehicle)`, `ADFieldFleet.isEnabled(mode, fleet, id)`, `ADFieldJobs.state`, `ADHitch.rearImplementsOf(vehicle)` (Task 3), `ADPool.isReturning(vehicle)` (Task 3), `adEnv.ADGraphManager:getMapMarkers()`, `:getMapMarkerById(index)`, `g_currentMission.vehicleSystem.vehicles`. - Produces: `ADPool.collect(adEnv, isBusy) -> implements, tractors` (formater som Task 1; implement har også `.vehicle`, tractor har `.vehicle`); `isBusy(vehicle) -> bool` gives af kalderen (markarbejdets optaget-regel). - [ ] **Step 1: Tilføj adapteren** Tilføj nederst i `FS25_ADSmartPickup/adPool.lua`: ```lua -- ---- spil ---- local function parkMarker(adEnv, vehicle) local index = vehicle.advd ~= nil and vehicle.advd.parkDestination or nil if type(index) ~= "number" or index < 1 then return nil end return adEnv.ADGraphManager:getMapMarkerById(index) end local function positionOf(vehicle) local x, _, z = getWorldTranslation(vehicle.rootNode) return x, z end -- Butiksdata: redskabets krævede effekt (hk). specs indlæses først, når butikken har vist varen. local function neededHpOf(vehicle) if g_storeManager == nil or vehicle.configFileName == nil then return nil end local item = g_storeManager:getItemByXMLFilename(vehicle.configFileName) if item == nil then return nil end if item.specs == nil and StoreItemUtil ~= nil and StoreItemUtil.loadSpecsFromXML ~= nil then pcall(StoreItemUtil.loadSpecsFromXML, item) end return item.specs ~= nil and ADPool.numberOf(item.specs.neededPower) or nil end -- Traktorens effekt i hk: motorens topeffekt (kW) omregnet; ellers butiksdata. local function tractorHpOf(vehicle) local motor = vehicle.spec_motorized ~= nil and vehicle.spec_motorized.motor or nil local kw = motor ~= nil and motor.peakMotorPower or nil if type(kw) == "number" and kw > 0 then return kw * 1.35962 end if g_storeManager == nil or vehicle.configFileName == nil then return nil end local item = g_storeManager:getItemByXMLFilename(vehicle.configFileName) return item ~= nil and item.specs ~= nil and ADPool.numberOf(item.specs.power) or nil end local function isAttached(vehicle) return vehicle.getAttacherVehicle ~= nil and vehicle:getAttacherVehicle() ~= nil end -- Puljeredskaber (frakoblet, parkering "X", markør "Connect X") og puljetraktorer. -- isBusy(vehicle): markarbejdets optaget-regel (opgave, AD/CP kører, lånt af marksilo, på hjemtur). function ADPool.collect(adEnv, isBusy) local markers = adEnv.ADGraphManager:getMapMarkers() local farmId = g_currentMission:getFarmId() local implements, tractors = {}, {} -- spillets egen liste: frakoblede redskaber er ikke nødvendigvis i AutoDrive's køretøjsliste for _, vehicle in ipairs(g_currentMission.vehicleSystem.vehicles) do local owned = vehicle.getOwnerFarmId ~= nil and vehicle:getOwnerFarmId() == farmId if owned and vehicle.spec_motorized == nil and vehicle.spec_attachable ~= nil and not isAttached(vehicle) then local park = parkMarker(adEnv, vehicle) local connect = park ~= nil and ADPool.connectFor(park.name, markers) or nil local specs = connect ~= nil and ADCpBridge.getSpecs(vehicle) or nil local role = specs ~= nil and ADPool.roleOf(specs) or nil if role ~= nil then local x, z = positionOf(vehicle) table.insert(implements, { id = ADRunsController.getVehicleId(vehicle), vehicle = vehicle, name = ADRuns.vehicleLabel(vehicle), role = role, roles = specs.sprayRoles, x = x, z = z, width = ADCpBridge.getWorkWidth(vehicle), neededHp = neededHpOf(vehicle), connectId = connect.id, parkId = park.id, }) end end end local state = ADFieldJobs.state for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do local id = ADRunsController.getVehicleId(vehicle) local park = parkMarker(adEnv, vehicle) local controlled = vehicle.getIsControlled ~= nil and vehicle:getIsControlled() if id ~= nil and park ~= nil and vehicle.spec_attacherJoints ~= nil and #ADHitch.rearImplementsOf(vehicle) == 0 and ADRunsController.getRunForVehicle(vehicle) == nil and not controlled and not isBusy(vehicle) and not ADPool.isReturning(vehicle) and ADFieldFleet.isEnabled(state.fleetMode, state.fleet, id) then local x, z = positionOf(vehicle) table.insert(tractors, {id = id, vehicle = vehicle, name = ADRuns.vehicleLabel(vehicle), x = x, z = z, hp = tractorHpOf(vehicle), parkId = park.id}) end end return implements, tractors end ``` - [ ] **Step 2: Registrér modulet** I `FS25_ADSmartPickup/modDesc.xml` efter linjen ``: ```xml ``` - [ ] **Step 3: Syntakstjek og tests** Run: `luajit -bl FS25_ADSmartPickup/adPool.lua >/dev/null && echo syntaks-ok && luajit tests/test_adPool.lua | tail -1` Expected: `syntaks-ok` og `ALLE TESTS OK` - [ ] **Step 4: Commit** ```bash git add FS25_ADSmartPickup/adPool.lua FS25_ADSmartPickup/modDesc.xml git commit -m "feat(pulje): spil-adapter — frakoblede redskaber med parkering+Connect og ledige solo-traktorer med hk" ``` --- ### Task 3: `adHitch.lua` — hjemtur og "på hjemtur"-status **Files:** - Modify: `FS25_ADSmartPickup/adHitch.lua` - Modify: `FS25_ADSmartPickup/adPool.lua` (isReturning-register) - Test: `tests/test_adHitch.lua`, `tests/test_adPool.lua` **Interfaces:** - Consumes: `ADHitch.route(adEnv, vehicle, markerIds, onDone)` (findes), `attachedImplements`/`rearImplements` (findes i adHitch.lua). - Produces: - `ADHitch.homeRoute(implementParkIds, tractorParkId) -> {id, ...}` (ren) - `ADHitch.rearImplementsOf(vehicle) -> {{object, z, name}}` (offentlig udgave af den lokale) - `ADHitch.returnHome(adEnv, tractor, onDone) -> bool` (false = intet puljeredskab bag / ingen parkering) - `ADPool.setReturning(vehicle, isOn)`, `ADPool.isReturning(vehicle) -> bool` - [ ] **Step 1: Write the failing tests** Tilføj i `tests/test_adHitch.lua` før `print(failures == 0`: ```lua -- hjemtur: hvert puljeredskabs parkering (afkobles der), så traktorens egen parkering local home = H.homeRoute({4}, 2) check("hjem: to trin", #home, 2); check("hjem: redskab først", home[1], 4); check("hjem: traktor sidst", home[2], 2) check("hjem: uden redskab kun traktor", #H.homeRoute({}, 2), 1) check("hjem: samme parkering to gange tæller én", #H.homeRoute({4, 4}, 2), 2) check("hjem: uden traktorparkering", #H.homeRoute({4}, nil), 1) ``` Tilføj i `tests/test_adPool.lua` før `print(failures == 0`: ```lua -- på hjemtur: optaget indtil hjemme local tractorObject = {} check("hjemtur: ikke fra start", P.isReturning(tractorObject), false) P.setReturning(tractorObject, true) check("hjemtur: markeret", P.isReturning(tractorObject), true) P.setReturning(tractorObject, false) check("hjemtur: hjemme", P.isReturning(tractorObject), false) ``` - [ ] **Step 2: Run tests to verify they fail** Run: `luajit tests/test_adHitch.lua | tail -3; luajit tests/test_adPool.lua | tail -3` Expected: fejl `attempt to call field 'homeRoute' (a nil value)` og `attempt to call field 'isReturning'` - [ ] **Step 3: Implement** I `FS25_ADSmartPickup/adPool.lua`, efter `ADPool.blocked`: ```lua -- Traktorer på hjemtur (sætter redskab af, kører hjem): optaget for markarbejdet til de er hjemme. local returning = setmetatable({}, {__mode = "k"}) function ADPool.setReturning(vehicle, isOn) returning[vehicle] = isOn and true or nil end function ADPool.isReturning(vehicle) return returning[vehicle] == true end ``` I `FS25_ADSmartPickup/adHitch.lua`, i den rene del (efter `ADHitch.waitAfter`): ```lua -- Hjemtur: hvert puljeredskabs parkering (der sættes det af), så traktorens egen parkering. function ADHitch.homeRoute(implementParkIds, tractorParkId) local route, seen = {}, {} for _, id in ipairs(implementParkIds or {}) do if not seen[id] then seen[id] = true table.insert(route, id) end end if tractorParkId ~= nil then table.insert(route, tractorParkId) end return route end ``` I spil-delen af `adHitch.lua`, lige efter `local function isImplementPark(...) ... end`: ```lua function ADHitch.rearImplementsOf(vehicle) return ADHitch.rearImplements(attachedImplements(vehicle)) end local function parkIdOf(adEnv, vehicle) local index = vehicle.advd ~= nil and vehicle.advd.parkDestination or nil local marker = type(index) == "number" and index >= 1 and adEnv.ADGraphManager:getMapMarkerById(index) or nil return marker ~= nil and marker.id or nil end ``` Efter `function ADHitch.route(...) ... end`: ```lua -- Puljeredskaber bag traktoren sættes af på deres parkering, og traktoren kører hjem til sin egen. -- Kun redskaber med en Connect-markør til deres parkering er puljeredskaber; andre bliver siddende. function ADHitch.returnHome(adEnv, tractor, onDone) local markers = adEnv.ADGraphManager:getMapMarkers() local parkIds = {} for _, implement in ipairs(ADHitch.rearImplementsOf(tractor)) do local parkId = parkIdOf(adEnv, implement.object) local park = parkId ~= nil and adEnv.ADGraphManager:getMapMarkerByWayPointId(parkId) or nil if park ~= nil and ADPool.connectFor(park.name, markers) ~= nil then table.insert(parkIds, parkId) end end if #parkIds == 0 then return false end local route = ADHitch.homeRoute(parkIds, parkIdOf(adEnv, tractor)) ADPool.setReturning(tractor, true) ADHitch.route(adEnv, tractor, route, function(vehicle, ok, detail) ADPool.setReturning(vehicle, false) if onDone ~= nil then onDone(vehicle, ok, detail) end end) return true end ``` - [ ] **Step 4: Run tests to verify they pass** Run: `luajit tests/test_adHitch.lua | tail -1; luajit tests/test_adPool.lua | tail -1; luajit -bl FS25_ADSmartPickup/adHitch.lua >/dev/null && echo syntaks-ok` Expected: `ALLE TESTS OK` to gange og `syntaks-ok` - [ ] **Step 5: Commit** ```bash git add FS25_ADSmartPickup/adHitch.lua FS25_ADSmartPickup/adPool.lua tests/test_adHitch.lua tests/test_adPool.lua git commit -m "feat(pulje): hjemtur — puljeredskaber sættes af på deres parkering, traktoren kører til sin egen; optaget imens" ``` --- ### Task 4: Markarbejde — puljen som fallback og fasen `fetching` **Files:** - Modify: `FS25_ADSmartPickup/adFieldWork.lua` (`getRigs` :132-170, `dispatch` :382-482, `follow` :1083-1143, `release` :278-283) - Modify: `FS25_ADSmartPickup/l10n/l10n_da.xml`, `FS25_ADSmartPickup/l10n/l10n_en.xml` **Interfaces:** - Consumes: `ADCpBridge.getSeedFruits/hasSeeds/isMechanicalWeeder(vehicle)`, `ADPool.collect(adEnv, isBusy)`, `ADPool.pairFor(...)`, `ADPool.blocked(nowMs)`, `ADPool.reserve/release/quarantine`, `ADPool.isReturning`, `ADHitch.fetch(adEnv, vehicle, markerId, onDone)`, `ADHitch.returnHome`. - Produces: assignment-felter `pool = {implementId, implementName, connectId}`, `phase = "fetching"`, `hitch = {done=bool, ok=bool, detail=any}`; field-årsager `spu_field_reason_noPoolTractor`, `spu_field_reason_fetching` (tooWeak som fritekst). - [ ] **Step 1: Optaget-regel genbrugelig + hjemtur tæller som optaget** I `adFieldWork.lua` før `function ADFieldWork.getRigs`: ```lua -- Optaget for markarbejdet: vores egen opgave, AutoDrive/Courseplay kører den, lånt af marksilo-flowet, -- eller på hjemtur fra puljen (sætter redskab af). local function isBusyVehicle(vehicle, id) return ADFieldJobs.state.assignments[id] ~= nil or isAdActive(vehicle) or ADCpBridge.isWorking(vehicle) or (ADFieldSilos ~= nil and ADFieldSilos.isLoaned(vehicle)) or (ADPool ~= nil and ADPool.isReturning(vehicle)) end ``` I `getRigs` erstat `busy = state.assignments[id] ~= nil or isAdActive(vehicle) or ADCpBridge.isWorking(vehicle)\n or (ADFieldSilos ~= nil and ADFieldSilos.isLoaned(vehicle)),` med: ```lua busy = isBusyVehicle(vehicle, id), ``` og tilføj som sidste alternativ i `busyWhy`-kæden (før `or nil`): ```lua or (ADPool ~= nil and ADPool.isReturning(vehicle) and "sætter redskab af") or nil, ``` - [ ] **Step 2: Fallback i dispatch** I `ADFieldWork.dispatch`, lige efter `if rigs == nil then` og FØR `if #offNetwork > 0 ...`, indsæt: ```lua -- ingen færdig kombination: en ledig solo-traktor henter et puljeredskab (kun hovedopgaver, ikke tømmevogne) if not decision.helper and (decision.count or 1) == 1 and ADPool ~= nil and (why == "noRig" or why == "allBusy" or why == "cannotDo") then local pooled, poolWhy, poolArgs = ADFieldWork.dispatchPool(adEnv, field, info, role, roleAccept) if pooled then return end if poolWhy == "tooWeak" or (poolWhy == "noPoolTractor" and why ~= "allBusy") then why, args = poolWhy, poolArgs end end ``` Tilføj før `function ADFieldWork.dispatch` en ny funktion: ```lua -- Puljekombination: nærmeste puljeredskab til marken + nærmeste ledige traktor med nok hk. Traktoren kører til -- "Connect X", kobler på og fortsætter så som en almindelig udsendelse (fase fetching -> driving). function ADFieldWork.dispatchPool(adEnv, field, info, role, roleAccept) local implements, tractors = ADPool.collect(adEnv, function(vehicle) return isBusyVehicle(vehicle, ADRunsController.getVehicleId(vehicle)) end) local accept = function(implement) -- samme felter som getRigs giver sæt, så markens krav (frø, afgrøde, ukrudt) tjekkes på redskabet selv local candidate = {role = implement.role, roles = implement.roles, vehicle = implement.vehicle} if role == "seeder" then candidate.seedFruits = ADCpBridge.getSeedFruits(implement.vehicle) candidate.hasSeeds = ADCpBridge.hasSeeds(implement.vehicle) end if role == "weeder" then candidate.mechanical = ADCpBridge.isMechanicalWeeder(implement.vehicle) end if roleAccept ~= nil and not roleAccept(candidate) then return false end local required = ADFieldModel.requiredWidth(field, role) return (implement.width or 0) <= 0 or ADFieldModel.checkWidth(role, implement.width, required) end local pair, why, args = ADPool.pairFor(role, info.x, info.z, implements, tractors, ADPool.blocked(now()), accept) if pair == nil then return false, why, args end local tractor, implement = pair.tractor, pair.implement local assignment = { vehicleId = tractor.id, vehicle = tractor.vehicle, fieldId = field.id, role = role, stage = field.stage, helper = false, phase = "fetching", since = now(), width = implement.width, groupSize = 1, leader = true, startX = tractor.x, startZ = tractor.z, pool = {implementId = implement.id, implementName = implement.name, connectId = implement.connectId}, hitch = {done = false}, } ADFieldJobs.state.assignments[tractor.id] = assignment ADPool.reserve(assignment, {implement.id, tractor.id}) ADHitch.fetch(adEnv, tractor.vehicle, implement.connectId, function(_, ok, detail) assignment.hitch = {done = true, ok = ok, detail = detail} end) note(field.id, {reason = {key = "spu_field_reason_fetching", name = implement.name}}) log(field.id, "%s: '%s' henter '%s' (%s)", role, tractor.name, implement.name, "pulje") return true end ``` - [ ] **Step 3: Frigiv reservation ved release** I `ADFieldWork.release`, før `ADFieldJobs.state.assignments[assignment.vehicleId] = nil`: ```lua if ADPool ~= nil and assignment.pool ~= nil then ADPool.release(assignment) end ``` - [ ] **Step 4: Fasen fetching i follow** I `ADFieldWork.follow`, efter linjen `wake(vehicle)` og før `if assignment.phase == "driving" then`, gør `if` til en kæde ved at indsætte: ```lua if assignment.phase == "fetching" then local hitch = assignment.hitch if not hitch.done then return end ADPool.release(assignment) if not hitch.ok then -- koblingen fejlede: redskabet i karantæne, traktoren hjem, marken prøver igen ADPool.quarantine(assignment.pool.implementId, now()) ADFieldWork.release(assignment) note(assignment.fieldId, {retryAt = now() + ADFieldWork.NO_RIG_RETRY_MS, reason = {text = string.format("kunne ikke koble '%s' på (%s) — prøver et andet om lidt", tostring(assignment.pool.implementName), tostring(hitch.detail))}}) local park = parkMarkerId(adEnv, vehicle) if park ~= nil then startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, park, nil) end return end -- koblet på: bredden med redskabet på (for bred -> hjem igen) local width = ADCpBridge.getWorkWidth(vehicle) local okWidth, widthReason, widthArgs = ADFieldModel.checkWidth(assignment.role, width, ADFieldModel.requiredWidth(field, assignment.role)) if not okWidth then ADFieldWork.release(assignment) note(assignment.fieldId, {retryAt = now() + ADFieldModel.RETRY_MS, reason = {key = "spu_field_reason_" .. tostring(widthReason), role = assignment.role, args = widthArgs}}) ADHitch.returnHome(adEnv, vehicle) return end assignment.width, assignment.phase, assignment.since = width, "driving", now() assignment.startX, assignment.startZ = vehiclePosition(vehicle) startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, info.markerId, nil) assignment.drive = {mode = adEnv.AutoDrive.MODE_DRIVETO, first = info.markerId} note(assignment.fieldId, {reason = false}) log(assignment.fieldId, "%s '%s' har '%s' på — kører til %s (%.1f m)", assignment.role, ADRuns.vehicleLabel(vehicle), tostring(assignment.pool.implementName), tostring(info.markerName), width or 0) elseif assignment.phase == "driving" then ``` (og fjern det oprindelige `if assignment.phase == "driving" then`, som nu er `elseif`.) Tilføj også `fetching` til spiller-reglen øverst i `follow`, så AD's kørsel under hentning ikke tolkes som spillerens: `steeredByAi` er allerede sand, mens AD kører; ingen ændring nødvendig. - [ ] **Step 5: Tekster** `l10n/l10n_da.xml` (ved de andre `spu_field_reason_`): ```xml ``` `l10n/l10n_en.xml`: ```xml ``` `tooWeak` bruger fritekst (hk står før rollen, og `fieldReasonText` sætter rollen først): i Step 2 erstattes linjen `why, args = poolWhy, poolArgs` med: ```lua if poolWhy == "tooWeak" then note(field.id, {reason = {text = string.format("ingen ledig traktor med ≥%.0f hk til %s-redskabet", (poolArgs or {})[1] or 0, tostring(role))}, retryAt = now() + ADFieldWork.NO_RIG_RETRY_MS}) return end why, args = poolWhy, poolArgs ``` - [ ] **Step 6: Syntakstjek og alle tests** Run: `luajit -bl FS25_ADSmartPickup/adFieldWork.lua >/dev/null && echo syntaks-ok; for f in tests/test_*.lua; do luajit $f >/dev/null 2>&1 || echo "FEJL $f"; done; echo færdig` Expected: `syntaks-ok`, ingen `FEJL`-linjer, `færdig` - [ ] **Step 7: Commit** ```bash git add FS25_ADSmartPickup/adFieldWork.lua FS25_ADSmartPickup/l10n/l10n_da.xml FS25_ADSmartPickup/l10n/l10n_en.xml git commit -m "feat(pulje): markarbejdet sender en solo-traktor efter et puljeredskab når ingen færdig kombination er ledig (fase fetching)" ``` --- ### Task 5: Markarbejde — hjemtur når opgaven slutter **Files:** - Modify: `FS25_ADSmartPickup/adFieldWork.lua` (`finishAssignment` :834-862, fejlvejen i `follow` "nåede ikke" :1115-1120) **Interfaces:** - Consumes: `ADHitch.returnHome(adEnv, tractor, onDone) -> bool`. - Produces: ingen nye navne. - [ ] **Step 1: finishAssignment** Erstat i `finishAssignment`: ```lua local park = not assignment.adHandlesEnd and not assignment.manual and parkMarkerId(adEnv, vehicle) or nil if park ~= nil then startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, park, nil) end ``` med: ```lua -- puljeredskab bag traktoren: sæt det af på sin parkering, traktoren hjem (afhænger kun af hvad der hænger -- bag, så det virker også efter genindlæsning); ellers som før local pooled = not assignment.adHandlesEnd and not assignment.manual and ADHitch ~= nil and ADHitch.returnHome(adEnv, vehicle, function(_, ok, detail) if not ok then log(assignment.fieldId, "'%s' kom ikke hjem fra puljen: %s", ADRuns.vehicleLabel(vehicle), tostring(detail)) end end) local park = not pooled and not assignment.adHandlesEnd and not assignment.manual and parkMarkerId(adEnv, vehicle) or nil if park ~= nil then startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, park, nil) end if pooled then log(assignment.fieldId, "'%s' sætter redskabet af og kører hjem", ADRuns.vehicleLabel(vehicle)) end ``` - [ ] **Step 2: "nåede ikke frem" og "tom" — også hjem med redskabet** I `follow`, i grenen der slutter med ```lua local park = parkMarkerId(adEnv, vehicle) if park ~= nil then startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, park, nil) end ``` erstat de to linjer med: ```lua if not (ADHitch ~= nil and ADHitch.returnHome(adEnv, vehicle)) then local park = parkMarkerId(adEnv, vehicle) if park ~= nil then startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, park, nil) end end ``` I `finishAssignment`, i "tom såmaskine/sprøjte"-grenen efter `ADFieldWork.release(assignment)`, indsæt: ```lua if ADHitch ~= nil then ADHitch.returnHome(adEnv, vehicle) end ``` - [ ] **Step 3: Syntakstjek og alle tests** Run: `luajit -bl FS25_ADSmartPickup/adFieldWork.lua >/dev/null && echo syntaks-ok; for f in tests/test_*.lua; do luajit $f >/dev/null 2>&1 || echo "FEJL $f"; done; echo færdig` Expected: `syntaks-ok`, ingen `FEJL`, `færdig` - [ ] **Step 4: Commit** ```bash git add FS25_ADSmartPickup/adFieldWork.lua git commit -m "feat(pulje): når opgaven slutter (eller fejler) sættes puljeredskabet af på sin parkering og traktoren kører hjem" ``` --- ### Task 6: Kø — længst ventende mark først **Files:** - Modify: `FS25_ADSmartPickup/adFieldJobs.lua` (`stepField` dispatch-gren ~:421-426, `tick` løkke ~:486) - Test: `tests/test_adPool.lua` (dækket af `queueOrder`-tests i Task 1) **Interfaces:** - Consumes: `ADPool.queueOrder(infos, waitingSince)`. - Produces: `ADFieldJobs.waitingSince = {[fieldId] = nowMs}`. - [ ] **Step 1: Registrér ventetid** I `adFieldJobs.lua` ved siden af `ADFieldJobs.lastDecision = {}`: ```lua -- Hvornår en mark begyndte at vente på en maskine (kø: længst ventende får næste ledige traktor). ADFieldJobs.waitingSince = {} ``` I `stepField`, erstat dispatch-grenen: ```lua local before = ADFieldJobs.assignmentCount() ADFieldWork.dispatch(adEnv, field, info, decision) if ADFieldJobs.assignmentCount() > before then ADFieldJobs.lastDispatchAt = ADFieldJobs.now end ``` med: ```lua local before = ADFieldJobs.assignmentCount() ADFieldWork.dispatch(adEnv, field, info, decision) if ADFieldJobs.assignmentCount() > before then ADFieldJobs.lastDispatchAt = ADFieldJobs.now ADFieldJobs.waitingSince[info.id] = nil elseif ADFieldJobs.waitingSince[info.id] == nil then ADFieldJobs.waitingSince[info.id] = ADFieldJobs.now end ``` I samme funktion, i grenen `decision.action == "advance"` efter `ADFieldJobs.setField(nextField)`: ```lua ADFieldJobs.waitingSince[info.id] = nil ``` - [ ] **Step 2: Kørsel i kø-rækkefølge** I `ADFieldJobs.tick` erstat `for index, info in ipairs(ADFields.list()) do` med: ```lua local infos = ADFields.list() if ADPool ~= nil then infos = ADPool.queueOrder(infos, ADFieldJobs.waitingSince) end for index, info in ipairs(infos) do ``` - [ ] **Step 3: Tests** Run: `for f in tests/test_*.lua; do luajit $f >/dev/null 2>&1 || echo "FEJL $f"; done; luajit -bl FS25_ADSmartPickup/adFieldJobs.lua >/dev/null && echo syntaks-ok` Expected: ingen `FEJL`, `syntaks-ok` - [ ] **Step 4: Commit** ```bash git add FS25_ADSmartPickup/adFieldJobs.lua git commit -m "feat(pulje): marker der venter på en maskine betjenes længst-ventende først" ``` --- ### Task 7: Version, build, deploy og in-game test **Files:** - Modify: `FS25_ADSmartPickup/modDesc.xml` (`1.47.4.0` → `1.48.0.0`) - [ ] **Step 1: Bump version, alle tests, build, deploy** ```bash cd ~/fs25-adsmartpickup sed -i 's#1\.47\.4\.0#1.48.0.0#' FS25_ADSmartPickup/modDesc.xml for f in tests/test_*.lua; do luajit $f >/dev/null 2>&1 || echo "FEJL $f"; done rm -f FS25_ADSmartPickup.zip; (cd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip .) cp FS25_ADSmartPickup.zip ~/FS25-data/mods/.FS25_ADSmartPickup.new && mv ~/FS25-data/mods/.FS25_ADSmartPickup.new ~/FS25-data/mods/FS25_ADSmartPickup.zip unzip -p ~/FS25-data/mods/FS25_ADSmartPickup.zip modDesc.xml | grep -m1 '' ``` Expected: ingen `FEJL`, `1.48.0.0` - [ ] **Step 2: Commit og push** ```bash git add FS25_ADSmartPickup/modDesc.xml git commit -m "chore: v1.48.0.0 — maskinpulje etape 1 (markarbejde)" TOKEN=$(grep -A15 -i "^## Gitea" ~/credentials-vault.md | grep -oE "[a-f0-9]{40}" | head -1); git -c http.extraHeader="Authorization: Basic $(printf 'masterdraco:%s' "$TOKEN" | base64 -w0)" push -q gitea main ``` - [ ] **Step 3: In-game test (savegame4, Golden Lands) — spilleren udfører, vi læser loggen** Forudsætning: et redskab til markarbejde (fx kultivator/plov) med AD-parkering "X" og markør "Connect X" (bakkespor), Challengeren solo med egen AD-parkering, og en mark med markør der skal kultiveres/pløjes (plan auto eller Start). Forventet i `log.txt` (grep `ADSmartPickup`): 1. `F: cultivator: 'Challenger MT635' henter '' (pulje)` 2. `kobling: 'Challenger MT635' koblede '' på ved 'Connect X'` 3. `F: cultivator 'Challenger MT635' har '' på — kører til ` 4. CP arbejder; `F: cultivator '…' færdig` 5. `F: '…' sætter redskabet af og kører hjem`, `kobling: … koblede '' af ved 'X'`, `… er færdig med turen (2 trin)` Fejlscenarie: flyt redskabet 3 m væk fra sin plads → `kunne ikke koble … på` efter 8 s, traktoren kører hjem, fanen viser årsagen, redskabet springes over i 10 min.