Files
fs25-adsmartpickup/docs/superpowers/plans/2026-09-21-behovsstyret-forsyning.md
T

12 KiB
Raw Blame History

Behovsstyret forsyning 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: Én AutoDrive-traktor holder alle input i en produktion (fx TMR-blander) fyldt ved selv at vælge vare + kilde-silo pr. tur.

Architecture: Den eksisterende wrapper om ADMultipleTargetsManager.getNextPickup udvides: ved >1 valgt vare i AD læses aflæsningsmålets lager, ingredienserne rangeres efter behov (rene funktioner i ny fil), og en kilde findes blandt alle AD-markører. AD's varevalg indsnævres til én vare pr. tur og gendannes når AD stopper.

Tech Stack: Lua 5.1 (FS25 mod-sandbox), luajit til tests med håndskrevne mocks, AutoDrive 3.0.1.2 via global FS25_AutoDrive.

Spec: docs/superpowers/specs/2026-09-21-behovsstyret-forsyning-design.md

Global Constraints

  • Rør aldrig FS25_AutoDrive.zip; al adgang via den globale FS25_AutoDrive-tabel.
  • Enhver fejl i spil-/AD-API → Logging.warning + AutoDrives originale getNextPickup. Aldrig crash.
  • Præcis én valgt vare → opførsel uændret; de 11 eksisterende tests forbliver grønne.
  • Markør↔station-tærskel: 40 m (MAX_MARKER_TO_TRIGGER_DISTANCE). MIN_FREE_LITERS = 1000.
  • Rene funktioner muterer ikke input (returnér nye tabeller).
  • Tests køres fra repo-roden: luajit tests/<fil>.lua. Commits uden Co-Authored-By.
  • Verificerede AD-fakta: selectedFillTypes er et array-felt på stateModule og gemmes i savegame; setFillType(ft) bevarer valget hvis ft er i det; isActive(), getSecondMarker(), raiseDirtyFlag() findes; ADGraphManager:getMapMarkers() returnerer array indekseret på markerIndex.

Task 1: adSupplyPlanner.lua — rene funktioner

Files:

  • Create: FS25_ADSmartPickup/adSupplyPlanner.lua
  • Test: tests/test_adSupplyPlanner.lua

Interfaces:

  • Produces (global tabel ADSupplyPlanner):

    • MIN_FREE_LITERS (number)
    • scoreNeed(level, capacity, freeSpace) -> number (højere = mere trængende)
    • rankNeeds(needs) -> ranked hvor needs = {{fillType, level, capacity}, …} og ranked = {{fillType, level, capacity, freeSpace, score}, …} sorteret mest trængende først, uden dem med freeSpace < MIN_FREE_LITERS
    • resolveIngredients(state, selected) -> ingredients|nil hvor state = nil | {ingredients = {ft…}, narrowedTo = ft}
    • copyList(list) -> ny liste
  • Step 1: Skriv fejlende test — tests/test_adSupplyPlanner.lua:

-- Kør: luajit tests/test_adSupplyPlanner.lua   (fra repo-roden)
dofile("FS25_ADSmartPickup/adSupplyPlanner.lua")

local HAY, GRASS, SILAGE = 20, 21, 22
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

-- scoreNeed: tommere lager scorer højere; ugyldig kapacitet giver 0
check("tomt scorer højere end halvfuldt", ADSupplyPlanner.scoreNeed(0, 1000, 1000) > ADSupplyPlanner.scoreNeed(500, 1000, 500), true)
check("kapacitet 0 giver score 0", ADSupplyPlanner.scoreNeed(0, 0, 0), 0)

