Files
fs25-adsmartpickup/docs/superpowers/plans/2026-09-28-maskinpulje-etape1.md
T

43 KiB

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_<navn>.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:

-- 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:

-- 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 <parkering>" 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
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:

-- ---- 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 <sourceFile filename="adHitch.lua"/>:

        <sourceFile filename="adPool.lua"/>
  • 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
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:

-- 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:

-- 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:

-- 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):

-- 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:

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:

-- 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
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:

-- 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:

                busy = isBusyVehicle(vehicle, id),

og tilføj som sidste alternativ i busyWhy-kæden (før or nil):

                    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:

        -- 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:

-- 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:

    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:

    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_):

        <text name="spu_field_reason_noPoolTractor" text="ingen ledig traktor til at hente et redskab til %s"/>
        <text name="spu_field_reasonShort_noPoolTractor" text="venter på traktor"/>
        <text name="spu_field_reason_fetching" text="henter '%s'"/>
        <text name="spu_field_reasonShort_fetching" text="henter redskab"/>

l10n/l10n_en.xml:

        <text name="spu_field_reason_noPoolTractor" text="no free tractor to fetch an implement for %s"/>
        <text name="spu_field_reasonShort_noPoolTractor" text="waiting for tractor"/>
        <text name="spu_field_reason_fetching" text="fetching '%s'"/>
        <text name="spu_field_reasonShort_fetching" text="fetching implement"/>

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:

                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
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:

    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:

    -- 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

                local park = parkMarkerId(adEnv, vehicle)
                if park ~= nil then startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, park, nil) end

erstat de to linjer med:

                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:

        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
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 = {}:

-- 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:

        local before = ADFieldJobs.assignmentCount()
        ADFieldWork.dispatch(adEnv, field, info, decision)
        if ADFieldJobs.assignmentCount() > before then ADFieldJobs.lastDispatchAt = ADFieldJobs.now end

med:

        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):

        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:

    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
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 (<version>1.47.4.0</version> → 1.48.0.0)

  • Step 1: Bump version, alle tests, build, deploy

cd ~/fs25-adsmartpickup
sed -i 's#<version>1\.47\.4\.0</version>#<version>1.48.0.0</version>#' 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 '<version>'

Expected: ingen FEJL, <version>1.48.0.0</version>

  • Step 2: Commit og push
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<n>: cultivator: 'Challenger MT635' henter '<redskab>' (pulje)
  2. kobling: 'Challenger MT635' koblede '<redskab>' på ved 'Connect X'
  3. F<n>: cultivator 'Challenger MT635' har '<redskab>' på — kører til <markør>
  4. CP arbejder; F<n>: cultivator '…' færdig
  5. F<n>: '…' sætter redskabet af og kører hjem, kobling: … koblede '<redskab>' 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.