diff --git a/docs/superpowers/plans/2026-09-28-maskinpulje-etape1.md b/docs/superpowers/plans/2026-09-28-maskinpulje-etape1.md new file mode 100644 index 0000000..6afa4fd --- /dev/null +++ b/docs/superpowers/plans/2026-09-28-maskinpulje-etape1.md @@ -0,0 +1,919 @@ +# 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.