-- rankNeeds: mest trængende først, fulde springes over, input muteres ikke
local needs = {
    {fillType = HAY, level = 80000, capacity = 100000},
    {fillType = GRASS, level = 20000, capacity = 100000},
    {fillType = SILAGE, level = 99500, capacity = 100000},
}
local ranked = ADSupplyPlanner.rankNeeds(needs)
check("fuld ingrediens springes over", #ranked, 2)
check("mest trængende først", ranked[1].fillType, GRASS)
check("dernæst næstmest trængende", ranked[2].fillType, HAY)
check("freeSpace beregnes", ranked[1].freeSpace, 80000)
check("input muteres ikke", needs[1].freeSpace, nil)
check("tom liste giver tom liste", #ADSupplyPlanner.rankNeeds({}), 0)

-- rankNeeds: deterministisk ved lige score (laveste fillType først)
local tie = ADSupplyPlanner.rankNeeds({
    {fillType = SILAGE, level = 0, capacity = 5000},
    {fillType = HAY, level = 0, capacity = 5000},
})
check("lige score -> laveste fillType først", tie[1].fillType, HAY)

-- resolveIngredients
local multi = {HAY, GRASS}
local resolved = ADSupplyPlanner.resolveIngredients(nil, multi)
check("multi-valg bliver ingrediensliste", #resolved, 2)
check("listen er en kopi", resolved ~= multi, true)
check("enkelt-valg uden tilstand -> nil", ADSupplyPlanner.resolveIngredients(nil, {HAY}), nil)
local state = {ingredients = {HAY, GRASS, SILAGE}, narrowedTo = GRASS}
check("indsnævret valg genbruger gemt liste", #ADSupplyPlanner.resolveIngredients(state, {GRASS}), 3)
check("bruger valgte andet -> nil", ADSupplyPlanner.resolveIngredients(state, {HAY}), nil)
check("nyt multi-valg erstatter gemt liste", #ADSupplyPlanner.resolveIngredients(state, {HAY, SILAGE}), 2)
check("ugyldigt valg -> nil", ADSupplyPlanner.resolveIngredients(state, nil), nil)

print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
  • Step 2: Kør og se den fejle — luajit tests/test_adSupplyPlanner.lua → fejler med "cannot open FS25_ADSmartPickup/adSupplyPlanner.lua".

  • Step 3: Implementér — FS25_ADSmartPickup/adSupplyPlanner.lua:

-- AD Supply Planner
-- Rene funktioner (ingen spil-API) til behovsstyret forsyning: hvilken ingrediens
-- har aflæsningsmålet mest brug for, og hvilken ingrediensliste gælder for køretøjet.

ADSupplyPlanner = {}

-- Mindre fri plads end dette i målet regnes som "fuldt" (undgår ture med læs der ikke kan komme af).
ADSupplyPlanner.MIN_FREE_LITERS = 1000

function ADSupplyPlanner.copyList(list)
    local copy = {}
    for index, value in ipairs(list) do
        copy[index] = value
    end
    return copy
end

-- Hvor meget trænger målet til denne ingrediens? Højere = hentes først.
-- Alle tal er liter i aflæsningsmålets lager. Standard: relativ tomhed (0..1),
-- så en lille næsten-tom tank vinder over en stor halvfuld.
function ADSupplyPlanner.scoreNeed(level, capacity, freeSpace)
    if capacity <= 0 then
        return 0
    end
    return freeSpace / capacity
end

-- needs: {{fillType, level, capacity}, ...}. Returnerer NY liste, mest trængende først;
-- ingredienser uden plads i målet er sorteret fra.
function ADSupplyPlanner.rankNeeds(needs)
    local ranked = {}
    for _, need in ipairs(needs) do
        local freeSpace = need.capacity - need.level
        if freeSpace >= ADSupplyPlanner.MIN_FREE_LITERS then
            table.insert(ranked, {
                fillType = need.fillType,
                level = need.level,
                capacity = need.capacity,
                freeSpace = freeSpace,
                score = ADSupplyPlanner.scoreNeed(need.level, need.capacity, freeSpace),
            })
        end
    end
    table.sort(ranked, function(a, b)
        if a.score ~= b.score then
            return a.score > b.score
        end
        return a.fillType < b.fillType
    end)
    return ranked
end

-- state: nil eller {ingredients, narrowedTo} fra forrige tur. selected: AD's aktuelle varevalg.
-- >1 valgt = (ny) ingrediensliste. Præcis den vare vi selv indsnævrede til = gemt liste.
-- Alt andet = brugeren har valgt noget andet -> nil (behovsstyring fra).
function ADSupplyPlanner.resolveIngredients(state, selected)
    if type(selected) ~= "table" then
        return nil
    end
    if #selected > 1 then
        return ADSupplyPlanner.copyList(selected)
    end
    if state ~= nil and #selected == 1 and selected[1] == state.narrowedTo then
        return ADSupplyPlanner.copyList(state.ingredients)
    end
    return nil
end
  • Step 4: Kør tests — luajit tests/test_adSupplyPlanner.lua → ALLE TESTS OK.

  • Step 5: Commit — git add FS25_ADSmartPickup/adSupplyPlanner.lua tests/test_adSupplyPlanner.lua && git commit -m "feat: ADSupplyPlanner — behovsrangering og ingrediensliste-tilstand"


Task 2: Integration i adSmartPickup.lua

Files:

  • Modify: FS25_ADSmartPickup/adSmartPickup.lua (hele filen omstruktureres let; se kode)
  • Test: tests/test_adSmartPickup.lua

Interfaces:

  • Consumes: ADSupplyPlanner.rankNeeds, .resolveIngredients, .copyList (Task 1).

  • Produces: ADSmartPickup.choosePickup(adEnv, driver) -> choice|nil med choice = {markerIndex, markerName, group, level, fillType?, need?}; ADSmartPickup.findLeastFilledPickup returnerer nu samme choice-tabel (uden fillType).

  • Step 1: Udvid test-mocks og skriv fejlende tests i tests/test_adSmartPickup.lua:

    • dofile("FS25_ADSmartPickup/adSupplyPlanner.lua") før adSmartPickup.lua.
    • unloadStations-liste + getUnloadingStations i g_currentMission.storageSystem; nulstilles i resetWorld().
    • getMapMarkers = function() return markers end i ADGraphManager-mock.
    • getSecondMarker = function() return nil end i makeDriver (rigtig AD har den altid).
    • Nye helpers makeUnloadStation(node, x, z, levels, capacities, placeable, isBroken) og makeSupplyDriver(firstMarker, secondMarker, fillTypes) (muterbar stateModule-mock med setFillType, raiseDirtyFlag, isActive).
    • Tests S1–S9 (se Step 1-koden i implementeringen; dækker spec'ens testliste).
  • Step 2: Kør og se de nye fejle — luajit tests/test_adSmartPickup.lua → S-tests FAIL, de 11 gamle OK.

  • Step 3: Implementér — i adSmartPickup.lua:

    • supplyState = setmetatable({}, {__mode = "k"}) (køretøj → {ingredients, narrowedTo}).
    • getDistanceToTriggers(triggers, nodeFields, x, z) + getClosestStation(stations, triggersField, nodeFields, wayPoint, farmId); getLoadStationAtMarker (felt loadTriggers, node triggerNode) og getUnloadStationAtMarker (felt unloadTriggers, noder exactFillRootNode, triggerNode, aiNode).
    • collectMarkerStations(adEnv, candidateMarkers, farmId, excludedPlaceable) → {{marker, station}, …}; findLeastFilled(markerStations, fillTypes, farmId) → choice|nil (eksisterende eget-lager-først-rangering).
    • getTargetNeeds(station, ingredients, farmId): pr. understøttet ingrediens summeres storage:getFillLevel(ft) og storage:getFreeCapacity(ft) over station.targetStorages; capacity = level + free. Ingen storages → station:getFreeCapacity(ft, farmId) som capacity, level = 0.
    • ADSmartPickup.findSupplyPickup(adEnv, driver, ingredients), applySupplyChoice(driver, ingredients, choice) (setFillType, selectedFillTypes = {ft}, raiseDirtyFlag, gem tilstand), ADSmartPickup.choosePickup (supply → ellers mappe-logik → ellers nil).
    • Hook bruger choice.group til AD's rotations-bogføring og logger vare + behov ved supply-valg.
    • restoreStoppedVehicles() kaldt fra update (pcall): når not stateModule:isActive() og valget stadig er {narrowedTo} → selectedFillTypes = kopi af ingredients + dirty-flag; tilstand slettes.
  • Step 4: Kør alle tests — luajit tests/test_adSupplyPlanner.lua && luajit tests/test_adSmartPickup.lua → begge ALLE TESTS OK.

  • Step 5: Commit — git add -A && git commit -m "feat: behovsstyret forsyning — én traktor holder alle input fyldt"


Task 3: Pakning, docs, deploy

Files:

  • Modify: FS25_ADSmartPickup/modDesc.xml (version 1.1.0.0, <sourceFile filename="adSupplyPlanner.lua"/> FØR adSmartPickup.lua, beskrivelse), README.md

  • Step 1: Opdatér modDesc + README (betjening: multi-vælg varer; begrænsninger fra spec).

  • Step 2: Byg: cd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip . && cd ..; verificér med unzip -l at begge .lua + modDesc + icon er med.

  • Step 3: Deploy atomisk (spillet kan køre): cp FS25_ADSmartPickup.zip ~/FS25-data/mods/.FS25_ADSmartPickup.zip.tmp && mv ~/FS25-data/mods/.FS25_ADSmartPickup.zip.tmp ~/FS25-data/mods/FS25_ADSmartPickup.zip.

  • Step 4: Code-review (ecc:code-reviewer) af diff; ret CRITICAL/HIGH.

  • Step 5: Commit + git push gitea. Opdatér memory + Obsidian-noten projekter/FS25 silo-patches + AD Smart Pickup.md.