From 85ae4436f33030fd96d4a69287a8e1c89189b06f Mon Sep 17 00:00:00 2001 From: masterdraco Date: Tue, 22 Sep 2026 22:18:42 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20implementeringsplan=20for=20runs-UI=20(?= =?UTF-8?q?v1.8)=20og=20l=C3=A6s-bytte=20fra=20Wait=20(v1.9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/plans/2026-09-22-runs-ui.md | 2714 ++++++++++++++++++ 1 file changed, 2714 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-22-runs-ui.md diff --git a/docs/superpowers/plans/2026-09-22-runs-ui.md b/docs/superpowers/plans/2026-09-22-runs-ui.md new file mode 100644 index 0000000..e3e349f --- /dev/null +++ b/docs/superpowers/plans/2026-09-22-runs-ui.md @@ -0,0 +1,2714 @@ +# Runs-UI (v1.8) + læs-bytte fra Wait (v1.9) — implementeringsplan + +> **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:** En ESC-menu-fane "Smart Pickup" hvor spilleren definerer *runs* (mål + varer + Wait-pulje + traktorer + ture) og starter/stopper dem; bagefter (v1.9) skal en traktor der venter ved et Wait-punkt med en vare målet ikke mangler, aflæsse resten i en silo og hente det målet mangler. + +**Architecture:** Et run-lag oven på AutoDrives tilstand. `adRuns.lua` er rene funktioner (store → ny store), `adRunsStorage.lua` skriver/læser `savegameN/ADSmartPickup_runs.xml`, `adRunsController.lua` oversætter et run til AD-tilstand pr. traktor (Start/Stop, validering) og svarer på "hvilket run hører denne traktor til". De eksisterende hooks (`choosePickup`, `adUnloadWait`) læser run'et to steder. GUI'et (`gui/SmartPickupFrame.lua/.xml`) er en `TabbedMenuFrameElement` indsat i `InGameMenu`, der kun kalder controlleren. v1.9 er et nyt modul `adLoadSwap.lua`, som `adUnloadWait` kalder mens en traktor venter. + +**Tech Stack:** FS25 Lua (LuaJIT / Lua 5.1), AutoDrive-API via global `FS25_AutoDrive`, FS25 `XMLFile`, FS25 GUI (`g_gui`, `TabbedMenuFrameElement`, `SmoothList`, `MultiTextOption`). Tests med `luajit` + de eksisterende mock-mønstre i `tests/`. + +**Spec:** `docs/superpowers/specs/2026-09-22-runs-ui-design.md` (sektion 1–3 = v1.8; "Efterfølgende" = v1.9). + +## Global Constraints + +- Sprog i kode-kommentarer, log-tekster og dansk l10n: dansk; l10n også engelsk (`l10n_en.xml`). +- Al kode der rører spil-/AD-API køres i `pcall`; fejl logges med præfiks `"ADSmartPickup: "` (`Logging.warning`); moden må aldrig crashe spillet. +- Ingen nye tastaturgenveje. Singleplayer (ingen MP-events). +- En traktor kan kun være i ét run; tildeles den et andet, flyttes den. +- Runs der kørte ved gem genstartes IKKE ved load. +- Ukendte køretøjs-referencer i XML droppes med loglinje; run'et beholdes. +- Traktorer uden run opfører sig præcis som i v1.7.2. +- Test: `cd ~/fs25-adsmartpickup && luajit tests/test_adSupplyPlanner.lua && luajit tests/test_adRuns.lua && luajit tests/test_adRunsController.lua && luajit tests/test_adSmartPickup.lua` — alle skal ende med `ALLE TESTS OK`. +- Byg: `cd ~/fs25-adsmartpickup/FS25_ADSmartPickup && rm -f ../FS25_ADSmartPickup.zip && zip -qr ../FS25_ADSmartPickup.zip . && cp ../FS25_ADSmartPickup.zip ~/FS25-data/mods/`. +- Git: commit-format `: `; INGEN `Co-Authored-By`-trailer. Push: `cd ~/fs25-adsmartpickup && TOKEN=$(grep -A8 -i "gitea" ~/credentials-vault.md | grep -oE '[0-9a-f]{40}' | head -1); git -c http.extraHeader="Authorization: token $TOKEN" push -q gitea HEAD` — token må aldrig vises i chat/log/commit. +- Versioner: v1.8.0.0 efter Task 6, v1.9.0.0 efter Task 8 (`modDesc.xml` ``). +- AD-konstanter (verificeret i FS25_AutoDrive): `AutoDrive.MODE_PICKUPANDDELIVER = 2`, `RT_NONE=1, RT_ONLYPICKUP=2`; `AutoDrive.settings.useFolders.isVehicleSpecific = false` (global), `AutoDrive.settings.rotateTargets.isVehicleSpecific = true`; `AutoDrive.getSetting` returnerer `setting.values[setting.current]`; loop-tæller sættes med `stateModule.loopCounter = n; stateModule:raiseDirtyFlag()`; `stateModule:setLoopsDone(0)`; køretøjs-liste `AutoDrive.getAllVehicles()`; `ADGraphManager:getDistanceFromNetwork(vehicle)`; `ADGraphManager:getMapMarkerByWayPointId(id)`; savegame-sti `g_currentMission.missionInfo.savegameDirectory`; AD gemmer via `ItemSystem.save = Utils.prependedFunction(ItemSystem.save, ...)`; køretøjer har `vehicle.uniqueId` (streng `"vehicle…"`, gemmes i vehicles.xml). + +## Filstruktur + +| Fil | Ansvar | +|---|---| +| `FS25_ADSmartPickup/adRuns.lua` (ny) | Ren run-model: opret/slet/opdatér/tildel/flyt, records ↔ store | +| `FS25_ADSmartPickup/adRunsStorage.lua` (ny) | `XMLFile`-læs/skriv af records, sti i savegame | +| `FS25_ADSmartPickup/adRunsController.lua` (ny) | Køretøjs-id, farmens AD-køretøjer, Auto-vareliste, validering, Start/Stop, status, opslag run↔traktor, gem/indlæs-hooks | +| `FS25_ADSmartPickup/adSmartPickup.lua` (ændres) | Eksponerer stations-/niveau-opslag; `choosePickup` tager ingredienser fra run; `getIngredientsFor` (v1.9); `loadMap` indlæser runs | +| `FS25_ADSmartPickup/adUnloadWait.lua` (ændres) | Wait-kandidater filtreret på run'ets pulje; kalder `ADLoadSwap` mens der ventes (v1.9) | +| `FS25_ADSmartPickup/adLoadSwap.lua` (ny, v1.9) | Find silo til rest-læs + afgør om bytte er relevant | +| `FS25_ADSmartPickup/gui/SmartPickupFrame.lua`, `gui/SmartPickupFrame.xml`, `gui/guiProfiles.xml` (nye) | ESC-fanen | +| `FS25_ADSmartPickup/l10n/l10n_da.xml`, `l10n_en.xml` (nye) | Tekster | +| `tests/test_adRuns.lua`, `tests/test_adRunsController.lua` (nye), `tests/test_adSmartPickup.lua` (udvides) | luajit-tests | + +Afvigelse fra spec'ens filliste: XML ligger i egen fil `adRunsStorage.lua` (holder controlleren under 400 linjer), og `runMembership` er droppet — "hører traktoren til et run" slås op i store'en via køretøjs-id (enklere, og AD-start fra HUD på en run-traktor giver samme adfærd som Start fra UI'et). + +--- + +### Task 1: Run-model (`adRuns.lua`) + +**Files:** +- Create: `FS25_ADSmartPickup/adRuns.lua` +- Test: `tests/test_adRuns.lua` + +**Interfaces:** +- Produces (alle rene; input muteres aldrig): + - `ADRuns.newStore() -> store` hvor `store = {nextId = number, runs = {run, ...}}`, `run = {id, name, targetWayPointId|nil, ingredientMode = "auto"|"manual", ingredients = {"STRAW", ...}, waitPoolGroup = "", loops = 0, vehicleIds = {"vehicle…", ...}}` + - `ADRuns.createRun(store, name) -> newStore, run` + - `ADRuns.getRun(store, runId) -> run|nil` + - `ADRuns.deleteRun(store, runId) -> newStore` + - `ADRuns.updateRun(store, runId, changes) -> newStore` (`changes` delmængde af `{name, targetWayPointId, ingredientMode, ingredients, waitPoolGroup, loops}`; `targetWayPointId = false` rydder målet; loops klippes til 0..99) + - `ADRuns.findRunByVehicle(store, vehicleId) -> run|nil` + - `ADRuns.assignVehicle(store, runId, vehicleId) -> newStore, previousRunId|nil` + - `ADRuns.unassignVehicle(store, vehicleId) -> newStore` + - `ADRuns.toRecords(store) -> {{id, name, target, ingredientMode, ingredients = "STRAW FORAGE", waitPool, loops, vehicleIds}, ...}` + - `ADRuns.fromRecords(records) -> store` + - `ADRuns.parseIngredients(text) -> list`, `ADRuns.serializeIngredients(list) -> text` + - `ADRuns.MAX_LOOPS = 99`, `ADRuns.XML_VERSION = 1` + +- [ ] **Step 1: Skriv de fejlende tests** + +`tests/test_adRuns.lua`: + +```lua +-- Kør: luajit tests/test_adRuns.lua (fra repo-roden) +-- Tester run-modellen (rene funktioner) og XML-laget mod en XMLFile-mock. + +dofile("FS25_ADSmartPickup/adRuns.lua") + +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 + +-- R1: opret run -> løbenummer, standardværdier; input-store røres ikke +local empty = ADRuns.newStore() +local s1, cow = ADRuns.createRun(empty, "Cow Feed") +check("R1 id 1", cow.id, 1) +check("R1 navn", cow.name, "Cow Feed") +check("R1 auto-varer som standard", cow.ingredientMode, "auto") +check("R1 loops 0", cow.loops, 0) +check("R1 tom Wait-pulje", cow.waitPoolGroup, "") +check("R1 input-store urørt", #empty.runs, 0) +check("R1 nextId tæller op", s1.nextId, 2) +local s2, pig = ADRuns.createRun(s1, "Pig Feed") +check("R1 andet run får id 2", pig.id, 2) +check("R1 to runs", #s2.runs, 2) + +-- R2: opdatér felter; ukendt mode ignoreres; loops klippes +local s3 = ADRuns.updateRun(s2, 1, {name = "Cow 1", targetWayPointId = 94, ingredientMode = "manual", ingredients = {"STRAW", "FORAGE"}, waitPoolGroup = "Cow Wait", loops = 150}) +local run1 = ADRuns.getRun(s3, 1) +check("R2 navn", run1.name, "Cow 1") +check("R2 mål", run1.targetWayPointId, 94) +check("R2 manual", run1.ingredientMode, "manual") +check("R2 varer", table.concat(run1.ingredients, ","), "STRAW,FORAGE") +check("R2 pulje", run1.waitPoolGroup, "Cow Wait") +check("R2 loops klippet til 99", run1.loops, 99) +check("R2 gammel store urørt", ADRuns.getRun(s2, 1).name, "Cow Feed") +local s3b = ADRuns.updateRun(s3, 1, {ingredientMode = "bogus", loops = -5}) +check("R2 ukendt mode ignoreres", ADRuns.getRun(s3b, 1).ingredientMode, "manual") +check("R2 loops under 0 -> 0", ADRuns.getRun(s3b, 1).loops, 0) +check("R2 ukendt run -> uændret", #ADRuns.updateRun(s3, 99, {name = "x"}).runs, 2) +check("R2 targetWayPointId=false rydder målet", ADRuns.getRun(ADRuns.updateRun(s3, 1, {targetWayPointId = false}), 1).targetWayPointId, nil) +check("R2 nil rører ikke målet", ADRuns.getRun(ADRuns.updateRun(s3, 1, {name = "y"}), 1).targetWayPointId, 94) + +-- R3: tildel traktor; samme traktor flyttes mellem runs +local s4, prev = ADRuns.assignVehicle(s3, 1, "vehicleA") +check("R3 tildelt", ADRuns.getRun(s4, 1).vehicleIds[1], "vehicleA") +check("R3 ingen tidligere", prev, nil) +check("R3 opslag", ADRuns.findRunByVehicle(s4, "vehicleA").id, 1) +local s5, prev2 = ADRuns.assignVehicle(s4, 2, "vehicleA") +check("R3 flyttet fra run 1", prev2, 1) +check("R3 væk fra run 1", #ADRuns.getRun(s5, 1).vehicleIds, 0) +check("R3 i run 2", ADRuns.getRun(s5, 2).vehicleIds[1], "vehicleA") +local s5b, prev3 = ADRuns.assignVehicle(s5, 2, "vehicleA") +check("R3 gen-tildeling samme run -> ingen dublet", #ADRuns.getRun(s5b, 2).vehicleIds, 1) +check("R3 gen-tildeling -> ingen 'flyttet fra'", prev3, nil) +local s6 = ADRuns.unassignVehicle(s5, "vehicleA") +check("R3 fjernet", ADRuns.findRunByVehicle(s6, "vehicleA"), nil) +check("R3 ukendt run ved tildeling -> uændret", ADRuns.findRunByVehicle(ADRuns.assignVehicle(s6, 42, "vehicleB"), "vehicleB"), nil) + +-- R4: slet run +local s7 = ADRuns.deleteRun(s5, 1) +check("R4 ét run tilbage", #s7.runs, 1) +check("R4 det rigtige", s7.runs[1].id, 2) +check("R4 nextId bevares", s7.nextId, 3) + +-- R5: records frem og tilbage +local records = ADRuns.toRecords(s5) +check("R5 target som tal", records[1].target, 94) +check("R5 varer som streng", records[1].ingredients, "STRAW FORAGE") +check("R5 køretøjer", records[2].vehicleIds[1], "vehicleA") +local back = ADRuns.fromRecords(records) +check("R5 to runs tilbage", #back.runs, 2) +check("R5 nextId = højeste + 1", back.nextId, 3) +check("R5 varer parset", ADRuns.getRun(back, 1).ingredients[2], "FORAGE") +check("R5 pulje", ADRuns.getRun(back, 1).waitPoolGroup, "Cow Wait") +check("R5 target 0 -> nil", ADRuns.getRun(back, 2).targetWayPointId, nil) +check("R5 køretøj", ADRuns.findRunByVehicle(back, "vehicleA").id, 2) + +-- R6: defekte poster springes over; dubletter af id ignoreres +local dirty = ADRuns.fromRecords({ + {id = "x", name = "Broken"}, + {id = 5, name = "", }, + {id = 7, name = "Ok", target = "12", ingredientMode = "weird", ingredients = "", waitPool = nil, loops = "3", vehicleIds = {}}, + {id = 7, name = "Dublet"}, +}) +check("R6 kun den gyldige", #dirty.runs, 1) +check("R6 target parses fra streng", dirty.runs[1].targetWayPointId, 12) +check("R6 ukendt mode -> auto", dirty.runs[1].ingredientMode, "auto") +check("R6 tom varestreng -> tom liste", #dirty.runs[1].ingredients, 0) +check("R6 manglende pulje -> tom streng", dirty.runs[1].waitPoolGroup, "") +check("R6 loops fra streng", dirty.runs[1].loops, 3) +check("R6 nextId", dirty.nextId, 8) +check("R6 tom liste", #ADRuns.fromRecords(nil).runs, 0) +check("R6 parseIngredients trimmer", #ADRuns.parseIngredients(" STRAW FORAGE "), 2) + +print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) +os.exit(failures == 0 and 0 or 1) +``` + +- [ ] **Step 2: Kør testen og se den fejle** + +Kør: `cd ~/fs25-adsmartpickup && luajit tests/test_adRuns.lua` +Forventet: fejler med `cannot open FS25_ADSmartPickup/adRuns.lua` (filen findes ikke endnu). + +- [ ] **Step 3: Skriv modellen** + +`FS25_ADSmartPickup/adRuns.lua`: + +```lua +-- AD Runs +-- Rene funktioner (ingen spil-API) for "runs": navn, aflæsningsmål, varer, Wait-pulje, +-- loop-tæller og tildelte traktorer. Alle funktioner returnerer en NY store — +-- input røres aldrig, så GUI og controller kan holde referencer uden overraskelser. + +ADRuns = {} + +ADRuns.MAX_LOOPS = 99 +ADRuns.XML_VERSION = 1 +ADRuns.INGREDIENT_MODES = {auto = true, manual = true} + +local function copyList(list) + local copy = {} + for index, value in ipairs(list or {}) do + copy[index] = value + end + return copy +end + +local function copyRun(run) + return { + id = run.id, + name = run.name, + targetWayPointId = run.targetWayPointId, + ingredientMode = run.ingredientMode, + ingredients = copyList(run.ingredients), + waitPoolGroup = run.waitPoolGroup, + loops = run.loops, + vehicleIds = copyList(run.vehicleIds), + } +end + +local function copyStore(store) + local runs = {} + for index, run in ipairs(store.runs) do + runs[index] = copyRun(run) + end + return {nextId = store.nextId, runs = runs} +end + +local function clampLoops(loops) + local value = tonumber(loops) or 0 + return math.max(0, math.min(ADRuns.MAX_LOOPS, math.floor(value))) +end + +function ADRuns.newStore() + return {nextId = 1, runs = {}} +end + +function ADRuns.createRun(store, name) + local new = copyStore(store) + local run = { + id = new.nextId, + name = name, + targetWayPointId = nil, + ingredientMode = "auto", + ingredients = {}, + waitPoolGroup = "", + loops = 0, + vehicleIds = {}, + } + new.nextId = new.nextId + 1 + table.insert(new.runs, run) + return new, run +end + +function ADRuns.getRun(store, runId) + for _, run in ipairs(store.runs) do + if run.id == runId then + return run + end + end + return nil +end + +function ADRuns.deleteRun(store, runId) + local new = copyStore(store) + for index, run in ipairs(new.runs) do + if run.id == runId then + table.remove(new.runs, index) + break + end + end + return new +end + +-- changes: delmængde af {name, targetWayPointId, ingredientMode, ingredients, waitPoolGroup, loops}. +function ADRuns.updateRun(store, runId, changes) + local new = copyStore(store) + local run = ADRuns.getRun(new, runId) + if run == nil then + return new + end + if changes.name ~= nil then run.name = changes.name end + -- false = ryd målet (nil i changes betyder "rør ikke") + if changes.targetWayPointId == false then + run.targetWayPointId = nil + elseif changes.targetWayPointId ~= nil then + run.targetWayPointId = changes.targetWayPointId + end + if ADRuns.INGREDIENT_MODES[changes.ingredientMode] then run.ingredientMode = changes.ingredientMode end + if changes.ingredients ~= nil then run.ingredients = copyList(changes.ingredients) end + if changes.waitPoolGroup ~= nil then run.waitPoolGroup = changes.waitPoolGroup end + if changes.loops ~= nil then run.loops = clampLoops(changes.loops) end + return new +end + +function ADRuns.findRunByVehicle(store, vehicleId) + for _, run in ipairs(store.runs) do + for _, id in ipairs(run.vehicleIds) do + if id == vehicleId then + return run + end + end + end + return nil +end + +function ADRuns.unassignVehicle(store, vehicleId) + local new = copyStore(store) + for _, run in ipairs(new.runs) do + for index, id in ipairs(run.vehicleIds) do + if id == vehicleId then + table.remove(run.vehicleIds, index) + break + end + end + end + return new +end + +-- En traktor er kun i ét run. Returnerer ny store + id på det run den blev flyttet FRA (nil = ingen). +function ADRuns.assignVehicle(store, runId, vehicleId) + local previous = ADRuns.findRunByVehicle(store, vehicleId) + if previous ~= nil and previous.id == runId then + return copyStore(store), nil + end + local new = ADRuns.unassignVehicle(store, vehicleId) + local run = ADRuns.getRun(new, runId) + if run == nil then + return new, nil + end + table.insert(run.vehicleIds, vehicleId) + return new, previous ~= nil and previous.id or nil +end + +-- Varer gemmes som mellemrumsadskilte fillType-NAVNE ("STRAW FORAGE") — navne er +-- stabile på tværs af mods/kort, fillType-indeks er det ikke. +function ADRuns.serializeIngredients(list) + return table.concat(list or {}, " ") +end + +function ADRuns.parseIngredients(text) + local list = {} + for name in string.gmatch(tostring(text or ""), "%S+") do + table.insert(list, name) + end + return list +end + +-- Flade poster (kun strenge/tal) til XML-laget, i store'ens rækkefølge. +function ADRuns.toRecords(store) + local records = {} + for index, run in ipairs(store.runs) do + records[index] = { + id = run.id, + name = run.name, + target = run.targetWayPointId or 0, + ingredientMode = run.ingredientMode, + ingredients = ADRuns.serializeIngredients(run.ingredients), + waitPool = run.waitPoolGroup, + loops = run.loops, + vehicleIds = copyList(run.vehicleIds), + } + end + return records +end + +-- Poster uden gyldigt id/navn eller med dublet-id springes over; nextId = højeste id + 1. +function ADRuns.fromRecords(records) + local store = ADRuns.newStore() + for _, record in ipairs(records or {}) do + local id = tonumber(record.id) + local isValid = id ~= nil and id >= 1 and record.name ~= nil and record.name ~= "" + if isValid and ADRuns.getRun(store, id) == nil then + local target = tonumber(record.target) + table.insert(store.runs, { + id = id, + name = record.name, + targetWayPointId = target ~= nil and target > 0 and target or nil, + ingredientMode = ADRuns.INGREDIENT_MODES[record.ingredientMode] and record.ingredientMode or "auto", + ingredients = ADRuns.parseIngredients(record.ingredients), + waitPoolGroup = record.waitPool or "", + loops = clampLoops(record.loops), + vehicleIds = copyList(record.vehicleIds), + }) + store.nextId = math.max(store.nextId, id + 1) + end + end + return store +end +``` + +- [ ] **Step 4: Kør testen og se den bestå** + +Kør: `cd ~/fs25-adsmartpickup && luajit tests/test_adRuns.lua` +Forventet: `ALLE TESTS OK`. + +- [ ] **Step 5: Commit** + +```bash +cd ~/fs25-adsmartpickup && git add FS25_ADSmartPickup/adRuns.lua tests/test_adRuns.lua && git commit -q -m "feat: run-model (rene funktioner) til runs-UI" +``` + +--- + +### Task 2: XML-lagring (`adRunsStorage.lua`) + +**Files:** +- Create: `FS25_ADSmartPickup/adRunsStorage.lua` +- Modify: `tests/test_adRuns.lua` (tilføj X1–X3 før `print(failures ...)`) + +**Interfaces:** +- Consumes: `ADRuns.toRecords`, `ADRuns.fromRecords`, `ADRuns.XML_VERSION` (Task 1) +- Produces: + - `ADRunsStorage.FILE_NAME = "ADSmartPickup_runs.xml"`, `ADRunsStorage.ROOT = "ADSmartPickupRuns"` + - `ADRunsStorage.getPath() -> string|nil` + - `ADRunsStorage.writeRecords(xmlFile, records)`, `ADRunsStorage.readRecords(xmlFile) -> records` + - `ADRunsStorage.save(store) -> boolean`, `ADRunsStorage.load() -> store` + +XML-format (spec): ``. + +- [ ] **Step 1: Tilføj fejlende tests** + +Indsæt i `tests/test_adRuns.lua` lige før `print(failures == 0 ...)`: + +```lua +-- ==== XML-laget mod en flad XMLFile-mock (nøgle -> værdi) ==== +dofile("FS25_ADSmartPickup/adRunsStorage.lua") + +local function makeXmlMock(values) + local xml = {values = values or {}, saved = false, deleted = false} + xml.setInt = function(self, key, value) self.values[key] = value end + xml.setString = function(self, key, value) self.values[key] = value end + xml.getInt = function(self, key) return tonumber(self.values[key]) end + xml.getString = function(self, key) local v = self.values[key]; return v ~= nil and tostring(v) or nil end + -- iterate: kalder callback for "prefix(0)", "prefix(1)", ... så længe en nøgle med den stamme findes + xml.iterate = function(self, prefix, callback) + local index = 0 + while true do + local key = string.format("%s(%d)", prefix, index) + local found = false + for k in pairs(self.values) do + if k:sub(1, #key) == key then found = true break end + end + if not found then return end + callback(index + 1, key) + index = index + 1 + end + end + xml.save = function(self) self.saved = true end + xml.delete = function(self) self.deleted = true end + return xml +end + +-- X1: records skrives som attributter + vehicle-underelementer +local out = makeXmlMock() +ADRunsStorage.writeRecords(out, ADRuns.toRecords(s5)) +check("X1 version", out.values["ADSmartPickupRuns#version"], 1) +check("X1 run 0 navn", out.values["ADSmartPickupRuns.run(0)#name"], "Cow 1") +check("X1 run 0 target", out.values["ADSmartPickupRuns.run(0)#target"], 94) +check("X1 run 0 varer", out.values["ADSmartPickupRuns.run(0)#ingredients"], "STRAW FORAGE") +check("X1 run 0 pulje", out.values["ADSmartPickupRuns.run(0)#waitPool"], "Cow Wait") +check("X1 run 1 køretøj", out.values["ADSmartPickupRuns.run(1).vehicle(0)#id"], "vehicleA") + +-- X2: samme data læses tilbage +local records2 = ADRunsStorage.readRecords(makeXmlMock(out.values)) +check("X2 to poster", #records2, 2) +check("X2 id", records2[1].id, 1) +check("X2 køretøj", records2[2].vehicleIds[1], "vehicleA") +check("X2 loops", records2[1].loops, 99) +check("X2 round-trip", ADRuns.findRunByVehicle(ADRuns.fromRecords(records2), "vehicleA").id, 2) + +-- X3: save/load via XMLFile-globalen; ingen savegame-mappe -> tom store, intet skrives +local created, loaded = nil, nil +XMLFile = { + create = function(_, path, root) created = makeXmlMock(); created.path = path; created.root = root; return created end, + load = function(_, path) loaded = makeXmlMock(created and created.values or {}); loaded.path = path; return loaded end, +} +fileExists = function(path) return created ~= nil and created.path == path end +g_currentMission = {missionInfo = {savegameDirectory = "/tmp/sg1"}} +check("X3 gemt", ADRunsStorage.save(s5), true) +check("X3 sti", created.path, "/tmp/sg1/ADSmartPickup_runs.xml") +check("X3 rodelement", created.root, "ADSmartPickupRuns") +check("X3 save+delete kaldt", created.saved and created.deleted, true) +check("X3 indlæst", #ADRunsStorage.load().runs, 2) +check("X3 læse-handle lukket", loaded.deleted, true) +g_currentMission = {missionInfo = {}} +check("X3 uden savegame -> false", ADRunsStorage.save(s5), false) +check("X3 uden savegame -> tom store", #ADRunsStorage.load().runs, 0) +g_currentMission = {missionInfo = {savegameDirectory = "/tmp/sg-ny"}} +check("X3 fil mangler -> tom store", #ADRunsStorage.load().runs, 0) +``` + +- [ ] **Step 2: Kør og se den fejle** + +Kør: `cd ~/fs25-adsmartpickup && luajit tests/test_adRuns.lua` +Forventet: fejler med `cannot open FS25_ADSmartPickup/adRunsStorage.lua`. + +- [ ] **Step 3: Skriv lagringen** + +`FS25_ADSmartPickup/adRunsStorage.lua`: + +```lua +-- AD Runs Storage +-- Runs gemmes i savegame-mappen ved siden af AutoDrive_config.xml. Kun dette lag +-- kender XMLFile; modellen (adRuns) ser kun flade poster. + +ADRunsStorage = {} + +ADRunsStorage.FILE_NAME = "ADSmartPickup_runs.xml" +ADRunsStorage.ROOT = "ADSmartPickupRuns" +ADRunsStorage.LOG_PREFIX = "ADSmartPickup: " + +-- Samme opslag som AutoDrive.getXMLFile_new: mappen kendes efter første gem, ellers via indeks. +function ADRunsStorage.getPath() + local info = g_currentMission ~= nil and g_currentMission.missionInfo or nil + if info == nil then + return nil + end + if info.savegameDirectory ~= nil then + return info.savegameDirectory .. "/" .. ADRunsStorage.FILE_NAME + end + if info.savegameIndex ~= nil and getUserProfileAppPath ~= nil then + return getUserProfileAppPath() .. "savegame" .. tostring(info.savegameIndex) .. "/" .. ADRunsStorage.FILE_NAME + end + return nil +end + +function ADRunsStorage.writeRecords(xmlFile, records) + xmlFile:setInt(ADRunsStorage.ROOT .. "#version", ADRuns.XML_VERSION) + for index, record in ipairs(records) do + local key = string.format("%s.run(%d)", ADRunsStorage.ROOT, index - 1) + xmlFile:setInt(key .. "#id", record.id) + xmlFile:setString(key .. "#name", record.name) + xmlFile:setInt(key .. "#target", record.target) + xmlFile:setString(key .. "#ingredientMode", record.ingredientMode) + xmlFile:setString(key .. "#ingredients", record.ingredients) + xmlFile:setString(key .. "#waitPool", record.waitPool) + xmlFile:setInt(key .. "#loops", record.loops) + for vehicleIndex, vehicleId in ipairs(record.vehicleIds) do + xmlFile:setString(string.format("%s.vehicle(%d)#id", key, vehicleIndex - 1), vehicleId) + end + end +end + +function ADRunsStorage.readRecords(xmlFile) + local records = {} + xmlFile:iterate(ADRunsStorage.ROOT .. ".run", function(_, key) + local record = { + id = xmlFile:getInt(key .. "#id"), + name = xmlFile:getString(key .. "#name"), + target = xmlFile:getInt(key .. "#target"), + ingredientMode = xmlFile:getString(key .. "#ingredientMode"), + ingredients = xmlFile:getString(key .. "#ingredients"), + waitPool = xmlFile:getString(key .. "#waitPool"), + loops = xmlFile:getInt(key .. "#loops"), + vehicleIds = {}, + } + xmlFile:iterate(key .. ".vehicle", function(_, vehicleKey) + local id = xmlFile:getString(vehicleKey .. "#id") + if id ~= nil and id ~= "" then + table.insert(record.vehicleIds, id) + end + end) + table.insert(records, record) + end) + return records +end + +function ADRunsStorage.save(store) + local path = ADRunsStorage.getPath() + if path == nil then + return false + end + local xmlFile = XMLFile.create("ADSmartPickupRuns", path, ADRunsStorage.ROOT) + if xmlFile == nil then + Logging.warning(ADRunsStorage.LOG_PREFIX .. "kunne ikke oprette %s", path) + return false + end + ADRunsStorage.writeRecords(xmlFile, ADRuns.toRecords(store)) + xmlFile:save() + xmlFile:delete() + return true +end + +function ADRunsStorage.load() + local path = ADRunsStorage.getPath() + if path == nil or not fileExists(path) then + return ADRuns.newStore() + end + local xmlFile = XMLFile.load("ADSmartPickupRuns", path) + if xmlFile == nil then + Logging.warning(ADRunsStorage.LOG_PREFIX .. "kunne ikke læse %s; starter uden runs", path) + return ADRuns.newStore() + end + local records = ADRunsStorage.readRecords(xmlFile) + xmlFile:delete() + return ADRuns.fromRecords(records) +end +``` + +Bemærk: testen definerer ikke `Logging`; `save`/`load` når kun `Logging.warning` når `XMLFile.create/load` returnerer nil, hvilket mock'en ikke gør. Tilføj øverst i test-blokken X3 alligevel `Logging = Logging or {warning = function() end, info = function() end}` så en fremtidig test ikke crasher. + +- [ ] **Step 4: Kør og se den bestå** + +Kør: `cd ~/fs25-adsmartpickup && luajit tests/test_adRuns.lua` +Forventet: `ALLE TESTS OK`. + +- [ ] **Step 5: Commit** + +```bash +cd ~/fs25-adsmartpickup && git add FS25_ADSmartPickup/adRunsStorage.lua tests/test_adRuns.lua && git commit -q -m "feat: runs gemmes i savegameN/ADSmartPickup_runs.xml" +``` + +--- + +### Task 3: Eksponér stations-opslag i `adSmartPickup.lua` + +Controlleren (Task 4) og byttet (Task 8) skal bruge de lokale opslag. Ingen adfærdsændring. + +**Files:** +- Modify: `FS25_ADSmartPickup/adSmartPickup.lua:143-149` (load/unload-station), `:154-167` (getOwnStorageLevel), `:361-380` (getTargetLevelAndCapacity) +- Test: `tests/test_adSmartPickup.lua` (tilføj E1–E3 før `-- L6:`-blokken nederst) + +**Interfaces:** +- Produces: + - `ADSmartPickup.getLoadStationAtWayPointId(adEnv, wayPointId, farmId) -> station|nil` + - `ADSmartPickup.getUnloadStationAtWayPointId(adEnv, wayPointId, farmId) -> station|nil` + - `ADSmartPickup.getStationLevelAndCapacity(station, fillType, farmId) -> level, capacity | nil` + - `ADSmartPickup.getOwnStorageLevel(station, fillTypes) -> liter` + - `ADSmartPickup.getFarmId(vehicle) -> farmId|nil` + +- [ ] **Step 1: Tilføj fejlende tests** + +Indsæt i `tests/test_adSmartPickup.lua` lige før linjen `-- L6: samme valg logges kun én gang`: + +```lua +-- ==== E: eksponerede opslag (bruges af runs-controller og læs-bytte) ==== +local eDriver = buildCowWorld() +check("E1 aflæsningsstation ved waypoint", ADSmartPickup.getUnloadStationAtWayPointId(FS25_AutoDrive, 30, 1), unloadStations[1]) +check("E1 ukendt waypoint -> nil", ADSmartPickup.getUnloadStationAtWayPointId(FS25_AutoDrive, 9999, 1), nil) +check("E2 læssestation ved waypoint", ADSmartPickup.getLoadStationAtWayPointId(FS25_AutoDrive, 21, 1), stations[2]) +local eLevel, eCapacity = ADSmartPickup.getStationLevelAndCapacity(unloadStations[1], TMR, 1) +check("E3 niveau i målet", eLevel, 40000) +check("E3 kapacitet i målet", eCapacity, 100000) +check("E3 vare målet ikke tager -> nil", ADSmartPickup.getStationLevelAndCapacity(unloadStations[1], MILK, 1), nil) +check("E3 farmId", ADSmartPickup.getFarmId(eDriver), 1) +``` + +- [ ] **Step 2: Kør og se den fejle** + +Kør: `cd ~/fs25-adsmartpickup && luajit tests/test_adSmartPickup.lua 2>&1 | tail -3` +Forventet: fejler med `attempt to call field 'getUnloadStationAtWayPointId' (a nil value)`. + +- [ ] **Step 3: Eksponér funktionerne** + +I `adSmartPickup.lua`: + +1. Erstat `local function getFarmId(vehicle)` med `function ADSmartPickup.getFarmId(vehicle)` og tilføj lige under funktionen: `local getFarmId = ADSmartPickup.getFarmId`. +2. Erstat `local function getOwnStorageLevel(station, fillTypes)` med `function ADSmartPickup.getOwnStorageLevel(station, fillTypes)` og tilføj under den: `local getOwnStorageLevel = ADSmartPickup.getOwnStorageLevel`. +3. Erstat `local function getTargetLevelAndCapacity(station, fillType, farmId)` med `function ADSmartPickup.getStationLevelAndCapacity(station, fillType, farmId)` og tilføj under den: `local getTargetLevelAndCapacity = ADSmartPickup.getStationLevelAndCapacity`. +4. Tilføj lige efter `getUnloadStationAtMarker`: + +```lua +-- Station ved et AutoDrive-waypoint (bruges af runs-controlleren og læs-byttet); nil uden station. +function ADSmartPickup.getLoadStationAtWayPointId(adEnv, wayPointId, farmId) + local wayPoint = adEnv.ADGraphManager:getWayPointById(wayPointId) + return wayPoint ~= nil and getLoadStationAtMarker(adEnv, wayPoint, farmId) or nil +end + +function ADSmartPickup.getUnloadStationAtWayPointId(adEnv, wayPointId, farmId) + local wayPoint = adEnv.ADGraphManager:getWayPointById(wayPointId) + return wayPoint ~= nil and getUnloadStationAtMarker(adEnv, wayPoint, farmId) or nil +end +``` + +Lua 5.1-fælde: `local getFarmId = ADSmartPickup.getFarmId` skal stå EFTER definitionen, og de funktioner der bruger navnet skal stå efter `local`-linjen (det gør de allerede, da rækkefølgen bevares). + +- [ ] **Step 4: Kør alle tests** + +Kør: `cd ~/fs25-adsmartpickup && luajit tests/test_adSupplyPlanner.lua | tail -1 && luajit tests/test_adSmartPickup.lua | tail -1` +Forventet: `ALLE TESTS OK` to gange. + +- [ ] **Step 5: Commit** + +```bash +cd ~/fs25-adsmartpickup && git add -A FS25_ADSmartPickup/adSmartPickup.lua tests/test_adSmartPickup.lua && git commit -q -m "refactor: eksponér stations- og niveau-opslag til runs-controller" +``` + +--- + +### Task 4: Runs-controller (`adRunsController.lua`) + +**Files:** +- Create: `FS25_ADSmartPickup/adRunsController.lua` +- Test: `tests/test_adRunsController.lua` + +**Interfaces:** +- Consumes: `ADRuns.*` (Task 1), `ADRunsStorage.save/load` (Task 2), `ADSmartPickup.getUnloadStationAtWayPointId/getLoadStationAtWayPointId/getStationLevelAndCapacity/getFarmId` (Task 3), `ADSupplyPlanner.isWaitMarker`. +- Produces: + - `ADRunsController.store` (aktuel store; erstattes ved hver ændring), `ADRunsController.MAX_NETWORK_DISTANCE = 30` + - `ADRunsController.getVehicleId(vehicle) -> string|nil` + - `ADRunsController.getFarmVehicles(adEnv) -> {vehicle, ...}` (farmens køretøjer med `vehicle.ad`) + - `ADRunsController.findVehicleById(adEnv, id) -> vehicle|nil` + - `ADRunsController.getRunForVehicle(vehicle) -> run|nil` + - `ADRunsController.getCarriableFillTypes(adEnv, vehicle) -> {fillTypeIndex, ...}` + - `ADRunsController.getTargetFillTypes(adEnv, run, vehicle) -> {fillTypeIndex, ...}` (Auto-listen: det vognen kan bære ∩ det målet tager) + - `ADRunsController.getRunIngredients(adEnv, run, vehicle) -> {fillTypeIndex, ...}` (manual: navne → indeks; auto: getTargetFillTypes) + - `ADRunsController.validateVehicle(adEnv, run, vehicle) -> {ok = boolean, reason = "noTrailer"|"cannotCarry"|"farFromNetwork"|"noTarget"|nil, capacity = liter|nil, carriable = list}` + - `ADRunsController.startRun(adEnv, runId) -> {{vehicle, ok, reason}, ...}` + - `ADRunsController.stopRun(adEnv, runId) -> antal stoppede` + - `ADRunsController.getRunStatus(adEnv, run) -> {running = n, total = m}` + - `ADRunsController.getTargetMarkers(adEnv, farmId) -> {marker, ...}` (markører med aflæsningsstation, sorteret på navn) + - `ADRunsController.getWaitPools(adEnv) -> {{group = "Cow Wait", count = 6}, ...}` sorteret på navn + - `ADRunsController.createRun(name) -> run`, `.deleteRun(runId)`, `.updateRun(runId, changes)`, `.assignVehicle(runId, vehicle) -> previousRunId|nil`, `.unassignVehicle(vehicle)` — wrapper der erstatter `ADRunsController.store` + - `ADRunsController.load()`, `ADRunsController.save()`, `ADRunsController.installSaveHook()` + +- [ ] **Step 1: Skriv de fejlende tests** + +`tests/test_adRunsController.lua`: + +```lua +-- Kør: luajit tests/test_adRunsController.lua (fra repo-roden) +-- Controlleren oversætter et run til AutoDrive-tilstand pr. traktor. Mock'er AD + de +-- få ADSmartPickup-opslag den bruger. + +local STRAW, TMR, WATER, MILK = 31, 116, 8, 30 +local FILL_TYPE_NAMES = {[STRAW] = "STRAW", [TMR] = "FORAGE", [WATER] = "WATER", [MILK] = "MILK"} +local FILL_TYPE_INDEX = {} +for index, name in pairs(FILL_TYPE_NAMES) do FILL_TYPE_INDEX[name] = index end +g_fillTypeManager = { + getFillTypeNameByIndex = function(_, index) return FILL_TYPE_NAMES[index] end, + getFillTypeIndexByName = function(_, name) return FILL_TYPE_INDEX[name] end, +} +local logLines = {} +Logging = { + info = function(fmt, ...) table.insert(logLines, string.format(fmt, ...)) end, + warning = function(fmt, ...) table.insert(logLines, "WARN " .. string.format(fmt, ...)) end, +} +Utils = {prependedFunction = function(original, extra) return function(...) extra(...); return original(...) end end} +ItemSystem = {save = function() end} +g_currentMission = {getFarmId = function() return 1 end, missionInfo = {}} + +-- ---- AutoDrive-mock ---- +local RT_NONE, RT_ONLYPICKUP = 1, 2 +local MODE_PICKUPANDDELIVER = 2 +local vehicles, markers, wayPoints = {}, {}, {} +FS25_AutoDrive = { + AutoDrive = { + RT_NONE = RT_NONE, RT_ONLYPICKUP = RT_ONLYPICKUP, MODE_PICKUPANDDELIVER = MODE_PICKUPANDDELIVER, + settings = { + useFolders = {values = {false, true}, current = 1, isVehicleSpecific = false}, + rotateTargets = {values = {1, 2, 3, 4}, current = 1, isVehicleSpecific = true}, + }, + getAllVehicles = function() return vehicles end, + getAllUnits = function(vehicle) return vehicle.trailers end, + getAllFillLevels = function(trailers) return trailers.level or 0, trailers.capacity or 0, false, 0 end, + getSupportedFillTypesOfAllUnitsAlphabetically = function(vehicle) return vehicle.carriable or {} end, + }, + ADGraphManager = { + getMapMarkers = function() return markers end, + getMapMarkerByWayPointId = function(_, id) for _, m in ipairs(markers) do if m.id == id then return m end end end, + getWayPointById = function(_, id) return wayPoints[id] end, + getDistanceFromNetwork = function(_, vehicle) return vehicle.networkDistance or 0 end, + }, +} + +local function addMarker(id, name, group, x, z) + wayPoints[id] = {x = x, z = z, incoming = {}} + table.insert(markers, {id = id, markerIndex = #markers + 1, name = name, group = group}) +end + +-- ADSmartPickup-opslag: hvilke waypoints har hvilken slags station, og hvad målet tager +local unloadAt, loadAt = {}, {} +ADSmartPickup = { + getFarmId = function(vehicle) return vehicle.farmId end, + getUnloadStationAtWayPointId = function(_, id) return unloadAt[id] end, + getLoadStationAtWayPointId = function(_, id) return loadAt[id] end, + getStationLevelAndCapacity = function(station, fillType) + if station.accepts[fillType] == nil then return nil end + return 0, station.accepts[fillType] + end, +} +ADSupplyPlanner = {isWaitMarker = function(marker) return string.find(string.lower(marker.group or ""), "%f[%a]wait%f[%A]") ~= nil end} + +local function makeVehicle(uniqueId, name, carriable, capacity, x, z) + local stateModule = {selectedFillTypes = {}, fillType = 0, active = false, loopCounter = 5, loopsDone = 3, dirtyCount = 0} + stateModule.setMode = function(self, mode) self.mode = mode end + stateModule.setFirstMarkerByWayPointId = function(self, id) self.firstMarkerId = id end + stateModule.setSecondMarkerByWayPointId = function(self, id) self.secondMarkerId = id end + stateModule.setFillType = function(self, fillType) self.fillType = fillType end + stateModule.raiseDirtyFlag = function(self) self.dirtyCount = self.dirtyCount + 1 end + stateModule.setLoopsDone = function(self, n) self.loopsDone = n end + stateModule.isActive = function(self) return self.active end + local vehicle = { + uniqueId = uniqueId, farmId = 1, rootNode = uniqueId, + carriable = carriable, trailers = {level = 0, capacity = capacity}, + getName = function() return name end, + getOwnerFarmId = function() return 1 end, + startAutoDrive = function(self) self.ad.stateModule.active = true; self.started = (self.started or 0) + 1 end, + stopAutoDrive = function(self) self.ad.stateModule.active = false; self.stopped = (self.stopped or 0) + 1 end, + ad = {stateModule = stateModule, settings = {rotateTargets = {values = {1, 2, 3, 4}, current = 1, new = 1, isVehicleSpecific = true}}}, + } + nodePositions = nodePositions or {} + nodePositions[uniqueId] = {x = x or 0, z = z or 0} + table.insert(vehicles, vehicle) + return vehicle +end +function getWorldTranslation(node) local p = nodePositions[node]; return p.x, 0, p.z end + +dofile("FS25_ADSmartPickup/adRuns.lua") +dofile("FS25_ADSmartPickup/adRunsStorage.lua") +dofile("FS25_ADSmartPickup/adRunsController.lua") +local adEnv = FS25_AutoDrive + +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 + +local function buildWorld() + vehicles, markers, wayPoints, unloadAt, loadAt, logLines = {}, {}, {}, {}, {}, {} + ADRunsController.store = ADRuns.newStore() + addMarker(94, "Cow 1 Food", "Cow Food", 5000, 0); unloadAt[94] = {accepts = {[STRAW] = 300000, [TMR] = 300000, [WATER] = 50000}} + addMarker(44, "10 Silo Storage", "Siloer", 0, 0); loadAt[44] = {} + addMarker(75, "TMR Load", "Fabrication", 900, 0); loadAt[75] = {} + addMarker(100, "Cow Wait", "Cow Wait", 5100, 0) + addMarker(101, "Cow Wait", "Cow Wait", 5200, 0) + addMarker(200, "F91 Wait", "F91", 8000, 0) + addMarker(300, "Pig Wait", "Pig Wait", 9000, 0) +end + +-- C1: køretøjs-id og farmens AD-køretøjer +buildWorld() +local fastracA = makeVehicle("vehicleA", "Fastrac A", {STRAW, TMR}, 1000000, 100, 0) +local fastracB = makeVehicle("vehicleB", "Fastrac B", {STRAW}, 700000, 4000, 0) +local noAd = {uniqueId = "vehicleC", getOwnerFarmId = function() return 1 end} +table.insert(vehicles, noAd) +local otherFarm = makeVehicle("vehicleD", "Nabo", {STRAW}, 1000, 0, 0); otherFarm.getOwnerFarmId = function() return 2 end +check("C1 uniqueId", ADRunsController.getVehicleId(fastracA), "vehicleA") +check("C1 fallback savegame-id", ADRunsController.getVehicleId({currentSavegameId = 7}), "7") +check("C1 intet id -> nil", ADRunsController.getVehicleId({}), nil) +check("C1 farmens AD-køretøjer", #ADRunsController.getFarmVehicles(adEnv), 2) +check("C1 opslag på id", ADRunsController.findVehicleById(adEnv, "vehicleB"), fastracB) +check("C1 ukendt id -> nil", ADRunsController.findVehicleById(adEnv, "nope"), nil) + +-- C2: opret run, tildel; opslag run for køretøj; flyt mellem runs +local cow = ADRunsController.createRun("Cow Feed") +check("C2 run oprettet", ADRuns.getRun(ADRunsController.store, cow.id).name, "Cow Feed") +check("C2 ingen tidligere", ADRunsController.assignVehicle(cow.id, fastracA), nil) +check("C2 run for køretøj", ADRunsController.getRunForVehicle(fastracA).id, cow.id) +check("C2 ikke tildelt -> nil", ADRunsController.getRunForVehicle(fastracB), nil) +local pig = ADRunsController.createRun("Pig Feed") +check("C2 flyttet fra Cow", ADRunsController.assignVehicle(pig.id, fastracA), cow.id) +check("C2 nu i Pig", ADRunsController.getRunForVehicle(fastracA).id, pig.id) +ADRunsController.unassignVehicle(fastracA) +check("C2 fjernet", ADRunsController.getRunForVehicle(fastracA), nil) +ADRunsController.deleteRun(pig.id) +check("C2 slettet", ADRuns.getRun(ADRunsController.store, pig.id), nil) + +-- C3: Auto-vareliste = vognens varer ∩ målets varer; manual = navne -> indeks (ukendte droppes) +ADRunsController.updateRun(cow.id, {targetWayPointId = 94}) +local cowRun = ADRuns.getRun(ADRunsController.store, cow.id) +check("C3 auto: halm+TMR (vand kan vognen ikke bære)", table.concat(ADRunsController.getRunIngredients(adEnv, cowRun, fastracA), ","), STRAW .. "," .. TMR) +check("C3 auto: kun halm for halm-vogn", table.concat(ADRunsController.getRunIngredients(adEnv, cowRun, fastracB), ","), tostring(STRAW)) +ADRunsController.updateRun(cow.id, {ingredientMode = "manual", ingredients = {"FORAGE", "UNKNOWN_THING"}}) +cowRun = ADRuns.getRun(ADRunsController.store, cow.id) +check("C3 manual: FORAGE -> indeks, ukendt droppes", table.concat(ADRunsController.getRunIngredients(adEnv, cowRun, fastracA), ","), tostring(TMR)) +ADRunsController.updateRun(cow.id, {ingredientMode = "auto"}) +local noTarget = ADRunsController.createRun("Uden mål") +check("C3 uden mål -> tom liste", #ADRunsController.getRunIngredients(adEnv, ADRuns.getRun(ADRunsController.store, noTarget.id), fastracA), 0) + +-- C4: validering +cowRun = ADRuns.getRun(ADRunsController.store, cow.id) +check("C4 ok", ADRunsController.validateVehicle(adEnv, cowRun, fastracA).ok, true) +check("C4 kapacitet", ADRunsController.validateVehicle(adEnv, cowRun, fastracA).capacity, 1000000) +local noTrailer = makeVehicle("vehicleE", "Uden vogn", {}, 0) +check("C4 ingen vogn", ADRunsController.validateVehicle(adEnv, cowRun, noTrailer).reason, "noTrailer") +local milkTruck = makeVehicle("vehicleF", "Mælkebil", {MILK}, 20000) +check("C4 kan ikke bære", ADRunsController.validateVehicle(adEnv, cowRun, milkTruck).reason, "cannotCarry") +fastracB.networkDistance = 31 +check("C4 for langt fra vejnettet", ADRunsController.validateVehicle(adEnv, cowRun, fastracB).reason, "farFromNetwork") +fastracB.networkDistance = 0 +check("C4 intet mål", ADRunsController.validateVehicle(adEnv, ADRuns.getRun(ADRunsController.store, noTarget.id), fastracA).reason, "noTarget") +ADRunsController.updateRun(noTarget.id, {targetWayPointId = 4242}) +check("C4 mål-markør findes ikke", ADRunsController.validateVehicle(adEnv, ADRuns.getRun(ADRunsController.store, noTarget.id), fastracA).reason, "noTarget") + +-- C5: Start skriver AD-tilstand og starter; ✖-traktorer springes over +ADRunsController.updateRun(cow.id, {loops = 4, waitPoolGroup = "Cow Wait"}) +ADRunsController.assignVehicle(cow.id, fastracA) +ADRunsController.assignVehicle(cow.id, noTrailer) +local results = ADRunsController.startRun(adEnv, cow.id) +check("C5 to resultater", #results, 2) +check("C5 A startet", fastracA.started, 1) +check("C5 A ok", results[1].ok, true) +check("C5 uden vogn sprunget over", noTrailer.started, nil) +check("C5 uden vogn grund", results[2].reason, "noTrailer") +local state = fastracA.ad.stateModule +check("C5 mode P&D", state.mode, MODE_PICKUPANDDELIVER) +check("C5 mål = run'ets mål", state.secondMarkerId, 94) +check("C5 start-pickup = nærmeste læssestation", state.firstMarkerId, 44) +check("C5 varer = auto-listen", table.concat(state.selectedFillTypes, ","), STRAW .. "," .. TMR) +check("C5 fillType = første", state.fillType, STRAW) +check("C5 loops", state.loopCounter, 4) +check("C5 loopsDone nulstillet", state.loopsDone, 0) +check("C5 rotateTargets = kun pålæsning (current)", fastracA.ad.settings.rotateTargets.current, 2) +check("C5 rotateTargets = kun pålæsning (new)", fastracA.ad.settings.rotateTargets.new, 2) +check("C5 useFolders globalt til", adEnv.AutoDrive.settings.useFolders.current, 2) +check("C5 dirty flag", state.dirtyCount > 0, true) +check("C5 status", ADRunsController.getRunStatus(adEnv, ADRuns.getRun(ADRunsController.store, cow.id)).running, 1) +check("C5 total", ADRunsController.getRunStatus(adEnv, ADRuns.getRun(ADRunsController.store, cow.id)).total, 2) +check("C5 ukendt run -> tom", #ADRunsController.startRun(adEnv, 999), 0) + +-- C6: Stop +check("C6 stoppede", ADRunsController.stopRun(adEnv, cow.id), 1) +check("C6 A stoppet", fastracA.stopped, 1) +check("C6 status 0", ADRunsController.getRunStatus(adEnv, ADRuns.getRun(ADRunsController.store, cow.id)).running, 0) +check("C6 medlemskab bevaret", ADRunsController.getRunForVehicle(fastracA).id, cow.id) + +-- C7: Start på traktor der allerede kører -> ikke startet igen, men tilstand skrives +fastracA.ad.stateModule.active = true +ADRunsController.startRun(adEnv, cow.id) +check("C7 ikke dobbelt-startet", fastracA.started, 1) + +-- C8: mål-markører og Wait-puljer til dropdowns +local targets = ADRunsController.getTargetMarkers(adEnv, 1) +check("C8 kun markører med aflæsningsstation", #targets, 1) +check("C8 navn", targets[1].name, "Cow 1 Food") +local pools = ADRunsController.getWaitPools(adEnv) +check("C8 to puljer (F91 er ikke en Wait-mappe)", #pools, 2) +check("C8 sorteret", pools[1].group, "Cow Wait") +check("C8 antal", pools[1].count, 2) + +-- C9: gem/indlæs; ukendte køretøjs-id'er logges ved indlæsning men beholdes i run'et +local savedRecords +ADRunsStorage.save = function(store) savedRecords = ADRuns.toRecords(store); return true end +ADRunsStorage.load = function() return ADRuns.fromRecords(savedRecords) end +ADRunsController.assignVehicle(cow.id, {uniqueId = "vehicleGONE", ad = {}, getOwnerFarmId = function() return 1 end, getName = function() return "Væk" end}) +check("C9 gemt", ADRunsController.save(), true) +ADRunsController.store = ADRuns.newStore() +ADRunsController.load(adEnv) +check("C9 indlæst", ADRuns.getRun(ADRunsController.store, cow.id).name, "Cow Feed") +check("C9 ukendt køretøj logget", logLines[#logLines]:find("vehicleGONE", 1, true) ~= nil, true) +check("C9 run beholder kendte", ADRunsController.getRunForVehicle(fastracA).id, cow.id) +local hooked = false +ItemSystem.save = function() hooked = true end +ADRunsController.installSaveHook() +ItemSystem.save() +check("C9 save-hook kaldes før ItemSystem.save", hooked, true) + +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** + +Kør: `cd ~/fs25-adsmartpickup && luajit tests/test_adRunsController.lua` +Forventet: `cannot open FS25_ADSmartPickup/adRunsController.lua`. + +- [ ] **Step 3: Skriv controlleren** + +`FS25_ADSmartPickup/adRunsController.lua`: + +```lua +-- AD Runs Controller +-- Oversætter et run til AutoDrive-tilstand pr. traktor: validering, Start/Stop, status, +-- Auto-vareliste, og "hvilket run hører traktoren til". GUI'et kalder kun herind; +-- de eksisterende hooks (choosePickup, Wait-punkter) spørger getRunForVehicle/getRunIngredients. + +ADRunsController = {} + +ADRunsController.LOG_PREFIX = "ADSmartPickup: " +-- Længere væk fra vejnettet end dette kan AutoDrive ikke starte (samme grænse som AD's HUD). +ADRunsController.MAX_NETWORK_DISTANCE = 30 + +ADRunsController.store = ADRuns.newStore() + +local function copyList(list) + local copy = {} + for index, value in ipairs(list or {}) do + copy[index] = value + end + return copy +end + +-- Stabil nøgle: FS25's uniqueId ("vehicle…"), ellers savegame-id. Navne bruges aldrig. +function ADRunsController.getVehicleId(vehicle) + if vehicle.uniqueId ~= nil then + return tostring(vehicle.uniqueId) + end + if vehicle.getUniqueId ~= nil then + local id = vehicle:getUniqueId() + if id ~= nil then + return tostring(id) + end + end + if vehicle.currentSavegameId ~= nil then + return tostring(vehicle.currentSavegameId) + end + return nil +end + +function ADRunsController.getFarmVehicles(adEnv) + local farmId = g_currentMission:getFarmId() + local result = {} + for _, vehicle in pairs(adEnv.AutoDrive.getAllVehicles()) do + if vehicle.ad ~= nil and vehicle.getOwnerFarmId ~= nil and vehicle:getOwnerFarmId() == farmId then + table.insert(result, vehicle) + end + end + return result +end + +function ADRunsController.findVehicleById(adEnv, vehicleId) + for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do + if ADRunsController.getVehicleId(vehicle) == vehicleId then + return vehicle + end + end + return nil +end + +function ADRunsController.getRunForVehicle(vehicle) + local id = ADRunsController.getVehicleId(vehicle) + return id ~= nil and ADRuns.findRunByVehicle(ADRunsController.store, id) or nil +end + +-- ---- store-wrappers (erstatter altid hele store'en; modellen muterer aldrig) ---- + +function ADRunsController.createRun(name) + local store, run = ADRuns.createRun(ADRunsController.store, name) + ADRunsController.store = store + return run +end + +function ADRunsController.deleteRun(runId) + ADRunsController.store = ADRuns.deleteRun(ADRunsController.store, runId) +end + +function ADRunsController.updateRun(runId, changes) + ADRunsController.store = ADRuns.updateRun(ADRunsController.store, runId, changes) +end + +function ADRunsController.assignVehicle(runId, vehicle) + local id = ADRunsController.getVehicleId(vehicle) + if id == nil then + return nil + end + local store, previousRunId = ADRuns.assignVehicle(ADRunsController.store, runId, id) + ADRunsController.store = store + return previousRunId +end + +function ADRunsController.unassignVehicle(vehicle) + local id = ADRunsController.getVehicleId(vehicle) + if id ~= nil then + ADRunsController.store = ADRuns.unassignVehicle(ADRunsController.store, id) + end +end + +-- ---- varer ---- + +function ADRunsController.getCarriableFillTypes(adEnv, vehicle) + return adEnv.AutoDrive.getSupportedFillTypesOfAllUnitsAlphabetically(vehicle) or {} +end + +-- Auto-listen: det vognen kan bære, som målet også tager. Er målet en fabrik, indsnævrer +-- findSupplyPickup selv til de ønskede produktioners inputs bagefter. +function ADRunsController.getTargetFillTypes(adEnv, run, vehicle) + local farmId = ADSmartPickup.getFarmId(vehicle) + local target = run.targetWayPointId ~= nil and ADSmartPickup.getUnloadStationAtWayPointId(adEnv, run.targetWayPointId, farmId) or nil + if target == nil then + return {} + end + local result = {} + for _, fillType in ipairs(ADRunsController.getCarriableFillTypes(adEnv, vehicle)) do + if ADSmartPickup.getStationLevelAndCapacity(target, fillType, farmId) ~= nil then + table.insert(result, fillType) + end + end + table.sort(result) + return result +end + +function ADRunsController.getRunIngredients(adEnv, run, vehicle) + if run.ingredientMode == "manual" then + local result = {} + for _, name in ipairs(run.ingredients) do + local index = g_fillTypeManager:getFillTypeIndexByName(name) + if index ~= nil then + table.insert(result, index) + end + end + return result + end + return ADRunsController.getTargetFillTypes(adEnv, run, vehicle) +end + +-- ---- validering ---- + +local function getLoadCapacity(adEnv, vehicle) + local trailers = adEnv.AutoDrive.getAllUnits(vehicle) + if trailers == nil then + return nil + end + local _, capacity = adEnv.AutoDrive.getAllFillLevels(trailers) + return capacity ~= nil and capacity > 0 and capacity or nil +end + +local function intersects(listA, listB) + for _, a in ipairs(listA) do + for _, b in ipairs(listB) do + if a == b then + return true + end + end + end + return false +end + +-- reason: "noTarget" | "noTrailer" | "cannotCarry" | "farFromNetwork" (l10n-nøgler i GUI'et). +function ADRunsController.validateVehicle(adEnv, run, vehicle) + local carriable = ADRunsController.getCarriableFillTypes(adEnv, vehicle) + local result = {ok = false, carriable = carriable, capacity = getLoadCapacity(adEnv, vehicle)} + if run.targetWayPointId == nil or adEnv.ADGraphManager:getMapMarkerByWayPointId(run.targetWayPointId) == nil then + result.reason = "noTarget" + return result + end + if result.capacity == nil then + result.reason = "noTrailer" + return result + end + if not intersects(ADRunsController.getRunIngredients(adEnv, run, vehicle), carriable) then + result.reason = "cannotCarry" + return result + end + local distance = adEnv.ADGraphManager.getDistanceFromNetwork ~= nil and adEnv.ADGraphManager:getDistanceFromNetwork(vehicle) or 0 + if distance == nil or distance > ADRunsController.MAX_NETWORK_DISTANCE then + result.reason = "farFromNetwork" + return result + end + result.ok = true + return result +end + +-- ---- Start / Stop ---- + +-- AD gemmer indstillinger som INDEKS i values; useFolders er global, rotateTargets pr. køretøj. +local function setSetting(adEnv, vehicle, settingName, value) + local global = adEnv.AutoDrive.settings[settingName] + if global == nil then + return + end + local setting = global + if global.isVehicleSpecific and vehicle.ad.settings ~= nil and vehicle.ad.settings[settingName] ~= nil then + setting = vehicle.ad.settings[settingName] + end + for index, candidate in ipairs(setting.values) do + if candidate == value then + setting.current = index + setting.new = index + return + end + end +end + +-- Nærmeste markør med en læssestation: kun en gyldig start — moden vælger den reelle kilde pr. tur. +local function findNearestLoadMarker(adEnv, vehicle, farmId) + local x, _, z = getWorldTranslation(vehicle.rootNode) + local best, bestDistance = nil, math.huge + for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do + if ADSmartPickup.getLoadStationAtWayPointId(adEnv, marker.id, farmId) ~= nil then + local wayPoint = adEnv.ADGraphManager:getWayPointById(marker.id) + local dx, dz = wayPoint.x - x, wayPoint.z - z + local distance = dx * dx + dz * dz + if distance < bestDistance then + best, bestDistance = marker, distance + end + end + end + return best +end + +local function applyRunToVehicle(adEnv, run, vehicle, ingredients) + local stateModule = vehicle.ad.stateModule + local farmId = ADSmartPickup.getFarmId(vehicle) + stateModule:setMode(adEnv.AutoDrive.MODE_PICKUPANDDELIVER) + stateModule:setSecondMarkerByWayPointId(run.targetWayPointId) + local loadMarker = findNearestLoadMarker(adEnv, vehicle, farmId) + if loadMarker ~= nil then + stateModule:setFirstMarkerByWayPointId(loadMarker.id) + end + stateModule.selectedFillTypes = copyList(ingredients) + stateModule:setFillType(ingredients[1]) + stateModule.loopCounter = run.loops + stateModule:setLoopsDone(0) + setSetting(adEnv, vehicle, "useFolders", true) + setSetting(adEnv, vehicle, "rotateTargets", adEnv.AutoDrive.RT_ONLYPICKUP) + stateModule:raiseDirtyFlag() +end + +local function isVehicleActive(vehicle) + local stateModule = vehicle.ad ~= nil and vehicle.ad.stateModule or nil + return stateModule ~= nil and stateModule.isActive ~= nil and stateModule:isActive() +end + +-- Pr. tildelt traktor: validér, skriv AD-tilstand, start. ✖-traktorer springes over. +function ADRunsController.startRun(adEnv, runId) + local run = ADRuns.getRun(ADRunsController.store, runId) + local results = {} + if run == nil then + return results + end + for _, vehicleId in ipairs(run.vehicleIds) do + local vehicle = ADRunsController.findVehicleById(adEnv, vehicleId) + if vehicle ~= nil then + local validation = ADRunsController.validateVehicle(adEnv, run, vehicle) + local result = {vehicle = vehicle, ok = validation.ok, reason = validation.reason} + if validation.ok then + local ingredients = ADRunsController.getRunIngredients(adEnv, run, vehicle) + local ok, err = pcall(applyRunToVehicle, adEnv, run, vehicle, ingredients) + if ok then + if not isVehicleActive(vehicle) then + vehicle:startAutoDrive() + end + Logging.info(ADRunsController.LOG_PREFIX .. "run '%s': '%s' startet mod markør %s", tostring(run.name), tostring(vehicle:getName()), tostring(run.targetWayPointId)) + else + result.ok, result.reason = false, "error" + Logging.warning(ADRunsController.LOG_PREFIX .. "run '%s': kunne ikke starte '%s': %s", tostring(run.name), tostring(vehicle:getName()), tostring(err)) + end + end + table.insert(results, result) + end + end + return results +end + +function ADRunsController.stopRun(adEnv, runId) + local run = ADRuns.getRun(ADRunsController.store, runId) + local stopped = 0 + if run == nil then + return stopped + end + for _, vehicleId in ipairs(run.vehicleIds) do + local vehicle = ADRunsController.findVehicleById(adEnv, vehicleId) + if vehicle ~= nil and isVehicleActive(vehicle) then + vehicle:stopAutoDrive() + stopped = stopped + 1 + end + end + return stopped +end + +function ADRunsController.getRunStatus(adEnv, run) + local running = 0 + for _, vehicleId in ipairs(run.vehicleIds) do + local vehicle = ADRunsController.findVehicleById(adEnv, vehicleId) + if vehicle ~= nil and isVehicleActive(vehicle) then + running = running + 1 + end + end + return {running = running, total = #run.vehicleIds} +end + +-- ---- opslag til dropdowns ---- + +function ADRunsController.getTargetMarkers(adEnv, farmId) + local result = {} + for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do + if ADSmartPickup.getUnloadStationAtWayPointId(adEnv, marker.id, farmId) ~= nil then + table.insert(result, marker) + end + end + table.sort(result, function(a, b) return tostring(a.name) < tostring(b.name) end) + return result +end + +function ADRunsController.getWaitPools(adEnv) + local counts = {} + for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do + if ADSupplyPlanner.isWaitMarker(marker) then + counts[marker.group] = (counts[marker.group] or 0) + 1 + end + end + local result = {} + for group, count in pairs(counts) do + table.insert(result, {group = group, count = count}) + end + table.sort(result, function(a, b) return a.group < b.group end) + return result +end + +-- ---- gem / indlæs ---- + +function ADRunsController.save() + local ok, saved = pcall(ADRunsStorage.save, ADRunsController.store) + if not ok then + Logging.warning(ADRunsController.LOG_PREFIX .. "kunne ikke gemme runs: %s", tostring(saved)) + return false + end + return saved +end + +-- Køretøjs-referencer der ikke findes (solgt, andet savegame) logges; run'et beholdes. +function ADRunsController.load(adEnv) + local ok, store = pcall(ADRunsStorage.load) + if not ok then + Logging.warning(ADRunsController.LOG_PREFIX .. "kunne ikke indlæse runs: %s", tostring(store)) + return + end + ADRunsController.store = store + for _, run in ipairs(store.runs) do + for _, vehicleId in ipairs(run.vehicleIds) do + if ADRunsController.findVehicleById(adEnv, vehicleId) == nil then + Logging.info(ADRunsController.LOG_PREFIX .. "run '%s': køretøj %s findes ikke (endnu) i dette savegame", tostring(run.name), tostring(vehicleId)) + end + end + end +end + +function ADRunsController.installSaveHook() + if ItemSystem == nil or ItemSystem.save == nil or Utils == nil then + return + end + ItemSystem.save = Utils.prependedFunction(ItemSystem.save, function() + ADRunsController.save() + end) +end +``` + +Bemærk: køretøjer indlæses i FS25 EFTER `loadMap`, så "findes ikke"-loglinjen kan komme for køretøjer der dukker op lidt senere — deraf "(endnu)". Opslag i GUI'et sker altid live via id, så det er kun en loglinje. + +- [ ] **Step 4: Kør og se den bestå** + +Kør: `cd ~/fs25-adsmartpickup && luajit tests/test_adRunsController.lua` +Forventet: `ALLE TESTS OK`. Fejler C5 "useFolders globalt til" med `1`: tjek at `setSetting` bruger den globale tabel når `isVehicleSpecific` er false. + +- [ ] **Step 5: Commit** + +```bash +cd ~/fs25-adsmartpickup && git add FS25_ADSmartPickup/adRunsController.lua tests/test_adRunsController.lua && git commit -q -m "feat: runs-controller (validering, Start/Stop, Auto-vareliste, gem/indlæs)" +``` + +--- + +### Task 5: Integration i `choosePickup` og Wait-puljen + +**Files:** +- Modify: `FS25_ADSmartPickup/adSmartPickup.lua:478-493` (`choosePickup`), `:647-665` (`update`, + ny `loadMap`) +- Modify: `FS25_ADSmartPickup/adUnloadWait.lua:42-53` (`getWaitCandidates`), `:109-113` (kaldet i `startWaiting`) +- Modify: `FS25_ADSmartPickup/modDesc.xml` (`extraSourceFiles`) +- Test: `tests/test_adSmartPickup.lua` (I1–I4) + +**Interfaces:** +- Consumes: `ADRunsController.getRunForVehicle(vehicle)`, `ADRunsController.getRunIngredients(adEnv, run, vehicle)`, `ADRunsController.load(adEnv)`, `ADRunsController.installSaveHook()`. +- Produces: `ADSmartPickup.getIngredientsFor(adEnv, driver) -> list|nil` (run-liste, ellers supplyState-listen; bruges af v1.9). + +- [ ] **Step 1: Tilføj fejlende tests** + +I `tests/test_adSmartPickup.lua`: tilføj `dofile("FS25_ADSmartPickup/adRuns.lua")` og `dofile("FS25_ADSmartPickup/adRunsController.lua")` lige før `dofile("FS25_ADSmartPickup/adUnloadWait.lua")`. Udvid `g_fillTypeManager`-mock'en (linje 8) med `getFillTypeIndexByName = function(_, name) for index, candidate in pairs(FILL_TYPE_NAMES) do if candidate == name then return index end end return nil end` (run'ets manuelle varer er navne). Udvid AutoDrive-mock'en (`FS25_AutoDrive.AutoDrive`) med `getSupportedFillTypesOfAllUnitsAlphabetically = function(vehicle) return vehicle.carriable or {} end` og `getAllVehicles = function() return testVehicles end`; tilføj `local testVehicles = {}` over `FS25_AutoDrive = {`, og tilføj `g_currentMission.getFarmId = function() return 1 end`. `getSupportedFillTypesOfAllUnitsAlphabetically` findes måske allerede i mock'en (fabrikstestene bruger `driver.carriable`) — behold én definition. + +Indsæt før `-- ==== E: eksponerede opslag`: + +```lua +-- ==== I: run-integration — ingredienser fra run i stedet for multi-valg; Wait-pulje pr. run ==== +-- I1: traktor i et run med ÉN valgt vare i AD får alligevel behovsstyring (run'ets liste) +local runDriver = buildCowWorld() +runDriver.uniqueId = "vehicleRun" +runDriver.ad.stateModule.selectedFillTypes = {STRAW} +runDriver.ad.stateModule.fillType = STRAW +testVehicles = {runDriver} +ADRunsController.store = ADRuns.newStore() +local feedRun = ADRunsController.createRun("Cow Feed") +ADRunsController.updateRun(feedRun.id, {targetWayPointId = 30, ingredientMode = "manual", ingredients = {"FORAGE", "STRAW"}}) +ADRunsController.assignVehicle(feedRun.id, runDriver) +check("I1 run-liste styrer: halm mangler -> halmladen", manager:getNextPickup(runDriver, false), 21) +cowLevels[STRAW] = 99500 +check("I1 run-liste styrer: TMR når halm er fuld", manager:getNextPickup(runDriver, false), 20) +check("I1 getIngredientsFor = run'ets liste", table.concat(ADSmartPickup.getIngredientsFor(FS25_AutoDrive, runDriver), ","), TMR .. "," .. STRAW) + +-- I2: samme traktor uden run -> som før (én vare = mappe-logik i mappen "Lader") +ADRunsController.store = ADRuns.newStore() +runDriver.ad.stateModule.selectedFillTypes = {STRAW} +cowLevels[STRAW] = 0 +check("I2 uden run: mappe-logik", manager:getNextPickup(runDriver, false), 21) +check("I2 uden run og én vare: ingen ingrediensliste", ADSmartPickup.getIngredientsFor(FS25_AutoDrive, runDriver), nil) + +-- I3: run med tom auto-liste (intet mål) -> falder tilbage til AD's valg +local emptyRun = ADRunsController.createRun("Tomt") +ADRunsController.assignVehicle(emptyRun.id, runDriver) +check("I3 tom run-liste -> som uden run", manager:getNextPickup(runDriver, false), 21) + +-- I4: Wait-kandidater begrænses til run'ets pulje +buildWaitWorld() +addMarker(42, "Pig Wait 1", "Pig Wait", 5050, 0) -- nærmere end Cow Wait, men forkert pulje +local vp, tp = makeUnloadingVehicle("Fastrac Pulje", 511, 5000, 35000) +vp.uniqueId = "vehiclePool" +testVehicles = {vp} +ADRunsController.store = ADRuns.newStore() +local poolRun = ADRunsController.createRun("Cow Feed") +ADRunsController.updateRun(poolRun.id, {targetWayPointId = 30, waitPoolGroup = "Cow Wait"}) +ADRunsController.assignVehicle(poolRun.id, vp) +UnloadTask.update(tp, 16) +check("I4 kun run'ets pulje", tp.destinationID, 40) +ADRunsController.store = ADRuns.newStore() +``` + +- [ ] **Step 2: Kør og se den fejle** + +Kør: `cd ~/fs25-adsmartpickup && luajit tests/test_adSmartPickup.lua 2>&1 | grep -E "FAIL|FEJL|error" | head` +Forventet: I1 fejler ("forventede 21, fik ORIGINAL" eller `getIngredientsFor` nil-kald), I4 fejler med `42`. + +- [ ] **Step 3: Implementér** + +`adSmartPickup.lua` — erstat `ADSmartPickup.choosePickup`: + +```lua +-- Ingredienslisten for køretøjet: run'ets liste når det er i et run (ingen tændknap +-- nødvendig), ellers AD's multi-valg (>1 vare). nil = ingen behovsstyring. +function ADSmartPickup.getIngredientsFor(adEnv, driver) + local run = ADRunsController ~= nil and ADRunsController.getRunForVehicle(driver) or nil + if run ~= nil then + local ingredients = ADRunsController.getRunIngredients(adEnv, run, driver) + if #ingredients > 0 then + return ingredients + end + return nil + end + return ADSupplyPlanner.resolveIngredients(supplyState[driver], driver.ad.stateModule:getSelectedFillTypes()) +end + +-- Samlet valg: behovsstyret med ingrediensliste, ellers mappe-logik. nil = AutoDrives original. +function ADSmartPickup.choosePickup(adEnv, driver) + local ingredients = ADSmartPickup.getIngredientsFor(adEnv, driver) + if ingredients == nil then + supplyState[driver] = nil + return ADSmartPickup.findLeastFilledPickup(adEnv, driver) + end + + local choice = ADSmartPickup.findSupplyPickup(adEnv, driver, ingredients) + if choice == nil then + return ADSmartPickup.findLeastFilledPickup(adEnv, driver) + end + applySupplyChoice(driver, ingredients, choice) + return choice +end +``` + +Tilføj under `installHook`-kaldet i `ADSmartPickup:update` (inde i `if adEnv ~= nil and ...`-blokken, efter `isHooked = true`): + +```lua + if ADRunsController ~= nil then + pcall(ADRunsController.load, adEnv) + pcall(ADRunsController.installSaveHook) + end +``` + +(Runs indlæses her og ikke i `loadMap`, fordi AD's globals først er sikre når hook'en installeres, og køretøjerne er indlæst på det tidspunkt.) + +`adUnloadWait.lua` — erstat `getWaitCandidates`: + +```lua +-- poolGroup: run'ets Wait-mappe; tom/nil = alle Wait-mapper. +local function getWaitCandidates(adEnv, poolGroup) + local candidates = {} + for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do + local isInPool = poolGroup == nil or poolGroup == "" or marker.group == poolGroup + if isInPool and ADSupplyPlanner.isWaitMarker(marker) then + local wayPoint = adEnv.ADGraphManager:getWayPointById(marker.id) + if wayPoint ~= nil then + table.insert(candidates, {marker = marker, x = wayPoint.x, z = wayPoint.z}) + end + end + end + return candidates +end + +local function getWaitPoolGroup(vehicle) + local run = ADRunsController ~= nil and ADRunsController.getRunForVehicle(vehicle) or nil + return run ~= nil and run.waitPoolGroup or nil +end +``` + +og i `startWaiting` erstat `getWaitCandidates(adEnv)` med `getWaitCandidates(adEnv, getWaitPoolGroup(vehicle))`. Udvid WARN-teksten: `... intet ledigt Wait-punkt%s med vejnet ...` hvor `%s` er `" i puljen '" .. pool .. "'"` når puljen er sat, ellers `""`. + +`modDesc.xml` — `extraSourceFiles` i rækkefølge: `adSupplyPlanner.lua`, `adRuns.lua`, `adRunsStorage.lua`, `adRunsController.lua`, `adUnloadWait.lua`, `adSmartPickup.lua`. + +- [ ] **Step 4: Kør alle tests** + +Kør: `cd ~/fs25-adsmartpickup && for t in tests/test_*.lua; do luajit $t | tail -1; done` +Forventet: `ALLE TESTS OK` ×4. + +- [ ] **Step 5: Commit** + +```bash +cd ~/fs25-adsmartpickup && git add -A FS25_ADSmartPickup tests && git commit -q -m "feat: run-traktorer får ingredienser og Wait-pulje fra deres run" +``` + +--- + +### Task 6a: Controller-opslag til GUI'et (accepterede varer, Wait-reservationer) + +**Files:** +- Modify: `FS25_ADSmartPickup/adRunsController.lua` (nye funktioner), `FS25_ADSmartPickup/adUnloadWait.lua` (ny `getClaims`) +- Test: `tests/test_adRunsController.lua` (C10), `tests/test_adSmartPickup.lua` (W11) + +**Interfaces:** +- Produces: + - `ADRunsController.getTargetAcceptedFillTypes(adEnv, run) -> {{index, name, title}, ...}` (alle fillTypes målet tager, sorteret på title; tom uden mål) + - `ADRunsController.getVehicleLabel(adEnv, vehicle) -> "Fastrac 2135 4WS (#3)"` (navn + løbenummer i farmens AD-køretøjsliste) + - `ADUnloadWait.getClaims() -> {{marker = marker, vehicle = vehicle}, ...}` sorteret på `marker.group`, `marker.id` + +- [ ] **Step 1: Fejlende tests** + +`tests/test_adRunsController.lua` — udvid `g_fillTypeManager`-mock'en øverst med: + +```lua + getFillTypes = function() + return { + {index = STRAW, name = "STRAW", title = "Halm"}, + {index = TMR, name = "FORAGE", title = "TMR"}, + {index = WATER, name = "WATER", title = "Vand"}, + {index = MILK, name = "MILK", title = "Mælk"}, + } + end, +``` + +Indsæt før `print(failures == 0 ...)`: + +```lua +-- C10: målets accepterede varer (til afkrydsning i GUI'et) + køretøjs-label +buildWorld() +local labelA = makeVehicle("vehicleA", "Fastrac A", {STRAW, TMR}, 1000000) +local labelB = makeVehicle("vehicleB", "Fastrac B", {STRAW}, 700000) +local accRun = ADRunsController.createRun("Cow Feed") +check("C10 uden mål -> tom", #ADRunsController.getTargetAcceptedFillTypes(adEnv, ADRuns.getRun(ADRunsController.store, accRun.id)), 0) +ADRunsController.updateRun(accRun.id, {targetWayPointId = 94}) +local accepted = ADRunsController.getTargetAcceptedFillTypes(adEnv, ADRuns.getRun(ADRunsController.store, accRun.id)) +check("C10 tre varer (halm, TMR, vand)", #accepted, 3) +check("C10 sorteret på titel", accepted[1].title .. "," .. accepted[2].title .. "," .. accepted[3].title, "Halm,TMR,Vand") +check("C10 navn med", accepted[1].name, "STRAW") +check("C10 label", ADRunsController.getVehicleLabel(adEnv, labelB), "Fastrac B (#2)") +``` + +`tests/test_adSmartPickup.lua` — indsæt efter W-blokken (før `-- ==== I:`): + +```lua +-- W11: reservationer kan aflæses (GUI'ets Wait-puljer-visning) +buildWaitWorld() +local vc1, tc1 = makeUnloadingVehicle("Fastrac Claim", 531, 5000, 35000) +UnloadTask.update(tc1, 16) +local claims = ADUnloadWait.getClaims() +check("W11 én reservation", #claims, 1) +check("W11 markør", claims[1].marker.id, 40) +check("W11 køretøj", claims[1].vehicle, vc1) +``` + +- [ ] **Step 2: Kør og se dem fejle** + +Kør: `cd ~/fs25-adsmartpickup && luajit tests/test_adRunsController.lua | grep -E "FAIL|error"; luajit tests/test_adSmartPickup.lua 2>&1 | grep -E "FAIL|error"` +Forventet: `attempt to call field 'getTargetAcceptedFillTypes'` og `'getClaims' (a nil value)`. + +- [ ] **Step 3: Implementér** + +`adRunsController.lua` — tilføj efter `getRunIngredients`: + +```lua +-- Alle varer målet tager (uanset vogn) — afkrydsningslisten i GUI'et. {index, name, title}, sorteret på titel. +function ADRunsController.getTargetAcceptedFillTypes(adEnv, run) + if run.targetWayPointId == nil then + return {} + end + local farmId = g_currentMission:getFarmId() + local target = ADSmartPickup.getUnloadStationAtWayPointId(adEnv, run.targetWayPointId, farmId) + if target == nil then + return {} + end + local fillTypes = g_fillTypeManager.getFillTypes ~= nil and g_fillTypeManager:getFillTypes() or g_fillTypeManager.fillTypes or {} + local result = {} + for _, fillType in ipairs(fillTypes) do + if fillType.index ~= nil and ADSmartPickup.getStationLevelAndCapacity(target, fillType.index, farmId) ~= nil then + table.insert(result, {index = fillType.index, name = fillType.name, title = fillType.title or fillType.name}) + end + end + table.sort(result, function(a, b) return tostring(a.title) < tostring(b.title) end) + return result +end + +-- Fire traktorer kan hedde det samme; løbenummeret er pladsen i farmens AD-køretøjsliste. +function ADRunsController.getVehicleLabel(adEnv, vehicle) + for index, candidate in ipairs(ADRunsController.getFarmVehicles(adEnv)) do + if candidate == vehicle then + return string.format("%s (#%d)", tostring(vehicle:getName()), index) + end + end + return tostring(vehicle:getName()) +end +``` + +`adUnloadWait.lua` — tilføj før `ADUnloadWait.install`: + +```lua +-- Hvem holder hvor (GUI'ets Wait-puljer-visning). Sorteret på mappe, dernæst markør-id. +function ADUnloadWait.getClaims() + local result = {} + for _, wait in pairs(waits) do + if wait.waitMarker ~= nil and claims[wait.waitMarker.id] ~= nil then + table.insert(result, {marker = wait.waitMarker, vehicle = claims[wait.waitMarker.id]}) + end + end + table.sort(result, function(a, b) + if a.marker.group ~= b.marker.group then + return tostring(a.marker.group) < tostring(b.marker.group) + end + return a.marker.id < b.marker.id + end) + return result +end +``` + +- [ ] **Step 4: Kør alle tests** — `cd ~/fs25-adsmartpickup && for t in tests/test_*.lua; do luajit $t | tail -1; done` → `ALLE TESTS OK` ×4. + +- [ ] **Step 5: Commit** + +```bash +cd ~/fs25-adsmartpickup && git add -A FS25_ADSmartPickup tests && git commit -q -m "feat: controller-opslag til GUI (målets varer, køretøjs-label, Wait-reservationer)" +``` + +--- + +### Task 6b: ESC-fanen "Smart Pickup" (GUI + l10n) + +Ingen luajit-test (kræver spillets GUI-klasser); verificeres in-game i Task 7. Forbillede verificeret i FS25_FarmOperationsDashboard (`fixInGameMenu`) og FS25_AutoDrive (indstillings-rækker). + +**Files:** +- Create: `FS25_ADSmartPickup/gui/SmartPickupFrame.lua`, `FS25_ADSmartPickup/gui/SmartPickupFrame.xml`, `FS25_ADSmartPickup/gui/guiProfiles.xml`, `FS25_ADSmartPickup/l10n/l10n_da.xml`, `FS25_ADSmartPickup/l10n/l10n_en.xml` +- Modify: `FS25_ADSmartPickup/adSmartPickup.lua` (`loadMap` → registrér fanen), `FS25_ADSmartPickup/modDesc.xml` (``) + +**Interfaces:** +- Consumes: `ADRunsController.*` (Task 4 + 6a), `ADUnloadWait.getClaims()`, `ADSupplyPlanner.isWaitMarker`. +- Verificerede spil-API'er: `Class(Sub, TabbedMenuFrameElement)`, `Frame:superClass().new(nil, mt)`, `self:setMenuButtonInfo({...})` med `{inputAction = InputAction.MENU_BACK|MENU_ACTIVATE|MENU_EXTRA_1|MENU_EXTRA_2|MENU_CANCEL, text = ..., callback = fn}`, `g_gui:loadProfiles(path)`, `g_gui:loadGui(xmlPath, name, frame, true)`, `g_gui.screenControllers[InGameMenu]` + `pagingElement:addElement/updateAbsolutePosition/updatePageMapping`, `inGameMenu:registerPage(frame, position, predicate)`, `inGameMenu:addPageTab(frame, iconFile, GuiUtils.getUVs(uvs))`, `inGameMenu:rebuildTabList()`, `element:clone(parent)`, `element:unlinkElement()`, `element:delete()`, `FocusManager:removeElement/loadElementFromCustomValues/setFocus`, `parent:invalidateLayout()`, `MultiTextOption:setTexts(list)`, `:setState(index, true)`, `onClick="metode"` → `metode(self, state, element)`, `element:setDisabled(bool)`, `Text:setText(str)`, `TextInputDialog.show(callback, target, defaultText, title, description, maxChars, okText)` → `callback(target, value, clickOk)`, `YesNoDialog.show(callback, target, text, title)` → `callback(target, yes)`, `g_i18n:getText(key)`, `Utils.getFilename(rel, dir)`. + +Layout (én rullende indstillingsside; spec'ens under-faner er slået sammen: Wait-puljerne vises som skrivebeskyttede rækker nederst — mindre GUI-risiko, samme information): + +``` +┌ Smart Pickup ────────────────────────────────────────────────┐ +│ Run: [◄ Cow Feed (2/2) ►] (vælger run) │ +│ Mål: [◄ Cow 1 Food ►] │ +│ Varer: [◄ Auto ►] / Vælg │ +│ Halm [◄ Ja ►] (kun ved Vælg) │ +│ TMR [◄ Ja ►] │ +│ Wait-pulje: [◄ Cow Wait (6) ►] │ +│ Ture: [◄ 0 = uendeligt ►] │ +│ Traktorer: │ +│ Fastrac 2135 4WS (#1) — 1.000.000 l — ✔ [◄ Ja ►] │ +│ Fastrac 2135 4WS (#2) — ingen vogn ✖ [◄ Nej ►] │ +│ Wait-puljer: Cow Wait — 6 punkter — 1 optaget (Fastrac #1) │ +│ Status: Kører (2/2) │ +├───────────────────────────────────────────────────────────────┤ +│ [Tilbage] [Start run] [Nyt run] [Omdøb] [Slet run] │ +└───────────────────────────────────────────────────────────────┘ +``` + +- [ ] **Step 1: l10n** + +`FS25_ADSmartPickup/l10n/l10n_da.xml`: + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +`FS25_ADSmartPickup/l10n/l10n_en.xml` — samme nøgler, engelsk: + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +`modDesc.xml`: tilføj `` efter ``. + +- [ ] **Step 2: Profiler** + +`FS25_ADSmartPickup/gui/guiProfiles.xml`: + +```xml + + + + + + + + + + + + + + + + + + + + +``` + +(`gui.icon_ingameMenu_vehicles` er et basisspil-atlas-slice ligesom FOD's `gui.icon_ingameMenu_statistics`; virker det ikke, byt til `gui.icon_ingameMenu_statistics` som er bekræftet.) + +- [ ] **Step 3: Frame-XML** + +`FS25_ADSmartPickup/gui/SmartPickupFrame.xml`: + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +- [ ] **Step 4: Frame-Lua** + +`FS25_ADSmartPickup/gui/SmartPickupFrame.lua`: + +```lua +-- Smart Pickup — ESC-fane +-- Viser og redigerer runs og starter/stopper dem. Al logik ligger i ADRunsController; +-- her oversættes kun mellem GUI-elementer og run-felter. Alt kører i pcall så en GUI-fejl +-- aldrig tager spillet med sig. + +SmartPickupFrame = {} +SmartPickupFrame._mt = Class(SmartPickupFrame, TabbedMenuFrameElement) + +SmartPickupFrame.REFRESH_INTERVAL_MS = 1000 +SmartPickupFrame.LOG_PREFIX = "ADSmartPickup: " +SmartPickupFrame.STATE_OFF, SmartPickupFrame.STATE_ON = 1, 2 + +function SmartPickupFrame.new(i18n, getAdEnv) + local self = SmartPickupFrame:superClass().new(nil, SmartPickupFrame._mt) + self.name = "SmartPickupFrame" + self.i18n = i18n + self.getAdEnv = getAdEnv + self.selectedRunId = nil + self.runIds, self.targetIds, self.poolGroups = {}, {}, {} + self.rowData = {} -- element -> {kind = "ingredient"|"vehicle", ...} + self.clonedRows = {} + self.refreshTimer = 0 + self.statusOverride = nil + self.backButtonInfo = {inputAction = InputAction.MENU_BACK} + self.startButtonInfo = {inputAction = InputAction.MENU_ACTIVATE, text = i18n:getText("spu_button_start"), callback = function() self:onClickStartStop() end} + self.newButtonInfo = {inputAction = InputAction.MENU_EXTRA_1, text = i18n:getText("spu_button_new"), callback = function() self:onClickNew() end} + self.renameButtonInfo = {inputAction = InputAction.MENU_EXTRA_2, text = i18n:getText("spu_button_rename"), callback = function() self:onClickRename() end} + self.deleteButtonInfo = {inputAction = InputAction.MENU_CANCEL, text = i18n:getText("spu_button_delete"), callback = function() self:onClickDelete() end} + self:setMenuButtonInfo({self.backButtonInfo, self.startButtonInfo, self.newButtonInfo, self.renameButtonInfo, self.deleteButtonInfo}) + return self +end + +function SmartPickupFrame:delete() + SmartPickupFrame:superClass().delete(self) +end + +function SmartPickupFrame:copyAttributes(src) + SmartPickupFrame:superClass().copyAttributes(self, src) + self.i18n, self.getAdEnv = src.i18n, src.getAdEnv +end + +-- Prefabs skal ud af layoutet før de klones, ellers vises de selv som rækker. +function SmartPickupFrame:initialize() + for _, name in ipairs({"ingredientPrefab", "vehiclePrefab", "poolPrefab"}) do + local prefab = self[name] + if prefab ~= nil then + prefab:unlinkElement() + FocusManager:removeElement(prefab) + end + end + self.ingredientModeOption:setTexts({self.i18n:getText("spu_mode_auto"), self.i18n:getText("spu_mode_manual")}) + local loopTexts = {self.i18n:getText("spu_loops_infinite")} + for loops = 1, ADRuns.MAX_LOOPS do + table.insert(loopTexts, tostring(loops)) + end + self.loopsOption:setTexts(loopTexts) +end + +function SmartPickupFrame:onFrameOpen() + SmartPickupFrame:superClass().onFrameOpen(self) + self.statusOverride = nil + self:safe(self.rebuild) + FocusManager:setFocus(self.runOption) +end + +function SmartPickupFrame:onFrameClose() + SmartPickupFrame:superClass().onFrameClose(self) +end + +-- Live-opdatering af status/traktor-rækker mens siden er åben (uden at genopbygge rækkerne). +function SmartPickupFrame:update(dt) + SmartPickupFrame:superClass().update(self, dt) + self.refreshTimer = self.refreshTimer + dt + if self.refreshTimer >= SmartPickupFrame.REFRESH_INTERVAL_MS then + self.refreshTimer = 0 + self:safe(self.refreshLive) + end +end + +function SmartPickupFrame:safe(fn, ...) + local ok, err = pcall(fn, self, ...) + if not ok then + Logging.warning(SmartPickupFrame.LOG_PREFIX .. "GUI-fejl: %s", tostring(err)) + self.statusOverride = string.format(self.i18n:getText("spu_status_error"), tostring(err)) + if self.statusText ~= nil then + self.statusText:setText(self.statusOverride) + end + end +end + +function SmartPickupFrame:getRun() + return self.selectedRunId ~= nil and ADRuns.getRun(ADRunsController.store, self.selectedRunId) or nil +end + +-- ---- opbygning ---- + +function SmartPickupFrame:clearRows() + for _, row in ipairs(self.clonedRows) do + row:delete() + end + self.clonedRows, self.rowData = {}, {} +end + +function SmartPickupFrame:addRow(prefab, parent, data) + local row = prefab:clone(parent) + FocusManager:loadElementFromCustomValues(row) + table.insert(self.clonedRows, row) + local option = row:getDescendantByName("option") + if option ~= nil then + option:setTexts({self.i18n:getText("ui_no"), self.i18n:getText("ui_yes")}) + self.rowData[option] = data + end + return row +end + +function SmartPickupFrame:rebuild() + local adEnv = self.getAdEnv() + local store = ADRunsController.store + if self.selectedRunId == nil or ADRuns.getRun(store, self.selectedRunId) == nil then + self.selectedRunId = store.runs[1] ~= nil and store.runs[1].id or nil + end + self:clearRows() + self:fillRunOption(adEnv) + local run = self:getRun() + local hasRun = run ~= nil and adEnv ~= nil + for _, element in ipairs({self.targetOption, self.ingredientModeOption, self.waitPoolOption, self.loopsOption}) do + element:setDisabled(not hasRun) + end + if hasRun then + self:fillTargetOption(adEnv, run) + self.ingredientModeOption:setState(run.ingredientMode == "manual" and SmartPickupFrame.STATE_ON or SmartPickupFrame.STATE_OFF, true) + self:fillIngredientRows(adEnv, run) + self:fillWaitPoolOption(adEnv, run) + self.loopsOption:setState(run.loops + 1, true) + self:fillVehicleRows(adEnv, run) + end + self:fillPoolRows(adEnv) + for _, box in ipairs({self.ingredientBox, self.vehicleBox, self.poolBox, self.boxLayout}) do + box:invalidateLayout() + end + self:refreshLive() +end + +function SmartPickupFrame:fillRunOption(adEnv) + local texts, index = {}, 1 + self.runIds = {} + for position, run in ipairs(ADRunsController.store.runs) do + local status = adEnv ~= nil and ADRunsController.getRunStatus(adEnv, run) or {running = 0, total = #run.vehicleIds} + table.insert(texts, string.format("%s (%d/%d)", run.name, status.running, status.total)) + table.insert(self.runIds, run.id) + if run.id == self.selectedRunId then + index = position + end + end + if #texts == 0 then + texts = {self.i18n:getText("spu_noRuns")} + end + self.runOption:setTexts(texts) + self.runOption:setState(index, true) + self.runOption:setDisabled(#self.runIds == 0) +end + +function SmartPickupFrame:fillTargetOption(adEnv, run) + local texts, index = {self.i18n:getText("spu_noTarget")}, 1 + self.targetIds = {false} + for _, marker in ipairs(ADRunsController.getTargetMarkers(adEnv, g_currentMission:getFarmId())) do + table.insert(texts, tostring(marker.name)) + table.insert(self.targetIds, marker.id) + if marker.id == run.targetWayPointId then + index = #texts + end + end + self.targetOption:setTexts(texts) + self.targetOption:setState(index, true) +end + +function SmartPickupFrame:fillIngredientRows(adEnv, run) + if run.ingredientMode ~= "manual" then + return + end + local chosen = {} + for _, name in ipairs(run.ingredients) do + chosen[name] = true + end + for _, fillType in ipairs(ADRunsController.getTargetAcceptedFillTypes(adEnv, run)) do + local row = self:addRow(self.ingredientPrefab, self.ingredientBox, {kind = "ingredient", name = fillType.name}) + row:getDescendantByName("title"):setText(tostring(fillType.title)) + row:getDescendantByName("option"):setState(chosen[fillType.name] and SmartPickupFrame.STATE_ON or SmartPickupFrame.STATE_OFF, true) + end +end + +function SmartPickupFrame:fillWaitPoolOption(adEnv, run) + local texts, index = {self.i18n:getText("spu_waitPool_all")}, 1 + self.poolGroups = {""} + for _, pool in ipairs(ADRunsController.getWaitPools(adEnv)) do + table.insert(texts, string.format("%s (%d)", pool.group, pool.count)) + table.insert(self.poolGroups, pool.group) + if pool.group == run.waitPoolGroup then + index = #texts + end + end + self.waitPoolOption:setTexts(texts) + self.waitPoolOption:setState(index, true) +end + +function SmartPickupFrame:fillVehicleRows(adEnv, run) + local inRun = {} + for _, id in ipairs(run.vehicleIds) do + inRun[id] = true + end + for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do + local id = ADRunsController.getVehicleId(vehicle) + local row = self:addRow(self.vehiclePrefab, self.vehicleBox, {kind = "vehicle", vehicle = vehicle, row = nil}) + self.rowData[row:getDescendantByName("option")].row = row + row:getDescendantByName("option"):setState(inRun[id] and SmartPickupFrame.STATE_ON or SmartPickupFrame.STATE_OFF, true) + row:getDescendantByName("title"):setText(self:describeVehicle(adEnv, run, vehicle, inRun[id])) + end +end + +-- "Fastrac 2135 4WS (#1) — 1.000.000 l — ✔" / "… — ✖ ingen vogn"; reservationsnote fra Start bevares. +function SmartPickupFrame:describeVehicle(adEnv, run, vehicle, isInRun) + local label = ADRunsController.getVehicleLabel(adEnv, vehicle) + if not isInRun then + return label + end + local validation = ADRunsController.validateVehicle(adEnv, run, vehicle) + local capacity = validation.capacity ~= nil and string.format(self.i18n:getText("spu_capacity"), g_i18n:formatNumber(validation.capacity, 0)) or nil + local parts = {label} + if capacity ~= nil then + table.insert(parts, capacity) + end + if validation.ok then + table.insert(parts, "✔") + else + table.insert(parts, "✖ " .. self.i18n:getText("spu_reason_" .. tostring(validation.reason))) + end + return table.concat(parts, " — ") +end + +function SmartPickupFrame:fillPoolRows(adEnv) + if adEnv == nil then + return + end + local occupied = {} + for _, claim in ipairs(ADUnloadWait.getClaims()) do + occupied[claim.marker.group] = occupied[claim.marker.group] or {} + table.insert(occupied[claim.marker.group], ADRunsController.getVehicleLabel(adEnv, claim.vehicle)) + end + for _, pool in ipairs(ADRunsController.getWaitPools(adEnv)) do + local names = occupied[pool.group] or {} + local suffix = #names > 0 and (" (" .. table.concat(names, ", ") .. ")") or "" + local row = self:addRow(self.poolPrefab, self.poolBox, {kind = "pool"}) + row:getDescendantByName("title"):setText(string.format(self.i18n:getText("spu_waitPoolRow"), pool.group, pool.count, #names, suffix)) + end +end + +-- Status + Start/Stop-knappens tekst, kaldes hvert sekund. +function SmartPickupFrame:refreshLive() + local adEnv = self.getAdEnv() + local run = self:getRun() + if adEnv == nil then + self.statusText:setText(self.i18n:getText("spu_status_noAd")) + return + end + local isRunning = false + if run ~= nil then + local status = ADRunsController.getRunStatus(adEnv, run) + isRunning = status.running > 0 + local text = isRunning and string.format(self.i18n:getText("spu_status_running"), status.running, status.total) + or string.format(self.i18n:getText("spu_status_stopped"), status.total) + self.statusText:setText(self.statusOverride or text) + for option, data in pairs(self.rowData) do + if data.kind == "vehicle" and data.row ~= nil then + data.row:getDescendantByName("title"):setText(self:describeVehicle(adEnv, run, data.vehicle, option:getState() == SmartPickupFrame.STATE_ON)) + end + end + else + self.statusText:setText(self.statusOverride or self.i18n:getText("spu_noRuns")) + end + self.startButtonInfo.text = self.i18n:getText(isRunning and "spu_button_stop" or "spu_button_start") + self.startButtonInfo.disabled = run == nil + self.renameButtonInfo.disabled = run == nil + self.deleteButtonInfo.disabled = run == nil + self:setMenuButtonInfoDirty() +end + +-- ---- callbacks fra XML (state = 1-baseret indeks; element = det klikkede element) ---- + +function SmartPickupFrame:onClickRun(state) + self.selectedRunId = self.runIds[state] + self.statusOverride = nil + self:safe(self.rebuild) +end + +function SmartPickupFrame:onClickTarget(state) + local run = self:getRun() + if run == nil then return end + -- targetIds[1] er false = "(vælg mål)"; modellen tolker false som "ryd målet" + ADRunsController.updateRun(run.id, {targetWayPointId = self.targetIds[state] or false}) + self:safe(self.rebuild) +end + +function SmartPickupFrame:onClickIngredientMode(state) + local run = self:getRun() + if run == nil then return end + ADRunsController.updateRun(run.id, {ingredientMode = state == SmartPickupFrame.STATE_ON and "manual" or "auto"}) + self:safe(self.rebuild) +end + +function SmartPickupFrame:onClickIngredient(state, element) + local run, data = self:getRun(), self.rowData[element] + if run == nil or data == nil then return end + local names = {} + for _, name in ipairs(run.ingredients) do + if name ~= data.name then table.insert(names, name) end + end + if state == SmartPickupFrame.STATE_ON then + table.insert(names, data.name) + end + ADRunsController.updateRun(run.id, {ingredients = names}) +end + +function SmartPickupFrame:onClickWaitPool(state) + local run = self:getRun() + if run == nil then return end + ADRunsController.updateRun(run.id, {waitPoolGroup = self.poolGroups[state] or ""}) +end + +function SmartPickupFrame:onClickLoops(state) + local run = self:getRun() + if run == nil then return end + ADRunsController.updateRun(run.id, {loops = state - 1}) +end + +function SmartPickupFrame:onClickVehicle(state, element) + local run, data = self:getRun(), self.rowData[element] + if run == nil or data == nil then return end + if state == SmartPickupFrame.STATE_ON then + local previousRunId = ADRunsController.assignVehicle(run.id, data.vehicle) + if previousRunId ~= nil then + local previous = ADRuns.getRun(ADRunsController.store, previousRunId) + self.statusOverride = string.format(self.i18n:getText("spu_movedFrom"), previous ~= nil and previous.name or tostring(previousRunId)) + end + else + ADRunsController.unassignVehicle(data.vehicle) + end + self:safe(self.refreshLive) +end + +-- ---- knapper i bundlinjen ---- + +function SmartPickupFrame:onClickStartStop() + self:safe(function(frame) + local adEnv, run = frame.getAdEnv(), frame:getRun() + if adEnv == nil or run == nil then return end + if ADRunsController.getRunStatus(adEnv, run).running > 0 then + ADRunsController.stopRun(adEnv, run.id) + frame.statusOverride = nil + else + local started, skipped = 0, 0 + for _, result in ipairs(ADRunsController.startRun(adEnv, run.id)) do + if result.ok then started = started + 1 else skipped = skipped + 1 end + end + frame.statusOverride = string.format(frame.i18n:getText("spu_status_started"), started, skipped) + end + ADRunsController.save() + frame:rebuild() + end) +end + +function SmartPickupFrame:onClickNew() + self:safe(function(frame) + local run = ADRunsController.createRun(string.format(frame.i18n:getText("spu_newRunName"), ADRunsController.store.nextId - 1)) + frame.selectedRunId = run.id + frame.statusOverride = nil + frame:rebuild() + end) +end + +function SmartPickupFrame:onClickRename() + local run = self:getRun() + if run == nil then return end + TextInputDialog.show(SmartPickupFrame.onRenameResult, self, run.name, self.i18n:getText("spu_renameTitle"), nil, 30, g_i18n:getText("button_ok")) +end + +function SmartPickupFrame:onRenameResult(value, clickOk) + local run = self:getRun() + if clickOk and run ~= nil and value ~= nil and value:match("%S") then + ADRunsController.updateRun(run.id, {name = value}) + self:safe(self.rebuild) + end +end + +function SmartPickupFrame:onClickDelete() + local run = self:getRun() + if run == nil then return end + YesNoDialog.show(SmartPickupFrame.onDeleteResult, self, string.format(self.i18n:getText("spu_deleteConfirm"), run.name), self.i18n:getText("spu_button_delete")) +end + +function SmartPickupFrame:onDeleteResult(yes) + local run = self:getRun() + if yes and run ~= nil then + ADRunsController.deleteRun(run.id) + self.selectedRunId = nil + self:safe(self.rebuild) + end +end +``` + +Kendte usikkerheder (verificeres in-game, rettes hvis nødvendigt): `setMenuButtonInfoDirty()` er den FS22/25-metode `TabbedMenuFrameElement` bruger til at gentegne knapperne; findes den ikke, erstat med `self:setMenuButtonInfo(self.menuButtonInfo)`. `option:getState()` på `BinaryOption`; findes den ikke, brug `option.state`. `g_i18n:formatNumber(n, 0)` findes i FS25 (`I18N:formatNumber`). + +- [ ] **Step 5: Registrér fanen fra `adSmartPickup.lua`** + +Tilføj øverst i `adSmartPickup.lua` (efter `ADSmartPickup = {}`): + +```lua +ADSmartPickup.dir = g_currentModDirectory +if ADSmartPickup.dir ~= nil and source ~= nil then + source(ADSmartPickup.dir .. "gui/SmartPickupFrame.lua") +end +``` + +Tilføj funktionen (før `ADSmartPickup:update`): + +```lua +-- Indsætter fanen i ESC-menuen foran Statistik/Priser (mønster fra FS25_FarmOperationsDashboard). +local function insertInGameMenuPage(frame, pageName, iconFile, uvs, position, predicate) + local inGameMenu = g_gui ~= nil and g_gui.screenControllers ~= nil and g_gui.screenControllers[InGameMenu] or nil + if inGameMenu == nil or inGameMenu.pagingElement == nil then + return false + end + local insertAt = position + for i = 1, #inGameMenu.pagingElement.elements do + local child = inGameMenu.pagingElement.elements[i] + if child == inGameMenu.pageStatistics or child == inGameMenu.pagePrices then + insertAt = i + break + end + end + if inGameMenu.controlIDs ~= nil then + inGameMenu.controlIDs[pageName] = nil + end + inGameMenu[pageName] = frame + inGameMenu.pagingElement:addElement(frame) + if inGameMenu.exposeControlsAsFields ~= nil then + inGameMenu:exposeControlsAsFields(pageName) + end + for _, list in ipairs({inGameMenu.pagingElement.elements, inGameMenu.pageFrames}) do + for i = 1, #list do + if list[i] == frame then + table.remove(list, i) + table.insert(list, insertAt, frame) + break + end + end + end + for i = 1, #inGameMenu.pagingElement.pages do + if inGameMenu.pagingElement.pages[i].element == frame then + local page = table.remove(inGameMenu.pagingElement.pages, i) + table.insert(inGameMenu.pagingElement.pages, insertAt, page) + break + end + end + inGameMenu.pagingElement:updateAbsolutePosition() + inGameMenu.pagingElement:updatePageMapping() + inGameMenu:registerPage(frame, position, predicate) + inGameMenu:addPageTab(frame, iconFile, GuiUtils.getUVs(uvs)) + inGameMenu:rebuildTabList() + return true +end + +local function loadGui() + if g_gui == nil or SmartPickupFrame == nil or ADSmartPickup.dir == nil then + return + end + g_gui:loadProfiles(ADSmartPickup.dir .. "gui/guiProfiles.xml") + local frame = SmartPickupFrame.new(g_i18n, getAutoDriveEnv) + g_gui:loadGui(ADSmartPickup.dir .. "gui/SmartPickupFrame.xml", "smartPickupFrame", frame, true) + local iconFile = Utils.getFilename("icon.dds", ADSmartPickup.dir) + if not insertInGameMenuPage(frame, "smartPickupFrame", iconFile, {0, 0, 512, 512}, 2, function() return g_currentMission ~= nil end) then + Logging.warning(ADSmartPickup.LOG_PREFIX .. "kunne ikke indsætte fanen i ESC-menuen") + return + end + frame:initialize() + ADSmartPickup.guiFrame = frame + Logging.info(ADSmartPickup.LOG_PREFIX .. "ESC-fane 'Smart Pickup' registreret") +end + +function ADSmartPickup:loadMap() + local ok, err = pcall(loadGui) + if not ok then + Logging.warning(ADSmartPickup.LOG_PREFIX .. "GUI kunne ikke indlæses (resten af moden virker): %s", tostring(err)) + end +end +``` + +`icon.dds` er 512×512 (verificeret) — derfor UVs `{0, 0, 512, 512}`. Ikonet er modhub-ikonet og kan se tungt ud i fanelinjen; et dedikeret hvidt 1024×1024-ikon kan laves senere uden kodeændring ud over stien/UVs. + +- [ ] **Step 6: Syntakstjek + tests** + +```bash +cd ~/fs25-adsmartpickup && luajit -bl FS25_ADSmartPickup/gui/SmartPickupFrame.lua > /dev/null && luajit -bl FS25_ADSmartPickup/adSmartPickup.lua > /dev/null && echo SYNTAX-OK && for t in tests/test_*.lua; do luajit $t | tail -1; done +``` +Forventet: `SYNTAX-OK` + `ALLE TESTS OK` ×4 (testen kalder ikke `loadMap`; `g_currentModDirectory` er nil i luajit, så `source` springes over). + +- [ ] **Step 7: Commit** + +```bash +cd ~/fs25-adsmartpickup && git add -A FS25_ADSmartPickup && git commit -q -m "feat: ESC-fane 'Smart Pickup' (runs: mål, varer, Wait-pulje, ture, traktorer, Start/Stop)" +``` + +--- + +### Task 7: Udgivelse v1.8.0.0 (README, modDesc, byg, hukommelse) + +**Files:** +- Modify: `FS25_ADSmartPickup/modDesc.xml` (`1.8.0.0`, beskrivelse), `README.md` +- Modify: `~/.claude/projects/-var-home-masterdraco/memory/project_fs25_adsmartpickup.md`, `~/obsidian/mine_projekter/projekter/FS25 silo-patches + AD Smart Pickup.md` + +- [ ] **Step 1: modDesc.xml** + +Sæt `1.8.0.0`. Tilføj til beskrivelsen (før `Kræver FS25_AutoDrive`): `Ny ESC-fane "Smart Pickup": definér runs (mål + varer + Wait-pulje + traktorer + ture) og start/stop dem samlet.` + +- [ ] **Step 2: README.md** + +Tilføj afsnit `## v1.8 — Runs i ESC-menuen` med: hvad et run er, hvordan man opretter (ESC → Smart Pickup → + Nyt run), felterne (Mål, Varer Auto/Vælg, Wait-pulje, Ture, Traktorer), Start/Stop, at ✖-traktorer springes over med grund, at runs gemmes i `savegameN/ADSmartPickup_runs.xml`, at runs ikke auto-genstartes ved load, og at traktorer uden run kører som før. Nævn at Start sætter AD til P&D + "kun pålæsning" + mapper til, så manuelle indstillinger på den traktor overskrives. + +- [ ] **Step 3: Kør alle tests, byg, kopiér** + +```bash +cd ~/fs25-adsmartpickup && for t in tests/test_*.lua; do luajit $t | tail -1; done && cd FS25_ADSmartPickup && rm -f ../FS25_ADSmartPickup.zip && zip -qr ../FS25_ADSmartPickup.zip . && cp ../FS25_ADSmartPickup.zip ~/FS25-data/mods/ && unzip -l ../FS25_ADSmartPickup.zip | tail -n +4 | head -20 +``` +Forventet: 4× `ALLE TESTS OK`; zip indeholder `gui/`, `l10n/`, alle seks lua-filer, `modDesc.xml`, `icon.dds`. + +- [ ] **Step 4: Commit + push** + +```bash +cd ~/fs25-adsmartpickup && git add -A && git commit -q -m "feat: runs-UI i ESC-menuen (v1.8.0.0)" && TOKEN=$(grep -A8 -i "gitea" ~/credentials-vault.md | grep -oE '[0-9a-f]{40}' | head -1); git -c http.extraHeader="Authorization: token $TOKEN" push -q gitea HEAD && echo pushed +``` + +- [ ] **Step 5: In-game tjekliste (brugeren kører spillet; log i `~/FS25-data/log.txt`)** + +1. Start spillet, indlæs savegame 2. Log skal vise `ADSmartPickup: aktiv (...)` og INGEN `Error:`/`LUA`-linjer med `ADSmartPickup`/`SmartPickupFrame`. +2. ESC → fanen "Smart Pickup" findes; Runs-listen er tom; "+ Nyt run" opretter "Run 1". +3. Mål-dropdown viser "Cow 1 Food"/"Cow 2 Food"/"TMR Unload" (markører med aflæsningsstation), ikke siloer. +4. Varer: Auto viser målets varer; Vælg viser afkrydsning. +5. Wait-pulje-dropdown viser "Alle (nærmeste ledige)" + "Cow Wait (6)". +6. Tilføj 2 Fastrac + én traktor uden vogn → uden vogn får ✖ "ingen vogn". +7. Start run → de to Fastrac kører (log: `run 'Cow Feed': 'Fastrac 2135 4WS' startet mod markør 94`), traktoren uden vogn står stille; status "Kører (2/3)". +8. Stop run → begge stopper; status "(0/3)". +9. Gem, afslut, indlæs → run'et og traktorerne er der stadig; `savegame2/ADSmartPickup_runs.xml` findes. +10. Wait-puljer-fanen viser "Cow Wait — 6 punkter" og hvem der holder hvor. + +- [ ] **Step 6: Hukommelse + Obsidian** + +Opdatér `project_fs25_adsmartpickup.md` (v1.8 status: implementeret, in-game-tjekliste udestår/udført; runs-fil; afvigelser fra spec) og Obsidian-noten tilsvarende. + +--- + +### Task 8: v1.9 — læs-bytte fra Wait (`adLoadSwap.lua`) + +**Files:** +- Create: `FS25_ADSmartPickup/adLoadSwap.lua` +- Modify: `FS25_ADSmartPickup/adUnloadWait.lua:129-149` (`updateWaiting`), `:151-158` (`beforeUpdate` får `dt`), `:180-187` (`install` sender `dt`) +- Modify: `FS25_ADSmartPickup/modDesc.xml` (`adLoadSwap.lua` før `adUnloadWait.lua`) +- Test: `tests/test_adSmartPickup.lua` (S1–S5) + +**Interfaces:** +- Consumes: `ADSmartPickup.getIngredientsFor(adEnv, driver)`, `ADSmartPickup.findSupplyPickup(adEnv, driver, ingredients)` (returnerer `{fillType, need = {fits, ...}, ...}`), `ADSmartPickup.getUnloadStationAtWayPointId`, `ADSmartPickup.getOwnStorageLevel(station, fillTypes)`, `ADSmartPickup.getFarmId`, `ADSupplyPlanner.pickNearestFreeWaitPoint`-stil sortering. +- Produces: + - `ADLoadSwap.CHECK_INTERVAL_MS = 5000` + - `ADLoadSwap.findMissingIngredient(adEnv, vehicle, restFillType) -> choice|nil` (anden vare i listen der mangler et FULDT læs, og som findes i en silo) + - `ADLoadSwap.findDumpMarker(adEnv, vehicle, fillType, rest, homeWayPointId, isReachable) -> marker|nil` + - `ADLoadSwap.tryFromWait(adEnv, task, wait, dt, redirect, release) -> boolean` (true = omdirigeret til silo) + +Adfærd (spec "Efterfølgende"): mens traktoren venter, hver 5. sekund: er der en ANDEN vare i traktorens liste, som målet mangler så meget at et fuldt læs passer (`need.fits == true`) og som findes i en silo (`findSupplyPickup` fandt den)? Så find en silo (lager-placeable `spec_silo`, IKKE stald/fabrik/målet selv) med aflæsningsstation ved en AD-markør, der tager varen, har fri plads ≥ resten og har vejnet til (fra traktoren) og fra (til målet). Foretræk siloer der allerede har varen i eget lager, dernæst nærmeste. Omdirigér aflæsningsopgaven dertil, slip Wait-punktet, log `bytter læs: N l STRAW tilbage i , målet mangler FORAGE`. Ingen silo → bliv ved Wait (log én gang pr. vente-periode). + +- [ ] **Step 1: Tilføj fejlende tests** + +I `tests/test_adSmartPickup.lua` tilføj `dofile("FS25_ADSmartPickup/adLoadSwap.lua")` lige før `dofile("FS25_ADSmartPickup/adUnloadWait.lua")`. S-blokken nedenfor indsættes EFTER I-blokken og FØR `-- ==== E:` (I-blokken slutter med `ADRunsController.store = ADRuns.newStore()`, så ingen traktor er i et run). Stationer skal kunne være aflæsningsmål OG silo: udvid `makeStation` så den returnerede tabel også får `unloadTriggers = {{exactFillRootNode = node}}`, `getIsFillTypeSupported = function(_, fillType) return levels[fillType] ~= nil or (ownLevels ~= nil and ownLevels[fillType] ~= nil) end` og `getFreeCapacity = function(_, fillType) return (levels.capacity or 200000) - (levels[fillType] or 0) end`, og lad `placeable` altid være `{spec_silo = {storages = {...}}}` når `ownLevels ~= nil`. Registrér stationen i BÅDE `stations` og `unloadStations` i `buildCowWorld` for halmladen (markør 21) og i en ny `buildSwapWorld`: + +```lua +-- ==== S: læs-bytte fra Wait — halm-rest ved Wait, målet mangler TMR -> aflæs halm i silo ==== +local function buildSwapWorld() + buildWaitWorld() -- stald: halm FULD (50000/50000), TMR 10000/100000 + -- halmladen (markør 21) er også et aflæsningssted med plads + table.insert(unloadStations, stations[2]) + stations[2].unloadTriggers = {{exactFillRootNode = 121}} + stations[2].getIsFillTypeSupported = function(_, fillType) return fillType == STRAW end + stations[2].getFreeCapacity = function() return 100000 end + stations[2].owningPlaceable = {spec_silo = {storages = {{getFillLevel = function(_, fillType) return fillType == STRAW and 50000 or 0 end}}}} +end + +-- S1: ventende traktor med halm-rest -> TMR mangler et fuldt læs (40000 l vogn, 90000 l fri) -> til halmladen +buildSwapWorld() +local sv, st = makeUnloadingVehicle("Fastrac Bytte", 521, 5000, 35000) +UnloadTask.update(st, 16) +check("S1 venter først ved Wait", st.destinationID, 40) +UnloadTask.update(st, ADLoadSwap.CHECK_INTERVAL_MS + 1) +check("S1 omdirigeret til halmladen", st.destinationID, 21) +check("S1 logget bytte", countLogsSince(1, "bytter læs") >= 1, true) +check("S1 Wait-punkt frigivet", FS25_AutoDrive.AutoDrive.getSetting("rotateTargets", sv), RT_ONLYPICKUP) + +-- S2: målet mangler IKKE et fuldt læs af TMR -> bliv ved Wait +buildSwapWorld() +waitBarnLevels[TMR] = 75000 -- 25000 fri < 40000 vogn +local sv2, st2 = makeUnloadingVehicle("Fastrac Bliv", 522, 5000, 35000) +UnloadTask.update(st2, 16) +UnloadTask.update(st2, ADLoadSwap.CHECK_INTERVAL_MS + 1) +check("S2 bliver ved Wait", st2.destinationID, 40) + +-- S3: ingen silo med plads/vare -> bliv ved Wait, én WARN +buildSwapWorld() +stations[2].getFreeCapacity = function() return 1000 end +local sv3, st3 = makeUnloadingVehicle("Fastrac Ingen", 523, 5000, 35000) +UnloadTask.update(st3, 16) +local mark3 = #logLines +UnloadTask.update(st3, ADLoadSwap.CHECK_INTERVAL_MS + 1) +UnloadTask.update(st3, ADLoadSwap.CHECK_INTERVAL_MS + 1) +check("S3 bliver ved Wait", st3.destinationID, 40) +check("S3 én advarsel", countLogsSince(mark3, "ingen silo"), 1) + +-- S4: silo uden vejnet hjem springes over +buildSwapWorld() +FS25_AutoDrive.ADGraphManager.unreachable[21] = true +local sv4, st4 = makeUnloadingVehicle("Fastrac Vej", 524, 5000, 35000) +UnloadTask.update(st4, 16) +UnloadTask.update(st4, ADLoadSwap.CHECK_INTERVAL_MS + 1) +check("S4 unåelig silo -> bliv", st4.destinationID, 40) +FS25_AutoDrive.ADGraphManager.unreachable = {} + +-- S5: traktor med kun én vare i listen bytter aldrig +buildSwapWorld() +local sv5, st5 = makeUnloadingVehicle("Fastrac Solo", 525, 5000, 35000) +sv5.ad.stateModule.selectedFillTypes = {STRAW} +UnloadTask.update(st5, 16) +UnloadTask.update(st5, ADLoadSwap.CHECK_INTERVAL_MS + 1) +check("S5 én vare -> bliver", st5.destinationID, 40) +``` + +Bemærk til S1: `makeUnloadingVehicle` giver vognen `capacity = 40000` og listen `{TMR, STRAW}`; `buildWaitWorld` giver stalden TMR 10000/100000 → 90000 l fri ≥ 40000 → `fits == true`. Halmladen (markør 21) har halm i eget lager og 100000 l fri ≥ 35000 rest. TMR findes i fabrikkens læssestation (markør 20, output) så `findSupplyPickup` finder en kilde. + +- [ ] **Step 2: Kør og se den fejle** + +Kør: `cd ~/fs25-adsmartpickup && luajit tests/test_adSmartPickup.lua 2>&1 | grep -E "FAIL|FEJL|error" | head` +Forventet: `cannot open FS25_ADSmartPickup/adLoadSwap.lua`. + +- [ ] **Step 3: Skriv modulet** + +`FS25_ADSmartPickup/adLoadSwap.lua`: + +```lua +-- AD Load Swap +-- En traktor der venter ved et Wait-punkt med en vare målet er fuldt af, mens målet +-- MANGLER en anden vare i traktorens liste, låser forsyningen (alle venter med halm, +-- ingen henter TMR). Her afleveres resten i en silo der tager varen, så vognen er tom +-- og næste tur henter det målet mangler. + +ADLoadSwap = {} + +ADLoadSwap.LOG_PREFIX = "ADSmartPickup: " +-- Opslaget går over alle markører; ventende traktorer tjekker derfor kun så tit. +ADLoadSwap.CHECK_INTERVAL_MS = 5000 + +local warnedNoSilo = setmetatable({}, {__mode = "k"}) + +-- Er en anden vare i listen så tom i målet, at et FULDT læs passer, og findes den i en silo? +function ADLoadSwap.findMissingIngredient(adEnv, vehicle, restFillType) + local ingredients = ADSmartPickup.getIngredientsFor(adEnv, vehicle) + if ingredients == nil or #ingredients < 2 then + return nil + end + local choice = ADSmartPickup.findSupplyPickup(adEnv, vehicle, ingredients) + if choice == nil or choice.fillType == nil or choice.fillType == restFillType or choice.need.fits ~= true then + return nil + end + return choice +end + +-- Kun rene lager-siloer: stalde og fabrikker har egne regler, og målet selv er fuldt. +local function isSilo(station, homeStation) + local placeable = station.owningPlaceable + return placeable ~= nil and placeable.spec_silo ~= nil and placeable.getHusbandryFillLevel == nil + and placeable.spec_productionPoint == nil and (homeStation == nil or placeable ~= homeStation.owningPlaceable) +end + +local function acceptsRest(station, fillType, rest, farmId) + if station.getIsFillTypeSupported ~= nil and not station:getIsFillTypeSupported(fillType) then + return false + end + local free = station.getFreeCapacity ~= nil and station:getFreeCapacity(fillType, farmId) or 0 + return free >= rest +end + +-- Nærmeste silo med plads til resten og vejnet til/fra; siloer der allerede har varen foretrækkes. +function ADLoadSwap.findDumpMarker(adEnv, vehicle, fillType, rest, homeWayPointId, isReachable) + local farmId = ADSmartPickup.getFarmId(vehicle) + local homeStation = ADSmartPickup.getUnloadStationAtWayPointId(adEnv, homeWayPointId, farmId) + local x, _, z = getWorldTranslation(vehicle.rootNode) + local candidates = {} + for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do + local station = ADSmartPickup.getUnloadStationAtWayPointId(adEnv, marker.id, farmId) + if station ~= nil and isSilo(station, homeStation) and acceptsRest(station, fillType, rest, farmId) then + local wayPoint = adEnv.ADGraphManager:getWayPointById(marker.id) + local dx, dz = wayPoint.x - x, wayPoint.z - z + table.insert(candidates, { + marker = marker, + hasFillType = ADSmartPickup.getOwnStorageLevel(station, {fillType}) > 0, + distance = dx * dx + dz * dz, + }) + end + end + table.sort(candidates, function(a, b) + if a.hasFillType ~= b.hasFillType then + return a.hasFillType + end + return a.distance < b.distance + end) + for _, candidate in ipairs(candidates) do + if isReachable(candidate.marker.id) then + return candidate.marker + end + end + return nil +end + +-- Kaldes fra Wait-logikken hvert tick; redirect(task, id) og release(vehicle) er dens egne. +-- true = traktoren er sendt til en silo (Wait-punktet er frigivet). +function ADLoadSwap.tryFromWait(adEnv, task, wait, dt, redirect, release, isReachable) + wait.swapTimerMs = (wait.swapTimerMs or 0) + (dt or 0) + if wait.swapTimerMs < ADLoadSwap.CHECK_INTERVAL_MS then + return false + end + wait.swapTimerMs = 0 + local vehicle = task.vehicle + local missing = ADLoadSwap.findMissingIngredient(adEnv, vehicle, wait.fillType) + if missing == nil then + return false + end + local marker = ADLoadSwap.findDumpMarker(adEnv, vehicle, wait.fillType, wait.rest, wait.homeDestinationID, isReachable) + local restName = g_fillTypeManager ~= nil and g_fillTypeManager:getFillTypeNameByIndex(wait.fillType) or tostring(wait.fillType) + local missingName = g_fillTypeManager ~= nil and g_fillTypeManager:getFillTypeNameByIndex(missing.fillType) or tostring(missing.fillType) + if marker == nil then + if not warnedNoSilo[vehicle] then + warnedNoSilo[vehicle] = true + Logging.warning(ADLoadSwap.LOG_PREFIX .. "'%s' venter med %.0f l %s, og målet mangler %s — ingen silo med plads og vejnet til/fra tager resten, så den bliver ved Wait-punktet", tostring(vehicle:getName()), wait.rest, tostring(restName), tostring(missingName)) + end + return false + end + warnedNoSilo[vehicle] = nil + release(vehicle) + redirect(task, marker.id) + Logging.info(ADLoadSwap.LOG_PREFIX .. "'%s' bytter læs: %.0f l %s tilbage i %s (markør %s), målet mangler %s", tostring(vehicle:getName()), wait.rest, tostring(restName), tostring(marker.name), tostring(marker.markerIndex or marker.id), tostring(missingName)) + return true +end + +function ADLoadSwap.clearWarning(vehicle) + warnedNoSilo[vehicle] = nil +end +``` + +`adUnloadWait.lua` — ændringer: + +1. `updateWaiting(adEnv, task, wait)` → `updateWaiting(adEnv, task, wait, dt)`. Erstat blokken + +```lua + local free, capacity = ADSmartPickup.getTargetFreeCapacity(adEnv, vehicle, fillType) + if free == nil or not ADSupplyPlanner.canUnloadRest(free, rest, capacity) then + return + end +``` +med +```lua + local free, capacity = ADSmartPickup.getTargetFreeCapacity(adEnv, vehicle, fillType) + if free == nil or not ADSupplyPlanner.canUnloadRest(free, rest, capacity) then + if ADLoadSwap ~= nil then + local isReachable = function(markerId) + return hasRouteBothWays(adEnv, vehicle, markerId, wait.homeDestinationID) + end + ADLoadSwap.tryFromWait(adEnv, task, wait, dt, redirect, release, isReachable) + end + return + end +``` + (`hasRouteBothWays(adEnv, vehicle, waitId, homeId)` tjekker allerede "til punktet" + "fra punktet hjem" — præcis det byttet også kræver.) + +2. `ADUnloadWait.beforeUpdate(adEnv, task)` → `ADUnloadWait.beforeUpdate(adEnv, task, dt)` og kald `updateWaiting(adEnv, task, wait, dt)`. I `install`: `pcall(ADUnloadWait.beforeUpdate, adEnv, self, dt)`. + +3. I `release(vehicle)`: tilføj `if ADLoadSwap ~= nil then ADLoadSwap.clearWarning(vehicle) end` (ny vente-periode = ny chance for advarsel). + +4. `modDesc.xml`: `adLoadSwap.lua` indsættes FØR `adUnloadWait.lua` og EFTER `adRunsController.lua`. + +- [ ] **Step 4: Kør alle tests** + +Kør: `cd ~/fs25-adsmartpickup && for t in tests/test_*.lua; do luajit $t | tail -1; done` +Forventet: `ALLE TESTS OK` ×4. Fejler S1 fordi `findSupplyPickup` ikke finder TMR: tjek at fabrikkens læssestation (markør 20) i `buildCowWorld` stadig har `[TMR] = 30000` og at målets TMR-fri plads ≥ `MIN_FREE_LITERS`. + +- [ ] **Step 5: Commit** + +```bash +cd ~/fs25-adsmartpickup && git add -A FS25_ADSmartPickup tests && git commit -q -m "feat: læs-bytte fra Wait — rest afleveres i silo når målet mangler en anden vare" +``` + +--- + +### Task 9: Udgivelse v1.9.0.0 + +**Files:** +- Modify: `FS25_ADSmartPickup/modDesc.xml` (`1.9.0.0`, beskrivelse), `README.md`, hukommelse + Obsidian. + +- [ ] **Step 1: modDesc + README** + +`1.9.0.0`; beskrivelse: tilføj `Venter en traktor med en vare målet er fuldt af, mens målet mangler en anden vare på dens liste, afleveres resten i en silo og den manglende vare hentes.` README: afsnit `## v1.9 — læs-bytte fra Wait` med reglen (anden vare mangler et FULDT læs; kun rene siloer; fri plads ≥ rest; vejnet til/fra; foretrækker silo der allerede har varen; ellers bliver den ved Wait), loglinjerne `bytter læs: …` / `ingen silo …`, og at det kræver at traktoren har begge varer (run eller multi-valg). + +- [ ] **Step 2: Test, byg, commit, push** + +```bash +cd ~/fs25-adsmartpickup && for t in tests/test_*.lua; do luajit $t | tail -1; done && cd FS25_ADSmartPickup && rm -f ../FS25_ADSmartPickup.zip && zip -qr ../FS25_ADSmartPickup.zip . && cp ../FS25_ADSmartPickup.zip ~/FS25-data/mods/ && cd .. && git add -A && git commit -q -m "feat: læs-bytte fra Wait (v1.9.0.0)" && TOKEN=$(grep -A8 -i "gitea" ~/credentials-vault.md | grep -oE '[0-9a-f]{40}' | head -1); git -c http.extraHeader="Authorization: token $TOKEN" push -q gitea HEAD && echo pushed +``` + +- [ ] **Step 3: In-game tjekliste** + +1. Opsætning: run "Cow Feed" med Auto-varer (halm + TMR), 2 Fastrac, stalden fuld af halm, TMR lavt; en Fastrac med halm-rest venter ved Cow Wait. +2. Inden for ~5 s efter ankomst til Wait: log `'Fastrac 2135 4WS' bytter læs: N l STRAW tilbage i 10 Silo Storage (markør 44), målet mangler FORAGE` og traktoren kører til siloen og læsser af. +3. Når vognen er tom, kører den til TMR-kilden (log `-> TMR Load (FORAGE: kilde …)`). +4. Negativt: kobl siloens vejnet fra → `ingen silo med plads og vejnet …` én gang, traktoren bliver ved Wait. + +- [ ] **Step 4: Hukommelse + Obsidian** + +Opdatér `project_fs25_adsmartpickup.md` og Obsidian-noten: v1.9 leveret, regler, in-game-status.