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