diff --git a/FS25_ADSmartPickup/adSupplyPlanner.lua b/FS25_ADSmartPickup/adSupplyPlanner.lua new file mode 100644 index 0000000..5bc8411 --- /dev/null +++ b/FS25_ADSmartPickup/adSupplyPlanner.lua @@ -0,0 +1,67 @@ +-- 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 diff --git a/docs/superpowers/plans/2026-09-21-behovsstyret-forsyning.md b/docs/superpowers/plans/2026-09-21-behovsstyret-forsyning.md new file mode 100644 index 0000000..b054601 --- /dev/null +++ b/docs/superpowers/plans/2026-09-21-behovsstyret-forsyning.md @@ -0,0 +1,221 @@ +# 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/.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`: + +```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`: + +```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`, `` 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`. diff --git a/tests/test_adSupplyPlanner.lua b/tests/test_adSupplyPlanner.lua new file mode 100644 index 0000000..863e70c --- /dev/null +++ b/tests/test_adSupplyPlanner.lua @@ -0,0 +1,55 @@ +-- Kør: luajit tests/test_adSupplyPlanner.lua (fra repo-roden) +-- Tester de rene behovs-funktioner; ingen spil- eller AutoDrive-mocks nødvendige. + +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)