diff --git a/.gitignore b/.gitignore index c4c4ffc..5515b55 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ *.zip +docs/superpowers/ +bench/ +.superpowers/ diff --git a/README.md b/README.md index 8055842..ba0e390 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,6 @@ Fejl og idéer: opret et issue her i repoet. Vedlæg gerne `log.txt` fra ``` - Byg zip: `cd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip .` -- Design og planer: `docs/superpowers/` --- diff --git a/bench/README.md b/bench/README.md deleted file mode 100644 index bba8ed4..0000000 --- a/bench/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Simuleringer - -`bench_hak.lua` genskaber "hak hvert 5. sekund" uden spillet: falsk verden i brugerens proportioner -(13.550 waypoints, 600 markører, 120 stationer, 40 køretøjer, 15 holdende vogne) og modulernes rigtige kode. - - luajit bench/bench_hak.lua FS25_ADSmartPickup # nuværende kode - git worktree add /tmp/old ; luajit bench/bench_hak.lua /tmp/old/FS25_ADSmartPickup # sammenlign - -Resultat 26/9: værste frame 124,7 ms (v1.27.3) -> 0,9 ms (v1.28.0), 0 tunge frames efter opvarmning. -luajit er hurtigere end spillets Lua: forholdet før/efter er det der tæller, ikke de absolutte ms. diff --git a/bench/bench_hak.lua b/bench/bench_hak.lua deleted file mode 100644 index b6066b7..0000000 --- a/bench/bench_hak.lua +++ /dev/null @@ -1,103 +0,0 @@ --- Simulering af "hak hvert 5. sekund" (kør: luajit bench/bench_hak.lua ) --- Falsk verden i brugerens proportioner: 13.550 waypoints, 600 markører (150 Wait), 120 stationer med 3 --- triggere, 40 køretøjer, 15 holdende vogne der genplanlægger hvert 5. s. Kører modulernes RIGTIGE kode --- (Wait-punktsøgning, markør -> station, salgssteder) frame for frame og måler det værste frame. --- luajit er hurtigere end spillets Lua; FORHOLDET mellem før og efter er det der tæller. -local dir = arg[1] or "FS25_ADSmartPickup" -math.randomseed(42) -local function noop() end -Logging = {info = noop, warning = noop} -MathUtil = {vector2Length = function(x, z) return math.sqrt(x * x + z * z) end} -function entityExists() return true end -local nodes = {} -function getWorldTranslation(node) local p = nodes[node]; return p[1], 0, p[2] end -local simTime = 0 -g_currentMission = {time = 0, getFarmId = function() return 1 end, accessHandler = {canFarmAccess = function() return true end}} -function netGetTime() return os.clock() * 1000 end -addModEventListener = noop -Utils = {prependedFunction = function(a) return a end} - --- vejnet og markører -local wayPoints, markers = {}, {} -for id = 1, 13550 do - local x, z = (id % 120) * 60 - 3600, math.floor(id / 120) * 60 - 3600 - wayPoints[id] = {id = id, x = x, z = z, out = {id + 1 <= 13550 and id + 1 or 1}, incoming = {id > 1 and id - 1 or 13550}} -end -for index = 1, 600 do - local id = index * 22 - table.insert(markers, {id = id, markerIndex = index, name = (index <= 150 and "Wait " or "Marker ") .. index, group = index <= 150 and "Cow Wait" or "All"}) -end --- stationer ved hver 5. markør, 3 triggere hver -local stations = {} -for index = 1, 120 do - local wp = wayPoints[markers[150 + index].id] - local triggers = {} - for t = 1, 3 do - local node = #nodes + 1 - nodes[node] = {wp.x + t, wp.z + t} - table.insert(triggers, {exactFillRootNode = node}) - end - table.insert(stations, {unloadTriggers = triggers, owningPlaceable = {}, getOwnerFarmId = function() return 1 end, name = "S" .. index, - isa = function() return false end, getIsFillTypeAllowed = function() return true end}) -end -g_currentMission.storageSystem = {getUnloadingStations = function() return stations end, getLoadingStations = function() return {} end} -g_currentMission.placeableSystem = {placeables = {}} --- køretøjer -local vehicles = {} -for index = 1, 40 do - local node = #nodes + 1 - nodes[node] = {math.random(-3600, 3600), math.random(-3600, 3600)} - table.insert(vehicles, {rootNode = node, spec_motorized = {}, getChildVehicles = function() return {} end}) -end -g_currentMission.vehicleSystem = {vehicles = vehicles} -local adEnv = {ADGraphManager = { - getMapMarkers = function() return markers end, - getWayPointById = function(_, id) return wayPoints[id] end, - getMapMarkerByWayPointId = function(_, id) for _, m in ipairs(markers) do if m.id == id then return m end end end, - getPathTo = function(_, _, id) return {{id = id}} end, -}} - -for _, file in ipairs({"adCache.lua", "adSupplyPlanner.lua", "adWaitPool.lua", "adPrices.lua", "adSmartPickup.lua"}) do - local chunk = loadfile(dir .. "/" .. file) - if chunk ~= nil then pcall(chunk) end -end -if ADCache ~= nil then ADCache.enabled = true end -ADPrices = ADPrices or {} -ADPrices.isSellingStation = ADPrices.isSellingStation or function() return true end - --- én genplanlægning for en holdende vogn: Wait-punkt + stationsopslag over alle markører (salgssteder/lager) -local function replan(vehicle) - ADWaitPool.pickFree(adEnv, vehicle, "Cow Wait", function() return true end) - for _, marker in ipairs(markers) do - ADSmartPickup.getUnloadStationAtWayPointId(adEnv, marker.id, 1) - end -end - -local t0 = os.clock() -replan(vehicles[1]) -local single = (os.clock() - t0) * 1000 - --- 30 s med 60 fps; 15 holdende vogne. Gammel kode: alle timere starter på 0. Ny: jitter hvis koden har det. -local jitter = ADCache ~= nil and 5000 or 0 -- ny kode spreder vognenes genplanlægning over 0-5 s (adOutbound CHECK_JITTER_MS) -local timers = {} -for index = 1, 15 do timers[index] = jitter > 0 and math.random(0, jitter) or 0 end -local worst, steady, frames = 0, 0, 0 -for frame = 1, 60 * 60 do - simTime = simTime + 16.7 - g_currentMission.time = simTime - local started = os.clock() - for index = 1, 15 do - timers[index] = timers[index] + 16.7 - if timers[index] >= 5000 then - timers[index] = 0 - replan(vehicles[index]) - end - end - local spent = (os.clock() - started) * 1000 - if spent > worst then worst = spent end - if simTime > 12000 and spent > steady then steady = spent end - if simTime > 12000 and spent > 3 then heavy = (heavy or 0) + 1; heavyAt = heavyAt or {}; if #heavyAt < 8 then table.insert(heavyAt, string.format("%.1fs:%.1fms", simTime / 1000, spent)) end end - frames = frames + 1 -end -print(" tunge frames (>3 ms) efter opvarmning:", heavy or 0, table.concat(heavyAt or {}, " ")) -print(string.format("%-12s én genplanlægning %.2f ms | værste frame (60 s): %.2f ms | værste efter opvarmning: %.2f ms", dir:find("old") and "FØR" or "EFTER", single, worst, steady)) diff --git a/docs/superpowers/plans/2026-09-21-behovsstyret-forsyning.md b/docs/superpowers/plans/2026-09-21-behovsstyret-forsyning.md deleted file mode 100644 index b054601..0000000 --- a/docs/superpowers/plans/2026-09-21-behovsstyret-forsyning.md +++ /dev/null @@ -1,221 +0,0 @@ -# Behovsstyret forsyning Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Én AutoDrive-traktor holder alle input i en produktion (fx TMR-blander) fyldt ved selv at vælge vare + kilde-silo pr. tur. - -**Architecture:** Den eksisterende wrapper om `ADMultipleTargetsManager.getNextPickup` udvides: ved >1 valgt vare i AD læses aflæsningsmålets lager, ingredienserne rangeres efter behov (rene funktioner i ny fil), og en kilde findes blandt alle AD-markører. AD's varevalg indsnævres til én vare pr. tur og gendannes når AD stopper. - -**Tech Stack:** Lua 5.1 (FS25 mod-sandbox), luajit til tests med håndskrevne mocks, AutoDrive 3.0.1.2 via global `FS25_AutoDrive`. - -**Spec:** `docs/superpowers/specs/2026-09-21-behovsstyret-forsyning-design.md` - -## Global Constraints - -- Rør aldrig `FS25_AutoDrive.zip`; al adgang via den globale `FS25_AutoDrive`-tabel. -- Enhver fejl i spil-/AD-API → `Logging.warning` + AutoDrives originale `getNextPickup`. Aldrig crash. -- Præcis én valgt vare → opførsel uændret; de 11 eksisterende tests forbliver grønne. -- Markør↔station-tærskel: 40 m (`MAX_MARKER_TO_TRIGGER_DISTANCE`). `MIN_FREE_LITERS = 1000`. -- Rene funktioner muterer ikke input (returnér nye tabeller). -- Tests køres fra repo-roden: `luajit tests/.lua`. Commits uden Co-Authored-By. -- Verificerede AD-fakta: `selectedFillTypes` er et array-felt på stateModule og gemmes i savegame; `setFillType(ft)` bevarer valget hvis `ft` er i det; `isActive()`, `getSecondMarker()`, `raiseDirtyFlag()` findes; `ADGraphManager:getMapMarkers()` returnerer array indekseret på `markerIndex`. - ---- - -### Task 1: `adSupplyPlanner.lua` — rene funktioner - -**Files:** -- Create: `FS25_ADSmartPickup/adSupplyPlanner.lua` -- Test: `tests/test_adSupplyPlanner.lua` - -**Interfaces:** -- Produces (global tabel `ADSupplyPlanner`): - - `MIN_FREE_LITERS` (number) - - `scoreNeed(level, capacity, freeSpace) -> number` (højere = mere trængende) - - `rankNeeds(needs) -> ranked` hvor `needs = {{fillType, level, capacity}, …}` og `ranked = {{fillType, level, capacity, freeSpace, score}, …}` sorteret mest trængende først, uden dem med `freeSpace < MIN_FREE_LITERS` - - `resolveIngredients(state, selected) -> ingredients|nil` hvor `state = nil | {ingredients = {ft…}, narrowedTo = ft}` - - `copyList(list) -> ny liste` - -- [ ] **Step 1: Skriv fejlende test** — `tests/test_adSupplyPlanner.lua`: - -```lua --- Kør: luajit tests/test_adSupplyPlanner.lua (fra repo-roden) -dofile("FS25_ADSmartPickup/adSupplyPlanner.lua") - -local HAY, GRASS, SILAGE = 20, 21, 22 -local failures = 0 -local function check(name, actual, expected) - if actual == expected then - print("OK " .. name) - else - failures = failures + 1 - print(string.format("FAIL %s: forventede %s, fik %s", name, tostring(expected), tostring(actual))) - end -end - --- scoreNeed: tommere lager scorer højere; ugyldig kapacitet giver 0 -check("tomt scorer højere end halvfuldt", ADSupplyPlanner.scoreNeed(0, 1000, 1000) > ADSupplyPlanner.scoreNeed(500, 1000, 500), true) -check("kapacitet 0 giver score 0", ADSupplyPlanner.scoreNeed(0, 0, 0), 0) - --- rankNeeds: mest trængende først, fulde springes over, input muteres ikke -local needs = { - {fillType = HAY, level = 80000, capacity = 100000}, - {fillType = GRASS, level = 20000, capacity = 100000}, - {fillType = SILAGE, level = 99500, capacity = 100000}, -} -local ranked = ADSupplyPlanner.rankNeeds(needs) -check("fuld ingrediens springes over", #ranked, 2) -check("mest trængende først", ranked[1].fillType, GRASS) -check("dernæst næstmest trængende", ranked[2].fillType, HAY) -check("freeSpace beregnes", ranked[1].freeSpace, 80000) -check("input muteres ikke", needs[1].freeSpace, nil) -check("tom liste giver tom liste", #ADSupplyPlanner.rankNeeds({}), 0) - --- rankNeeds: deterministisk ved lige score (laveste fillType først) -local tie = ADSupplyPlanner.rankNeeds({ - {fillType = SILAGE, level = 0, capacity = 5000}, - {fillType = HAY, level = 0, capacity = 5000}, -}) -check("lige score -> laveste fillType først", tie[1].fillType, HAY) - --- resolveIngredients -local multi = {HAY, GRASS} -local resolved = ADSupplyPlanner.resolveIngredients(nil, multi) -check("multi-valg bliver ingrediensliste", #resolved, 2) -check("listen er en kopi", resolved ~= multi, true) -check("enkelt-valg uden tilstand -> nil", ADSupplyPlanner.resolveIngredients(nil, {HAY}), nil) -local state = {ingredients = {HAY, GRASS, SILAGE}, narrowedTo = GRASS} -check("indsnævret valg genbruger gemt liste", #ADSupplyPlanner.resolveIngredients(state, {GRASS}), 3) -check("bruger valgte andet -> nil", ADSupplyPlanner.resolveIngredients(state, {HAY}), nil) -check("nyt multi-valg erstatter gemt liste", #ADSupplyPlanner.resolveIngredients(state, {HAY, SILAGE}), 2) -check("ugyldigt valg -> nil", ADSupplyPlanner.resolveIngredients(state, nil), nil) - -print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) -os.exit(failures == 0 and 0 or 1) -``` - -- [ ] **Step 2: Kør og se den fejle** — `luajit tests/test_adSupplyPlanner.lua` → fejler med "cannot open FS25_ADSmartPickup/adSupplyPlanner.lua". - -- [ ] **Step 3: Implementér** — `FS25_ADSmartPickup/adSupplyPlanner.lua`: - -```lua --- AD Supply Planner --- Rene funktioner (ingen spil-API) til behovsstyret forsyning: hvilken ingrediens --- har aflæsningsmålet mest brug for, og hvilken ingrediensliste gælder for køretøjet. - -ADSupplyPlanner = {} - --- Mindre fri plads end dette i målet regnes som "fuldt" (undgår ture med læs der ikke kan komme af). -ADSupplyPlanner.MIN_FREE_LITERS = 1000 - -function ADSupplyPlanner.copyList(list) - local copy = {} - for index, value in ipairs(list) do - copy[index] = value - end - return copy -end - --- Hvor meget trænger målet til denne ingrediens? Højere = hentes først. --- Alle tal er liter i aflæsningsmålets lager. Standard: relativ tomhed (0..1), --- så en lille næsten-tom tank vinder over en stor halvfuld. -function ADSupplyPlanner.scoreNeed(level, capacity, freeSpace) - if capacity <= 0 then - return 0 - end - return freeSpace / capacity -end - --- needs: {{fillType, level, capacity}, ...}. Returnerer NY liste, mest trængende først; --- ingredienser uden plads i målet er sorteret fra. -function ADSupplyPlanner.rankNeeds(needs) - local ranked = {} - for _, need in ipairs(needs) do - local freeSpace = need.capacity - need.level - if freeSpace >= ADSupplyPlanner.MIN_FREE_LITERS then - table.insert(ranked, { - fillType = need.fillType, - level = need.level, - capacity = need.capacity, - freeSpace = freeSpace, - score = ADSupplyPlanner.scoreNeed(need.level, need.capacity, freeSpace), - }) - end - end - table.sort(ranked, function(a, b) - if a.score ~= b.score then - return a.score > b.score - end - return a.fillType < b.fillType - end) - return ranked -end - --- state: nil eller {ingredients, narrowedTo} fra forrige tur. selected: AD's aktuelle varevalg. --- >1 valgt = (ny) ingrediensliste. Præcis den vare vi selv indsnævrede til = gemt liste. --- Alt andet = brugeren har valgt noget andet -> nil (behovsstyring fra). -function ADSupplyPlanner.resolveIngredients(state, selected) - if type(selected) ~= "table" then - return nil - end - if #selected > 1 then - return ADSupplyPlanner.copyList(selected) - end - if state ~= nil and #selected == 1 and selected[1] == state.narrowedTo then - return ADSupplyPlanner.copyList(state.ingredients) - end - return nil -end -``` - -- [ ] **Step 4: Kør tests** — `luajit tests/test_adSupplyPlanner.lua` → `ALLE TESTS OK`. - -- [ ] **Step 5: Commit** — `git add FS25_ADSmartPickup/adSupplyPlanner.lua tests/test_adSupplyPlanner.lua && git commit -m "feat: ADSupplyPlanner — behovsrangering og ingrediensliste-tilstand"` - ---- - -### Task 2: Integration i `adSmartPickup.lua` - -**Files:** -- Modify: `FS25_ADSmartPickup/adSmartPickup.lua` (hele filen omstruktureres let; se kode) -- Test: `tests/test_adSmartPickup.lua` - -**Interfaces:** -- Consumes: `ADSupplyPlanner.rankNeeds`, `.resolveIngredients`, `.copyList` (Task 1). -- Produces: `ADSmartPickup.choosePickup(adEnv, driver) -> choice|nil` med `choice = {markerIndex, markerName, group, level, fillType?, need?}`; `ADSmartPickup.findLeastFilledPickup` returnerer nu samme `choice`-tabel (uden `fillType`). - -- [ ] **Step 1: Udvid test-mocks og skriv fejlende tests** i `tests/test_adSmartPickup.lua`: - - `dofile("FS25_ADSmartPickup/adSupplyPlanner.lua")` før `adSmartPickup.lua`. - - `unloadStations`-liste + `getUnloadingStations` i `g_currentMission.storageSystem`; nulstilles i `resetWorld()`. - - `getMapMarkers = function() return markers end` i `ADGraphManager`-mock. - - `getSecondMarker = function() return nil end` i `makeDriver` (rigtig AD har den altid). - - Nye helpers `makeUnloadStation(node, x, z, levels, capacities, placeable, isBroken)` og `makeSupplyDriver(firstMarker, secondMarker, fillTypes)` (muterbar stateModule-mock med `setFillType`, `raiseDirtyFlag`, `isActive`). - - Tests S1–S9 (se Step 1-koden i implementeringen; dækker spec'ens testliste). - -- [ ] **Step 2: Kør og se de nye fejle** — `luajit tests/test_adSmartPickup.lua` → S-tests FAIL, de 11 gamle OK. - -- [ ] **Step 3: Implementér** — i `adSmartPickup.lua`: - - `supplyState = setmetatable({}, {__mode = "k"})` (køretøj → `{ingredients, narrowedTo}`). - - `getDistanceToTriggers(triggers, nodeFields, x, z)` + `getClosestStation(stations, triggersField, nodeFields, wayPoint, farmId)`; `getLoadStationAtMarker` (felt `loadTriggers`, node `triggerNode`) og `getUnloadStationAtMarker` (felt `unloadTriggers`, noder `exactFillRootNode`, `triggerNode`, `aiNode`). - - `collectMarkerStations(adEnv, candidateMarkers, farmId, excludedPlaceable)` → `{{marker, station}, …}`; `findLeastFilled(markerStations, fillTypes, farmId)` → `choice|nil` (eksisterende eget-lager-først-rangering). - - `getTargetNeeds(station, ingredients, farmId)`: pr. understøttet ingrediens summeres `storage:getFillLevel(ft)` og `storage:getFreeCapacity(ft)` over `station.targetStorages`; `capacity = level + free`. Ingen storages → `station:getFreeCapacity(ft, farmId)` som `capacity`, `level = 0`. - - `ADSmartPickup.findSupplyPickup(adEnv, driver, ingredients)`, `applySupplyChoice(driver, ingredients, choice)` (`setFillType`, `selectedFillTypes = {ft}`, `raiseDirtyFlag`, gem tilstand), `ADSmartPickup.choosePickup` (supply → ellers mappe-logik → ellers nil). - - Hook bruger `choice.group` til AD's rotations-bogføring og logger vare + behov ved supply-valg. - - `restoreStoppedVehicles()` kaldt fra `update` (pcall): når `not stateModule:isActive()` og valget stadig er `{narrowedTo}` → `selectedFillTypes = kopi af ingredients` + dirty-flag; tilstand slettes. - -- [ ] **Step 4: Kør alle tests** — `luajit tests/test_adSupplyPlanner.lua && luajit tests/test_adSmartPickup.lua` → begge `ALLE TESTS OK`. - -- [ ] **Step 5: Commit** — `git add -A && git commit -m "feat: behovsstyret forsyning — én traktor holder alle input fyldt"` - ---- - -### Task 3: Pakning, docs, deploy - -**Files:** -- Modify: `FS25_ADSmartPickup/modDesc.xml` (version `1.1.0.0`, `` FØR `adSmartPickup.lua`, beskrivelse), `README.md` - -- [ ] **Step 1:** Opdatér modDesc + README (betjening: multi-vælg varer; begrænsninger fra spec). -- [ ] **Step 2:** Byg: `cd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip . && cd ..`; verificér med `unzip -l` at begge .lua + modDesc + icon er med. -- [ ] **Step 3:** Deploy atomisk (spillet kan køre): `cp FS25_ADSmartPickup.zip ~/FS25-data/mods/.FS25_ADSmartPickup.zip.tmp && mv ~/FS25-data/mods/.FS25_ADSmartPickup.zip.tmp ~/FS25-data/mods/FS25_ADSmartPickup.zip`. -- [ ] **Step 4:** Code-review (ecc:code-reviewer) af diff; ret CRITICAL/HIGH. -- [ ] **Step 5:** Commit + `git push gitea`. Opdatér memory + Obsidian-noten `projekter/FS25 silo-patches + AD Smart Pickup.md`. diff --git a/docs/superpowers/plans/2026-09-22-runs-ui.md b/docs/superpowers/plans/2026-09-22-runs-ui.md deleted file mode 100644 index e3e349f..0000000 --- a/docs/superpowers/plans/2026-09-22-runs-ui.md +++ /dev/null @@ -1,2714 +0,0 @@ -# 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. diff --git a/docs/superpowers/plans/2026-09-23-flow-tavle.md b/docs/superpowers/plans/2026-09-23-flow-tavle.md deleted file mode 100644 index af242ca..0000000 --- a/docs/superpowers/plans/2026-09-23-flow-tavle.md +++ /dev/null @@ -1,78 +0,0 @@ -# Flow-tavle — 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:** Én tavle med ét flow pr. række (Vare · Fra · Til · Regel · Traktorer · Status); Fra/Til vælges som bygninger, markører findes automatisk. Faner: Flows · Lagre. - -**Architecture:** Ny spil-adapter `adBuildings.lua` (bygninger + markør-opslag + cache). Modellen får bygnings-id-lister; controlleren udleder waypoint-lister fra dem (`resolveMarkers`) så planlægger/hooks er uændrede. GUI omskrives: `SmartPickupFrame.lua` (faner, tavle, fælles), `SmartPickupFlowTab.lua` (detail-blok), `SmartPickupStorageTab.lua` (uændret). - -**Tech Stack:** FS25 Lua, AutoDrive-globaler via `FS25_AutoDrive`, luajit-tests. - -**Spec:** `docs/superpowers/specs/2026-09-23-flow-tavle-design.md` - -## Global Constraints - -- Motoren (adOutbound, adOutboundPlanner, adSupplyPlanner, adUnloadWait, adSmartPickup-hooks) røres ikke. -- Byg kun når alle tests er grønne (`for t in tests/test_*.lua; do luajit $t|tail -1; done`), derefter zip → `~/FS25-data/mods/` → commit → push gitea main. -- Version 1.13.0.0. XML-version 3; v1/v2 læses. -- Wait-markører er aldrig bygningsmarkører. Siloers læssemarkør = strengt opslag; alt andet = nærmeste ≤ 40 m. - -## Review Focus - -1. Run med bygning der er solgt siden sidst: rækken viser "(mangler)", Start afviser med noMarker, intet crash. (Task 3 test) -2. v2-fil med markør der ikke kan oversættes til en bygning: markøren beholdes rå og virker som før. (Task 2 test) -3. To bygninger med markører inden for 40 m af hinanden (silo-rækker): hver silo får sin egen markør (strengt opslag). (Task 1 test) -4. Forsynings-run: Fra vises som "alle siloer" og sources-listen røres ikke. (Task 3 test) -5. Fanen rebuild'es mens et run kører: Start/Stop-knappen og status følger `isRunning`. (in-game) - ---- - -### Task 1: adBuildings - -**Files:** Create `FS25_ADSmartPickup/adBuildings.lua`; Test `tests/test_adBuildings.lua`. - -**Produces:** -```lua -ADBuildings.MAX_MARKER_DISTANCE = 40; ADBuildings.CACHE_MS = 1000 -ADBuildings.list(adEnv, farmId) -- {building...} sorteret kind, navn (cachet) -ADBuildings.find(adEnv, farmId, id) -- building | nil -ADBuildings.byMarker(adEnv, farmId, wayPointId, role) -- role "load"|"unload": building hvis markøren er dens markør, ellers nil -ADBuildings.getId(placeable) -- uniqueId eller "pos:x:z" -ADBuildings.invalidate() -building = {id, name, kind, placeable, loadMarker={id,name,distance}|nil, unloadMarker=…|nil, canBeSource, canBeTarget, canSell} -``` -Kilder til bygninger: `g_currentMission.placeableSystem.placeables` (ejet af farm eller sell) — kind: factory (spec_productionPoint), silo (spec_silo), husbandry (getHusbandryFillLevel), objectStorage (spec_objectStorage), sell (ADPrices.isSellingStation på placeable.spec_sellingPoint / unloadingStation via storageSystem). Læssetriggere: `productionPoint.loadingStation.loadTriggers`, `spec_silo.loadingStation.loadTriggers`, husbandry: alle loadingStations i storageSystem med owningPlaceable == placeable. Aflæsningstriggere: unloadingStations med owningPlaceable == placeable. Objektlager/pallespawner: rootNode/spawnPlaces[1].startNode. -Markørvalg: nærmeste markør (ikke Wait) til en trigger-node ≤ 40 m; for silo-load: kun markører hvor `ADSmartPickup.getLoadStationAtWayPointId(marker.id)` == siloens station. - -- [ ] Tests B1–B7 (kind, markører, silo strengt, Wait udelukket, byMarker, id-fallback, cache-invalidering) → RED → implementér → GREEN → commit `feat: adBuildings`. - -### Task 2: Model v3 - -**Files:** Modify `adRuns.lua`, `adRunsStorage.lua`; Test `tests/test_adRuns.lua`. - -**Produces:** run-felter `sourceBuildingIds`, `targetBuildingIds` (lister af strenge); `ADRuns.toggleSourceBuilding/toggleTargetBuilding(store, runId, id)`; XML v3 `#sourceBuildings`/`#targetBuildings` (mellemrumsadskilte). `ADRuns.XML_VERSION = 3`. Migration sker i controlleren (Task 3), ikke i modellen. - -- [ ] Tests R-v3 → RED → implementér → GREEN → commit. - -### Task 3: Controller — resolve + migration + validering - -**Files:** Modify `adRunsController.lua`; Test `tests/test_adRunsController.lua`. - -**Produces:** -```lua -ADRunsController.resolveMarkers(adEnv, run) -- udfylder run.sourceWayPointIds (outbound) / targetWayPointIds (supply) fra bygninger; rå markører (uden bygning) bevares; returnerer {missing = {buildingName...}} -ADRunsController.migrateBuildings(adEnv) -- ved load: markører → bygnings-id via byMarker; logger -ADRunsController.getFlowRow(adEnv, run) -- {product, from, to, rule, vehicles, status, warning} tekster til tavlen -validateVehicle: ny grund "noMarker" når en valgt bygning mangler markør -``` -Start/plan: `startRun` og `ADOutbound.buildPlanInput` kalder resolveMarkers først (buildPlanInput via ADRunsController.getRunForVehicle → resolve i getRunForVehicle? nej: i `ADSmartPickup.choosePickup`/`ADOutbound.choosePickup` kaldes `ADRunsController.resolveMarkers(adEnv, run)` én gang pr. plan). - -- [ ] Tests C-flow → RED → implementér → GREEN → commit. - -### Task 4: GUI — Flows-fane - -**Files:** Rewrite `gui/SmartPickupFrame.lua` (faner Flows/Lagre, tavle, fælles rækker, knapper), Create `gui/SmartPickupFlowTab.lua` (detail-blok, callbacks), delete `SmartPickupSupplyTab.lua`/`SmartPickupOutboundTab.lua` (indhold flyttes), Modify `gui/SmartPickupFrame.xml` (statiske rækker: runOption "Flow", waitPool, loops, returnBelow, sellAt, storeAbove; prefabs som i dag), l10n da/en (`spu_tab_flows`, `spu_flow_*`, `spu_direction_in/out`, `spu_from_allSilos`, `spu_noMarker`, `spu_reason_noMarker`, `spu_section_*`), `adSmartPickup.lua` (source()). - -- [ ] Implementér; syntaks- og definitions-tjek; in-game. - -### Task 5: README, version, byg, deploy, push, memory. diff --git a/docs/superpowers/plans/2026-09-23-udkoersel.md b/docs/superpowers/plans/2026-09-23-udkoersel.md deleted file mode 100644 index b9f1c1f..0000000 --- a/docs/superpowers/plans/2026-09-23-udkoersel.md +++ /dev/null @@ -1,569 +0,0 @@ -# Udkørsel af produkter — 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:** Runs med retning "Udkørsel": traktorer henter fabrikkers output og lagervarer (bulk og paller, også fra objektlagre) og kører dem til silo eller salgssted efter politik pr. vare, pristærskel og lagertærskel. - -**Architecture:** Ren planlægger (`adOutboundPlanner`) + rene prisregler (`adPrices`) uden spil-API; spiladaptere i `adSources`/`adPrices`; AutoDrive-integration i `adOutbound` via to hooks (næste pålæsning + læsseopgaven), samme mønster som `adUnloadWait`. Wait-puljen udskilles i `adWaitPool` og deles. GUI får tre faner (Oversigt · Forsyning · Udkørsel) med fælles byggeklodser. - -**Tech Stack:** FS25 Lua (GIANTS-sandbox), AutoDrive 3.x via `FS25_AutoDrive`-globalen, Universal Autoload via AD's AL-lag, luajit-tests uden spil. - -**Spec:** `docs/superpowers/specs/2026-09-23-udkoersel-design.md` - -## Global Constraints - -- Alle spil-API-kald i hooks i `pcall`; fejl → hold ved Wait + WARN én gang, aldrig AutoDrive-stop. -- Modellen (`adRuns`) muterer aldrig: ny store ved hver ændring. -- Log-præfiks `ADSmartPickup: ` i alle moduler; alle run-ændringer logges. -- XML-version 2; version 1-filer læses som `kind="supply"`. -- Standarder: `sellAtPercent` 90 (50–100), `storeAbovePercent` 80 (50–95), `collectRound` false. -- Politikker: `store` · `sell` · `sellElseStore` (+ "off" = varen udelades af `products`). -- Kildetyper: fabrik (produktionspunkt), silo (`spec_silo`), objektlager (`spec_objectStorage`), genkendt inden for 40 m af markøren. -- Tests køres fra repo-roden: `luajit tests/.lua`; alle eksisterende tests skal blive grønne. -- Version bump til 1.12.0.0 i `modDesc.xml`; byg `cd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip .`; deploy til `~/FS25-data/mods/`. - -## Review Focus - -1. Traktor med UAL-vogn står ved et objektlager hvis spawn sidder fast (`objectSpawn.isActive` for evigt): forventet: 30 s uden fremdrift → læsningen afsluttes med det der er, ingen palle efterlades ud over den ene der evt. er spawnet. (Task 6 test O5) -2. Bedst betalende salgssted er uopnåeligt, næstbedste kan nås: forventet: næstbedste vælges uden advarsel; kun når intet kan nås, vises rød advarsel én gang. (Task 4 test P6 + Task 6 test O7) -3. Vare hvis salgsstation ikke findes på kortet, politik `sell`: forventet: varen står som "intet salgssted" i UI og planlæggeren springer den over uden fejl. (Task 4 test P7) -4. Version 1-runs-fil (uden `kind`) indlæses: forventet: alle runs bliver Forsyning, intet går tabt, næste gem skriver version 2. (Task 1 test R-v2) -5. Bulk-vogn med opsamlingsrunde og to kilder hvor den første giver fuldt læs: forventet: der køres direkte til målet, kilde 2 besøges ikke. (Task 6 test O6) - ---- - -### Task 1: Model + lagring v2 - -**Files:** -- Modify: `FS25_ADSmartPickup/adRuns.lua` -- Modify: `FS25_ADSmartPickup/adRunsStorage.lua` -- Test: `tests/test_adRuns.lua` (tilføj sektion R-v2) - -**Interfaces:** -- Produces: `ADRuns.KINDS = {supply=true, outbound=true}`, `ADRuns.POLICIES = {store=true, sell=true, sellElseStore=true}`, `ADRuns.DEFAULT_SELL_AT_PERCENT=90`, `ADRuns.DEFAULT_STORE_ABOVE_PERCENT=80`, `ADRuns.createRun(store, name, kind)`, run-felter `kind, sourceWayPointIds, products={{fillType="SUGAR", policy="sell"}}, sellAtPercent, storeAbovePercent, collectRound`, `ADRuns.toggleSource(store, runId, wayPointId)`, `ADRuns.setProductPolicy(store, runId, fillTypeName, policy)` (policy "off" fjerner), `ADRuns.getProductPolicy(run, fillTypeName)` → policy eller "off", `ADRuns.serializeProducts/parseProducts`, `ADRuns.XML_VERSION = 2`. - -- [ ] **Step 1: Skriv fejlende tests** — tilføj nederst i `tests/test_adRuns.lua` før den afsluttende opsummering: - -```lua --- R-v2: udkørsels-run — kind, kilder, varer med politik, tærskler, runde; XML v2 + v1-migration -local o1, out = ADRuns.createRun(ADRuns.newStore(), "Sugar out", "outbound") -check("Rv2 kind", out.kind, "outbound") -check("Rv2 supply som standard", ADRuns.createRun(ADRuns.newStore(), "x").kind == nil and "nil" or select(2, ADRuns.createRun(ADRuns.newStore(), "x")).kind, "supply") -check("Rv2 sellAt 90", out.sellAtPercent, 90) -check("Rv2 storeAbove 80", out.storeAbovePercent, 80) -check("Rv2 runde fra", out.collectRound, false) -local o2 = ADRuns.toggleSource(o1, out.id, 12694) -o2 = ADRuns.toggleSource(o2, out.id, 12386) -check("Rv2 to kilder", table.concat(ADRuns.getRun(o2, out.id).sourceWayPointIds, ","), "12694,12386") -check("Rv2 toggle fjerner", #ADRuns.getRun(ADRuns.toggleSource(o2, out.id, 12694), out.id).sourceWayPointIds, 1) -local o3 = ADRuns.setProductPolicy(o2, out.id, "SUGAR", "sell") -o3 = ADRuns.setProductPolicy(o3, out.id, "FORAGE", "store") -check("Rv2 politik SUGAR", ADRuns.getProductPolicy(ADRuns.getRun(o3, out.id), "SUGAR"), "sell") -check("Rv2 politik ukendt = off", ADRuns.getProductPolicy(ADRuns.getRun(o3, out.id), "STRAW"), "off") -check("Rv2 ugyldig politik ignoreres", ADRuns.getProductPolicy(ADRuns.getRun(ADRuns.setProductPolicy(o3, out.id, "SUGAR", "bogus"), out.id), "SUGAR"), "sell") -check("Rv2 off fjerner", ADRuns.getProductPolicy(ADRuns.getRun(ADRuns.setProductPolicy(o3, out.id, "SUGAR", "off"), out.id), "SUGAR"), "off") -check("Rv2 gammel store urørt", #ADRuns.getRun(o2, out.id).products, 0) -local o4 = ADRuns.updateRun(o3, out.id, {sellAtPercent = 120, storeAbovePercent = 10, collectRound = true}) -check("Rv2 sellAt klippet 100", ADRuns.getRun(o4, out.id).sellAtPercent, 100) -check("Rv2 storeAbove klippet 50", ADRuns.getRun(o4, out.id).storeAbovePercent, 50) -check("Rv2 runde til", ADRuns.getRun(o4, out.id).collectRound, true) -check("Rv2 serialiser", ADRuns.serializeProducts(ADRuns.getRun(o3, out.id).products), "SUGAR:sell FORAGE:store") -local parsed = ADRuns.parseProducts("SUGAR:sell FORAGE:store BAD BAD:bogus") -check("Rv2 parse antal", #parsed, 2) -check("Rv2 parse politik", parsed[2].policy, "store") -local rt = ADRuns.fromRecords(ADRuns.toRecords(o4)) -local rtRun = ADRuns.getRun(rt, out.id) -check("Rv2 roundtrip kind", rtRun.kind, "outbound") -check("Rv2 roundtrip kilder", table.concat(rtRun.sourceWayPointIds, ","), "12694,12386") -check("Rv2 roundtrip products", ADRuns.serializeProducts(rtRun.products), "SUGAR:sell FORAGE:store") -check("Rv2 roundtrip sellAt", rtRun.sellAtPercent, 100) -check("Rv2 roundtrip runde", rtRun.collectRound, true) --- v1-post uden kind/sources/products -> supply med standarder -local v1 = ADRuns.fromRecords({{id = 7, name = "Old", target = 94, targets = "94", ingredientMode = "auto", ingredients = "", waitPool = "", loops = 0, returnBelow = 50, vehicleIds = {}}}) -check("Rv2 v1 -> supply", ADRuns.getRun(v1, 7).kind, "supply") -check("Rv2 v1 tomme kilder", #ADRuns.getRun(v1, 7).sourceWayPointIds, 0) -check("Rv2 v1 sellAt standard", ADRuns.getRun(v1, 7).sellAtPercent, 90) -``` - -- [ ] **Step 2: Kør testen** — `luajit tests/test_adRuns.lua` → FAIL (`toggleSource` nil). - -- [ ] **Step 3: Implementér i `adRuns.lua`**: - -```lua -ADRuns.XML_VERSION = 2 -ADRuns.KINDS = {supply = true, outbound = true} -ADRuns.POLICIES = {store = true, sell = true, sellElseStore = true} -ADRuns.DEFAULT_SELL_AT_PERCENT, ADRuns.MIN_SELL_AT_PERCENT, ADRuns.MAX_SELL_AT_PERCENT = 90, 50, 100 -ADRuns.DEFAULT_STORE_ABOVE_PERCENT, ADRuns.MIN_STORE_ABOVE_PERCENT, ADRuns.MAX_STORE_ABOVE_PERCENT = 80, 50, 95 - -local function copyProducts(products) - local copy = {} - for index, product in ipairs(products or {}) do - copy[index] = {fillType = product.fillType, policy = product.policy} - end - return copy -end - -local function clampRange(value, default, min, max) - local number = tonumber(value) or default - return math.max(min, math.min(max, math.floor(number))) -end -``` -`copyRun` får `kind, sourceWayPointIds = copyList(...), products = copyProducts(...), sellAtPercent, storeAbovePercent, collectRound`. `createRun(store, name, kind)` sætter `kind = ADRuns.KINDS[kind] and kind or "supply"`, `sourceWayPointIds = {}`, `products = {}`, `sellAtPercent = 90`, `storeAbovePercent = 80`, `collectRound = false`. `updateRun` håndterer `changes.kind` (kun gyldige), `sourceWayPointIds` (copyList), `products` (copyProducts), `sellAtPercent`/`storeAbovePercent` (clampRange), `collectRound` (`== true`). - -```lua -function ADRuns.toggleSource(store, runId, wayPointId) - local run = ADRuns.getRun(store, runId) - if run == nil then return copyStore(store) end - local sources, found = {}, false - for _, id in ipairs(run.sourceWayPointIds) do - if id == wayPointId then found = true else table.insert(sources, id) end - end - if not found then table.insert(sources, wayPointId) end - return ADRuns.updateRun(store, runId, {sourceWayPointIds = sources}) -end - -function ADRuns.getProductPolicy(run, fillTypeName) - for _, product in ipairs(run.products or {}) do - if product.fillType == fillTypeName then return product.policy end - end - return "off" -end - --- policy "off" fjerner varen; ukendt politik ignoreres (store uændret). -function ADRuns.setProductPolicy(store, runId, fillTypeName, policy) - local run = ADRuns.getRun(store, runId) - if run == nil or (policy ~= "off" and not ADRuns.POLICIES[policy]) then return copyStore(store) end - local products = {} - for _, product in ipairs(run.products) do - if product.fillType ~= fillTypeName then table.insert(products, product) end - end - if policy ~= "off" then table.insert(products, {fillType = fillTypeName, policy = policy}) end - return ADRuns.updateRun(store, runId, {products = products}) -end - -function ADRuns.serializeProducts(products) - local parts = {} - for _, product in ipairs(products or {}) do table.insert(parts, product.fillType .. ":" .. product.policy) end - return table.concat(parts, " ") -end - -function ADRuns.parseProducts(text) - local products = {} - for token in string.gmatch(tostring(text or ""), "%S+") do - local name, policy = string.match(token, "^([^:]+):(%a+)$") - if name ~= nil and ADRuns.POLICIES[policy] then table.insert(products, {fillType = name, policy = policy}) end - end - return products -end -``` -`toRecords` tilføjer `kind, sources = table.concat(run.sourceWayPointIds, " "), products = serializeProducts, sellAt, storeAbove, collectRound = run.collectRound and 1 or 0`. `fromRecords` læser dem med standarder (`kind` ugyldig/nil → "supply"; `collectRound = tonumber(record.collectRound) == 1`). - -`adRunsStorage.lua`: `writeRecords` skriver `#kind` (string), `#sources`, `#products`, `#sellAt` (int), `#storeAbove` (int), `#collectRound` (int); `readRecords` læser dem (`getString`/`getInt`; manglende attribut → nil → standard i fromRecords). - -- [ ] **Step 4: Kør** `luajit tests/test_adRuns.lua && luajit tests/test_adRunsController.lua` → alle OK. -- [ ] **Step 5: Commit** `feat: run-model v2 — retning, kilder, varepolitik, tærskler, runde (XML v2 + v1-migration)` - ---- - -### Task 2: adWaitPool udskilt fra adUnloadWait - -**Files:** -- Create: `FS25_ADSmartPickup/adWaitPool.lua` -- Modify: `FS25_ADSmartPickup/adUnloadWait.lua`, `adLoadSwap.lua` (excludedPlaceables), `modDesc.xml` (sourceFile før adUnloadWait) -- Test: `tests/test_adWaitPool.lua` (ny), eksisterende `tests/test_adSmartPickup.lua` skal stadig køre - -**Interfaces (Produces):** -```lua -ADWaitPool.getCandidates(adEnv, poolGroup) -- {{marker, x, z}} -ADWaitPool.claim(markerId, vehicle) / ADWaitPool.releaseVehicle(vehicle) / ADWaitPool.getOwner(markerId) -ADWaitPool.pickFree(adEnv, vehicle, poolGroup, isReachable) -- candidate eller nil (nærmeste ledige fra vognens position) -ADWaitPool.hasRouteTo(adEnv, vehicle, wayPointId) / ADWaitPool.hasRouteBothWays(adEnv, vehicle, toId, homeId) -ADWaitPool.redirect(task, destinationID) -- stopper AD's trigger-aktivitet, sætter task.destinationID/state/setPathTo -ADWaitPool.getClaims() -- {{marker, vehicle}} sorteret (til GUI) -ADWaitPool.STATE_DRIVING -- sættes af adUnloadWait.install (task.STATE_DRIVING or 2) -ADLoadSwap.findDumpMarker(adEnv, vehicle, fillType, rest, homeWayPointId, isReachable, excludedPlaceables) -- 7. param: {[placeable]=true} udelukkes -``` - -- [ ] **Step 1: Test** `tests/test_adWaitPool.lua`: mock `FS25_AutoDrive.ADGraphManager` (markers i mapperne "Cow Wait" og "silo", waypoints, `getPathTo` der returnerer `{ {id=target} }` for nåelige), `ADSupplyPlanner` via `dofile`. Tjek: (W1) `getCandidates(adEnv, "Cow Wait")` giver kun Wait-markører i den mappe, tom pulje = alle Wait-mapper; (W2) `pickFree` returnerer nærmeste ledige, springer reserverede over, returnerer eget reserverede; (W3) `claim/releaseVehicle/getOwner`; (W4) `pickFree` med `isReachable` der afviser nærmeste → næste; (W5) `getClaims` sorteret på mappe. -- [ ] **Step 2: Kør** → FAIL (fil mangler). -- [ ] **Step 3: Implementér** `adWaitPool.lua` ved at flytte `getWaitCandidates`, `claims`, `pathReaches`, `hasRouteTo`, `hasRouteBothWays`, `redirect`, `getClaims`-sorteringen fra `adUnloadWait.lua` (samme kode, offentlige navne som ovenfor; `claims` er `setmetatable({}, {__mode="v"})`; `getClaims` itererer `claims` direkte: `{marker = ADGraphManager:getMapMarkerByWayPointId(id), vehicle}`). `adUnloadWait.lua` kalder dem (`claims[...]` → `ADWaitPool.claim/getOwner/releaseVehicle`; `pickNearestFreeWaitPoint(getWaitCandidates(...), claims, ...)` → `ADSupplyPlanner.pickNearestFreeWaitPoint(ADWaitPool.getCandidates(adEnv, pool), ADWaitPool.claims, x, z, vehicle, isReachable)`); `ADUnloadWait.getClaims/hasRouteTo/hasRouteBothWays` bliver delegater. `ADLoadSwap.findDumpMarker`: `if excludedPlaceables ~= nil and excludedPlaceables[station.owningPlaceable] then skip`. -- [ ] **Step 4: Kør** alle tests: `for t in tests/test_*.lua; do luajit $t | tail -1; done` → grønne. -- [ ] **Step 5: Commit** `refactor: Wait-puljen udskilt i adWaitPool (reservationer, rutetjek, redirect) til deling med udkørsel` - ---- - -### Task 3: adPrices — toppris og pris-% - -**Files:** -- Create: `FS25_ADSmartPickup/adPrices.lua` -- Test: `tests/test_adPrices.lua` - -**Interfaces (Produces):** -```lua -ADPrices.maxFactor(factors) -- højeste værdi i {[period]=factor}; 1 uden faktorer -ADPrices.topPrice(baseNow, factorNow, maxFactor) -- baseNow * maxFactor / factorNow; baseNow når factorNow <= 0 -ADPrices.percentOfTop(price, top) -- afrundet heltal; 0 når top <= 0 -ADPrices.isSellingStation(station) -- SellingStation, isSellingPoint, ikke hideFromPricesMenu -ADPrices.getQuote(station, fillType) -- {price, top, percent} eller nil (spil-API i pcall) -ADPrices.bestQuote(quotes) -- højeste percent, dernæst price; nil ved tom -``` - -- [ ] **Step 1: Test** (P1) `maxFactor({EARLY_SPRING=0.9, LATE_AUTUMN=1.2})` = 1.2; (P2) `topPrice(500, 1.0, 1.25)` = 625; `topPrice(500, 0, 1.25)` = 500; (P3) `percentOfTop(470, 625)` = 75; `percentOfTop(700, 625)` = 112 (stor efterspørgsel > 100); `percentOfTop(1, 0)` = 0; (P4) `getQuote` med fake station `{fillTypePrices={[5]=500}, getEffectiveFillTypePrice=function(_,ft) return 470 end, isa=function() return true end, isSellingPoint=true}` og `g_fillTypeManager.getFillTypeByIndex` → `{economy={factors={A=1.0,B=1.25}}, pricePerLiter=400}`, `g_currentMission.environment.currentPeriod="A"` → percent 75; (P5) uden `fillTypePrices` bruges `pricePerLiter × factorNow × economyManager:getPriceMultiplier()` (1.0) → top 500, percent 94 (470/500); (P6) `bestQuote` vælger højeste percent. -- [ ] **Step 2: Kør** → FAIL. -- [ ] **Step 3: Implementér**: - -```lua -ADPrices = {} -ADPrices.LOG_PREFIX = "ADSmartPickup: " - -function ADPrices.maxFactor(factors) - local best = nil - for _, factor in pairs(factors or {}) do - if type(factor) == "number" and (best == nil or factor > best) then best = factor end - end - return best or 1 -end - -function ADPrices.topPrice(baseNow, factorNow, maxFactor) - if baseNow == nil or baseNow <= 0 then return 0 end - if factorNow == nil or factorNow <= 0 or maxFactor == nil or maxFactor <= 0 then return baseNow end - return baseNow * maxFactor / factorNow -end - -function ADPrices.percentOfTop(price, top) - if top == nil or top <= 0 or price == nil then return 0 end - return math.floor(100 * price / top + 0.5) -end - -function ADPrices.isSellingStation(station) - return station ~= nil and station.isa ~= nil and SellingStation ~= nil and station:isa(SellingStation) - and station.isSellingPoint == true and not station.hideFromPricesMenu -end - -local function getCurrentPeriod() - local environment = g_currentMission ~= nil and g_currentMission.environment or nil - return environment ~= nil and environment.currentPeriod or nil -end - -function ADPrices.getQuote(station, fillType) - local ok, effective = pcall(station.getEffectiveFillTypePrice, station, fillType) - if not ok or type(effective) ~= "number" then return nil end - local fillTypeDesc = g_fillTypeManager ~= nil and g_fillTypeManager.getFillTypeByIndex ~= nil and g_fillTypeManager:getFillTypeByIndex(fillType) or nil - local factors = fillTypeDesc ~= nil and fillTypeDesc.economy ~= nil and fillTypeDesc.economy.factors or nil - local period = getCurrentPeriod() - local factorNow = factors ~= nil and period ~= nil and tonumber(factors[period]) or 1 - local baseNow = station.fillTypePrices ~= nil and tonumber(station.fillTypePrices[fillType]) or nil - if baseNow == nil or baseNow <= 0 then - local multiplier = 1 - local economy = g_currentMission ~= nil and g_currentMission.economyManager or nil - if economy ~= nil and economy.getPriceMultiplier ~= nil then - local okMult, value = pcall(economy.getPriceMultiplier, economy) - if okMult and type(value) == "number" then multiplier = value end - end - baseNow = (fillTypeDesc ~= nil and tonumber(fillTypeDesc.pricePerLiter) or 0) * factorNow * multiplier - end - local top = ADPrices.topPrice(baseNow, factorNow, ADPrices.maxFactor(factors)) - return {price = effective, top = top, percent = ADPrices.percentOfTop(effective, top)} -end - -function ADPrices.bestQuote(quotes) - local best = nil - for _, quote in ipairs(quotes or {}) do - if best == nil or quote.percent > best.percent or (quote.percent == best.percent and quote.price > best.price) then best = quote end - end - return best -end -``` -- [ ] **Step 4: Kør** → OK. **Step 5: Commit** `feat: adPrices — toppris fra årets prisforløb og pris-% pr. salgssted` - ---- - -### Task 4: adOutboundPlanner (ren) - -**Files:** -- Create: `FS25_ADSmartPickup/adOutboundPlanner.lua` -- Test: `tests/test_adOutboundPlanner.lua` - -**Interfaces (Produces):** -```lua --- input: --- { capacity = liter, hasAutoload = bool, sellAtPercent, storeAbovePercent, collectRound, --- products = { { fillType = idx, name = "SUGAR", policy = "sell"|"store"|"sellElseStore", isPallet = bool, --- sources = { {wayPointId, available, level, capacity} ... }, -- kun kilder med available > 0 --- quote = {percent, price, wayPointId, stationName} | nil, -- bedste NÅELIGE salgssted --- store = {wayPointId, name, free} | nil } ... } } --- output: trip | nil, reasons --- trip = { kind = "sell"|"store", fillTypes = {idx...}, names = {...}, sourceIds = {wayPointId...}, targetWayPointId, targetName, percent|nil } --- reasons = { "SUGAR 78 % af top (kræver 90), lager 41 %", ... } -ADOutboundPlanner.plan(input) -ADOutboundPlanner.evaluate(product, input) -- {available, fullLoad, storageHigh, storagePercent, priceOk, percent} -ADOutboundPlanner.MIN_AVAILABLE_LITERS = 1 -ADOutboundPlanner.ROUND_MIN_FREE_FRACTION = 0.1 -``` - -- [ ] **Step 1: Tests** (`local P = ADOutboundPlanner` efter `dofile`): - - P1 salg ved pris over tærskel, uanset mængde: SUGAR sell, sources {{1, 5000, ...}}, quote 92, capacity 20000 → trip.kind "sell", targetWayPointId = quote.wayPointId, sourceIds {1}. - - P2 pris under tærskel, lager lavt → nil, reasons[1] = "SUGAR 78 % af top (kræver 90), lager 41 %". - - P3 sellElseStore, pris lav, lager 85 % → "store" til store.wayPointId. - - P4 store med fuldt læs (available ≥ capacity) og lager 20 % → store. - - P5 rangering: to sell-kandidater 95 % og 91 % → første vælges (højeste %). - - P6 quote nil (intet nåeligt salgssted), policy sell, lager højt → nil, reason "SUGAR: intet nåeligt salgssted". - - P7 store nil ved store-behov → nil, reason "FORAGE: ingen silo med plads". - - P8 runde bulk (collectRound, hasAutoload=false): SUGAR i kilde A (3000) og B (9000), capacity 10000 → sourceIds {B, A} (mest først), ét fillType. - - P9 runde UAL: STRAW_BASKET (sell 95, station S) + STRAW_CRATE (sell 93, station S) + STRAW_BROOM (sell 96, station T) → fillTypes {BROOM} kun (højest, T) — og med BROOM fjernet: {BASKET, CRATE} samme station S. - - P10 uden runde: kun første fillType og kun én kilde (den med mest). - - P11 policy off/ingen sources → nil, reasons tom for varer uden kilder ("SUGAR: intet på lager"). -- [ ] **Step 2: Kør** → FAIL. **Step 3: Implementér**: - -```lua -ADOutboundPlanner = {} -ADOutboundPlanner.MIN_AVAILABLE_LITERS = 1 -ADOutboundPlanner.ROUND_MIN_FREE_FRACTION = 0.1 - -local function sumAvailable(product) - local total, high, percent = 0, false, 0 - for _, source in ipairs(product.sources or {}) do - total = total + (source.available or 0) - if source.capacity ~= nil and source.capacity > 0 then - local p = 100 * (source.level or 0) / source.capacity - percent = math.max(percent, p) - end - end - return total, percent -end - -function ADOutboundPlanner.evaluate(product, input) - local available, storagePercent = sumAvailable(product) - local percent = product.quote ~= nil and product.quote.percent or nil - return { - available = available, - fullLoad = input.capacity ~= nil and input.capacity > 0 and available >= input.capacity, - storagePercent = storagePercent, - storageHigh = storagePercent >= (input.storeAbovePercent or 80), - percent = percent, - priceOk = percent ~= nil and percent >= (input.sellAtPercent or 90), - } -end - -local function wantsSell(policy) return policy == "sell" or policy == "sellElseStore" end -local function wantsStore(policy) return policy == "store" or policy == "sellElseStore" end - -local function reasonFor(product, e, input) - if e.available < ADOutboundPlanner.MIN_AVAILABLE_LITERS then return product.name .. ": intet på lager" end - if wantsSell(product.policy) and product.quote == nil and not (wantsStore(product.policy) and product.store ~= nil) then - return product.name .. ": intet nåeligt salgssted" - end - if wantsStore(product.policy) and product.store == nil and (e.fullLoad or e.storageHigh) and not e.priceOk then - return product.name .. ": ingen silo med plads" - end - local parts = {} - if wantsSell(product.policy) and e.percent ~= nil then - table.insert(parts, string.format("%d %% af top (kræver %d)", e.percent, input.sellAtPercent or 90)) - end - table.insert(parts, string.format("lager %d %%", math.floor(e.storagePercent + 0.5))) - return product.name .. " " .. table.concat(parts, ", ") -end - --- kandidater: {product, e, kind, targetWayPointId, targetName, score} -local function collectCandidates(input) - local sell, store, reasons = {}, {}, {} - for _, product in ipairs(input.products or {}) do - local e = ADOutboundPlanner.evaluate(product, input) - local added = false - if e.available >= ADOutboundPlanner.MIN_AVAILABLE_LITERS then - if wantsSell(product.policy) and e.priceOk and product.quote ~= nil then - table.insert(sell, {product = product, e = e, kind = "sell", targetWayPointId = product.quote.wayPointId, targetName = product.quote.stationName, percent = e.percent}) - added = true - elseif wantsStore(product.policy) and (e.fullLoad or e.storageHigh) and product.store ~= nil then - table.insert(store, {product = product, e = e, kind = "store", targetWayPointId = product.store.wayPointId, targetName = product.store.name}) - added = true - end - end - if not added then table.insert(reasons, reasonFor(product, e, input)) end - end - table.sort(sell, function(a, b) - if a.e.percent ~= b.e.percent then return a.e.percent > b.e.percent end - if a.e.available ~= b.e.available then return a.e.available > b.e.available end - return a.product.name < b.product.name - end) - table.sort(store, function(a, b) - if a.e.storagePercent ~= b.e.storagePercent then return a.e.storagePercent > b.e.storagePercent end - if a.e.available ~= b.e.available then return a.e.available > b.e.available end - return a.product.name < b.product.name - end) - return sell, store, reasons -end - -local function sortedSources(product) - local sources = {} - for _, source in ipairs(product.sources or {}) do - if (source.available or 0) >= ADOutboundPlanner.MIN_AVAILABLE_LITERS then table.insert(sources, source) end - end - table.sort(sources, function(a, b) return a.available > b.available end) - return sources -end - -local function buildTrip(primary, candidates, input) - local trip = {kind = primary.kind, fillTypes = {primary.product.fillType}, names = {primary.product.name}, sourceIds = {}, targetWayPointId = primary.targetWayPointId, targetName = primary.targetName, percent = primary.percent} - local members = {primary} - if input.collectRound and input.hasAutoload and primary.product.isPallet then - for _, candidate in ipairs(candidates) do - if candidate ~= primary and candidate.kind == primary.kind and candidate.targetWayPointId == primary.targetWayPointId and candidate.product.isPallet then - table.insert(members, candidate) - table.insert(trip.fillTypes, candidate.product.fillType) - table.insert(trip.names, candidate.product.name) - end - end - end - local seen = {} - for _, member in ipairs(members) do - local sources = sortedSources(member.product) - local limit = input.collectRound and #sources or math.min(1, #sources) - for index = 1, limit do - local id = sources[index].wayPointId - if not seen[id] then seen[id] = true; table.insert(trip.sourceIds, id) end - end - end - return trip -end - -function ADOutboundPlanner.plan(input) - local sell, store, reasons = collectCandidates(input) - if sell[1] ~= nil then return buildTrip(sell[1], sell, input), reasons end - if store[1] ~= nil then return buildTrip(store[1], store, input), reasons end - return nil, reasons -end -``` -- [ ] **Step 4: Kør** → OK. **Step 5: Commit** `feat: adOutboundPlanner — turvurdering, prisregel, politikker, opsamlingsrunde (ren)` - ---- - -### Task 5: adSources — kilde-genkendelse og lagerstatus - -**Files:** -- Create: `FS25_ADSmartPickup/adSources.lua` -- Test: `tests/test_adSources.lua` - -**Interfaces (Produces):** -```lua -ADSources.MAX_DISTANCE = 40; ADSources.PALLET_RADIUS = 25 -ADSources.identify(adEnv, wayPointId, farmId) -- {kind="factory"|"silo"|"objectStorage", placeable, station|nil, wayPointId, name} | nil -ADSources.getProducts(adEnv, source, farmId) -- { {fillType, name, available, level, capacity, isPallet} } (kun varer med kapacitet/indhold) -ADSources.isPalletFillType(fillType) -- fillTypeDesc.palletFilename ~= nil -ADSources.getSellMarkers(adEnv, farmId) -- { {marker, station} } salgssteder (ADPrices.isSellingStation) -ADSources.getObjectInfoIndex(source, fillType) -- objectInfo-indeks + antal for varen i et objektlager, eller nil -ADSources.spawnOne(source, objectInfoIndex) -- pcall placeable:removeAbstractObjectsFromStorage(index, 1, nil) -> ok -ADSources.countStored(source, fillType) -- antal objekter af varen i lageret -ADSources.getSourcePlaceables(sources) -- {[placeable]=true} til udelukkelse -``` -Genkendelse: læssestation ved markøren (`ADSmartPickup.getLoadStationAtWayPointId`) med `owningPlaceable.spec_productionPoint` → factory; med `spec_silo` → silo; ellers nærmeste placeable med `spec_objectStorage` inden for 40 m af waypointet (`g_currentMission.placeableSystem.placeables`, `getWorldTranslation(placeable.rootNode)`, `canFarmAccess`) → objectStorage. -Fabrik-produkter: `for production in productionPoint.productions: for output in production.outputs: type` (unik); `level = productionPoint.storage:getFillLevel(ft)`, `capacity = storage:getCapacity(ft)` (fallback `storage.capacities[ft]`); paller i nærheden: `g_currentMission.vehicleSystem.vehicles` med `spec_pallet` eller `isPallet`, ejer = farm, fillUnit-varen = ft, afstand ≤ 25 m fra `productionPoint.palletSpawner.spawnPlaces[1].startNode` (fallback placeable.rootNode); `available = level + Σ palle-fyldning`. -Silo: `spec_silo.storages[*]` fillLevels > 0 → `level = getOwnStorageLevel`, `capacity = storage:getCapacity(ft)`. -Objektlager: `spec_objectStorage.objectInfos[i].objects[1]` med `palletAttributes.fillType/fillLevel` eller `baleAttributes.fillType/fillLevel`; `numObjects` → available = numObjects × fillLevel; level = `spec.numStoredObjects`, capacity = `spec.capacity`; isPallet = true. - -- [ ] **Step 1: Tests** med fakes: (S1) markør ved fabrik → factory med SUGAR output level 150000, capacity 180000, ingen paller → available 150000; (S2) markør ved silo → silo med FORAGE 74500; (S3) markør uden station, objektlager 20 m væk → objectStorage, produkt STRAW_BASKET available 8×1000, isPallet true, `getObjectInfoIndex` = 1 og 8; (S4) paller ved spawner: 2 paller à 1000 STRAW_BASKET inden for 25 m + 1 uden for → available = storage + 2000; (S5) `getSellMarkers` giver kun markører hvis unload-station er SellingStation med isSellingPoint; (S6) `spawnOne` kalder `removeAbstractObjectsFromStorage(1, 1, nil)` (fake tæller); (S7) markør 60 m fra objektlager → nil. -- [ ] **Step 2–4:** implementér som beskrevet, kør, grønne. **Step 5: Commit** `feat: adSources — fabrik, silo og objektlager som kilder; paller ved spawner; salgssteder` - ---- - -### Task 6: adOutbound — hooks, hold, objektlager-udtag, runde, advarsler - -**Files:** -- Create: `FS25_ADSmartPickup/adOutbound.lua` -- Test: `tests/test_adOutbound.lua` - -**Interfaces (Produces):** -```lua -ADOutbound.CHECK_INTERVAL_MS = 5000; ADOutbound.NO_PROGRESS_MS = 15000; ADOutbound.SPAWN_STALL_MS = 30000 -ADOutbound.isOutboundVehicle(vehicle) -- run for vognen har kind "outbound" -ADOutbound.choosePickup(adEnv, vehicle) -- kaldes fra getNextPickup-wrap; returnerer choice {markerIndex, markerName, group, station|nil, fillTypes, fillType} eller nil -ADOutbound.buildPlanInput(adEnv, run, vehicle) -- samler kilder/priser/lager til planlæggeren (spil-API) -ADOutbound.beforeLoadUpdate(adEnv, task, dt) -- true = håndteret (spring original update over) -ADOutbound.beforeLoadFinished(adEnv, task) -- true = fortsæt runde (spring original finished over) -ADOutbound.releaseInactive() -ADOutbound.getHoldFor(vehicle) -- {markerName, reasons} | nil -ADOutbound.getRunStatus(run) -- {reasons, warning} (seneste) -ADOutbound.install(adEnv) -- wrapper LoadAtDestinationTask.update/finished -``` - -Adfærd (fra spec afsnit 3–4): -- `choosePickup`: `trips[vehicle]=nil`; input = buildPlanInput; `trip, reasons = ADOutboundPlanner.plan(input)`; status[run.id] = reasons. Tur → `stateModule.selectedFillTypes = trip.fillTypes; setFillType(trip.fillTypes[1]); setSecondMarkerByWayPointId(trip.targetWayPointId); raiseDirtyFlag`; `trips[vehicle] = {run, trip, queue = copy(trip.sourceIds), visited = {}, current = trip.sourceIds[1]}`; log `'MT635' udkørsel: 8×STRAW_BASKET fra Straw factory → Halm-salg (95 % af top)`; returnér choice for første kilde (station = kildens læssestation hvis bulk, ellers nil). Ingen tur → `ADWaitPool.pickFree(adEnv, vehicle, run.waitPoolGroup, isReachable=hasRouteTo)`; fundet → claim, `holds[vehicle] = {markerId, reasons}`, log én gang pr. ændret begrundelse, returnér choice for Wait-markøren (station nil); intet Wait-punkt → hold ved første kilde (warn én gang) og returnér den. -- `buildPlanInput`: for hver kilde i `run.sourceWayPointIds`: `ADSources.identify` (nil → spring over + warn én gang "kilde markør X ikke genkendt"); products = union over kilder af `ADSources.getProducts`, filtreret til `ADRuns.getProductPolicy ~= "off"` og til det vognen kan bære (`bulk: AutoDrive.getSupportedFillTypesOfAllUnitsAlphabetically`, pallet: hasAutoload = `AutoDrive:hasAL(trailer)` for en af `getAllUnits`); pr. produkt med sell-politik: quotes over `ADSources.getSellMarkers` hvor `station.acceptedFillTypes[ft]` (eller `getIsFillTypeSupported`) og `ADWaitPool.hasRouteBothWays(adEnv, vehicle, marker.id, sourceId)` → `ADPrices.getQuote`; bedste = `ADPrices.bestQuote`; ingen nåelig men mindst én unåelig → warnOnce(vehicle, "sell:"..ft, "'%s' kan ikke nå salgssted '%s' for %s – tjek vejnettet"); pr. produkt med store-politik: `ADLoadSwap.findDumpMarker(adEnv, vehicle, ft, math.min(available, capacity), nil, isReachable, ADSources.getSourcePlaceables(sources))` → store; nil → warnOnce(vehicle, "store:"..ft, "'%s' finder ingen silo med plads til %s (%s)"). -- `warnOnce(vehicle, key, text)`: `warned[vehicle][key] ~= text` → Logging.warning + pcall `adEnv.AutoDriveMessageEvent.sendMessageOrNotification(vehicle, adEnv.ADMessagesManager.messageTypes.ERROR, "ADSmartPickup: " .. text, 8000)`; status[run.id].warning = text. Ryddes når nøglens tilstand ændrer sig (ny tekst) eller turen lykkes. -- `beforeLoadUpdate`: hold → hvis `task.destinationID == hold.markerId` og `drivePathModule:isTargetReached()`: `specialDrivingModule:stopVehicle(); specialDrivingModule:update(dt)`; timer 5 s → `choosePickup` igen; tur → `ADWaitPool.releaseVehicle`, `holds[vehicle]=nil`, `ADWaitPool.redirect(task, trip.sourceIds[1])`, `task.activatedUALLoading=false`; returnér true. Ikke fremme → false. - Tur aktiv og `isTargetReached()`: kilde = `state.sources[task.destinationID]`; objektlager → `updateSpawn` (nedenfor); derefter fremdriftsvagt: `level = AutoDrive.getAllFillLevels(task.trailers)`; stiger → `noProgressMs=0`; ellers `+dt`; `> NO_PROGRESS_MS` og `level > 0` og ingen spawn i gang → `tryNextSourceOrFinish(task)`; returnér false (original kører altid, den aktiverer UAL og stopper vognen). -- `updateSpawn(state, task, source, dt)`: første gang: `index = ADSources.getObjectInfoIndex(source, currentFillType)`; hvis `task.activatedUALLoading` og vognen står (`vehicle.lastSpeedReal < 0.0005`): hvis ingen udestående spawn: `ualIsFull()` på en UAL-vogn → done; `countStored == 0` → skift til næste fillType i `trip.fillTypes` (nyt index) eller done; ellers `ADSources.spawnOne` → `pending = {stored = countStored, loaded = countLoaded(trailers), ms = 0}`; udestående: `countStored < pending.stored and countLoaded > pending.loaded` → pending=nil (næste); `pending.ms > SPAWN_STALL_MS` → done + warn "objektlager spawner ikke". `countLoaded` = antal nøgler i `spec_universalAutoload.loadedObjects` summeret over trailers. done → `task:finished()` (via original finished) og returnér. -- `tryNextSourceOrFinish(task)`: næste id i `state.queue` som ikke er besøgt, og fri kapacitet ≥ 10 % (`fillFreeCapacity/capacity`) → `visited[current]=true; current=next; ADWaitPool.redirect(task, next); task.activatedUALLoading=false; activePickups opdateres via ADSmartPickup.setActivePickup(vehicle, station, fillTypes)` (ny lille funktion i adSmartPickup); ellers `task:finished()`. -- `beforeLoadFinished`: kaldes fra wrap af `finished`; hvis der er tur, kilde ikke fuld (ikke `filledToUnload`) og en ubesøgt kilde med plads → redirect som ovenfor og returnér true (spring original over); ellers `trips[vehicle]=nil` og false. -- `releaseInactive`: for inaktive køretøjer: trips/holds ryddes, `ADWaitPool.releaseVehicle`. - -- [ ] **Step 1: Tests** (fake AD-miljø som i `test_adRunsController.lua` + fakes for `ADSources`, `ADPrices`, `ADWaitPool`, `ADRunsController.getRunForVehicle`): (O1) tur → selectedFillTypes/2. markør sat, choice = kilde; (O2) ingen tur → Wait-markør claimet, hold registreret, choice = Wait; (O3) hold + 5 s + nu tur → redirect til kilde, hold frigivet; (O4) objektlager: spawn kaldes én ad gangen; stored falder + loaded stiger → næste spawn; `ualIsFull` → finished kaldt; (O5) spawn uden fremdrift 30 s → finished + WARN; (O6) runde bulk: kilde A fylder vognen (`filledToUnload`) → finished kaldt, ingen redirect; kilde A giver 30 % → redirect til B; (O7) intet nåeligt salgssted → `sendMessageOrNotification` kaldt én gang med ERROR, anden gang ikke; (O8) `releaseInactive` rydder hold og claim. -- [ ] **Step 2–4:** implementér, kør, grønne. **Step 5: Commit** `feat: adOutbound — udkørsels-hooks: turvalg, hold ved Wait, objektlager-udtag, opsamlingsrunde, advarsler på skærm` - ---- - -### Task 7: Controller — udkørsels-runs - -**Files:** -- Modify: `FS25_ADSmartPickup/adRunsController.lua` -- Test: `tests/test_adRunsController.lua` (sektion C-out) - -**Interfaces (Produces):** -```lua -ADRunsController.createRun(name, kind) -ADRunsController.getSourceMarkers(adEnv, farmId) -- markører der er kilder: {marker, kind} -ADRunsController.getRunProducts(adEnv, run) -- { {fillType, name, title, policy, isPallet, available, percent|nil, stationName|nil} } sorteret på titel -ADRunsController.validateVehicle(adEnv, run, vehicle) -- outbound: reasons noSource | noTrailer | noProducts | needsAutoload | cannotCarry | farFromNetwork -ADRunsController.getOutboundStatus(adEnv, run) -- {products = getRunProducts, reasons, warning} (fra ADOutbound.getRunStatus) -``` -`applyRunToVehicle`: `if run.kind == "outbound"`: mode P&D, `setFirstMarkerByWayPointId(run.sourceWayPointIds[1])`, `setSecondMarkerByWayPointId(run.sourceWayPointIds[1])`, `selectedFillTypes` = produkt-fillTypes vognen kan bære, `setFillType(first)`, loopCounter 0, useFolders true, rotateTargets ONLYPICKUP; ingen dump-logik. `getVehicleActivity`: `ADOutbound.getHoldFor(vehicle)` → `{kind="waiting", place=markerName, reason=reasons[1]}`. `updateRun` logger som før. `getRunTargets` for outbound returnerer `{}` (mål vælges pr. tur). - -- [ ] **Step 1: Tests** (C-out1) createRun kind; (C-out2) validate: ingen kilder → noSource; bulk-vogn og kun pallevarer → needsAutoload; ingen politik → noProducts; ok-tilfælde; (C-out3) startRun sætter 1./2. markør = kilde 1 og selectedFillTypes; (C-out4) getRunProducts slår politik op. -- [ ] **Step 2–4:** implementér, kør alle tests. **Step 5: Commit** `feat: controller — udkørsels-runs: start, validering, produkter, status` - ---- - -### Task 8: Wiring i adSmartPickup, adUnloadWait, modDesc - -**Files:** -- Modify: `adSmartPickup.lua` (source() nye gui-filer, `installHook`: outbound-gren i `getNextPickup`-wrap, `ADOutbound.install(adEnv)`, `ADSmartPickup.setActivePickup`, `update`: `ADOutbound.releaseInactive`), `adUnloadWait.lua` (`beforeUpdate`: return hvis `ADOutbound.isOutboundVehicle`), `modDesc.xml` (sourceFiles: adPrices, adOutboundPlanner, adSources, adWaitPool, adOutbound før adUnloadWait/adSmartPickup; version 1.12.0.0; beskrivelse EN udvidet med udkørsel). - -`getNextPickup`-wrap, ny gren først: -```lua -if ADOutbound ~= nil and ADOutbound.isOutboundVehicle(driver) then - local ok, choice = pcall(ADOutbound.choosePickup, adEnv, driver) - if not ok then - Logging.warning(ADSmartPickup.LOG_PREFIX .. "fejl i udkørsel, bruger AutoDrives rotation: %s", tostring(choice)) - activePickups[driver] = nil - return originalGetNextPickup(self, driver, forcedSkip) - end - if choice == nil then activePickups[driver] = nil; return originalGetNextPickup(self, driver, forcedSkip) end - activePickups[driver] = choice.station ~= nil and {station = choice.station, fillTypes = choice.fillTypes, markerName = choice.markerName, fillType = choice.fillType} or nil - self.pickups[choice.group] = self.pickups[choice.group] or {} - self.pickups[choice.group].lastTarget = choice.markerIndex - self.pickups[choice.group].lastVehicle = driver - return choice.markerIndex -end -``` -- [ ] **Step 1:** ret filer. **Step 2:** `luajit tests/test_adSmartPickup.lua` grøn; `for t in tests/test_*.lua; do luajit $t | tail -1; done`. **Step 3: Commit** `feat: udkørsel koblet på AutoDrive-hooks; Wait-logik springer udkørsels-traktorer over` - ---- - -### Task 9: GUI — tre faner - -**Files:** -- Modify: `gui/SmartPickupFrame.lua` (faner, fælles), `gui/SmartPickupFrame.xml` (nye statiske rækker + productPrefab + sourcePrefab), `l10n/l10n_da.xml`, `l10n/l10n_en.xml` -- Create: `gui/SmartPickupSupplyTab.lua` (flyt: fillTargetRows, fillNeedsRows, refreshNeedsRows, fillIngredientRows, describeTargetNeeds, onClickTarget/IngredientMode/Ingredient/Loops/ReturnBelow, `SmartPickupFrame:rebuildSupplyTab(adEnv, run)`), `gui/SmartPickupOutboundTab.lua` (`rebuildOutboundTab(adEnv, run)`: Kilder-rækker (ingredientPrefab med kind="source"), Varer-rækker (productPrefab: MultiTextOption med 4 tilstande Fra/Lager/Salg/Salg, ellers lager + titel "Sukker — 150.0k · 78 % af top (Grain Elevator)"), Regler (sellAtOption 50..100 trin 5, storeAboveOption 50..95 trin 5, collectRoundOption), status-/advarselsrække; callbacks onClickSource/onClickPolicy/onClickSellAt/onClickStoreAbove/onClickCollectRound; `describeOutboundOverview(run)` til Oversigt). -- `SmartPickupFrame`: `TAB_OVERVIEW, TAB_SUPPLY, TAB_OUTBOUND = 1, 2, 3`, `TAB_NAMES = {"spu_tab_overview","spu_tab_supply","spu_tab_outbound"}`, `tabKind = {nil, "supply", "outbound"}`; `fillRunOption` viser kun runs med fanens kind; `onNewRunName` opretter med fanens kind (Oversigt → supply); `setStaticRowsVisible(tab)` viser forsynings-rækker (ingredientMode, waitPool, loops, returnBelow) kun på fane 2, udkørsels-rækker (waitPool, sellAt, storeAbove, collectRound) kun på fane 3 (waitPool er fælles); blok-overskrifter via `addTextRow` ("— Mål og varer —" stil: brug tekstnøglerne `spu_section_*`). `refreshLive` opdaterer også produktrækkernes titler (pris-%) hvert sekund og udkørsels-status. -- l10n (da/en): `spu_tab_supply` "Forsyning"/"Supply", `spu_tab_outbound` "Udkørsel"/"Outbound", `spu_sources` "Kilder (fabrik, silo eller objektlager)", `spu_products` "Varer", `spu_policy_off/store/sell/sellElseStore` "Fra/Lager/Salg/Salg, ellers lager", `spu_sellAt` "Sælg fra (% af årets toppris)", `spu_sellAt_tooltip`, `spu_storeAbove` "Kør til lager over", `spu_storeAbove_tooltip`, `spu_collectRound` "Saml op på én tur", `spu_collectRound_tooltip`, `spu_section_run/targets/rules/wait/vehicles/status`, `spu_reason_noSource` "ingen genkendt kilde", `spu_reason_noProducts` "ingen vare valgt", `spu_reason_needsAutoload` "pallevarer kræver Universal Autoload på vognen", `spu_product_row` "%s — %s · %s", `spu_price_pct` "%d %% af top (%s)", `spu_price_none` "intet salgssted", `spu_outbound_waiting` "venter: %s", `spu_activity_holding` "holder ved %s (%s)". -- [ ] **Step 1:** XML + l10n; **Step 2:** split + ny fane; **Step 3:** `luajit` alle tests (GUI testes in-game); **Step 4: Commit** `feat(gui): faner Oversigt/Forsyning/Udkørsel; udkørsels-run med kilder, varepolitik, regler` - ---- - -### Task 10: README, version, byg, deploy, in-game - -- [ ] README: nyt afsnit "Udkørsel (v1.12)" (kilder, politikker, prisregel, lagertærskel, runde, objektlager-krav om markørplacering, advarsler). modDesc 1.12.0.0. -- [ ] `cd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip . && cp ../FS25_ADSmartPickup.zip ~/FS25-data/mods/` -- [ ] Commit `chore: v1.12.0.0 — udkørsel af produkter` + push til Gitea (`git push gitea main` med token fra vault). -- [ ] In-game-test (bruger): savegame 3 — se spec afsnit 4. diff --git a/docs/superpowers/plans/2026-09-25-markarbejde-etape1.md b/docs/superpowers/plans/2026-09-25-markarbejde-etape1.md deleted file mode 100644 index 3148152..0000000 --- a/docs/superpowers/plans/2026-09-25-markarbejde-etape1.md +++ /dev/null @@ -1,1666 +0,0 @@ -# Markarbejde etape 1 (slåafgrøder) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Smart Pickup sender selv slåmaskine, vender/river og læssevogne ud til græs-/kløver-/lucernemarker og får skårene i den rigtige silo — AutoDrive kører på vej, Courseplay arbejder på marken. - -**Architecture:** Tre rene moduler (model, flåde, visning) med al beslutningslogik og `luajit`-tests; tre tynde spil-moduler (marker, Courseplay-bro, job-løkke) der kun oversætter til spillets/AD's/CP's API; én lagringsfil; én ny ESC-underfane "Marker". Job-løkken tikker hvert sekund fra `ADSmartPickup:update`. - -**Tech Stack:** FS25 Lua (mod-sandbox), luajit til tests, AutoDrive 3.0.1.2 (`FS25_AutoDrive` global), Courseplay 8.1.0.3 (`AIJobType.FIELDWORK_CP`, `vehicle:getIsCpFieldWorkActive`). - -**Spec:** `docs/superpowers/specs/2026-09-25-markarbejde-etape1-design.md` - -## Global Constraints - -- Staging-markør pr. mark: AD-markør med navn `F` (fx `F109`, også `f109`, `F 109`). -- Courseplay-maksbredde: `ADFieldModel.CP_MAX_WIDTH = 50` m. Bredde-tolerance ved sammenligning: 0,05 m. -- Klar-tærskel: `ADFieldModel.READY_RATIO = 0.9`; "slået" = klar-andel < `ADFieldModel.CUT_RATIO = 0.5`. -- Marksilo-afstand: `ADFields.FIELD_SILO_DISTANCE = 150` m. -- Højst 2 læssevogne pr. mark, 1 sæt pr. mark for slå/vende/rive. -- CP-fejl-pause: `ADFieldModel.RETRY_MS = 300000` (5 min). -- Standardplan: `mode = "manual"`, `product = "silage"`, `rake = false`, `storeBuildingIds = {}`. -- Alle spil-kald pcall'es; en fejl i én mark må ikke stoppe de andre. Loglinjer starter `ADSmartPickup: F …`. -- UI: dansk + engelsk nøgler i begge l10n-filer; ingen tegn uden for FS25-fonten (brug `x`, `>=`, `!`, ikke ×/≥/✔). -- Lua i FS25: ingen `goto`; globale moduler (`X = {}`), ikke `local` på modulniveau for ting andre filer skal bruge. - -## Review Focus - -1. Spilleren sætter sig i et sæt midt i arbejdet → sættet frigives, marken står med grund, intet genstarter bag spillerens ryg. (Task 6 test `J3`.) -2. Mark uden `F`-markør eller med markør langt væk → "!" og intet sendes. (Task 4 test `F2`, Task 3 test `V3`.) -3. Læssevogn smallere end slåbredden eller sæt > 50 m → sættet springes over med præcis grund, et andet sæt prøves. (Task 2 test `FL4`–`FL6`.) -4. Genindlæsning midt i "Samles op" → marken fortsætter på samme trin, sæt sendes ud igen. (Task 1 test `M8`, Task 6 test `J5`.) -5. Mark slået manuelt af spilleren mens den står "Klar" → går tilbage til "Venter", intet sendes. (Task 1 test `M3`.) - ---- - -### Task 1: Mark-model (ren) - -**Files:** -- Create: `FS25_ADSmartPickup/adFieldModel.lua` -- Test: `tests/test_adFieldModel.lua` - -**Interfaces:** -- Produces: - - `ADFieldModel.newField(id) -> field` hvor `field = {id, stage, plan = {mode, product, rake, storeBuildingIds}, mowWidth, retryAt, reason, startRequested}` - - `ADFieldModel.workStages(plan) -> {"mowing", ...}` - - `ADFieldModel.roleFor(stage) -> "mower"|"tedder"|"rake"|"collector"|nil` - - `ADFieldModel.maxRigs(stage) -> number` - - `ADFieldModel.onReadiness(field, ratio) -> field` (ny tabel) - - `ADFieldModel.shouldStart(field) -> bool`, `ADFieldModel.start(field) -> field`, `ADFieldModel.advance(field) -> field`, `ADFieldModel.stop(field) -> field` - - `ADFieldModel.isWorking(field) -> bool` - - `ADFieldModel.checkWidth(role, width, mowWidth) -> ok, reason, args` - - `ADFieldModel.wantsRig(field, assignedCount, now) -> role|nil` - - `ADFieldModel.toRecord(field) -> record`, `ADFieldModel.fromRecord(record) -> field` - - `ADFieldModel.with(field, changes) -> field` (kopi med ændrede felter; `changes.plan` flettes ind i planen) - -- [ ] **Step 1: Write the failing test** - -File: tests/test_adFieldModel.lua -```lua --- Kør: luajit tests/test_adFieldModel.lua (fra repo-roden) -dofile("FS25_ADSmartPickup/adFieldModel.lua") -local M = ADFieldModel -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 - --- M1: ny mark = venter, manuel ensilage uden riv -local f = M.newField(109) -check("M1 stage", f.stage, "idle") -check("M1 mode", f.plan.mode, "manual") -check("M1 product", f.plan.product, "silage") -check("M1 rake", f.plan.rake, false) - --- M2: klar ved >= 90 % -check("M2 89 % ikke klar", M.onReadiness(f, 0.89).stage, "idle") -local ready = M.onReadiness(f, 0.9) -check("M2 90 % klar", ready.stage, "ready") -check("M2 immutabel", f.stage, "idle") - --- M3: klar mark slået af spilleren -> tilbage til venter -check("M3 slået manuelt", M.onReadiness(ready, 0.3).stage, "idle") -check("M3 lidt under 90 % forbliver klar", M.onReadiness(ready, 0.8).stage, "ready") - --- M4: trin pr. plan -check("M4 ensilage", table.concat(M.workStages({product = "silage"}), ","), "mowing,collecting") -check("M4 hø", table.concat(M.workStages({product = "hay", rake = false}), ","), "mowing,tedding,collecting") -check("M4 hø m. riv", table.concat(M.workStages({product = "hay", rake = true}), ","), "mowing,tedding,raking,collecting") - --- M5: start: auto starter selv, manuel kun på forespørgsel -check("M5 manuel starter ikke", M.shouldStart(ready), false) -local auto = M.onReadiness(M.newField(5), 1) -auto.plan.mode = "auto" -check("M5 auto starter", M.shouldStart(auto), true) -local requested = M.newField(6); requested.stage = "ready"; requested.startRequested = true -check("M5 Start nu", M.shouldStart(requested), true) -local started = M.start(ready) -check("M5 start -> slås", started.stage, "mowing") -check("M5 start rydder forespørgsel", started.startRequested, false) - --- M6: videre gennem trinene; færdig mark bliver klar igen når den er vokset -local hay = M.start(M.onReadiness(M.newField(7), 1)); hay.plan.product = "hay" -hay = M.advance(hay); check("M6 hø -> vendes", hay.stage, "tedding") -hay = M.advance(hay); check("M6 -> samles op", hay.stage, "collecting") -hay = M.advance(hay); check("M6 -> færdig", hay.stage, "done") -check("M6 færdig forbliver ved lav andel", M.onReadiness(hay, 0.1).stage, "done") -check("M6 færdig -> klar når vokset", M.onReadiness(hay, 0.95).stage, "ready") -check("M6 arbejdende mark ignorerer andel", M.onReadiness(started, 0).stage, "mowing") -check("M6 isWorking", M.isWorking(started), true) -check("M6 stop -> klar", M.stop(started).stage, "ready") - --- M7: roller, maks sæt, bredde -check("M7 rolle slå", M.roleFor("mowing"), "mower") -check("M7 rolle saml", M.roleFor("collecting"), "collector") -check("M7 rolle klar = ingen", M.roleFor("ready"), nil) -check("M7 maks læssevogne", M.maxRigs("collecting"), 2) -check("M7 maks slå", M.maxRigs("mowing"), 1) -local okWide, whyWide = M.checkWidth("mower", 60, nil) -check("M7 over 50 m", okWide, false); check("M7 grund", whyWide, "tooWide") -check("M7 40,8 m slå ok", (M.checkWidth("mower", 40.8, nil)), true) -local okNarrow, whyNarrow, args = M.checkWidth("collector", 20, 40.8) -check("M7 for smal", okNarrow, false); check("M7 grund smal", whyNarrow, "tooNarrow"); check("M7 args", args[2], 40.8) -check("M7 lige bred ok (tolerance)", (M.checkWidth("collector", 40.76, 40.8)), true) -check("M7 ukendt slåbredde ok", (M.checkWidth("tedder", 10, nil)), true) - --- wantsRig: kun arbejdende trin, under maks, efter pause -check("M7 wantsRig slå", M.wantsRig(started, 0, 0), "mower") -check("M7 wantsRig fuld", M.wantsRig(started, 1, 0), nil) -local paused = M.start(M.onReadiness(M.newField(8), 1)); paused.retryAt = 1000 -check("M7 wantsRig i pause", M.wantsRig(paused, 0, 500), nil) -check("M7 wantsRig efter pause", M.wantsRig(paused, 0, 1000), "mower") -check("M7 wantsRig klar = nil", M.wantsRig(ready, 0, 0), nil) - --- M8: gem/indlæs bevarer trin, plan og slåbredde -local saved = M.advance(M.start(M.onReadiness(M.newField(109), 1))); saved.mowWidth = 40.8 -saved.plan = {mode = "auto", product = "hay", rake = true, storeBuildingIds = {"b12", "b7"}} -local back = M.fromRecord(M.toRecord(saved)) -check("M8 id", back.id, 109) -check("M8 stage", back.stage, saved.stage) -check("M8 mode", back.plan.mode, "auto") -check("M8 product", back.plan.product, "hay") -check("M8 rake", back.plan.rake, true) -check("M8 lager", table.concat(back.plan.storeBuildingIds, " "), "b12 b7") -check("M8 slåbredde", back.mowWidth, 40.8) -check("M8 ukendt trin -> venter", M.fromRecord({id = 3, stage = "bogus"}).stage, "idle") - --- M9: with() kopierer og fletter planen -local base = M.start(M.onReadiness(M.newField(1), 1)) -local noted = M.with(base, {reason = {text = "x"}, retryAt = 5, plan = {product = "hay"}}) -check("M9 grund", noted.reason.text, "x"); check("M9 pause", noted.retryAt, 5) -check("M9 plan flettet", noted.plan.product, "hay"); check("M9 mode bevaret", noted.plan.mode, "manual") -check("M9 trin bevaret", noted.stage, "mowing"); check("M9 original urørt", base.plan.product, "silage") - -print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) -os.exit(failures == 0 and 0 or 1) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `luajit tests/test_adFieldModel.lua` -Expected: FAIL — `cannot open FS25_ADSmartPickup/adFieldModel.lua` - -- [ ] **Step 3: Write minimal implementation** - -File: FS25_ADSmartPickup/adFieldModel.lua -```lua --- AD Field Model --- Markarbejdets regler som ren data (ingen spil-API): en marks plan og trin, hvornår den er klar, --- hvilket trin der følger, hvor mange sæt et trin må have, og bredde-reglerne. Alle funktioner --- returnerer nye tabeller; job-løkken (adFieldJobs) og GUI'et kalder kun herind. - -ADFieldModel = {} - -ADFieldModel.READY_RATIO = 0.9 -ADFieldModel.CUT_RATIO = 0.5 -ADFieldModel.CP_MAX_WIDTH = 50 -ADFieldModel.WIDTH_TOLERANCE = 0.05 -ADFieldModel.RETRY_MS = 300000 -ADFieldModel.MAX_COLLECTORS = 2 - -ADFieldModel.ROLES = {mowing = "mower", tedding = "tedder", raking = "rake", collecting = "collector"} -local KNOWN_STAGES = {idle = true, ready = true, mowing = true, tedding = true, raking = true, collecting = true, done = true} - -local function copyList(list) - local copy = {} - for index, value in ipairs(list or {}) do copy[index] = value end - return copy -end - -local function copyField(field) - local copy = {} - for key, value in pairs(field) do copy[key] = value end - copy.plan = { - mode = field.plan.mode, product = field.plan.product, rake = field.plan.rake, - storeBuildingIds = copyList(field.plan.storeBuildingIds), - } - return copy -end - -function ADFieldModel.newField(id) - return { - id = id, stage = "idle", mowWidth = nil, retryAt = 0, reason = nil, startRequested = false, - plan = {mode = "manual", product = "silage", rake = false, storeBuildingIds = {}}, - } -end - -function ADFieldModel.workStages(plan) - if plan.product == "hay" then - if plan.rake then - return {"mowing", "tedding", "raking", "collecting"} - end - return {"mowing", "tedding", "collecting"} - end - return {"mowing", "collecting"} -end - -function ADFieldModel.roleFor(stage) - return ADFieldModel.ROLES[stage] -end - -function ADFieldModel.isWorking(field) - return ADFieldModel.ROLES[field.stage] ~= nil -end - -function ADFieldModel.maxRigs(stage) - return stage == "collecting" and ADFieldModel.MAX_COLLECTORS or 1 -end - --- Venter/færdig -> klar ved fuld vækst; klar -> venter hvis marken blev slået uden os. --- Arbejdende marker styres af job-løkken, ikke af vækst. -function ADFieldModel.onReadiness(field, ratio) - local copy = copyField(field) - if (field.stage == "idle" or field.stage == "done") and ratio >= ADFieldModel.READY_RATIO then - copy.stage = "ready" - elseif field.stage == "ready" and ratio < ADFieldModel.CUT_RATIO then - copy.stage = "idle" - copy.startRequested = false - end - return copy -end - -function ADFieldModel.shouldStart(field) - return field.stage == "ready" and (field.plan.mode == "auto" or field.startRequested == true) -end - -function ADFieldModel.start(field) - local copy = copyField(field) - copy.stage = ADFieldModel.workStages(field.plan)[1] - copy.startRequested = false - copy.retryAt = 0 - copy.reason = nil - copy.mowWidth = nil - return copy -end - -function ADFieldModel.advance(field) - local copy = copyField(field) - local stages = ADFieldModel.workStages(field.plan) - copy.stage = "done" - for index, stage in ipairs(stages) do - if stage == field.stage and stages[index + 1] ~= nil then - copy.stage = stages[index + 1] - end - end - copy.retryAt = 0 - copy.reason = nil - return copy -end - --- Stop fra menuen: arbejdet afbrydes; marken står klar igen (manuel start kræves). -function ADFieldModel.stop(field) - local copy = copyField(field) - if ADFieldModel.isWorking(field) then - copy.stage = "ready" - end - copy.startRequested = false - copy.reason = nil - return copy -end - --- Et sæt må ikke over Courseplays grænse; efter-trin skal dække mindst slåbredden. -function ADFieldModel.checkWidth(role, width, mowWidth) - if width > ADFieldModel.CP_MAX_WIDTH + ADFieldModel.WIDTH_TOLERANCE then - return false, "tooWide", {width, ADFieldModel.CP_MAX_WIDTH} - end - if role ~= "mower" and mowWidth ~= nil and width + ADFieldModel.WIDTH_TOLERANCE < mowWidth then - return false, "tooNarrow", {width, mowWidth} - end - return true -end - -function ADFieldModel.wantsRig(field, assignedCount, now) - local role = ADFieldModel.roleFor(field.stage) - if role == nil or (field.retryAt or 0) > now or assignedCount >= ADFieldModel.maxRigs(field.stage) then - return nil - end - return role -end - -function ADFieldModel.with(field, changes) - local copy = copyField(field) - for key, value in pairs(changes) do - if key == "plan" then - for planKey, planValue in pairs(value) do copy.plan[planKey] = planValue end - else - copy[key] = value - end - end - return copy -end - -function ADFieldModel.toRecord(field) - return { - id = field.id, stage = field.stage, mode = field.plan.mode, product = field.plan.product, - rake = field.plan.rake and 1 or 0, store = table.concat(field.plan.storeBuildingIds, " "), - mowWidth = field.mowWidth or 0, - } -end - -function ADFieldModel.fromRecord(record) - local field = ADFieldModel.newField(record.id) - field.stage = KNOWN_STAGES[record.stage] and record.stage or "idle" - field.plan.mode = record.mode == "auto" and "auto" or "manual" - field.plan.product = record.product == "hay" and "hay" or "silage" - field.plan.rake = record.rake == 1 or record.rake == true - for id in string.gmatch(record.store or "", "%S+") do table.insert(field.plan.storeBuildingIds, id) end - field.mowWidth = (record.mowWidth or 0) > 0 and record.mowWidth or nil - return field -end -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `luajit tests/test_adFieldModel.lua` -Expected: `ALLE TESTS OK` - -- [ ] **Step 5: Commit** - -```bash -git add FS25_ADSmartPickup/adFieldModel.lua tests/test_adFieldModel.lua -git commit -m "feat(marker): ren mark-model — trin, klar-regel, bredde-regler, gem/indlæs" -``` - ---- - -### Task 2: Markflåde (ren) - -**Files:** -- Create: `FS25_ADSmartPickup/adFieldFleet.lua` -- Test: `tests/test_adFieldFleet.lua` - -**Interfaces:** -- Consumes: `ADFieldModel.checkWidth(role, width, mowWidth)` -- Produces: - - `ADFieldFleet.roleFromSpecs(specs) -> role|nil` (`specs = {spec_mower = true, ...}`) - - `ADFieldFleet.pick(candidates, role, x, z, mowWidth) -> candidate|nil, reason, args` hvor `candidate = {id, role, enabled, busy, controlled, width, x, z, label}`; `reason ∈ {"noRig", "allBusy", "tooWide", "tooNarrow"}` - - `ADFieldFleet.toggle(enabledIds, id) -> newEnabledIds` (sæt `{[id]=true}`) - -- [ ] **Step 1: Write the failing test** - -File: tests/test_adFieldFleet.lua -```lua --- Kør: luajit tests/test_adFieldFleet.lua (fra repo-roden) -dofile("FS25_ADSmartPickup/adFieldModel.lua") -dofile("FS25_ADSmartPickup/adFieldFleet.lua") -local F = ADFieldFleet -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 - --- FL1: rolle ud fra redskabets spec (læssevogn vinder over andet på samme køretøj) -check("FL1 slåmaskine", F.roleFromSpecs({spec_mower = true}), "mower") -check("FL1 vender", F.roleFromSpecs({spec_tedder = true}), "tedder") -check("FL1 river", F.roleFromSpecs({spec_windrower = true}), "rake") -check("FL1 læssevogn", F.roleFromSpecs({spec_forageWagon = true, spec_mower = true}), "collector") -check("FL1 intet", F.roleFromSpecs({spec_plow = true}), nil) - -local function rig(id, role, x, extra) - local r = {id = id, role = role, enabled = true, busy = false, controlled = false, width = 40.8, x = x, z = 0, label = id} - for k, v in pairs(extra or {}) do r[k] = v end - return r -end - --- FL2: nærmeste ledige med flueben og rigtig rolle -local picked = F.pick({rig("far", "mower", 500), rig("near", "mower", 50), rig("wagon", "collector", 10)}, "mower", 0, 0, nil) -check("FL2 nærmeste slåmaskine", picked.id, "near") - --- FL3: uden flueben / optaget / spiller i sædet springes over -local _, why = F.pick({rig("a", "mower", 1, {enabled = false})}, "mower", 0, 0) -check("FL3 intet med flueben", why, "noRig") -local _, whyBusy = F.pick({rig("a", "mower", 1, {busy = true}), rig("b", "mower", 2, {controlled = true})}, "mower", 0, 0) -check("FL3 alle optaget", whyBusy, "allBusy") -check("FL3 ingen med rollen", select(2, F.pick({rig("a", "tedder", 1)}, "mower", 0, 0)), "noRig") - --- FL4: læssevogn smallere end slåbredden springes over, bredere vælges -local wide = F.pick({rig("smal", "collector", 1, {width = 20}), rig("bred", "collector", 900, {width = 40.8})}, "collector", 0, 0, 40.8) -check("FL4 bred valgt selv om længere væk", wide.id, "bred") --- FL5: kun for smalle -> grund tooNarrow med tal -local none, whyNarrow, args = F.pick({rig("smal", "collector", 1, {width = 20})}, "collector", 0, 0, 40.8) -check("FL5 ingen", none, nil); check("FL5 grund", whyNarrow, "tooNarrow"); check("FL5 bredde", args[1], 20) --- FL6: over 50 m -> tooWide -check("FL6 for bred", select(2, F.pick({rig("x", "mower", 1, {width = 60})}, "mower", 0, 0)), "tooWide") - --- FL7: toggle giver ny tabel -local enabled = {a = true} -local toggled = F.toggle(enabled, "b") -check("FL7 b til", toggled.b, true); check("FL7 original urørt", enabled.b, nil) -check("FL7 a fra", F.toggle(toggled, "a").a, nil) - -print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) -os.exit(failures == 0 and 0 or 1) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `luajit tests/test_adFieldFleet.lua` -Expected: FAIL — `cannot open FS25_ADSmartPickup/adFieldFleet.lua` - -- [ ] **Step 3: Write minimal implementation** - -File: FS25_ADSmartPickup/adFieldFleet.lua -```lua --- AD Field Fleet --- Markflåden som ren logik: hvilken rolle et sæt har ud fra redskabernes specs, og hvilket ledigt sæt --- der sendes til en mark. Kun sæt med flueben bruges; et sæt spilleren sidder i, røres aldrig. - -ADFieldFleet = {} - --- Rækkefølge = prioritet når et køretøj har flere (læssevogn med egen pickup m.m.) -ADFieldFleet.ROLE_SPECS = { - {role = "collector", spec = "spec_forageWagon"}, - {role = "rake", spec = "spec_windrower"}, - {role = "tedder", spec = "spec_tedder"}, - {role = "mower", spec = "spec_mower"}, -} - -function ADFieldFleet.roleFromSpecs(specs) - for _, entry in ipairs(ADFieldFleet.ROLE_SPECS) do - if specs[entry.spec] then return entry.role end - end - return nil -end - --- Nærmeste brugbare sæt til rollen. Grund når intet passer: noRig (ingen med flueben og rollen), --- allBusy (alle optaget/i brug af spilleren), tooWide/tooNarrow (bredden, med tal fra den første). -function ADFieldFleet.pick(candidates, role, x, z, mowWidth) - local best, bestDistance = nil, math.huge - local sawRole, widthReason, widthArgs = false, nil, nil - for _, candidate in ipairs(candidates) do - if candidate.role == role and candidate.enabled then - sawRole = true - if not candidate.busy and not candidate.controlled then - local ok, reason, args = ADFieldModel.checkWidth(role, candidate.width or 0, mowWidth) - if ok then - local dx, dz = candidate.x - x, candidate.z - z - local distance = dx * dx + dz * dz - if distance < bestDistance then best, bestDistance = candidate, distance end - elseif widthReason == nil then - widthReason, widthArgs = reason, args - end - end - end - end - if best ~= nil then return best end - if not sawRole then return nil, "noRig" end - if widthReason ~= nil then return nil, widthReason, widthArgs end - return nil, "allBusy" -end - -function ADFieldFleet.toggle(enabledIds, id) - local copy = {} - for key, value in pairs(enabledIds or {}) do copy[key] = value end - copy[id] = (not copy[id]) or nil - return copy -end -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `luajit tests/test_adFieldFleet.lua` -Expected: `ALLE TESTS OK` - -- [ ] **Step 5: Commit** - -```bash -git add FS25_ADSmartPickup/adFieldFleet.lua tests/test_adFieldFleet.lua -git commit -m "feat(marker): markflåde — rolle fra redskab, valg af nærmeste ledige sæt m. bredde-regler" -``` - ---- - -### Task 3: Marker-visning (ren) - -**Files:** -- Create: `FS25_ADSmartPickup/adFieldView.lua` -- Test: `tests/test_adFieldView.lua` - -**Interfaces:** -- Consumes: felt-info fra `ADFields.list` (Task 4): `{id, mowable, cropTitle, markerId, readyRatio}` og model-felter `ADFieldModel` -- Produces: - - `ADFieldView.rows(infos, fieldsById, showAll) -> {{info, field}}` (sorteret efter id; kun slåafgrøder uden showAll) - - `ADFieldView.statusKey(field, info) -> l10n-nøgle` (`spu_field_stage_` eller `spu_field_noMarker`) - - `ADFieldView.hiddenCount(infos, showAll) -> number` - -- [ ] **Step 1: Write the failing test** - -File: tests/test_adFieldView.lua -```lua --- Kør: luajit tests/test_adFieldView.lua (fra repo-roden) -dofile("FS25_ADSmartPickup/adFieldModel.lua") -dofile("FS25_ADSmartPickup/adFieldView.lua") -local V = ADFieldView -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 infos = { - {id = 109, mowable = true, markerId = 5}, - {id = 12, mowable = true, markerId = nil}, - {id = 90, mowable = false, markerId = 7}, -} -local fields = {[109] = ADFieldModel.newField(109), [12] = ADFieldModel.newField(12)} - --- V1: kun slåafgrøder, sorteret efter nummer -local rows = V.rows(infos, fields, false) -check("V1 to rækker", #rows, 2) -check("V1 sorteret", rows[1].info.id, 12) -check("V1 model med", rows[2].field.id, 109) --- V2: vis alle tager resten med; mangler model -> ny standardmark -local all = V.rows(infos, fields, true) -check("V2 tre rækker", #all, 3) -check("V2 standardmodel", all[2].field.stage, "idle") -check("V2 skjulte", V.hiddenCount(infos, false), 1) -check("V2 vis alle skjuler intet", V.hiddenCount(infos, true), 0) --- V3: status — manglende markør vinder over trin -check("V3 mangler markør", V.statusKey(fields[12], infos[2]), "spu_field_noMarker") -check("V3 trin", V.statusKey(fields[109], infos[1]), "spu_field_stage_idle") - -print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) -os.exit(failures == 0 and 0 or 1) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `luajit tests/test_adFieldView.lua` -Expected: FAIL — `cannot open FS25_ADSmartPickup/adFieldView.lua` - -- [ ] **Step 3: Write minimal implementation** - -File: FS25_ADSmartPickup/adFieldView.lua -```lua --- AD Field View --- Rene regler for fanen Marker: hvilke marker vises (slåafgrøder som standard), rækkefølge og --- statusord. GUI'et oversætter nøglerne. - -ADFieldView = {} - -function ADFieldView.rows(infos, fieldsById, showAll) - local rows = {} - for _, info in ipairs(infos or {}) do - if showAll or info.mowable then - table.insert(rows, {info = info, field = fieldsById[info.id] or ADFieldModel.newField(info.id)}) - end - end - table.sort(rows, function(a, b) return a.info.id < b.info.id end) - return rows -end - -function ADFieldView.hiddenCount(infos, showAll) - if showAll then return 0 end - local hidden = 0 - for _, info in ipairs(infos or {}) do - if not info.mowable then hidden = hidden + 1 end - end - return hidden -end - -function ADFieldView.statusKey(field, info) - if info.markerId == nil then - return "spu_field_noMarker" - end - return "spu_field_stage_" .. tostring(field.stage) -end -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `luajit tests/test_adFieldView.lua` -Expected: `ALLE TESTS OK` - -- [ ] **Step 5: Commit** - -```bash -git add FS25_ADSmartPickup/adFieldView.lua tests/test_adFieldView.lua -git commit -m "feat(marker): visningsregler for fanen Marker" -``` - ---- - -### Task 4: Marker i spillet (`adFields.lua`) - -**Files:** -- Create: `FS25_ADSmartPickup/adFields.lua` -- Test: `tests/test_adFields.lua` (kun de rene hjælpere: markørnavn, slåafgrøder ud fra konvertere) - -**Interfaces:** -- Produces: - - `ADFields.parseMarkerName(name) -> fieldId|nil` - - `ADFields.buildMowable(mowerConverter, tedderConverter, fillTypeName) -> {[fruitIndex] = {windrow, hay}}` - - `ADFields.getMowable() -> samme tabel fra spillets konvertere (cachet)` - - `ADFields.list(adEnv, farmId) -> infos` med `info = {id, field, x, z, points, mowable, fruitIndex, cropTitle, readyRatio, markerId, markerName, windrow, hay}`; én mark scannes pr. kald til `ADFields.scanNext()` (round-robin), resten fra cache - - `ADFields.getInfo(id) -> info|nil` - - `ADFields.fieldSilos(adEnv, farmId, info) -> {building, ...}` (siloer ≤ 150 m) - -- [ ] **Step 1: Write the failing test** - -File: tests/test_adFields.lua -```lua --- Kør: luajit tests/test_adFields.lua (fra repo-roden) -dofile("FS25_ADSmartPickup/adFields.lua") -local F = ADFields -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 - --- F1/F2: markørnavne -check("F1 F109", F.parseMarkerName("F109"), 109) -check("F1 småt", F.parseMarkerName("f7"), 7) -check("F1 mellemrum", F.parseMarkerName(" F 12 "), 12) -check("F2 andet navn", F.parseMarkerName("Field 109"), nil) -check("F2 tekst efter", F.parseMarkerName("F109 wait"), nil) -check("F2 nil", F.parseMarkerName(nil), nil) - --- F3: slåafgrøde = MOWER giver et skår som TEDDER gør til DRY…; halm udelukket -local names = {[10] = "GRASS_WINDROW", [11] = "DRYGRASS_WINDROW", [20] = "STRAW", [30] = "CLOVER_WINDROW", [31] = "DRYCLOVER_WINDROW", [40] = "BEANSTRAW"} -local mower = {[1] = {fillTypeIndex = 10}, [2] = {fillTypeIndex = 20}, [3] = {fillTypeIndex = 30}, [4] = {fillTypeIndex = 40}} -local tedder = {[10] = {targetFillTypeIndex = 11}, [30] = {targetFillTypeIndex = 31}, [40] = {targetFillTypeIndex = 20}} -local mowable = F.buildMowable(mower, tedder, function(index) return names[index] end) -check("F3 græs", mowable[1] ~= nil, true) -check("F3 græs skår", mowable[1].windrow, 10) -check("F3 græs hø", mowable[1].hay, 11) -check("F3 rug (halm) ikke", mowable[2], nil) -check("F3 kløver", mowable[3].hay, 31) -check("F3 bønnehalm -> halm ikke", mowable[4], nil) - -print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) -os.exit(failures == 0 and 0 or 1) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `luajit tests/test_adFields.lua` -Expected: FAIL — `cannot open FS25_ADSmartPickup/adFields.lua` - -- [ ] **Step 3: Write implementation** - -File: FS25_ADSmartPickup/adFields.lua -```lua --- AD Fields --- Gårdens marker set fra spillet: ejede marker, prøvepunkter i marken, slåafgrøde og klar-andel --- (density maps, samme metode som Arbejdsseddel — field.fieldState er en frossen NPC-plan og bruges --- ikke), markens F-markør (AD-markør "F109") og siloer ved marken. Én mark scannes pr. kald. - -ADFields = {} - -ADFields.FIELD_SILO_DISTANCE = 150 -ADFields.MARKER_MAX_DISTANCE = 400 -ADFields.cache = {} -ADFields.order = {} -ADFields.scanIndex = 0 -ADFields.mowable = nil - -function ADFields.parseMarkerName(name) - if type(name) ~= "string" then return nil end - local digits = string.match(name, "^%s*[Ff]%s*(%d+)%s*$") - return digits ~= nil and tonumber(digits) or nil -end - --- MOWER: frugt -> skår; TEDDER: skår -> DRY…-skår. Kun kæder der ender i DRY… er slåafgrøder. -function ADFields.buildMowable(mowerConverter, tedderConverter, fillTypeName) - local result = {} - for fruitIndex, data in pairs(mowerConverter or {}) do - local windrow = data.fillTypeIndex or data.targetFillTypeIndex - local tedded = windrow ~= nil and tedderConverter ~= nil and tedderConverter[windrow] or nil - local hay = tedded ~= nil and (tedded.targetFillTypeIndex or tedded.fillTypeIndex) or nil - local hayName = hay ~= nil and fillTypeName(hay) or nil - if hay ~= nil and hay ~= windrow and type(hayName) == "string" and string.sub(hayName, 1, 3) == "DRY" then - result[fruitIndex] = {windrow = windrow, hay = hay} - end - end - return result -end - -function ADFields.getMowable() - if ADFields.mowable ~= nil then return ADFields.mowable end - local ok, result = pcall(function() - local mower = g_fruitTypeManager:getConverterDataByName("MOWER") - local tedder = g_fillTypeManager:getConverterDataByName("TEDDER") - return ADFields.buildMowable(mower, tedder, function(index) return g_fillTypeManager:getFillTypeNameByIndex(index) end) - end) - ADFields.mowable = ok and result or {} - if not ok then - Logging.warning("ADSmartPickup: slåafgrøder kunne ikke læses: %s", tostring(result)) - end - return ADFields.mowable -end - -local function fieldOwner(field) - local farmland = field.farmland or (field.getFarmland ~= nil and field:getFarmland()) or nil - if farmland == nil then return nil end - if farmland.id ~= nil and g_farmlandManager ~= nil and g_farmlandManager.getFarmlandOwner ~= nil then - return g_farmlandManager:getFarmlandOwner(farmland.id) - end - return farmland.farmId -end - -local function isOnField(x, z) - local y = getTerrainHeightAtWorldPos(g_currentMission.terrainRootNode, x, 1, z) - return FSDensityMapUtil.getFieldDataAtWorldPosition(x, y, z) and true or false -end - --- Centroid + halvvejs mod op til 8 hjørner; kun punkter der stadig er mark (bygninger fjerner areal). -local function samplePoints(field) - local verts = {} - for _, node in ipairs(field.polygonPoints or {}) do - if type(node) == "number" then - local x, _, z = getWorldTranslation(node) - table.insert(verts, {x = x, z = z}) - end - end - local cx, cz = field.posX, field.posZ - if (cx == nil or cz == nil) and #verts > 0 then - cx, cz = 0, 0 - for _, v in ipairs(verts) do cx, cz = cx + v.x, cz + v.z end - cx, cz = cx / #verts, cz / #verts - end - if cx == nil then return {}, verts end - local candidates = {{x = cx, z = cz}} - local step = math.max(1, math.floor(#verts / 8)) - for index = 1, #verts, step do - table.insert(candidates, {x = (cx + verts[index].x) / 2, z = (cz + verts[index].z) / 2}) - end - local points = {} - for _, p in ipairs(candidates) do - local ok, onField = pcall(isOnField, p.x, p.z) - if ok and onField then table.insert(points, p) end - end - return points, verts -end - -local function fruitAt(points) - local votes, best, bestVotes = {}, nil, 0 - for _, p in ipairs(points) do - local ok, index = pcall(FSDensityMapUtil.getFruitTypeIndexAtWorldPos, p.x, p.z) - if ok and type(index) == "number" and index > 0 then - votes[index] = (votes[index] or 0) + 1 - if votes[index] > bestVotes then best, bestVotes = index, votes[index] end - end - end - return best -end - --- Andel af prøvefladen i høstvinduet (samme kald som Arbejdsseddel: any = true,true; klar = false,false). -local function readyRatio(fruitIndex, points) - local anySum, readySum = 0, 0 - for _, p in ipairs(points) do - local okA, _, anyPixels = pcall(FSDensityMapUtil.getFruitArea, fruitIndex, p.x - 2, p.z - 2, p.x + 2, p.z, p.x, p.z + 2, true, true) - if okA and anyPixels then anySum = anySum + anyPixels end - local okR, _, readyPixels = pcall(FSDensityMapUtil.getFruitArea, fruitIndex, p.x - 2, p.z - 2, p.x + 2, p.z, p.x, p.z + 2, false, false) - if okR and readyPixels then readySum = readySum + readyPixels end - end - return anySum > 0 and readySum / anySum or 0 -end - -local function findMarker(adEnv, fieldId, x, z) - local best, bestDistance = nil, math.huge - for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do - if ADFields.parseMarkerName(marker.name) == fieldId then - local wayPoint = adEnv.ADGraphManager:getWayPointById(marker.id) - if wayPoint ~= nil then - local distance = math.sqrt((wayPoint.x - x) ^ 2 + (wayPoint.z - z) ^ 2) - if distance < bestDistance then best, bestDistance = marker, distance end - end - end - end - if best ~= nil and bestDistance <= ADFields.MARKER_MAX_DISTANCE then - return best, bestDistance - end - return nil, best ~= nil and bestDistance or nil -end - -local function scanField(adEnv, field, fieldId) - local points, verts = samplePoints(field) - local info = {id = fieldId, field = field, points = points, verts = verts, mowable = false, readyRatio = 0} - info.x, info.z = field.posX, field.posZ - if (info.x == nil or info.z == nil) and points[1] ~= nil then info.x, info.z = points[1].x, points[1].z end - info.fruitIndex = #points > 0 and fruitAt(points) or nil - local mowable = info.fruitIndex ~= nil and ADFields.getMowable()[info.fruitIndex] or nil - if info.fruitIndex ~= nil then - local fruit = g_fruitTypeManager:getFruitTypeByIndex(info.fruitIndex) - local fillType = fruit ~= nil and fruit.fillType or nil - info.cropTitle = fillType ~= nil and fillType.title or (fruit ~= nil and fruit.name) or "?" - end - if mowable ~= nil then - info.mowable, info.windrow, info.hay = true, mowable.windrow, mowable.hay - info.readyRatio = readyRatio(info.fruitIndex, points) - end - if info.x ~= nil then - local marker, distance = findMarker(adEnv, fieldId, info.x, info.z) - info.markerId = marker ~= nil and marker.id or nil - info.markerName = marker ~= nil and marker.name or nil - info.markerDistance = distance - end - return info -end - -local function ownedFields(farmId) - local result = {} - local all = g_fieldManager ~= nil and (g_fieldManager:getFields() or g_fieldManager.fields) or {} - for _, field in pairs(all) do - local id = field.getId ~= nil and field:getId() or field.fieldId - if id ~= nil and fieldOwner(field) == farmId then - table.insert(result, {id = id, field = field}) - end - end - table.sort(result, function(a, b) return a.id < b.id end) - return result -end - --- Scanner næste mark (round-robin) og returnerer alle kendte. Kaldes hvert sekund af job-løkken. -function ADFields.scanNext(adEnv, farmId) - local owned = ownedFields(farmId) - local ids = {} - for _, entry in ipairs(owned) do ids[entry.id] = true end - for id in pairs(ADFields.cache) do - if not ids[id] then ADFields.cache[id] = nil end - end - ADFields.order = owned - if #owned == 0 then return end - ADFields.scanIndex = ADFields.scanIndex % #owned + 1 - local entry = owned[ADFields.scanIndex] - local ok, info = pcall(scanField, adEnv, entry.field, entry.id) - if ok then - ADFields.cache[entry.id] = info - else - Logging.warning("ADSmartPickup: F%s kunne ikke scannes: %s", tostring(entry.id), tostring(info)) - end -end - -function ADFields.list() - local infos = {} - for _, entry in ipairs(ADFields.order) do - local info = ADFields.cache[entry.id] - if info ~= nil then table.insert(infos, info) end - end - return infos -end - -function ADFields.getInfo(id) - return ADFields.cache[id] -end - --- Siloer tæt på markens polygon (eller centrum): mellemlagre, som et udkørselsflow skal tømme hjem. -function ADFields.fieldSilos(adEnv, farmId, info) - local result = {} - if ADBuildings == nil or info == nil or info.x == nil then return result end - local anchors = #info.verts > 0 and info.verts or {{x = info.x, z = info.z}} - for _, building in ipairs(ADBuildings.list(adEnv, farmId)) do - local node = building.kind == "silo" and building.placeable ~= nil and building.placeable.rootNode or nil - if node ~= nil then - local bx, _, bz = getWorldTranslation(node) - for _, anchor in ipairs(anchors) do - if (anchor.x - bx) ^ 2 + (anchor.z - bz) ^ 2 <= ADFields.FIELD_SILO_DISTANCE ^ 2 then - table.insert(result, building) - break - end - end - end - end - return result -end -``` - -- [ ] **Step 4: Run test + syntax check** - -Run: `luajit tests/test_adFields.lua && luajit -b FS25_ADSmartPickup/adFields.lua /dev/null` -Expected: `ALLE TESTS OK`, ingen syntaksfejl - -- [ ] **Step 5: Commit** - -```bash -git add FS25_ADSmartPickup/adFields.lua tests/test_adFields.lua -git commit -m "feat(marker): marker fra spillet — slåafgrøde, klar-andel, F-markør, marksiloer" -``` - ---- - -### Task 5: Courseplay-bro (`adCpBridge.lua`) - -**Files:** -- Create: `FS25_ADSmartPickup/adCpBridge.lua` - -**Interfaces:** -- Produces: - - `ADCpBridge.isAvailable() -> bool` - - `ADCpBridge.start(vehicle, fieldX, fieldZ, onDone) -> handle|nil, err` — `onDone(ok, reason)` kaldes én gang; `handle.cancelled = true` annullerer sene callbacks - - `ADCpBridge.isWorking(vehicle) -> bool` (CP-markarbejde eller anden AI aktiv) - - `ADCpBridge.getWorkWidth(vehicle) -> meter` (max over køretøj + redskaber, `getAIMarkers` 5. retur) - - `ADCpBridge.getSpecs(vehicle) -> {spec_mower = true, ...}` (hele redskabskæden) - -- [ ] **Step 1: Write implementation** (kun i spillet; mønster bevist i AI-Bonden `AIHelperGlue.startCP`) - -File: FS25_ADSmartPickup/adCpBridge.lua -```lua --- AD Courseplay Bridge --- Den eneste kode der rører Courseplay: start markarbejde på en mark (grænse -> kursus -> start, alt --- async), og læs om et sæt arbejder. Opskriften er bevist i spillet i AI-Bonden (AIHelperGlue.startCP): --- CP kalder grænse-callbacken to gange (først false ved fejl) — kun første kald tæller. - -ADCpBridge = {} - -ADCpBridge.SPECS = {"spec_mower", "spec_tedder", "spec_windrower", "spec_forageWagon"} - -function ADCpBridge.isAvailable() - return AIJobType ~= nil and AIJobType.FIELDWORK_CP ~= nil -end - -local function collect(vehicle, list) - list = list or {} - table.insert(list, vehicle) - if vehicle.getAttachedImplements ~= nil then - for _, implement in ipairs(vehicle:getAttachedImplements()) do - if implement.object ~= nil then collect(implement.object, list) end - end - end - return list -end - -function ADCpBridge.getSpecs(vehicle) - local specs = {} - for _, object in ipairs(collect(vehicle)) do - for _, name in ipairs(ADCpBridge.SPECS) do - if object[name] ~= nil then specs[name] = true end - end - end - return specs -end - -function ADCpBridge.getWorkWidth(vehicle) - local best = 0 - for _, object in ipairs(collect(vehicle)) do - if object.getAIMarkers ~= nil then - pcall(function() if object.updateAIMarkerWidth ~= nil then object:updateAIMarkerWidth() end end) - local ok, _, _, _, _, width = pcall(object.getAIMarkers, object) - if ok and type(width) == "number" and width > best then best = width end - end - end - return best -end - -function ADCpBridge.isWorking(vehicle) - if vehicle.getIsCpFieldWorkActive ~= nil and vehicle:getIsCpFieldWorkActive() then return true end - return vehicle.getIsAIActive ~= nil and vehicle:getIsAIActive() or false -end - -function ADCpBridge.start(vehicle, fieldX, fieldZ, onDone) - if not ADCpBridge.isAvailable() then - return nil, "Courseplay (FIELDWORK_CP) findes ikke" - end - local farmId = vehicle:getOwnerFarmId() - local job = g_currentMission.aiJobTypeManager:createJob(AIJobType.FIELDWORK_CP) - if job == nil then return nil, "kunne ikke oprette Courseplay-job" end - job:applyCurrentState(vehicle, g_currentMission, farmId, false, false) - local vx, _, vz = getWorldTranslation(vehicle.rootNode) - local params = job.cpJobParameters - if params ~= nil and params.fieldPosition ~= nil then params.fieldPosition:setPosition(fieldX, fieldZ) end - if params ~= nil and params.startPosition ~= nil then params.startPosition:setPosition(vx, vz) end - job:setValues() - local handle = {cancelled = false, fired = false, finished = false} - local function finish(ok, reason) - if handle.finished then return end - handle.finished = true - if not handle.cancelled then onDone(ok, reason) end - end - local function generateAndStart() - if handle.cancelled then return end - job:onClickGenerateFieldWorkCourse(function(course) - if handle.cancelled then return end - if not course then return finish(false, "Courseplay kunne ikke lave kursus") end - job:setValues() - local ok, err = job:validate(farmId) - if not ok then return finish(false, "Courseplay: " .. tostring(err)) end - g_currentMission.aiSystem:startJob(job, farmId) - finish(true) - end) - end - handle.onBoundary = function(_, success, message) - if handle.fired then return end - handle.fired = true - if handle.cancelled then return end - if not success then return finish(false, "Courseplay fandt ikke markgrænsen: " .. tostring(message)) end - generateAndStart() - end - job:registerFieldBoundaryDetectionCallback(handle, handle.onBoundary) - local isValid, isRunning, message = job:detectFieldBoundary() - if isValid and not isRunning then - generateAndStart() - elseif not isRunning then - return nil, "Courseplay: " .. tostring(message) - end - return handle -end -``` - -- [ ] **Step 2: Syntax check** - -Run: `luajit -b FS25_ADSmartPickup/adCpBridge.lua /dev/null` -Expected: ingen output - -- [ ] **Step 3: Commit** - -```bash -git add FS25_ADSmartPickup/adCpBridge.lua -git commit -m "feat(marker): Courseplay-bro — start markarbejde async, bredde og redskabs-specs" -``` - ---- - -### Task 6: Job-løkke, lagring og indbinding - -**Files:** -- Create: `FS25_ADSmartPickup/adFieldStorage.lua` -- Create: `FS25_ADSmartPickup/adFieldJobs.lua` -- Modify: `FS25_ADSmartPickup/modDesc.xml` (sourceFiles før `adSmartPickup.lua`) -- Modify: `FS25_ADSmartPickup/adSmartPickup.lua` (`update`: load + tick; gem via `ADRunsController.save`) -- Modify: `FS25_ADSmartPickup/adRunsController.lua` (`save()` gemmer også marker) -- Test: `tests/test_adFieldJobs.lua` (rene dele: `ADFieldJobs.decide`) - -**Interfaces:** -- Consumes: Task 1–5 -- Produces: - - `ADFieldJobs.state = {fields = {[id] = field}, fleet = {[vehicleId] = true}, assignments = {[vehicleId] = assignment}}` - - `ADFieldJobs.decide(field, info, assignments, now) -> {action = "start"|"advance"|"wait"|"none", role}` (ren) - - `ADFieldJobs.tick(adEnv, dt)`; `ADFieldJobs.requestStart(id)`; `ADFieldJobs.stopField(adEnv, id)`; `ADFieldJobs.setPlan(id, changes)`; `ADFieldJobs.toggleFleet(vehicleId)`; `ADFieldJobs.getRigs(adEnv) -> candidates`; `ADFieldJobs.getField(id)`; `ADFieldJobs.assignmentsFor(id) -> list` - - `ADFieldStorage.save(state)`, `ADFieldStorage.load() -> {fields, fleet}` -- `assignment = {vehicleId, fieldId, role, phase = "driving"|"starting"|"working"|"returning", since, handle}` - -- [ ] **Step 1: Write the failing test** - -File: tests/test_adFieldJobs.lua -```lua --- Kør: luajit tests/test_adFieldJobs.lua (fra repo-roden) -dofile("FS25_ADSmartPickup/adFieldModel.lua") -dofile("FS25_ADSmartPickup/adFieldJobs.lua") -local J = ADFieldJobs -local M = ADFieldModel -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 info = {id = 109, markerId = 5, mowable = true, readyRatio = 1} - --- J1: klar auto-mark med markør -> start; uden markør -> vent -local auto = M.onReadiness(M.newField(109), 1); auto.plan.mode = "auto" -check("J1 start", J.decide(auto, info, {}, 0).action, "start") -check("J1 uden markør", J.decide(auto, {id = 109, mowable = true}, {}, 0).action, "wait") - --- J2: arbejdende trin uden sæt -> send rolle; alle sæt færdige -> næste trin -local mowing = M.start(auto) -local wanted = J.decide(mowing, info, {}, 0) -check("J2 send slåmaskine", wanted.action, "dispatch"); check("J2 rolle", wanted.role, "mower") -check("J2 sæt i gang -> ingenting", J.decide(mowing, info, {{phase = "working"}}, 0).action, "none") -local mowedInfo = {id = 109, markerId = 5, mowable = true, readyRatio = 0} -check("J2 færdig -> videre", J.decide(mowing, mowedInfo, {{phase = "finished"}}, 0).action, "advance") --- J3: sæt frigivet (spilleren tog det) tæller ikke som færdigt -check("J3 frigivet -> send nyt", J.decide(mowing, info, {{phase = "released"}}, 0).action, "dispatch") --- J4: slåning færdig men marken stadig klar -> ikke videre (afbrudt) -check("J4 afbrudt slåning", J.decide(mowing, {id = 109, markerId = 5, mowable = true, readyRatio = 0.8}, {{phase = "finished"}}, 0).action, "dispatch") --- J5: opsamling efter genindlæsning uden sæt -> send læssevogn -local collecting = M.advance(mowing) -check("J5 genoptag opsamling", J.decide(collecting, info, {}, 0).role, "collector") -check("J5 to vogne maks", J.decide(collecting, info, {{phase = "working"}, {phase = "driving"}}, 0).action, "none") - -print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) -os.exit(failures == 0 and 0 or 1) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `luajit tests/test_adFieldJobs.lua` -Expected: FAIL — `cannot open FS25_ADSmartPickup/adFieldJobs.lua` - -- [ ] **Step 3: Write storage** - -File: FS25_ADSmartPickup/adFieldStorage.lua -```lua --- AD Field Storage --- Markernes trin/plan og markflåden gemmes i savegame-mappen ved siden af runs. - -ADFieldStorage = {} - -ADFieldStorage.FILE_NAME = "ADSmartPickup_fields.xml" -ADFieldStorage.ROOT = "ADSmartPickupFields" - -function ADFieldStorage.getPath() - local runsPath = ADRunsStorage ~= nil and ADRunsStorage.getPath() or nil - if runsPath == nil then return nil end - return (string.gsub(runsPath, ADRunsStorage.FILE_NAME .. "$", ADFieldStorage.FILE_NAME)) -end - -function ADFieldStorage.save(state) - local path = ADFieldStorage.getPath() - if path == nil then return false end - local xmlFile = XMLFile.create("ADSmartPickupFields", path, ADFieldStorage.ROOT) - if xmlFile == nil then return false end - local index = 0 - for _, field in pairs(state.fields) do - local record = ADFieldModel.toRecord(field) - local key = string.format("%s.field(%d)", ADFieldStorage.ROOT, index) - xmlFile:setInt(key .. "#id", record.id) - xmlFile:setString(key .. "#stage", record.stage) - xmlFile:setString(key .. "#mode", record.mode) - xmlFile:setString(key .. "#product", record.product) - xmlFile:setInt(key .. "#rake", record.rake) - xmlFile:setString(key .. "#store", record.store) - xmlFile:setFloat(key .. "#mowWidth", record.mowWidth) - index = index + 1 - end - local vehicleIndex = 0 - for vehicleId in pairs(state.fleet) do - xmlFile:setString(string.format("%s.fleet.vehicle(%d)#id", ADFieldStorage.ROOT, vehicleIndex), vehicleId) - vehicleIndex = vehicleIndex + 1 - end - xmlFile:save() - xmlFile:delete() - return true -end - -function ADFieldStorage.load() - local result = {fields = {}, fleet = {}} - local path = ADFieldStorage.getPath() - if path == nil or not fileExists(path) then return result end - local xmlFile = XMLFile.load("ADSmartPickupFields", path) - if xmlFile == nil then return result end - xmlFile:iterate(ADFieldStorage.ROOT .. ".field", function(_, key) - local field = ADFieldModel.fromRecord({ - id = xmlFile:getInt(key .. "#id"), stage = xmlFile:getString(key .. "#stage"), - mode = xmlFile:getString(key .. "#mode"), product = xmlFile:getString(key .. "#product"), - rake = xmlFile:getInt(key .. "#rake"), store = xmlFile:getString(key .. "#store"), - mowWidth = xmlFile:getFloat(key .. "#mowWidth"), - }) - if field.id ~= nil then result.fields[field.id] = field end - end) - xmlFile:iterate(ADFieldStorage.ROOT .. ".fleet.vehicle", function(_, key) - local id = xmlFile:getString(key .. "#id") - if id ~= nil and id ~= "" then result.fleet[id] = true end - end) - xmlFile:delete() - return result -end -``` - -- [ ] **Step 4: Write the job loop** - -File: FS25_ADSmartPickup/adFieldJobs.lua -```lua --- AD Field Jobs --- Markarbejdets job-løkke (hvert sekund): opdater klar-andel, start marker, send sæt ud, følg dem --- gennem AutoDrive-kørsel -> Courseplay-arbejde -> hjem, og gå videre til næste trin. decide() er ren --- og testet; resten oversætter til AutoDrive/Courseplay og er pcall'et pr. mark og pr. sæt. - -ADFieldJobs = {} - -ADFieldJobs.LOG_PREFIX = "ADSmartPickup: " -ADFieldJobs.TICK_MS = 1000 -ADFieldJobs.ARRIVE_DISTANCE = 30 -ADFieldJobs.IDLE_CONFIRM_MS = 5000 -ADFieldJobs.state = {fields = {}, fleet = {}, assignments = {}} -ADFieldJobs.timer = 0 -ADFieldJobs.now = 0 -ADFieldJobs.loaded = false - -local ACTIVE_PHASES = {driving = true, starting = true, working = true} - --- ---- ren beslutning ---- - -function ADFieldJobs.decide(field, info, assignments, now) - if field.stage == "ready" then - if not ADFieldModel.shouldStart(field) then return {action = "none"} end - if info == nil or info.markerId == nil then return {action = "wait"} end - return {action = "start"} - end - if not ADFieldModel.isWorking(field) then return {action = "none"} end - local active, finished = 0, 0 - for _, assignment in ipairs(assignments) do - if ACTIVE_PHASES[assignment.phase] then active = active + 1 end - if assignment.phase == "finished" or assignment.phase == "returning" then finished = finished + 1 end - end - if active == 0 and finished > 0 then - local stillStanding = field.stage == "mowing" and info ~= nil and (info.readyRatio or 0) >= ADFieldModel.CUT_RATIO - if not stillStanding then return {action = "advance"} end - end - if info == nil or info.markerId == nil then return {action = "wait"} end - local role = ADFieldModel.wantsRig(field, active, now) - if role ~= nil then return {action = "dispatch", role = role} end - return {action = "none"} -end - --- ---- tilstand ---- - -local function log(fieldId, fmt, ...) - Logging.info(ADFieldJobs.LOG_PREFIX .. "F%s " .. fmt, tostring(fieldId), ...) -end - -function ADFieldJobs.getField(id) - return ADFieldJobs.state.fields[id] or ADFieldModel.newField(id) -end - -local function setField(field) - ADFieldJobs.state.fields[field.id] = field -end - -function ADFieldJobs.assignmentsFor(fieldId) - local list = {} - for _, assignment in pairs(ADFieldJobs.state.assignments) do - if assignment.fieldId == fieldId then table.insert(list, assignment) end - end - return list -end - -function ADFieldJobs.save() - local ok, err = pcall(ADFieldStorage.save, ADFieldJobs.state) - if not ok then Logging.warning(ADFieldJobs.LOG_PREFIX .. "marker kunne ikke gemmes: %s", tostring(err)) end -end - -function ADFieldJobs.load() - local ok, loaded = pcall(ADFieldStorage.load) - if ok then - ADFieldJobs.state.fields, ADFieldJobs.state.fleet = loaded.fields, loaded.fleet - else - Logging.warning(ADFieldJobs.LOG_PREFIX .. "marker kunne ikke indlæses: %s", tostring(loaded)) - end - ADFieldJobs.loaded = true -end - -function ADFieldJobs.setPlan(id, changes) - setField(ADFieldModel.with(ADFieldJobs.getField(id), {plan = changes})) - ADFieldJobs.save() -end - -function ADFieldJobs.requestStart(id) - setField(ADFieldModel.with(ADFieldJobs.getField(id), {startRequested = true})) -end - -local function note(fieldId, changes) - setField(ADFieldModel.with(ADFieldJobs.getField(fieldId), changes)) -end - -function ADFieldJobs.toggleFleet(vehicleId) - ADFieldJobs.state.fleet = ADFieldFleet.toggle(ADFieldJobs.state.fleet, vehicleId) - ADFieldJobs.save() -end - --- ---- AutoDrive ---- - -local function stateModule(vehicle) - return vehicle.ad ~= nil and vehicle.ad.stateModule or nil -end - -local function isAdActive(vehicle) - local module = stateModule(vehicle) - return module ~= nil and module:isActive() -end - -local function stopAd(vehicle) - if isAdActive(vehicle) then vehicle:stopAutoDrive() end -end - -local function startAd(adEnv, vehicle, mode, firstId, secondId, withCp) - local module = stateModule(vehicle) - stopAd(vehicle) - module:setMode(mode) - module:setFirstMarkerByWayPointId(firstId) - if secondId ~= nil then module:setSecondMarkerByWayPointId(secondId) end - module:setStartHelper(withCp == true) - if withCp then - module.usedHelper = adEnv.ADStateModule ~= nil and adEnv.ADStateModule.HELPER_CP or 1 - if vehicle.ad.settings ~= nil and vehicle.ad.settings.enableParkAtJobFinished ~= nil then - vehicle.ad.settings.enableParkAtJobFinished.current = 2 - vehicle.ad.settings.enableParkAtJobFinished.new = 2 - end - end - local farmId = vehicle:getOwnerFarmId() - if module.setActualFarmId ~= nil then module:setActualFarmId(farmId) end - module:raiseDirtyFlag() - local current = module:getCurrentMode() - if current ~= nil and current.start ~= nil then - current:start(adEnv.AutoDrive.USER_PLAYER or 1) - else - vehicle:startAutoDrive() - end -end - -local function parkMarkerId(adEnv, vehicle) - local module = stateModule(vehicle) - local index = module ~= nil and module.getParkDestinationAtJobFinished ~= nil and module:getParkDestinationAtJobFinished() or nil - if index == nil or index < 1 then return nil end - local marker = adEnv.ADGraphManager:getMapMarkerById(index) - return marker ~= nil and marker.id or nil -end - -local function restLoad(adEnv, vehicle) - local ok, level = pcall(function() - return (adEnv.AutoDrive.getAllFillLevels(adEnv.AutoDrive.getAllUnits(vehicle))) - end) - return ok and tonumber(level) or 0 -end - -local function vehiclePosition(vehicle) - local x, _, z = getWorldTranslation(vehicle.rootNode) - return x, z -end - -local function isControlledByPlayer(vehicle) - return (vehicle.getIsControlled ~= nil and vehicle:getIsControlled()) or false -end - --- ---- flåde ---- - -function ADFieldJobs.getRigs(adEnv) - local rigs = {} - for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do - local role = ADFieldFleet.roleFromSpecs(ADCpBridge.getSpecs(vehicle)) - local id = ADRunsController.getVehicleId(vehicle) - if role ~= nil and id ~= nil then - local x, z = vehiclePosition(vehicle) - table.insert(rigs, { - id = id, vehicle = vehicle, role = role, label = ADRuns.vehicleLabel(vehicle), - enabled = ADFieldJobs.state.fleet[id] == true, - busy = ADFieldJobs.state.assignments[id] ~= nil or ADRunsController.getRunForVehicle(vehicle) ~= nil, - controlled = isControlledByPlayer(vehicle), - width = ADCpBridge.getWorkWidth(vehicle), x = x, z = z, - }) - end - end - return rigs -end - --- ---- silo ---- - -local function chooseStore(adEnv, field, info, vehicle) - local fillType = field.plan.product == "hay" and info.hay or info.windrow - local isReachable = function(markerId) return ADUnloadWait.hasRouteTo(adEnv, vehicle, markerId) end - if #field.plan.storeBuildingIds > 0 then - local markerIds = {} - for _, buildingId in ipairs(field.plan.storeBuildingIds) do - local building = ADBuildings.find(adEnv, vehicle:getOwnerFarmId(), buildingId) - for _, marker in ipairs(building ~= nil and ADBuildings.getMarkers(building, "unload") or {}) do - table.insert(markerIds, marker.id) - end - end - return ADLoadSwap.findForcedDumpMarker(adEnv, vehicle, fillType, 1, markerIds, isReachable) - end - return ADLoadSwap.findDumpMarker(adEnv, vehicle, fillType, 1, info.markerId, isReachable, nil, nil, false) -end - --- ---- udsendelse og opfølgning ---- - -local function release(assignment, phase) - assignment.phase = phase or "released" - ADFieldJobs.state.assignments[assignment.vehicleId] = nil -end - -local function dispatch(adEnv, field, info, role) - local candidates = ADFieldJobs.getRigs(adEnv) - local rig, reason, args = ADFieldFleet.pick(candidates, role, info.x, info.z, field.mowWidth) - if rig == nil then - note(field.id, {reason = {key = "spu_field_reason_" .. reason, role = role, args = args}}) - return - end - local assignment = {vehicleId = rig.id, vehicle = rig.vehicle, fieldId = field.id, role = role, phase = "driving", since = ADFieldJobs.now, width = rig.width} - ADFieldJobs.state.assignments[rig.id] = assignment - startAd(adEnv, rig.vehicle, adEnv.AutoDrive.MODE_DRIVETO, info.markerId, nil, false) - log(field.id, "%s '%s' sendt til %s (%.1f m)", role, rig.label, tostring(info.markerName), rig.width) - note(field.id, {reason = false}) -end - -local function beginWork(adEnv, field, info, assignment) - local vehicle = assignment.vehicle - if assignment.role == "collector" then - local store, why = chooseStore(adEnv, field, info, vehicle) - if store == nil then - release(assignment) - note(field.id, {retryAt = ADFieldJobs.now + ADFieldModel.RETRY_MS, reason = {text = tostring(why)}}) - log(field.id, "intet lager: %s", tostring(why)) - return - end - assignment.storeId = store.id - -- AD overtager ved "fuld": Hent og aflever F -> silo, hjælper = Courseplay - local module = stateModule(vehicle) - module:setMode(adEnv.AutoDrive.MODE_PICKUPANDDELIVER) - module:setFirstMarkerByWayPointId(info.markerId) - module:setSecondMarkerByWayPointId(store.id) - module:setStartHelper(true) - module.usedHelper = adEnv.ADStateModule ~= nil and adEnv.ADStateModule.HELPER_CP or 1 - if vehicle.ad.settings ~= nil and vehicle.ad.settings.enableParkAtJobFinished ~= nil then - vehicle.ad.settings.enableParkAtJobFinished.current = 2 - vehicle.ad.settings.enableParkAtJobFinished.new = 2 - end - module:raiseDirtyFlag() - log(field.id, "læssevogn '%s' aflæsser i %s", ADRuns.vehicleLabel(vehicle), tostring(store.name)) - end - local point = info.points[1] or {x = info.x, z = info.z} - assignment.phase = "starting" - assignment.since = ADFieldJobs.now - local handle, err = ADCpBridge.start(vehicle, point.x, point.z, function(ok, reason) - if ok then - assignment.phase = "working" - assignment.since = ADFieldJobs.now - if assignment.role == "mower" then - note(field.id, {mowWidth = assignment.width}) - end - log(field.id, "Courseplay startet (%s)", assignment.role) - else - release(assignment) - note(field.id, {retryAt = ADFieldJobs.now + ADFieldModel.RETRY_MS, reason = {text = tostring(reason)}}) - log(field.id, "Courseplay fejlede: %s", tostring(reason)) - end - end) - if handle == nil then - release(assignment) - log(field.id, "Courseplay kunne ikke startes: %s", tostring(err)) - note(field.id, {retryAt = ADFieldJobs.now + ADFieldModel.RETRY_MS, reason = {text = tostring(err)}}) - return - end - assignment.handle = handle -end - -local function follow(adEnv, assignment) - local vehicle = assignment.vehicle - if isControlledByPlayer(vehicle) then - if assignment.handle ~= nil then assignment.handle.cancelled = true end - log(assignment.fieldId, "'%s' frigivet — spilleren kører det", ADRuns.vehicleLabel(vehicle)) - return release(assignment) - end - local field = ADFieldJobs.getField(assignment.fieldId) - local info = ADFields.getInfo(assignment.fieldId) - if assignment.phase == "driving" then - if not isAdActive(vehicle) and ADFieldJobs.now - assignment.since > 2000 then - local x, z = vehiclePosition(vehicle) - local wayPoint = adEnv.ADGraphManager:getWayPointById(info.markerId) - local distance = wayPoint ~= nil and math.sqrt((wayPoint.x - x) ^ 2 + (wayPoint.z - z) ^ 2) or math.huge - if distance <= ADFieldJobs.ARRIVE_DISTANCE then - beginWork(adEnv, field, info, assignment) - else - log(assignment.fieldId, "'%s' nåede ikke %s (%.0f m væk)", ADRuns.vehicleLabel(vehicle), tostring(info.markerName), distance) - release(assignment) - end - end - elseif assignment.phase == "working" then - local busy = ADCpBridge.isWorking(vehicle) or isAdActive(vehicle) - if busy then - assignment.idleSince = nil - else - assignment.idleSince = assignment.idleSince or ADFieldJobs.now - local rest = assignment.role == "collector" and assignment.storeId ~= nil and not assignment.deliveredRest and restLoad(adEnv, vehicle) or 0 - if rest > 0 and ADFieldJobs.now - assignment.idleSince >= ADFieldJobs.IDLE_CONFIRM_MS then - -- CP færdig med læs på: AD tømmer kun selv med parkering sat, så vi sender resten til silo - assignment.deliveredRest = true - assignment.idleSince = nil - startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DELIVERTO, assignment.storeId, nil, false) - log(assignment.fieldId, "'%s' kører rest-læs (%.0f l) til silo", ADRuns.vehicleLabel(vehicle), rest) - elseif ADFieldJobs.now - assignment.idleSince >= ADFieldJobs.IDLE_CONFIRM_MS then - log(assignment.fieldId, "%s '%s' færdig", assignment.role, ADRuns.vehicleLabel(vehicle)) - local park = assignment.role ~= "collector" and parkMarkerId(adEnv, vehicle) or nil - if park ~= nil then startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, park, nil, false) end - assignment.phase = "finished" - ADFieldJobs.finished = ADFieldJobs.finished or {} - table.insert(ADFieldJobs.finished, assignment) - ADFieldJobs.state.assignments[assignment.vehicleId] = nil - end - end - end -end - -function ADFieldJobs.stopField(adEnv, id) - for _, assignment in ipairs(ADFieldJobs.assignmentsFor(id)) do - if assignment.handle ~= nil then assignment.handle.cancelled = true end - pcall(stopAd, assignment.vehicle) - pcall(function() if assignment.vehicle.stopCurrentAIJob ~= nil and ADCpBridge.isWorking(assignment.vehicle) then assignment.vehicle:stopCurrentAIJob(AIMessageSuccessStoppedByUser.new()) end end) - release(assignment) - end - setField(ADFieldModel.stop(ADFieldJobs.getField(id))) - ADFieldJobs.save() - log(id, "stoppet fra menuen") -end - -local function finishedFor(fieldId) - local list = {} - for _, assignment in ipairs(ADFieldJobs.finished or {}) do - if assignment.fieldId == fieldId then table.insert(list, assignment) end - end - return list -end - -local function stepField(adEnv, info) - local field = ADFieldModel.onReadiness(ADFieldJobs.getField(info.id), info.readyRatio or 0) - setField(field) - local assignments = ADFieldJobs.assignmentsFor(info.id) - for _, done in ipairs(finishedFor(info.id)) do table.insert(assignments, done) end - local decision = ADFieldJobs.decide(field, info, assignments, ADFieldJobs.now) - if decision.action == "start" then - setField(ADFieldModel.start(field)) - log(info.id, "starter (%s)", field.plan.product) - ADFieldJobs.save() - elseif decision.action == "advance" then - local nextField = ADFieldModel.advance(field) - setField(nextField) - local remaining = {} - for _, done in ipairs(ADFieldJobs.finished or {}) do - if done.fieldId ~= info.id then table.insert(remaining, done) end - end - ADFieldJobs.finished = remaining - log(info.id, "%s -> %s", field.stage, nextField.stage) - ADFieldJobs.save() - elseif decision.action == "dispatch" then - dispatch(adEnv, field, info, decision.role) - end -end - -function ADFieldJobs.tick(adEnv, dt) - ADFieldJobs.now = ADFieldJobs.now + dt - ADFieldJobs.timer = ADFieldJobs.timer + dt - if ADFieldJobs.timer < ADFieldJobs.TICK_MS then return end - ADFieldJobs.timer = 0 - if not ADFieldJobs.loaded then ADFieldJobs.load() end - local farmId = g_currentMission:getFarmId() - ADFields.scanNext(adEnv, farmId) - for _, assignment in pairs(ADFieldJobs.state.assignments) do - local ok, err = pcall(follow, adEnv, assignment) - if not ok then - Logging.warning(ADFieldJobs.LOG_PREFIX .. "F%s opfølgning fejlede: %s", tostring(assignment.fieldId), tostring(err)) - release(assignment) - end - end - for _, info in ipairs(ADFields.list()) do - if info.mowable or ADFieldModel.isWorking(ADFieldJobs.getField(info.id)) then - local ok, err = pcall(stepField, adEnv, info) - if not ok then Logging.warning(ADFieldJobs.LOG_PREFIX .. "F%s fejl: %s", tostring(info.id), tostring(err)) end - end - end -end -``` - -- [ ] **Step 5: Run test to verify it passes** - -Run: `luajit tests/test_adFieldJobs.lua && luajit -b FS25_ADSmartPickup/adFieldJobs.lua /dev/null && luajit -b FS25_ADSmartPickup/adFieldStorage.lua /dev/null` -Expected: `ALLE TESTS OK` - -- [ ] **Step 6: Wire into the mod** - -`modDesc.xml` — indsæt før ``: -```xml - - - - - - - -``` - -`adSmartPickup.lua` — i `ADSmartPickup:update(dt)` efter `applyVehicleNamesThrottled`-kaldet: -```lua - if ADFieldJobs ~= nil then - local okFields, errFields = pcall(ADFieldJobs.tick, getAutoDriveEnv(), dt) - if not okFields then - Logging.warning(ADSmartPickup.LOG_PREFIX .. "markarbejde fejlede: %s", tostring(errFields)) - end - end -``` - -`adRunsController.lua` — i `ADRunsController.save()` før `return saved`: -```lua - if ADFieldJobs ~= nil and ADFieldJobs.loaded then - pcall(ADFieldJobs.save) - end -``` - -- [ ] **Step 7: Run all tests + commit** - -Run: `for t in tests/test_*.lua; do luajit $t >/dev/null || echo "FEJL $t"; done` -Expected: ingen FEJL-linjer - -```bash -git add FS25_ADSmartPickup tests/test_adFieldJobs.lua -git commit -m "feat(marker): job-løkke — AutoDrive til F-markør, Courseplay på marken, næste trin, gem" -``` - ---- - -### Task 7: Fanen Marker - -**Files:** -- Create: `FS25_ADSmartPickup/gui/SmartPickupFieldTab.lua` -- Modify: `FS25_ADSmartPickup/gui/SmartPickupFrame.lua` (fane-indeks, rebuild-gren, knapper, klik) -- Modify: `FS25_ADSmartPickup/gui/SmartPickupFrame.xml` (prefab `fieldPrefab`) -- Modify: `FS25_ADSmartPickup/gui/SmartPickupFlowTab.lua` (`makeRowClickable` tager callback) -- Modify: `FS25_ADSmartPickup/adSmartPickup.lua` (source af fanen) -- Modify: `FS25_ADSmartPickup/l10n/l10n_da.xml`, `l10n_en.xml` - -**Interfaces:** -- Consumes: `ADFieldJobs.*`, `ADFields.list/getInfo/fieldSilos`, `ADFieldView.*`, `ADFieldModel.workStages` -- Produces: `SmartPickupFrame.TAB_FIELDS = 3`; `SmartPickupFrame.TAB_KINDS = {[4] = "factory", [5] = "silo", [6] = "husbandry", [7] = "objectStorage"}`; `SmartPickupFrame:fillFieldRows(adEnv)`, `refreshFieldRows(adEnv)`, `onClickFieldToggle(kind, isOn, data)` - -Udførelsen følger de eksisterende mønstre i `SmartPickupFlowTab.lua` (addCellRow, addToggleRow, makeRowClickable, -pendingJump via `update()`), med denne opførsel: - -- Tabel (`fieldPrefab`, 6 kolonner: Mark 20/100 · Afgrøde 120/130 · Plan 250/110 · Auto 360/80 · Status 440/230 · Maskiner 670/140): række pr. `ADFieldView.rows(ADFields.list(), ADFieldJobs.state.fields, self.showAllFields)`, "!" foran marknummer uden markør; klik → `self.selectedFieldId`. -- *Vis alle marker* (toggle-række) kun når `hiddenCount > 0` eller slået til. -- Detalje for valgt mark: Plan (Ensilage/Hø), Kørsel (Manuel/Auto), Riv (Nej/Ja, kun ved hø), Tving lager (ingredientPrefab-rækker med kind `fieldStore` for silo-bygninger der tager markens skår/hø), status med grund, markør + afstand, slåbredde, marksiloer uden udkørselsflow ("!"). -- Markflåde: vehiclePrefab-række pr. `ADFieldJobs.getRigs(adEnv)` (kind `fleetVehicle`), titel "Label — rolle, bredde m" + "!" over 50 m. -- Knapper på fanen: MENU_ACTIVATE = Start nu/Stop for valgt mark; MENU_EXTRA_1 = Nyt run (som Oversigt). - -- [ ] **Step 1: Write the tab, prefab, l10n, wiring** (se opførsel ovenfor) -- [ ] **Step 2: Syntax + XML check** - -Run: `for f in FS25_ADSmartPickup/gui/*.lua; do luajit -b $f /dev/null; done; python3 -c "import xml.dom.minidom as m;[m.parse(p) for p in ['FS25_ADSmartPickup/gui/SmartPickupFrame.xml','FS25_ADSmartPickup/l10n/l10n_da.xml','FS25_ADSmartPickup/l10n/l10n_en.xml']]"` -Expected: ingen output - -- [ ] **Step 3: Run all tests + commit** - -```bash -git add FS25_ADSmartPickup -git commit -m "feat(marker): fanen Marker — tabel, detalje, markflåde, Start nu/Stop" -``` - ---- - -### Task 8: Manual, version, build, deploy - -**Files:** -- Modify: `docs/BRUGERMANUAL.md`, `docs/USER_MANUAL.md` (nyt afsnit "Markarbejde (græs, kløver, lucerne)" + fane-tabel) -- Modify: `FS25_ADSmartPickup/modDesc.xml` (version `1.21.0.0`) - -- [ ] **Step 1:** Skriv manual-afsnit (opsætning: F-markører, markflåde, bredder, plan, Auto/Manuel, hvad status betyder, testprotokol). -- [ ] **Step 2:** `cd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip . && cp ../FS25_ADSmartPickup.zip ~/FS25-data/mods/` -- [ ] **Step 3:** Commit, merge til `main`, push til Gitea. diff --git a/docs/superpowers/plans/2026-09-26-guider.md b/docs/superpowers/plans/2026-09-26-guider.md deleted file mode 100644 index 069883d..0000000 --- a/docs/superpowers/plans/2026-09-26-guider.md +++ /dev/null @@ -1,174 +0,0 @@ -# Guider til opsætning (marker + flows) — implementeringsplan - -> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans (native; brugeren har givet fuld autonomi). Trin bruger `- [ ]`. - -**Goal:** Fields- og Flows-fanen får en oversigt med resumé (klik = ret ét trin) og en trin-for-trin-guide ("Opsæt mark" / "Opsæt flow") på én fælles guide-ramme. - -**Architecture:** En ren guide-motor (`adGuide.lua`) styrer trin, synlighed, tælling, næste/tilbage/spring. Oven på den to rene trin-definitioner (`adFieldGuide.lua`, `adFlowGuide.lua`) der ud fra en kladde + et `context` (det spil-afhængige, hentet af GUI'et) giver rækker (som data), valg, standarder, resumé og gem-resultat. GUI-laget (`gui/SmartPickupGuide.lua` fælles trin-skærm, `gui/SmartPickupFieldGuide.lua`, `gui/SmartPickupFlowGuide.lua`) er tyndt: bygger context, tegner rækkerne, router klik og gemmer via de eksisterende `ADFieldJobs`/`ADRunsController`-funktioner. - -**Tech Stack:** Lua 5.1 (FS25), luajit til tests, FS25 GUI (klonede prefabs). - -**Spec:** `docs/superpowers/specs/2026-09-26-guider-design.md` - -## Global Constraints - -- Lua 5.1: ingen goto, ingen coroutines, ingen `#` på tabeller med huller; pcall om spil-API. -- Gemt format (`ADSmartPickup_fields.xml`, `ADSmartPickup_runs.xml`) ændres ikke. Guiderne skriver kun via `ADFieldJobs.setTarget/setPlan` og `ADRunsController.createRun/updateRun/assignVehicle/unassignVehicle`. -- Intet gemmes før [Gem]/[Kør automatisk]/[Start flow]. [Annullér], fanebyt og lukket menu kasserer kladden. -- Rene moduler returnerer nye tabeller (kladden kopieres ved hvert valg). -- Filer under ~800 linjer; `SmartPickupFrame.lua` (971) får kun små routing-ændringer. -- Nye .lua i modDesc (rene moduler) eller `source()` i adSmartPickup.lua (GUI-filer), i rigtig rækkefølge. -- Al ny tekst i både `l10n_da.xml` og `l10n_en.xml`. -- Guide-rækker laves KUN med `ingredientPrefab` (Ja/Nej, flueben) og `productPrefab` (valg) med `kind = "guideRow"`, routet i `onClickIngredient`/`onClickProduct`; klikbare tekstlinjer via `makeRowClickable` + pendingClick. Ingen guide-rækker på `vehiclePrefab` (v1.30.4-fælden). -- Bundknapper: kun MENU_ACTIVATE, MENU_EXTRA_1, MENU_EXTRA_2, MENU_CANCEL (+ MENU_BACK) — andre findes ikke sikkert i FS25. -- Tests: `for t in tests/test_*.lua; do luajit $t | tail -1; done` — alle grønne. - -## Review Focus - -1. Klik på guide-rækker gemmes aldrig (forkert routing) → alle nye kinds routes via `guideRow`; testplan-punkt in-game. -2. Kladde muteres ved valg (Annullér gemmer alligevel) → test: `choose` ændrer ikke den gamle guide/kladde. -3. Trin forsvinder/dukker op efter et valg (fx mål-afgrøde græs på kornmark) → tælling og næste følger de synlige trin; test. -4. [Ret] fra oversigten må kun gemme det ene trin og bevare Auto/Manuel → test `result(guide, nil)` bevarer mode. -5. Faste maskiner: maskine fast på anden mark flyttes kun hvis brugeren vælger den (skjult i listen) → test `applyRigs`. - ---- - -## Beslutninger (uklarheder i spec'en) - -- **[Opsæt mark] uden valgt mark** vises også (åbner trin 1 "Mark"); ellers kunne trin 1 aldrig nås. -- **"Så igen bagefter"** (plan.sow uden mål-afgrøde) står i trin 2 under "Behold det der gror" for høst-/bare marker — ellers forsvinder muligheden. -- **Kalk**: i Klargøring ("Vælg selv"); når Klargøring ikke vises (ren græsmark) står Kalk i Pleje. -- **Klargøring "Automatisk"** = jord auto, kalk ja, mulch nej, tromle nej (anvendes kun når trinnet er synligt). -- **Maskiner-trinnet**: antal ad gangen + aflæsningsvogne først, så "Faste maskiner?" + listen (listen kan være lang). Aflæsningsvogne 1–3 (modellens `MAX_UNLOADERS`), ikke 1–5. -- **Mark uden markør**: gemmes med den valgte tilstand; job-løkken venter allerede på markøren (dagens regel). Oversigten viser "sæt markøren". -- **Mål-afgrøde** tvinger Auto (som i dag); på Oversigt-trinnet vises da kun [Kør automatisk]. -- **[Ret] fra trin 8** vender tilbage til Oversigt-trinnet (kladden bevares); [Tilbage] i et [Ret]-trin fortryder det ene trins ændringer. -- **Flows**: bundlinjen har plads til 4 knapper → [Start/Stop] [Opsæt flow] [Nyt flow] [Slet]; Omdøb ligger på resuméets Navn-linje; Start alle/Stop alle bliver på fanen Oversigt. -- **Halm**: `context.straw` = afgrøder med halm (FS25 `fruitType.hasWindrow/windrowName`); kan det ikke aflæses (tom), vises halm-valget for alle høstafgrøder som i dag. - ---- - -# Etape A — fælles ramme + Marker (v1.31.0.0) - -### Task A1: Guide-motoren `adGuide.lua` - -**Files:** Create `FS25_ADSmartPickup/adGuide.lua`, `tests/test_adGuide.lua`; Modify `modDesc.xml` (før adFieldModel.lua). - -**Interfaces (Produces):** -```lua -ADGuide.new(def, draft, context, options) -> guide --- def = {order = {stepId...}, isVisible = function(stepId, guide) -> bool} --- options = {step = id|nil, single = bool, returnTo = "close"|"summary"} --- guide = {def, draft, context, step, single, returnTo, snapshot} -ADGuide.visibleSteps(guide) -> {stepId...} -ADGuide.position(guide) -> index|nil, total -ADGuide.isLast(guide) -> bool -ADGuide.next(guide) -> guide, event -- event nil | "done" -ADGuide.back(guide) -> guide, event -- event nil | "cancel" -ADGuide.jump(guide, stepId, returnTo) -> guide (single, snapshot = kopi af kladden) -ADGuide.withDraft(guide, draft) / ADGuide.withContext(guide, context) -> guide -ADGuide.deepCopy(value) ; ADGuide.toggleItem(list, item, on) -> ny liste -ADGuide.labelText(label, getText) -> string -- label = {key, args} | {text} -ADGuide.lineText(line, getText) -> "Titel: del, del" -- line = {step, title = label, parts = {label...}} -``` - -- [ ] Test (RED): næste/tilbage springer skjulte trin over; tælling "3 af 5" over synlige; `next` på sidste = "done"; `back` på første = "cancel"; `jump(..., "summary")` + `next` lander på `summary` med single=false; `jump(..., "close")` + `next` = "done"; `back` i spring gendanner snapshot; `withDraft` returnerer ny guide (gammel urørt); trin der bliver usynligt efter et valg: `next` går til næste synlige i `order`; `toggleItem` uden dubletter; `labelText` med args og manglende args (pcall). -- [ ] Kør `luajit tests/test_adGuide.lua` → FAIL (fil mangler). -- [ ] Implementér; kør → ALLE TESTS OK. - -### Task A2: Markguidens trin `adFieldGuide.lua` - -**Files:** Create `FS25_ADSmartPickup/adFieldGuide.lua`, `tests/test_adFieldGuide.lua`; Modify `modDesc.xml` (efter adFieldView.lua). - -**Interfaces:** -- Consumes: `ADGuide.*`, `ADFieldModel.product/MAX_RIGS/MAX_UNLOADERS`, `ADFieldFleet.visibleForField`, `ADFlowView.naturalLess`. -- Produces: -```lua -ADFieldGuide.ORDER = {"field","crop","cropChoice","prep","care","machines","store","summary"} -ADFieldGuide.open(field, context, options) -> guide -- context.info påkrævet -ADFieldGuide.openPicker(context) -> guide -- trin "field" -ADFieldGuide.pickField(guide, field, context) -> guide -- videre til trinnet efter "field" -ADFieldGuide.isVisible(stepId, guide) -> bool -ADFieldGuide.rows(guide) -> {row...} --- row = {type="choice", id, title, options={{label, value}}, value} --- | {type="toggle", id, title, off, on, value} --- | {type="check", id, item, title, value} --- | {type="text", label} | {type="link", id, value, label} -ADFieldGuide.choose(guide, id, value) -> guide -ADFieldGuide.summary(guide) -> {line...} -ADFieldGuide.result(guide, start) -> {fieldId, targetFruit, targetChanged, plan, rigIds} -ADFieldGuide.applyRigs(rigIdsByField, fieldId, rigIds) -> ny {[fieldId] = ids} -ADFieldGuide.stepKey(stepId) / hintKey(stepId) -> l10n-nøgler -``` -- context: `{info, sowable={{name,title}}, mowable={[name]=true}, straw={[name]=true}|nil, rigs={{id,text,role}}, rigIdsByField, fieldSilos={{id,text}}, stores={{id,text}}|nil, storeHidden, fields={{id,text}}}`. -- kladde: `{fieldId, plan (kopi), originalTarget, prepAuto, useDedicated, storeMode ("auto"|"manual"|"fieldSilo"), storeIds, pickField}`. - -- [ ] Test (RED): synlige trin for (a) græs uden mål: crop, cropChoice, care, machines, store, summary (ingen prep); (b) høst m. halm: + prep, cropChoice (halm); (c) høst uden halm (`straw` sat, afgrøden ikke med): ingen cropChoice; (d) bar mark uden mål: crop, prep, machines, summary (ingen care/store); (e) kornmark med mål græs: cropChoice (ensilage/hø) + prep; picker: kun "field". Standarder: ny mark → prepAuto, useDedicated=false, storeMode auto; plan med soil=plow → prepAuto=false; plan med rigIds → useDedicated; storeIds lig marksilo → fieldSilo. `choose` ændrer ikke gammel guide; `rigIds`-check toggler; `result`: prepAuto overskriver soil/mulch/lime/roll kun når prep er synlig; mode auto/manual efter start, uændret ved start=nil, auto ved mål; storeBuildingIds pr. storeMode; rigIds tom når useDedicated=false; targetChanged. `applyRigs` fjerner maskinen fra anden mark. Rækker: maskiner-trinnet skjuler maskiner faste på andre marker + tekst med antal; marksilo-spørgsmål kun når fieldSilos findes; "Vælg selv" viser siloerne. Resumé: linjer kun for synlige trin, med rigtige nøgler (fx `spu_guide_sum_dedicated` args {3}). -- [ ] Kør → FAIL; implementér; kør → OK. Hele suiten grøn. - -### Task A3: Fælles trin-skærm `gui/SmartPickupGuide.lua` - -**Files:** Create `gui/SmartPickupGuide.lua`; Modify `adSmartPickup.lua` (source efter SmartPickupFlowTab.lua), `gui/SmartPickupFrame.lua` (initGuideButtons i new, routing `guideRow` i onClickIngredient, refreshButtons → `getGuideButtons`, switchTab/onFrameClose lukker guiden, rebuild for faner med åben guide), `gui/SmartPickupOutboundTab.lua` (onClickProduct → guideRow). - -**Interfaces (Produces):** -```lua -SmartPickupFrame.GUIDE_HOOKS[kind] = {model, tab, context(frame, adEnv, guide) -> context, save(frame, guide, start), choose(frame, id, value) -> handled} -SmartPickupFrame:initGuideButtons(i18n) -SmartPickupFrame:openGuide(kind, guide) SmartPickupFrame:closeGuide() -SmartPickupFrame:fillGuideRows(adEnv) -- overskrift "Trin 3 af 7 — …", forklaring, rækker -SmartPickupFrame:guideText(label) -> string -SmartPickupFrame:addLinkRow(text, action) -SmartPickupFrame:onClickGuideRow(state, data) -SmartPickupFrame:getGuideButtons() -> knapliste -SmartPickupFrame:onClickGuideNext/Back/Cancel/SaveOnly() -``` -- [ ] Skriv filen; `luajit -bl` syntakstjek. - -### Task A4: Marker-fanen: oversigt, markflåde, guide - -**Files:** Create `gui/SmartPickupFieldGuide.lua` (context, gem, knapper for fanen); Modify `gui/SmartPickupFieldTab.lua` (fillFieldRows → oversigt/markflåde/guide; fjern den lange detaljeliste), l10n da/en. - -- Oversigt: hint, tabel, for valgt mark: overskrift, resumé-linjer (klik = `ADGuide.jump(step, "close")`), fuld status, markør-linje, marksilo-advarsel. Knapper: [Opsæt mark] (EXTRA_1), [Start nu/Stop] (ACTIVATE), [Markflåde] (EXTRA_2). -- Markflåde: `fillFleetRows(adEnv, nil)` (alle maskiner). Knap [Til marker] (EXTRA_2). -- Gem: `setTarget` hvis ændret → `setPlan(plan)` → `applyRigs` → `setPlan(fid, {rigIds})` for ændrede marker. -- [ ] Skriv, syntakstjek, suiten grøn. - -### Task A5: Docs + ship v1.31.0.0 - -- [ ] TESTPLAN-markarbejde.md: afsnit "Guider (v1.31)"; BRUGERMANUAL/USER_MANUAL: Marker-afsnit kort. -- [ ] Ship-procedure (version 1.30.4.0 → 1.31.0.0), commit `feat(gui): guide "Opsæt mark" …`, push gitea. - -# Etape B — Flows på samme ramme (v1.32.0.0) - -### Task B1: Flowguidens trin `adFlowGuide.lua` - -**Files:** Create `FS25_ADSmartPickup/adFlowGuide.lua`, `tests/test_adFlowGuide.lua`; modDesc (efter adFlowView.lua). - -**Interfaces (Produces):** -```lua -ADFlowGuide.ORDER = {"name","direction","buildings","goods","store","rules","wait","tractors","summary"} -ADFlowGuide.open(run, context, options) -> guide -- run = eksisterende run -ADFlowGuide.openNew(name, context) -> guide -- nyt flow (trin "name" synligt) -ADFlowGuide.isVisible(stepId, guide); ADFlowGuide.rows(guide); ADFlowGuide.choose(guide, id, value) -ADFlowGuide.summary(guide) -> {line...} -ADFlowGuide.result(guide) -> {isNew, runId, name, kind, changes, vehicleIds} -ADFlowGuide.vehicleChanges(oldIds, newIds) -> added, removed -ADFlowGuide.previewRun(draft) -> run-formet tabel til controllerens opslag -``` -- context: `{buildings={{id,name,label,hasMarker,text}}, fillTypes={{name,title}}, products={{name,title}}, storeBuildings={{id,text}}, waitPools={{group,count}}, vehicles={{id,text,otherRunId,otherRunName,blocked}}, sellAtSteps, storeAboveSteps, returnBelowSteps, maxLoops}`. -- kladde: kopi af run-felterne + `isNew`, `showAllBuildings`, `storeAuto`. -- [ ] Test (RED): name-trin kun ved nyt flow; store-trin kun ved udkørsel med en vare på Lager/Salg-ellers-lager; retning skifter bygnings-rolle; varer: forsyning auto/vælg (ingredients toggles), udkørsel politik pr. vare (products); regler pr. retning; traktorer i andre flows/blokerede skjult + tekst; resumé pr. retning; result.changes; vehicleChanges; choose immutabelt. -- [ ] FAIL → implementér → OK. - -### Task B2: Flows-fanen i GUI'et - -**Files:** Create `gui/SmartPickupFlowGuide.lua`; Modify `gui/SmartPickupFrame.lua` (rebuild for Flows-fanen: tavle + resumé, statiske rækker skjult; refreshLive/refreshButtons), l10n. - -- Oversigt: tavlen (`fillOverviewRows`), valgt flow: resumé (klik = ret ét trin; Navn-linjen = omdøb-dialog), status-linje (live). Knapper: [Start/Stop] (ACTIVATE), [Opsæt flow] (EXTRA_1), [Nyt flow] (EXTRA_2), [Slet] (CANCEL). -- Gem: nyt → `createRun`; `updateRun(changes)`; traktorer via `vehicleChanges` + `assignVehicle/unassignVehicle`; `resolveMarkers`; `save()`; [Start flow] → `startRun`. -- [ ] Skriv, syntakstjek, suiten grøn. - -### Task B3: Docs + ship v1.32.0.0; selv-review - -- [ ] TESTPLAN + manualer (Flows-afsnit), ship 1.31.0.0 → 1.32.0.0. -- [ ] Code-review-agent på hele diffen; ret CRITICAL/HIGH; evt. v1.32.1.0. diff --git a/docs/superpowers/plans/2026-09-27-marksilo-flow.md b/docs/superpowers/plans/2026-09-27-marksilo-flow.md deleted file mode 100644 index 7f3d7d6..0000000 --- a/docs/superpowers/plans/2026-09-27-marksilo-flow.md +++ /dev/null @@ -1,1032 +0,0 @@ -# Marksilo-flow Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Et fast flow der hvert 60. s kigger alle marksiloer igennem og låner op til x ledige vogne til at køre varerne hjem til nærmeste valgte gårdlager. - -**Architecture:** Ren planlægger (`adFieldSiloPlanner.lua`) + ren opsætningsmodel (`adFieldSiloConfig.lua`), begge testet med luajit. Et tyndt spil-lag (`adFieldSilos.lua`) samler input fra spillet, låner vogne via AutoDrive "Hent og aflever" og frigiver dem. Egen underfane `gui/SmartPickupFieldSiloTab.lua`. Markflådens `getRigs` markerer lånte vogne som optaget. - -**Tech Stack:** Lua 5.1 (FS25 / LuaJIT), AutoDrive 3.0.1.2 API, Courseplay-specs via `ADCpBridge`, tests med `luajit tests/test_*.lua` fra repo-roden. - -**Spec:** `docs/superpowers/specs/2026-09-27-marksilo-flow-design.md` - -## Global Constraints - -- Branch: `feature/marksilo-flow`; merge til `main` efter review; version `1.36.0.0` i `modDesc.xml`. -- Log-præfiks `ADSmartPickup: ` (via `Logging.info/warning`), tekster på dansk i log, l10n-nøgler `spu_fs_*` i både `l10n_da.xml` og `l10n_en.xml`. -- Tal fra spec'en: planlægning hvert `60000` ms, `maxVehicles` 1–10 standard `2`, `minLiters` ∈ {5000, 10000, 25000, 50000} standard `10000`, vagt `120000` ms, pause efter vagt `600000` ms, marksilo-afstand = `ADFields.FIELD_SILO_DISTANCE` (150). -- Kun vogne med rolle `unloader` fra `ADFieldWork.getRigs` må lånes; `enabled and not busy and not controlled`. -- Gårdlagre er bygninger med `kind == "silo"`; en valgt gårdlager-bygning er aldrig marksilo. -- Lån gemmes IKKE; opsætningen gemmes i `ADSmartPickup_fieldsilos.xml` sammen med runs. -- Ingen mutation af input i de rene moduler (projektets stil: ny tabel ud). - -## Review Focus - -- Vogn med rest af en ANDEN vare på sig → må ikke lånes til en ny vare (planner-test "vogn med anden vare springes over"). -- Gårdlager fyldes op midt i runden → to vogne må ikke begge sendes til et lager med plads til kun én (planner reserverer plads pr. lager; test). -- Marksilo med flere varer (fx hvede + raps) → hver vare er sin egen opgave, og lån tælles pr. (silo, vare) (test). -- Spilleren sætter sig i en lånt vogn → lånet slettes straks, og vognen bruges ikke igen før den er ledig (loan-lifecycle-test i Task 3). -- Flowet slås fra mens vogne er ude → AD stoppes på lånte vogne, og lånene slettes (Task 3-test). - ---- - -## File Structure - -| Fil | Ansvar | -|---|---| -| Create `FS25_ADSmartPickup/adFieldSiloPlanner.lua` | Ren planlægning: kvalificering, rækkefølge, vogn- og lagervalg | -| Create `FS25_ADSmartPickup/adFieldSiloConfig.lua` | Ren opsætningsmodel + records til/fra XML | -| Create `FS25_ADSmartPickup/adFieldSilos.lua` | Spil-lag: input, lån, AD-start, frigivelse, vagt, gem/indlæs | -| Create `FS25_ADSmartPickup/gui/SmartPickupFieldSiloTab.lua` | Underfanen: indstillinger + status | -| Create `tests/test_adFieldSiloPlanner.lua`, `tests/test_adFieldSiloConfig.lua`, `tests/test_adFieldSilos.lua` | Tests | -| Modify `FS25_ADSmartPickup/modDesc.xml` | sourceFiles + version | -| Modify `FS25_ADSmartPickup/adSmartPickup.lua` | tick + source af fane-fil | -| Modify `FS25_ADSmartPickup/adFieldWork.lua:133` | `busy` inkluderer lån | -| Modify `FS25_ADSmartPickup/adRunsController.lua` (`save`/`load`) | gem/indlæs opsætning | -| Modify `FS25_ADSmartPickup/gui/SmartPickupFrame.lua` | fane-konstanter, rebuild, knapper, klik-routing | -| Modify `FS25_ADSmartPickup/l10n/l10n_da.xml`, `l10n_en.xml` | tekster | - ---- - -### Task 1: Planlæggeren - -**Files:** -- Create: `FS25_ADSmartPickup/adFieldSiloPlanner.lua` -- Test: `tests/test_adFieldSiloPlanner.lua` - -**Interfaces:** -- Produces: `ADFieldSiloPlanner.key(siloId, fillType) -> string`; - `ADFieldSiloPlanner.plan(input) -> {dispatch = {{vehicleId, siloId, siloMarkerId, fillType, storeId, storeMarkerId, liters}}, status = {{siloId, siloName, fillType, liters, onWay, state}}, freeSlots}` - hvor `input = {silos = {{id, name, markerId|nil, x, z, stock = {[fillType] = liters}}}, stores = {{id, markerId, x, z, free = {[fillType] = liters}}}, loans = {{siloId, fillType, liters}}, vehicles = {{id, x, z, capacity, carries = {[fillType] = true}, cargo = fillType|nil}}, paused = {[key] = true}, maxVehicles, minLiters}`; `state` ∈ `"ok" | "noMarker" | "noFarmStore" | "paused"`. - -- [ ] **Step 1: Write the failing test** — `tests/test_adFieldSiloPlanner.lua`: - -```lua --- Kør: luajit tests/test_adFieldSiloPlanner.lua (fra repo-roden) -dofile("FS25_ADSmartPickup/adFieldSiloPlanner.lua") -local P = ADFieldSiloPlanner -local WHEAT, CANOLA = 1, 2 -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 silo(id, x, stock, markerId) return {id = id, name = id, markerId = markerId == nil and (id .. "m") or markerId or nil, x = x, z = 0, stock = stock} end -local function store(id, x, free) return {id = id, markerId = id .. "m", x = x, z = 0, free = free} end -local function vehicle(id, x, capacity, carries, cargo) return {id = id, x = x, z = 0, capacity = capacity, carries = carries or {[WHEAT] = true, [CANOLA] = true}, cargo = cargo} end -local function base(over) - local input = {silos = {}, stores = {store("farm", 0, {[WHEAT] = 1000000, [CANOLA] = 1000000})}, loans = {}, vehicles = {}, paused = {}, maxVehicles = 2, minLiters = 10000} - for k, v in pairs(over or {}) do input[k] = v end - return input -end - --- kvalificering -local r = P.plan(base({silos = {silo("A", 500, {[WHEAT] = 9999})}, vehicles = {vehicle("v1", 400, 50000)}})) -check("under tærskel -> ingen afsendelse", #r.dispatch, 0) -check("under tærskel -> ingen status", #r.status, 0) -r = P.plan(base({silos = {silo("A", 500, {[WHEAT] = 30000}, false)}, vehicles = {vehicle("v1", 400, 50000)}})) -check("uden markør -> noMarker", r.status[1].state, "noMarker") -check("uden markør -> ingen afsendelse", #r.dispatch, 0) -r = P.plan(base({stores = {store("farm", 0, {[CANOLA] = 1000000})}, silos = {silo("A", 500, {[WHEAT] = 30000})}, vehicles = {vehicle("v1", 400, 50000)}})) -check("ingen gård tager varen -> noFarmStore", r.status[1].state, "noFarmStore") -r = P.plan(base({silos = {silo("A", 500, {[WHEAT] = 30000})}, vehicles = {vehicle("v1", 400, 50000)}, paused = {[P.key("A", WHEAT)] = true}})) -check("pause -> paused, ingen afsendelse", r.status[1].state .. #r.dispatch, "paused0") - --- afsendelse, fuldeste først, x-loft -r = P.plan(base({silos = {silo("A", 500, {[WHEAT] = 20000}), silo("B", 900, {[WHEAT] = 80000})}, vehicles = {vehicle("v1", 400, 50000), vehicle("v2", 950, 50000), vehicle("v3", 1000, 50000)}})) -check("x=2 -> to afsendt", #r.dispatch, 2) -check("fuldeste silo først", r.dispatch[1].siloId, "B") -check("nærmeste vogn til B", r.dispatch[1].vehicleId, "v2") -check("B's rest (30000) får vogn nr. 2", r.dispatch[2].siloId, "B") -check("lager-markør med", r.dispatch[1].storeMarkerId, "farmm") -check("liter = vognens kapacitet", r.dispatch[1].liters, 50000) -check("anden tur = resten", r.dispatch[2].liters, 30000) - --- aktive lån tæller mod x og mod siloens rest -r = P.plan(base({silos = {silo("A", 500, {[WHEAT] = 60000})}, loans = {{siloId = "A", fillType = WHEAT, liters = 50000}}, vehicles = {vehicle("v1", 400, 50000), vehicle("v2", 400, 50000)}})) -check("lån på vej -> rest 10000 -> én ny vogn", #r.dispatch, 1) -check("status viser på vej", r.status[1].onWay, 50000) -r = P.plan(base({maxVehicles = 1, silos = {silo("A", 500, {[WHEAT] = 90000})}, loans = {{siloId = "Z", fillType = WHEAT, liters = 1}}, vehicles = {vehicle("v1", 400, 50000)}})) -check("x=1 med ét lån ude -> ingen", #r.dispatch, 0) -check("frie pladser 0", r.freeSlots, 0) - --- flere varer i én silo = hver sin opgave -r = P.plan(base({silos = {silo("A", 500, {[WHEAT] = 40000, [CANOLA] = 20000})}, vehicles = {vehicle("v1", 400, 50000), vehicle("v2", 410, 50000)}})) -check("to varer -> to afsendelser", #r.dispatch, 2) -check("fuldeste vare først", r.dispatch[1].fillType, WHEAT) - --- vogne der ikke kan -r = P.plan(base({silos = {silo("A", 500, {[WHEAT] = 40000})}, vehicles = {vehicle("v1", 400, 50000, {[CANOLA] = true}), vehicle("v2", 2000, 50000)}})) -check("vogn der ikke kan bære varen springes over", r.dispatch[1].vehicleId, "v2") -r = P.plan(base({silos = {silo("A", 500, {[WHEAT] = 40000})}, vehicles = {vehicle("v1", 400, 50000, nil, CANOLA), vehicle("v2", 2000, 50000)}})) -check("vogn med anden vare på springes over", r.dispatch[1].vehicleId, "v2") -r = P.plan(base({silos = {silo("A", 500, {[WHEAT] = 40000})}, vehicles = {vehicle("v1", 400, 0)}})) -check("vogn uden kapacitet -> ingen", #r.dispatch, 0) - --- lagervalg -r = P.plan(base({stores = {store("near", 400, {[WHEAT] = 20000}), store("far", 5000, {[WHEAT] = 900000})}, silos = {silo("A", 500, {[WHEAT] = 90000})}, vehicles = {vehicle("v1", 400, 50000), vehicle("v2", 400, 50000)}})) -check("nærmeste lager uden plads til læsset -> det med plads", r.dispatch[1].storeId, "far") -r = P.plan(base({stores = {store("small", 400, {[WHEAT] = 30000}), store("tiny", 300, {[WHEAT] = 15000})}, silos = {silo("A", 500, {[WHEAT] = 90000})}, vehicles = {vehicle("v1", 400, 50000), vehicle("v2", 400, 50000)}})) -check("intet har plads til hele læsset -> mest plads", r.dispatch[1].storeId, "small") -check("reserveret plads: vogn 2 til det andet lager", r.dispatch[2].storeId, "tiny") -r = P.plan(base({stores = {store("full", 400, {[WHEAT] = 5000})}, silos = {silo("A", 500, {[WHEAT] = 90000})}, vehicles = {vehicle("v1", 400, 50000)}})) -check("lager med under tærsklen -> noFarmStore", r.status[1].state, "noFarmStore") - --- input muteres ikke -local input = base({silos = {silo("A", 500, {[WHEAT] = 90000})}, vehicles = {vehicle("v1", 400, 50000)}}) -P.plan(input) -check("input.stores urørt", input.stores[1].free[WHEAT], 1000000) - -print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) -os.exit(failures == 0 and 0 or 1) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `luajit tests/test_adFieldSiloPlanner.lua` -Expected: FAIL (fil `adFieldSiloPlanner.lua` findes ikke). - -- [ ] **Step 3: Write implementation** — `FS25_ADSmartPickup/adFieldSiloPlanner.lua`: - -```lua --- AD Field Silo Planner --- Ren planlægning for marksilo-flowet: hvilke (silo, vare) skal hjem, hvor mange lånte vogne sendes, og til --- hvilket gårdlager. Ingen spil-kald; input muteres ikke. - -ADFieldSiloPlanner = {} - -function ADFieldSiloPlanner.key(siloId, fillType) - return tostring(siloId) .. ":" .. tostring(fillType) -end -local key = ADFieldSiloPlanner.key - -local function dist2(a, b) - return ((a.x or 0) - (b.x or 0)) ^ 2 + ((a.z or 0) - (b.z or 0)) ^ 2 -end - -local function freeIn(store, fillType, reserved) - return ((store.free or {})[fillType] or 0) - (reserved[store.id .. ":" .. tostring(fillType)] or 0) -end - -local function anyStoreTakes(stores, fillType, minLiters, reserved) - for _, store in ipairs(stores or {}) do - if store.markerId ~= nil and freeIn(store, fillType, reserved) >= minLiters then return true end - end - return false -end - --- Nærmeste lager med plads til hele turen; ellers det med mest plads (over tærsklen). -local function chooseStore(stores, silo, fillType, liters, minLiters, reserved) - local best, bestD, most, mostFree = nil, nil, nil, nil - for _, store in ipairs(stores or {}) do - local free = freeIn(store, fillType, reserved) - if store.markerId ~= nil and free >= minLiters then - local d = dist2(store, silo) - if free >= liters and (bestD == nil or d < bestD) then best, bestD = store, d end - if mostFree == nil or free > mostFree then most, mostFree = store, free end - end - end - return best or most -end - -local function canCarry(vehicle, fillType) - if (vehicle.capacity or 0) <= 0 then return false end - if vehicle.cargo ~= nil and vehicle.cargo ~= fillType then return false end - return (vehicle.carries or {})[fillType] == true -end - -local function nearestVehicle(vehicles, used, silo, fillType) - local best, bestD = nil, nil - for _, vehicle in ipairs(vehicles or {}) do - if not used[vehicle.id] and canCarry(vehicle, fillType) then - local d = dist2(vehicle, silo) - if bestD == nil or d < bestD then best, bestD = vehicle, d end - end - end - return best -end - -function ADFieldSiloPlanner.plan(input) - local minLiters = input.minLiters or 10000 - local reserved, onWay = {}, {} - for _, loan in ipairs(input.loans or {}) do - local k = key(loan.siloId, loan.fillType) - onWay[k] = (onWay[k] or 0) + (loan.liters or 0) - end - local status, tasks = {}, {} - for _, silo in ipairs(input.silos or {}) do - for fillType, liters in pairs(silo.stock or {}) do - if (liters or 0) >= minLiters then - local k = key(silo.id, fillType) - local entry = {siloId = silo.id, siloName = silo.name, fillType = fillType, liters = liters, onWay = onWay[k] or 0} - if silo.markerId == nil then - entry.state = "noMarker" - elseif not anyStoreTakes(input.stores, fillType, minLiters, reserved) then - entry.state = "noFarmStore" - elseif (input.paused or {})[k] then - entry.state = "paused" - else - entry.state = "ok" - local rest = liters - entry.onWay - if rest >= minLiters then table.insert(tasks, {silo = silo, fillType = fillType, rest = rest}) end - end - table.insert(status, entry) - end - end - end - table.sort(tasks, function(a, b) - if a.rest ~= b.rest then return a.rest > b.rest end - if tostring(a.silo.id) ~= tostring(b.silo.id) then return tostring(a.silo.id) < tostring(b.silo.id) end - return a.fillType < b.fillType - end) - table.sort(status, function(a, b) - if tostring(a.siloId) ~= tostring(b.siloId) then return tostring(a.siloId) < tostring(b.siloId) end - return a.fillType < b.fillType - end) - local freeSlots = math.max(0, (input.maxVehicles or 2) - #(input.loans or {})) - local used, dispatch = {}, {} - for _, task in ipairs(tasks) do - while freeSlots > 0 and task.rest >= minLiters do - local vehicle = nearestVehicle(input.vehicles, used, task.silo, task.fillType) - if vehicle == nil then break end - local liters = math.min(vehicle.capacity, task.rest) - local store = chooseStore(input.stores, task.silo, task.fillType, liters, minLiters, reserved) - if store == nil then break end - used[vehicle.id] = true - local reserveKey = store.id .. ":" .. tostring(task.fillType) - reserved[reserveKey] = (reserved[reserveKey] or 0) + liters - table.insert(dispatch, {vehicleId = vehicle.id, siloId = task.silo.id, siloMarkerId = task.silo.markerId, - fillType = task.fillType, storeId = store.id, storeMarkerId = store.markerId, liters = liters}) - task.rest = task.rest - liters - freeSlots = freeSlots - 1 - end - end - return {dispatch = dispatch, status = status, freeSlots = freeSlots} -end -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `luajit tests/test_adFieldSiloPlanner.lua` -Expected: `ALLE TESTS OK` - -- [ ] **Step 5: Commit** - -```bash -git add FS25_ADSmartPickup/adFieldSiloPlanner.lua tests/test_adFieldSiloPlanner.lua -git commit -m "feat(marksilo): ren planlægger — kvalificering, fuldeste først, x-loft, vogn- og lagervalg med reservation" -``` - ---- - -### Task 2: Opsætningsmodellen - -**Files:** -- Create: `FS25_ADSmartPickup/adFieldSiloConfig.lua` -- Test: `tests/test_adFieldSiloConfig.lua` - -**Interfaces:** -- Produces: `ADFieldSiloConfig.new() -> cfg` (`{enabled=false, storeIds={}, maxVehicles=2, minLiters=10000}`); - `ADFieldSiloConfig.with(cfg, changes) -> cfg` (changes ⊆ `{enabled, maxVehicles, minLiters}`; clamps); - `ADFieldSiloConfig.toggleStore(cfg, id) -> cfg`; `ADFieldSiloConfig.hasStore(cfg, id) -> bool`; - `ADFieldSiloConfig.MIN_LITERS_CHOICES = {5000, 10000, 25000, 50000}`; `ADFieldSiloConfig.MAX_VEHICLES = 10`; - `ADFieldSiloConfig.toRecord(cfg) -> {enabled=0|1, stores="id id", maxVehicles, minLiters}`; `ADFieldSiloConfig.fromRecord(record) -> cfg`. - -- [ ] **Step 1: Write the failing test** — `tests/test_adFieldSiloConfig.lua`: - -```lua --- Kør: luajit tests/test_adFieldSiloConfig.lua (fra repo-roden) -dofile("FS25_ADSmartPickup/adFieldSiloConfig.lua") -local C = ADFieldSiloConfig -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 cfg = C.new() -check("standard fra", cfg.enabled, false) -check("standard x=2", cfg.maxVehicles, 2) -check("standard tærskel 10000", cfg.minLiters, 10000) -check("x under 1 -> 1", C.with(cfg, {maxVehicles = 0}).maxVehicles, 1) -check("x over 10 -> 10", C.with(cfg, {maxVehicles = 99}).maxVehicles, 10) -check("tærskel snappes til nærmeste valg", C.with(cfg, {minLiters = 12000}).minLiters, 10000) -check("tærskel 40000 -> 50000", C.with(cfg, {minLiters = 40000}).minLiters, 50000) -check("with muterer ikke", cfg.maxVehicles, 2) -local on = C.toggleStore(cfg, "silo1") -check("toggle til", C.hasStore(on, "silo1"), true) -check("toggle muterer ikke", C.hasStore(cfg, "silo1"), false) -check("toggle fra", C.hasStore(C.toggleStore(on, "silo1"), "silo1"), false) -local full = C.with(C.toggleStore(C.toggleStore(cfg, "a"), "b"), {enabled = true, maxVehicles = 4, minLiters = 25000}) -local back = C.fromRecord(C.toRecord(full)) -check("record: enabled", back.enabled, true) -check("record: lagre", table.concat(back.storeIds, " "), "a b") -check("record: x", back.maxVehicles, 4) -check("record: tærskel", back.minLiters, 25000) -check("tom record -> standard", C.fromRecord(nil).maxVehicles, 2) -check("record uden tal -> standard tærskel", C.fromRecord({enabled = 1}).minLiters, 10000) -print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) -os.exit(failures == 0 and 0 or 1) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `luajit tests/test_adFieldSiloConfig.lua` — Expected: FAIL (fil findes ikke). - -- [ ] **Step 3: Write implementation** — `FS25_ADSmartPickup/adFieldSiloConfig.lua`: - -```lua --- AD Field Silo Config --- Marksilo-flowets opsætning som ren model (ny tabel ud, input muteres ikke) + flade records til XML-laget. - -ADFieldSiloConfig = {} - -ADFieldSiloConfig.MAX_VEHICLES = 10 -ADFieldSiloConfig.DEFAULT_MAX_VEHICLES = 2 -ADFieldSiloConfig.MIN_LITERS_CHOICES = {5000, 10000, 25000, 50000} -ADFieldSiloConfig.DEFAULT_MIN_LITERS = 10000 - -local function copy(cfg) - local ids = {} - for index, id in ipairs(cfg.storeIds or {}) do ids[index] = id end - return {enabled = cfg.enabled == true, storeIds = ids, maxVehicles = cfg.maxVehicles, minLiters = cfg.minLiters} -end - -local function clampVehicles(value) - value = tonumber(value) or ADFieldSiloConfig.DEFAULT_MAX_VEHICLES - return math.max(1, math.min(ADFieldSiloConfig.MAX_VEHICLES, math.floor(value))) -end - -local function snapLiters(value) - value = tonumber(value) - if value == nil then return ADFieldSiloConfig.DEFAULT_MIN_LITERS end - local best, bestDiff = nil, nil - for _, choice in ipairs(ADFieldSiloConfig.MIN_LITERS_CHOICES) do - local diff = math.abs(choice - value) - if bestDiff == nil or diff < bestDiff then best, bestDiff = choice, diff end - end - return best -end - -function ADFieldSiloConfig.new() - return {enabled = false, storeIds = {}, maxVehicles = ADFieldSiloConfig.DEFAULT_MAX_VEHICLES, minLiters = ADFieldSiloConfig.DEFAULT_MIN_LITERS} -end - -function ADFieldSiloConfig.with(cfg, changes) - local new = copy(cfg) - if changes.enabled ~= nil then new.enabled = changes.enabled == true end - if changes.maxVehicles ~= nil then new.maxVehicles = clampVehicles(changes.maxVehicles) end - if changes.minLiters ~= nil then new.minLiters = snapLiters(changes.minLiters) end - return new -end - -function ADFieldSiloConfig.hasStore(cfg, id) - for _, storeId in ipairs(cfg.storeIds or {}) do - if storeId == id then return true end - end - return false -end - -function ADFieldSiloConfig.toggleStore(cfg, id) - local new = copy(cfg) - local kept, found = {}, false - for _, storeId in ipairs(new.storeIds) do - if storeId == id then found = true else table.insert(kept, storeId) end - end - if not found then table.insert(kept, id) end - new.storeIds = kept - return new -end - -function ADFieldSiloConfig.toRecord(cfg) - return {enabled = cfg.enabled and 1 or 0, stores = table.concat(cfg.storeIds or {}, " "), - maxVehicles = cfg.maxVehicles, minLiters = cfg.minLiters} -end - -function ADFieldSiloConfig.fromRecord(record) - local cfg = ADFieldSiloConfig.new() - if record == nil then return cfg end - for token in string.gmatch(tostring(record.stores or ""), "%S+") do table.insert(cfg.storeIds, token) end - cfg.enabled = tonumber(record.enabled) == 1 - cfg.maxVehicles = clampVehicles(record.maxVehicles) - cfg.minLiters = snapLiters(record.minLiters) - return cfg -end -``` - -- [ ] **Step 4: Run test** — `luajit tests/test_adFieldSiloConfig.lua` → `ALLE TESTS OK` - -- [ ] **Step 5: Commit** - -```bash -git add FS25_ADSmartPickup/adFieldSiloConfig.lua tests/test_adFieldSiloConfig.lua -git commit -m "feat(marksilo): opsætningsmodel (til/fra, gårdlagre, x 1-10, tærskel) + records" -``` - ---- - -### Task 3: Spil-laget — lån, afsendelse, frigivelse, gem/indlæs - -**Files:** -- Create: `FS25_ADSmartPickup/adFieldSilos.lua` -- Modify: `FS25_ADSmartPickup/adFieldWork.lua` (eksportér `startAd`, `enableParkAtJobFinished`; `busy` inkluderer lån) -- Modify: `FS25_ADSmartPickup/adSmartPickup.lua` (tick i `update`) -- Modify: `FS25_ADSmartPickup/adRunsController.lua` (`save`/`load` kalder `ADFieldSilos.save/load`) -- Modify: `FS25_ADSmartPickup/modDesc.xml` (sourceFiles: `adFieldSiloPlanner.lua`, `adFieldSiloConfig.lua`, `adFieldSilos.lua` — efter `adFieldWork.lua`) -- Test: `tests/test_adFieldSilos.lua` - -**Interfaces:** -- Consumes: `ADFieldSiloPlanner.plan/key` (Task 1), `ADFieldSiloConfig.*` (Task 2), `ADFieldWork.getRigs(adEnv)`, `ADFields.list()`, `ADFields.fieldSilos(adEnv, farmId, info)`, `ADBuildings.list(adEnv, farmId)`, `ADBuildings.getMarkers(building, role)`, `ADRunsController.getVehicleId(vehicle)`, `ADRuns.vehicleLabel(vehicle)`, `ADSmartPickup.getFarmId(vehicle)`. -- Produces: `ADFieldSilos.config` (cfg), `ADFieldSilos.setConfig(cfg)`, `ADFieldSilos.isLoaned(vehicle) -> bool`, `ADFieldSilos.tick(adEnv, dt)`, `ADFieldSilos.lastPlan -> plan|nil`, `ADFieldSilos.loanCount() -> n`, `ADFieldSilos.stopAll(adEnv)`, `ADFieldSilos.save()`, `ADFieldSilos.load()`, `ADFieldSilos.releaseReason(loan, vehicleState, nowMs) -> nil|"done"|"player"|"stuck"` (ren, testet), `ADFieldWork.startAd(adEnv, vehicle, mode, firstId, secondId)`, `ADFieldWork.enableParkAtJobFinished(vehicle)`. - -- [ ] **Step 1: Write the failing test** — `tests/test_adFieldSilos.lua` (tester den rene frigivelsesregel): - -```lua --- Kør: luajit tests/test_adFieldSilos.lua (fra repo-roden) -dofile("FS25_ADSmartPickup/adFieldSiloPlanner.lua") -dofile("FS25_ADSmartPickup/adFieldSiloConfig.lua") -dofile("FS25_ADSmartPickup/adFieldSilos.lua") -local S = ADFieldSilos -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 loan = {startedMs = 0, lastProgressMs = 0, lastFill = 0} -check("kører, tidligt -> behold", S.releaseReason(loan, {adActive = true, controlled = false, fill = 0, moving = true}, 5000), nil) -check("AD slukket -> done", S.releaseReason(loan, {adActive = false, controlled = false, fill = 0}, 5000), "done") -check("spiller i vognen -> player", S.releaseReason(loan, {adActive = true, controlled = true, fill = 0}, 5000), "player") -check("står stille uden fyld i 120 s -> stuck", S.releaseReason(loan, {adActive = true, controlled = false, fill = 0, moving = false}, 120001), "stuck") -check("kører stadig efter 120 s -> behold", S.releaseReason(loan, {adActive = true, controlled = false, fill = 0, moving = true}, 120001), nil) -local loading = {startedMs = 0, lastProgressMs = 110000, lastFill = 0} -check("fyldning steg for nylig -> behold", S.releaseReason(loading, {adActive = true, controlled = false, fill = 20000, moving = false}, 125000), nil) -print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) -os.exit(failures == 0 and 0 or 1) -``` - -- [ ] **Step 2: Run test to verify it fails** — `luajit tests/test_adFieldSilos.lua` → FAIL (fil findes ikke). - -- [ ] **Step 3: Export helpers from adFieldWork** — i `adFieldWork.lua` efter den lokale `startAd`-funktion (og `enableParkAtJobFinished`), tilføj: - -```lua -ADFieldWork.startAd = startAd -ADFieldWork.enableParkAtJobFinished = enableParkAtJobFinished -``` - -og i `ADFieldWork.getRigs` ændr `busy`-linjen til: - -```lua - busy = state.assignments[id] ~= nil or isAdActive(vehicle) or ADCpBridge.isWorking(vehicle) - or (ADFieldSilos ~= nil and ADFieldSilos.isLoaned(vehicle)), -``` - -- [ ] **Step 4: Write implementation** — `FS25_ADSmartPickup/adFieldSilos.lua`: - -```lua --- AD Field Silos --- Marksilo-flowet i spillet: samler marksiloer, gårdlagre og ledige vogne, lader ADFieldSiloPlanner vælge, --- låner vogne (AutoDrive "Hent og aflever", én tur, parkering bagefter) og frigiver dem igen. --- Lån ligger kun i hukommelsen; opsætningen gemmes i ADSmartPickup_fieldsilos.xml. - -ADFieldSilos = {} - -ADFieldSilos.PLAN_INTERVAL_MS = 60000 -ADFieldSilos.CHECK_INTERVAL_MS = 2000 -ADFieldSilos.STUCK_MS = 120000 -ADFieldSilos.PAUSE_MS = 600000 -ADFieldSilos.MOVING_SPEED = 0.00028 -- m/ms, samme grænse som AutoDrive -ADFieldSilos.FILE_NAME = "ADSmartPickup_fieldsilos.xml" -ADFieldSilos.ROOT = "ADSmartPickupFieldSilos" -ADFieldSilos.LOG_PREFIX = "ADSmartPickup: marksilo: " - -ADFieldSilos.config = ADFieldSiloConfig.new() -ADFieldSilos.lastPlan = nil -local loans = setmetatable({}, {__mode = "k"}) -- vehicle -> {siloId, siloName, fillType, liters, startedMs, lastProgressMs, lastFill} -local paused = {} -- key -> untilMs -local planTimerMs, checkTimerMs, nowMs = ADFieldSilos.PLAN_INTERVAL_MS, 0, 0 - --- ---- ren frigivelsesregel ---- - --- vehicleState = {adActive, controlled, fill, moving} -function ADFieldSilos.releaseReason(loan, vehicleState, now) - if vehicleState.controlled then return "player" end - if not vehicleState.adActive then return "done" end - if vehicleState.moving then return nil end - if (vehicleState.fill or 0) > (loan.lastFill or 0) then return nil end - if now - (loan.lastProgressMs or loan.startedMs or 0) > ADFieldSilos.STUCK_MS then return "stuck" end - return nil -end - --- ---- opsætning ---- - -function ADFieldSilos.setConfig(cfg) - local wasEnabled = ADFieldSilos.config.enabled - ADFieldSilos.config = cfg - if wasEnabled and not cfg.enabled and ADFieldSilos.adEnv ~= nil then ADFieldSilos.stopAll(ADFieldSilos.adEnv) end - planTimerMs = ADFieldSilos.PLAN_INTERVAL_MS -- planlæg straks ved næste tick -end - -function ADFieldSilos.isLoaned(vehicle) - return loans[vehicle] ~= nil -end - -function ADFieldSilos.loanCount() - local n = 0 - for _ in pairs(loans) do n = n + 1 end - return n -end - --- ---- spil-input ---- - -local function farmId() - return g_currentMission ~= nil and g_currentMission.getFarmId ~= nil and g_currentMission:getFarmId() or 1 -end - -local function position(node) - if node == nil then return 0, 0 end - local x, _, z = getWorldTranslation(node) - return x, z -end - -local function siloStock(placeable) - local stock = {} - local spec = placeable ~= nil and placeable.spec_silo or nil - for _, storage in pairs(spec ~= nil and spec.storages or {}) do - for fillType, level in pairs(storage.fillLevels or {}) do - if (level or 0) > 0 then stock[fillType] = (stock[fillType] or 0) + level end - end - end - return stock -end - -local function siloFree(placeable) - local free = {} - local spec = placeable ~= nil and placeable.spec_silo or nil - for _, storage in pairs(spec ~= nil and spec.storages or {}) do - for fillType in pairs(storage.fillTypes or {}) do - local amount = storage.getFreeCapacity ~= nil and (storage:getFreeCapacity(fillType) or 0) or 0 - free[fillType] = (free[fillType] or 0) + amount - end - end - return free -end - -local function firstMarker(building, role) - local markers = ADBuildings.getMarkers(building, role) - return markers[1] ~= nil and markers[1].id or nil -end - --- Gårdlagre fra opsætningen + marksiloer (alle markers marksiloer, uden dubletter og uden gårdlagre). -function ADFieldSilos.collectBuildings(adEnv) - local fid = farmId() - local stores, byId = {}, {} - for _, building in ipairs(ADBuildings.list(adEnv, fid)) do - byId[building.id] = building - if building.kind == "silo" and ADFieldSiloConfig.hasStore(ADFieldSilos.config, building.id) then - local x, z = position(building.placeable ~= nil and building.placeable.rootNode or nil) - table.insert(stores, {id = building.id, name = building.name, markerId = firstMarker(building, "unload"), - x = x, z = z, free = siloFree(building.placeable)}) - end - end - local silos, seen = {}, {} - for _, info in ipairs(ADFields.list()) do - for _, building in ipairs(ADFields.fieldSilos(adEnv, fid, info)) do - if not seen[building.id] and not ADFieldSiloConfig.hasStore(ADFieldSilos.config, building.id) then - seen[building.id] = true - local x, z = position(building.placeable ~= nil and building.placeable.rootNode or nil) - table.insert(silos, {id = building.id, name = building.name, markerId = firstMarker(building, "load"), - x = x, z = z, stock = siloStock(building.placeable)}) - end - end - end - return silos, stores -end - -local function trailerInfo(adEnv, vehicle) - local capacity, carries, cargo = 0, {}, nil - for _, unit in ipairs(adEnv.AutoDrive.getAllDischargeableUnits(vehicle) or {}) do - local object, index = unit.object, unit.fillUnitIndex - if object ~= nil and object.getFillUnitCapacity ~= nil then - capacity = capacity + (object:getFillUnitCapacity(index) or 0) - for fillType in pairs(object.getFillUnitSupportedFillTypes ~= nil and object:getFillUnitSupportedFillTypes(index) or {}) do - carries[fillType] = true - end - if (object:getFillUnitFillLevel(index) or 0) > 0 then cargo = object:getFillUnitFillType(index) end - end - end - return capacity, carries, cargo -end - -local function fillLevel(adEnv, vehicle) - local ok, level = pcall(function() return (adEnv.AutoDrive.getAllFillLevels(adEnv.AutoDrive.getAllUnits(vehicle))) end) - return ok and tonumber(level) or 0 -end - -function ADFieldSilos.collectVehicles(adEnv) - local vehicles, byId = {}, {} - for _, rig in ipairs(ADFieldWork.getRigs(adEnv)) do - if rig.role == "unloader" and rig.enabled and not rig.busy and not rig.controlled then - local capacity, carries, cargo = trailerInfo(adEnv, rig.vehicle) - table.insert(vehicles, {id = rig.id, x = rig.x, z = rig.z, capacity = capacity, carries = carries, cargo = cargo}) - byId[rig.id] = rig.vehicle - end - end - return vehicles, byId -end - --- ---- lån ---- - -local function fillTypeName(fillType) - local desc = g_fillTypeManager ~= nil and g_fillTypeManager:getFillTypeByIndex(fillType) or nil - return desc ~= nil and (desc.title or desc.name) or tostring(fillType) -end - -local function dispatchOne(adEnv, order, vehicle, silosById, storesById) - local module = vehicle.ad.stateModule - module:setMode(adEnv.AutoDrive.MODE_PICKUPANDDELIVER) - module:setFirstMarkerByWayPointId(order.siloMarkerId) - module:setSecondMarkerByWayPointId(order.storeMarkerId) - module.selectedFillTypes = {order.fillType} - module:setFillType(order.fillType) - module.loopCounter = 1 - if module.setLoopsDone ~= nil then module:setLoopsDone(0) end - ADFieldWork.enableParkAtJobFinished(vehicle) - module:raiseDirtyFlag() - ADFieldWork.startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_PICKUPANDDELIVER, order.siloMarkerId, order.storeMarkerId) - local silo, store = silosById[order.siloId], storesById[order.storeId] - loans[vehicle] = {siloId = order.siloId, siloName = silo ~= nil and silo.name or order.siloId, fillType = order.fillType, - liters = order.liters, startedMs = nowMs, lastProgressMs = nowMs, lastFill = fillLevel(adEnv, vehicle)} - Logging.info(ADFieldSilos.LOG_PREFIX .. "'%s' henter %d l %s i %s -> %s", tostring(ADRuns.vehicleLabel(vehicle)), - math.floor(order.liters), fillTypeName(order.fillType), tostring(silo ~= nil and silo.name or order.siloId), - tostring(store ~= nil and store.name or order.storeId)) -end - -local function release(adEnv, vehicle, reason) - local loan = loans[vehicle] - loans[vehicle] = nil - if loan == nil then return end - if reason == "stuck" then - local k = ADFieldSiloPlanner.key(loan.siloId, loan.fillType) - paused[k] = nowMs + ADFieldSilos.PAUSE_MS - pcall(vehicle.stopAutoDrive, vehicle) - Logging.warning(ADFieldSilos.LOG_PREFIX .. "'%s' kom ikke i gang ved %s (%s) på 2 min — stoppet; siloen springes over i 10 min", - tostring(ADRuns.vehicleLabel(vehicle)), tostring(loan.siloName), fillTypeName(loan.fillType)) - elseif reason == "stopped" then - pcall(vehicle.stopAutoDrive, vehicle) - end - Logging.info(ADFieldSilos.LOG_PREFIX .. "'%s' frigivet (%s)", tostring(ADRuns.vehicleLabel(vehicle)), tostring(reason)) -end - -function ADFieldSilos.stopAll(adEnv) - local list = {} - for vehicle in pairs(loans) do table.insert(list, vehicle) end - for _, vehicle in ipairs(list) do release(adEnv, vehicle, "stopped") end -end - -local function checkLoans(adEnv) - local list = {} - for vehicle in pairs(loans) do table.insert(list, vehicle) end - for _, vehicle in ipairs(list) do - local loan = loans[vehicle] - local stateModule = vehicle.ad ~= nil and vehicle.ad.stateModule or nil - local fill = fillLevel(adEnv, vehicle) - local vehicleState = { - adActive = stateModule ~= nil and stateModule:isActive(), - controlled = vehicle.getIsControlled ~= nil and vehicle:getIsControlled() or false, - fill = fill, moving = (vehicle.lastSpeedReal or 0) >= ADFieldSilos.MOVING_SPEED, - } - if vehicleState.moving or fill > (loan.lastFill or 0) then loan.lastProgressMs = nowMs end - loan.lastFill = math.max(loan.lastFill or 0, fill) - local reason = ADFieldSilos.releaseReason(loan, vehicleState, nowMs) - if reason ~= nil then release(adEnv, vehicle, reason) end - end -end - -local function currentPaused() - local result = {} - for k, untilMs in pairs(paused) do - if untilMs > nowMs then result[k] = true else paused[k] = nil end - end - return result -end - -local function loanList() - local list = {} - for _, loan in pairs(loans) do table.insert(list, {siloId = loan.siloId, fillType = loan.fillType, liters = loan.liters}) end - return list -end - -function ADFieldSilos.planNow(adEnv) - local cfg = ADFieldSilos.config - local silos, stores = ADFieldSilos.collectBuildings(adEnv) - local vehicles, vehiclesById = ADFieldSilos.collectVehicles(adEnv) - local plan = ADFieldSiloPlanner.plan({silos = silos, stores = stores, loans = loanList(), vehicles = vehicles, - paused = currentPaused(), maxVehicles = cfg.maxVehicles, minLiters = cfg.minLiters}) - ADFieldSilos.lastPlan = plan - if not cfg.enabled then return plan end - local silosById, storesById = {}, {} - for _, silo in ipairs(silos) do silosById[silo.id] = silo end - for _, store in ipairs(stores) do storesById[store.id] = store end - for _, order in ipairs(plan.dispatch) do - local vehicle = vehiclesById[order.vehicleId] - if vehicle ~= nil then - local ok, err = pcall(dispatchOne, adEnv, order, vehicle, silosById, storesById) - if not ok then - loans[vehicle] = nil - Logging.warning(ADFieldSilos.LOG_PREFIX .. "kunne ikke sende '%s': %s", tostring(ADRuns.vehicleLabel(vehicle)), tostring(err)) - end - end - end - return plan -end - -function ADFieldSilos.tick(adEnv, dt) - if adEnv == nil then return end - ADFieldSilos.adEnv = adEnv - nowMs = nowMs + (dt or 0) - checkTimerMs = checkTimerMs + (dt or 0) - if checkTimerMs >= ADFieldSilos.CHECK_INTERVAL_MS then - checkTimerMs = 0 - checkLoans(adEnv) - end - planTimerMs = planTimerMs + (dt or 0) - if planTimerMs >= ADFieldSilos.PLAN_INTERVAL_MS then - planTimerMs = 0 - ADFieldSilos.planNow(adEnv) - end -end - --- ---- gem / indlæs (samme sti-opslag som runs) ---- - -local function getPath() - local path = ADRunsStorage ~= nil and ADRunsStorage.getPath() or nil - if path == nil then return nil end - return (path:gsub(ADRunsStorage.FILE_NAME .. "$", ADFieldSilos.FILE_NAME)) -end - -function ADFieldSilos.save() - local path = getPath() - if path == nil then return false end - local xmlFile = XMLFile.create("ADSmartPickupFieldSilos", path, ADFieldSilos.ROOT) - if xmlFile == nil then return false end - local record = ADFieldSiloConfig.toRecord(ADFieldSilos.config) - xmlFile:setInt(ADFieldSilos.ROOT .. "#enabled", record.enabled) - xmlFile:setString(ADFieldSilos.ROOT .. "#stores", record.stores) - xmlFile:setInt(ADFieldSilos.ROOT .. "#maxVehicles", record.maxVehicles) - xmlFile:setInt(ADFieldSilos.ROOT .. "#minLiters", record.minLiters) - xmlFile:save() - xmlFile:delete() - return true -end - -function ADFieldSilos.load() - local path = getPath() - if path == nil or not fileExists(path) then - ADFieldSilos.config = ADFieldSiloConfig.new() - return - end - local xmlFile = XMLFile.load("ADSmartPickupFieldSilos", path) - if xmlFile == nil then - ADFieldSilos.config = ADFieldSiloConfig.new() - return - end - ADFieldSilos.config = ADFieldSiloConfig.fromRecord({ - enabled = xmlFile:getInt(ADFieldSilos.ROOT .. "#enabled"), - stores = xmlFile:getString(ADFieldSilos.ROOT .. "#stores"), - maxVehicles = xmlFile:getInt(ADFieldSilos.ROOT .. "#maxVehicles"), - minLiters = xmlFile:getInt(ADFieldSilos.ROOT .. "#minLiters"), - }) - xmlFile:delete() -end -``` - -- [ ] **Step 5: Wire tick, save, load, modDesc** - -`adSmartPickup.lua` — i `ADSmartPickup:update(dt)`, lige efter `ADFieldJobs.tick`-blokken: - -```lua - if ADFieldSilos ~= nil then - local okSilos, errSilos = pcall(ADFieldSilos.tick, getAutoDriveEnv(), dt) - if not okSilos then - Logging.warning(ADSmartPickup.LOG_PREFIX .. "marksilo-flow fejlede: %s", tostring(errSilos)) - end - end -``` - -`adRunsController.lua` — i `ADRunsController.save()` efter `pcall(ADFieldJobs.save)`-blokken: `if ADFieldSilos ~= nil then pcall(ADFieldSilos.save) end`; i `ADRunsController.load(adEnv)` efter `pcall(ADRunsController.migrateBuildings, adEnv)`: `if ADFieldSilos ~= nil then pcall(ADFieldSilos.load) end`. - -`modDesc.xml` — tilføj efter linjen med `adFieldWork.lua` i ``: - -```xml - - - -``` - -- [ ] **Step 6: Run all tests + syntax** - -Run: `for f in FS25_ADSmartPickup/*.lua; do luajit -bl "$f" >/dev/null || echo "SYNTAKS $f"; done; for t in tests/test_*.lua; do luajit $t >/dev/null 2>&1 || echo "FEJLER $t"; done` -Expected: ingen output. - -- [ ] **Step 7: Commit** - -```bash -git add FS25_ADSmartPickup/adFieldSilos.lua FS25_ADSmartPickup/adFieldWork.lua FS25_ADSmartPickup/adSmartPickup.lua FS25_ADSmartPickup/adRunsController.lua FS25_ADSmartPickup/modDesc.xml tests/test_adFieldSilos.lua -git commit -m "feat(marksilo): låner ledige tømmevogne hvert minut, AD hent-og-aflever til gårdlager, frigiver ved færdig/spiller/2 min stilstand; opsætning gemmes" -``` - ---- - -### Task 4: Underfanen "Marksiloer" - -**Files:** -- Create: `FS25_ADSmartPickup/gui/SmartPickupFieldSiloTab.lua` -- Modify: `FS25_ADSmartPickup/gui/SmartPickupFrame.lua` (konstanter, `rebuild`, `refreshButtons`, `refreshLive`, `onClickIngredient`-routing) -- Modify: `FS25_ADSmartPickup/adSmartPickup.lua` (`source(ADSmartPickup.dir .. "gui/SmartPickupFieldSiloTab.lua")` efter Month-tab) -- Modify: `FS25_ADSmartPickup/l10n/l10n_da.xml`, `l10n_en.xml` -- Modify: `FS25_ADSmartPickup/modDesc.xml` (version `1.36.0.0`) - -**Interfaces:** -- Consumes: `ADFieldSilos.config/setConfig/lastPlan/loanCount/planNow/save`, `ADFieldSiloConfig.with/toggleStore/hasStore/MIN_LITERS_CHOICES/MAX_VEHICLES`, `SmartPickupFrame:addToggleRow(kind, title, texts, isOn)`, `addTextRow`, `addCellRow(prefab, texts)`, `self.fieldPrefab`, `ADBuildings.list`. -- Produces: `SmartPickupFrame.TAB_FIELDSILOS = 5`, `SmartPickupFrame:fillFieldSiloRows(adEnv)`, `SmartPickupFrame:onClickFieldSiloRow(state, data)`, `SmartPickupFrame.FIELDSILO_ROW_KINDS`. - -- [ ] **Step 1: Tab constants in SmartPickupFrame.lua** — ret konstant-linjerne til: - -```lua -SmartPickupFrame.TAB_OVERVIEW, SmartPickupFrame.TAB_FLOWS, SmartPickupFrame.TAB_FIELDS, SmartPickupFrame.TAB_MONTH, SmartPickupFrame.TAB_FIELDSILOS = 1, 2, 3, 4, 5 -SmartPickupFrame.TAB_NAMES = {"spu_tab_overview", "spu_tab_flows", "spu_tab_fields", "spu_tab_month", "spu_tab_fieldsilos", "spu_tab_factories", "spu_tab_silos", "spu_tab_barns", "spu_tab_sheds"} -SmartPickupFrame.TAB_KINDS = {[6] = "factory", [7] = "silo", [8] = "husbandry", [9] = "objectStorage"} -``` - -`grep -n "TAB_KINDS\|\[4\]\|\[5\]\|TAB_MONTH" FS25_ADSmartPickup/gui/*.lua` og ret enhver hårdkodet fane-indeks-antagelse (fx `for tab = 4, 7`) til de nye tal. - -- [ ] **Step 2: Rebuild/buttons/live** — i `rebuild()` efter Month-blokken: - -```lua - if self.currentTab == SmartPickupFrame.TAB_FIELDSILOS then - if adEnv ~= nil then - self:fillFieldSiloRows(adEnv) - end - self.monthRebuildTicks = SmartPickupFrame.MONTH_REBUILD_TICKS - self:colorRows() - self.boxLayout:invalidateLayout() - self:refreshButtons() - self:restoreFocusIfLost() - return - end -``` - -i `refreshButtons()`: `elseif self.currentTab == SmartPickupFrame.TAB_FIELDSILOS then wanted = self.monthButtons`; i `refreshLive()` ret betingelsen for Month-genopbygning til `if self.currentTab == SmartPickupFrame.TAB_MONTH or self.currentTab == SmartPickupFrame.TAB_FIELDSILOS then`. - -I `onClickIngredient` før `FIELD_ROW_KINDS`-grenen: - -```lua - elseif data ~= nil and SmartPickupFrame.FIELDSILO_ROW_KINDS ~= nil and SmartPickupFrame.FIELDSILO_ROW_KINDS[data.kind] then - return self:onClickFieldSiloRow(state, data) -``` - -- [ ] **Step 3: Tab file** — `FS25_ADSmartPickup/gui/SmartPickupFieldSiloTab.lua`: - -```lua --- Smart Pickup — fanen Marksiloer --- Opsætning (til/fra, maks. vogne, mindste mængde, gårdlagre) og status for marksilo-flowet. --- Klik går til onClickFieldSiloRow; reglerne ligger i ADFieldSilos/ADFieldSiloPlanner. - -SmartPickupFrame.FIELDSILO_ROW_KINDS = {fsEnabled = true, fsMaxVehicles = true, fsMinLiters = true, fsStore = true} - -local function numberTexts(from, to) - local texts = {} - for n = from, to do table.insert(texts, tostring(n)) end - return texts -end - -local function literTexts() - local texts = {} - for _, liters in ipairs(ADFieldSiloConfig.MIN_LITERS_CHOICES) do table.insert(texts, string.format("%d l", liters)) end - return texts -end - -local function indexOf(list, value) - for index, entry in ipairs(list) do if entry == value then return index end end - return 1 -end - -function SmartPickupFrame:fillFieldSiloRows(adEnv) - local i18n, cfg = self.i18n, ADFieldSilos.config - local onOff = {i18n:getText("ui_no"), i18n:getText("ui_yes")} - self:addToggleRow("fsEnabled", i18n:getText("spu_fs_enabled"), onOff, cfg.enabled) - local maxOption = self:addToggleRow("fsMaxVehicles", i18n:getText("spu_fs_maxVehicles"), numberTexts(1, ADFieldSiloConfig.MAX_VEHICLES), false) - maxOption:setState(cfg.maxVehicles, false) - local minOption = self:addToggleRow("fsMinLiters", i18n:getText("spu_fs_minLiters"), literTexts(), false) - minOption:setState(indexOf(ADFieldSiloConfig.MIN_LITERS_CHOICES, cfg.minLiters), false) - - local plan = ADFieldSilos.lastPlan - local waiting = 0 - for _, entry in ipairs(plan ~= nil and plan.status or {}) do if entry.state == "ok" then waiting = waiting + 1 end end - self:addTextRow(string.format(i18n:getText("spu_fs_status"), ADFieldSilos.loanCount(), cfg.maxVehicles, waiting)) - if #cfg.storeIds == 0 then self:addTextRow(i18n:getText("spu_fs_noStores")) end - - self:addTextRow(i18n:getText("spu_fs_silosTitle")) - if plan == nil or #plan.status == 0 then - self:addTextRow(" " .. i18n:getText("spu_fs_nothing")) - else - self:addCellRow(self.fieldPrefab, {i18n:getText("spu_fs_col_silo"), i18n:getText("spu_col_crop"), i18n:getText("spu_fs_col_liters"), - i18n:getText("spu_fs_col_onWay"), i18n:getText("spu_fs_col_state"), ""}) - for _, entry in ipairs(plan.status) do - local desc = g_fillTypeManager:getFillTypeByIndex(entry.fillType) - local title = desc ~= nil and (desc.title or desc.name) or tostring(entry.fillType) - local state = i18n:getText("spu_fs_state_" .. entry.state) - if entry.state == "noFarmStore" then state = string.format(state, title) end - self:addCellRow(self.fieldPrefab, {tostring(entry.siloName), title, string.format("%d", math.floor(entry.liters)), - string.format("%d", math.floor(entry.onWay)), state, ""}) - end - end - - self:addTextRow(i18n:getText("spu_fs_storesTitle")) - local farmId = g_currentMission:getFarmId() - for _, building in ipairs(ADBuildings.list(adEnv, farmId)) do - if building.kind == "silo" then - local option = self:addToggleRow("fsStore", tostring(building.name), onOff, ADFieldSiloConfig.hasStore(cfg, building.id)) - self.rowData[option] = {kind = "fsStore", buildingId = building.id} - end - end -end - -function SmartPickupFrame:onClickFieldSiloRow(state, data) - self:safe(function(frame) - local cfg = ADFieldSilos.config - if data.kind == "fsEnabled" then - cfg = ADFieldSiloConfig.with(cfg, {enabled = state == SmartPickupFrame.STATE_ON}) - elseif data.kind == "fsMaxVehicles" then - cfg = ADFieldSiloConfig.with(cfg, {maxVehicles = state}) - elseif data.kind == "fsMinLiters" then - cfg = ADFieldSiloConfig.with(cfg, {minLiters = ADFieldSiloConfig.MIN_LITERS_CHOICES[state]}) - elseif data.kind == "fsStore" then - cfg = ADFieldSiloConfig.toggleStore(cfg, data.buildingId) - end - ADFieldSilos.setConfig(cfg) - pcall(ADFieldSilos.save) - local adEnv = frame.getAdEnv() - if adEnv ~= nil then pcall(ADFieldSilos.planNow, adEnv) end - frame:requestRebuild() - end) -end -``` - -(`addToggleRow` gemmer `{kind = kind}` i `rowData` via `addRow`; for gårdlager-rækkerne overskrives den med `buildingId` som ovenfor.) - -- [ ] **Step 4: l10n** — tilføj i `l10n_da.xml` (og engelsk i `l10n_en.xml`) inden ``: - -```xml - - - - - - - - - - - - - - - - - -``` - -Engelsk: `Field silos`, `Bring field silos home automatically`, `Max. vehicles out`, `Minimum amount per product`, `%d/%d vehicles out · %d silos waiting`, `Select at least one farm storage below — otherwise nothing is brought home`, `Field silos with products`, `No field silo has products above the minimum amount`, `Silo`, `Liters`, `On the way`, `Status`, `ok`, `missing marker`, `no farm storage takes %s`, `paused (did not get going)`, `Farm storages (where products are brought home to)`. - -- [ ] **Step 5: Source the tab file** — i `adSmartPickup.lua` efter `source(ADSmartPickup.dir .. "gui/SmartPickupMonthTab.lua")`: - -```lua - source(ADSmartPickup.dir .. "gui/SmartPickupFieldSiloTab.lua") -``` - -- [ ] **Step 6: Version, syntax, tests, build** - -`modDesc.xml`: `1.36.0.0`. -Run: `for f in FS25_ADSmartPickup/*.lua FS25_ADSmartPickup/gui/*.lua; do luajit -bl "$f" >/dev/null || echo "SYNTAKS $f"; done; for t in tests/test_*.lua; do luajit $t >/dev/null 2>&1 || echo "FEJLER $t"; done; xmllint --noout FS25_ADSmartPickup/l10n/*.xml FS25_ADSmartPickup/modDesc.xml` -Expected: ingen output. Byg: `rm -f FS25_ADSmartPickup.zip && (cd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip .) && cp FS25_ADSmartPickup.zip ~/FS25-data/mods/` - -- [ ] **Step 7: Commit** - -```bash -git add FS25_ADSmartPickup tests -git commit -m "feat(gui): fanen Marksiloer — til/fra, maks. vogne, mindste mængde, gårdlagre, status pr. silo (v1.36.0.0)" -``` - ---- - -## Self-Review (udført) - -- Spec-dækning: marksilo-definition (Task 3 `collectBuildings`), gårdlagre ekskluderet (Task 3), kvalificering/tærskel/rækkefølge/x/lagervalg (Task 1), lån/afsendelse/parkering/frigivelse/vagt/pause (Task 3), ikke-persistente lån (Task 3), markflåde-busy (Task 3 step 3), GUI + status + tekster (Task 4), opsætning gemmes (Task 2+3). Ingen huller. -- Navne: `ADFieldSiloPlanner.plan/key`, `ADFieldSiloConfig.new/with/toggleStore/hasStore/toRecord/fromRecord`, `ADFieldSilos.tick/isLoaned/setConfig/planNow/lastPlan/loanCount/save/load/releaseReason` — brugt ens i alle tasks. -- Review Focus-linjerne har tests i Task 1 (anden vare, reservation, flere varer) og Task 3 (spiller, frakobling via `setConfig` → `stopAll`; `releaseReason` "player"). diff --git a/docs/superpowers/plans/2026-09-28-maskinpulje-etape1.md b/docs/superpowers/plans/2026-09-28-maskinpulje-etape1.md deleted file mode 100644 index 6afa4fd..0000000 --- a/docs/superpowers/plans/2026-09-28-maskinpulje-etape1.md +++ /dev/null @@ -1,919 +0,0 @@ -# Maskinpulje etape 1 (v1.48.0.0) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Markarbejdet kan sende en ledig solo-traktor, der henter et frakoblet puljeredskab (plov, kultivator, -såmaskine …), udfører arbejdet med Courseplay, sætter redskabet af på dets AD-parkering og kører hjem. - -**Architecture:** Nyt modul `adPool.lua` (ren planner + tynd spil-adapter) finder puljeredskaber (frakoblet + -AD-parkering "X" + markør "Connect X") og puljetraktorer og parrer dem. `ADFieldWork.dispatch` bruger puljen som -fallback, når ingen færdig kombination er ledig; en ny fase `fetching` kører `ADHitch.fetch` og fortsætter så i -den eksisterende `driving`-fase. Når en opgave slutter, kører `ADHitch.returnHome` redskabet til dets parkering -(afkobling) og traktoren hjem. Marker, der venter, betjenes længst-ventende først. - -**Tech Stack:** Lua 5.1 (FS25-scripting), luajit til tests, AutoDrive 3.0.1.2 og Courseplay 8.1.0.3 API'er. - -**Spec:** `docs/superpowers/specs/2026-09-28-maskinpulje-design.md` - -## Global Constraints - -- Kun etape 1 (markarbejde). Tømmevogne ved høst (`decision.helper == true`) og marksilo-flowet bruger IKKE puljen i denne version. -- Puljeredskab = frakoblet + AD-parkering "X" (`advd.parkDestination >= 1`) + markør "Connect X" (store/små bogstaver ligegyldigt). -- Puljetraktor = motoriseret, intet koblet bag på, ikke i et flow (`ADRunsController.getRunForVehicle == nil`), ikke optaget, ikke styret af spilleren, egen AD-parkering, flueben i markflåden (`ADFieldFleet.isEnabled`). -- Færdige kombinationer foretrækkes altid; puljen kun når `pick`/`pickMany` intet giver. -- Redskab nærmest marken, derefter nærmeste traktor til redskabet med nok hk; manglende hk-data = tilladt. -- Karantæne for et redskab, der ikke kunne kobles på: 10 min (`ADPool.QUARANTINE_MS = 600000`). -- En traktor køres kun hjem, når et af mod'ens egne jobs slutter — aldrig af sig selv. -- Flows (runs) rører aldrig puljen. -- Kommentarer og logtekster på dansk, som resten af koden. Ingen Co-Authored-By i commits. Push kun til remote `gitea`. -- Tests køres fra repo-roden: `luajit tests/test_.lua` — output slutter med `ALLE TESTS OK`. -- Build: `cd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip .`; deploy mens spillet kører: `cp` til `~/FS25-data/mods/.FS25_ADSmartPickup.new` og `mv` på plads. - -## Review Focus - -- **To marker vil have samme plov samtidig** → kun den første får den; den anden venter (reservation) — testet i Task 1 (`pairFor` springer reserverede over). -- **Redskab uden hk-data eller traktor uden motor-data** → tillades (intet går i stå) — testet i Task 1 (`powerOk` nil-tilfælde, `numberOf` af tabel-specs). -- **Traktor på hjemtur bliver valgt til et nyt job** → må ikke ske; den er optaget til den er hjemme — testet i Task 3 (`ADPool.isReturning`) og brugt i `getRigs`/`poolTractors`. -- **Spilleren kobler selv et puljeredskab på og kører** → traktoren køres ikke hjem af mod'en — dækket af, at `returnHome` kun kaldes fra `finishAssignment`/fejlveje (Task 5); ingen tick-regel. -- **Kobling fejler (trækøje står skævt)** → traktor hjem, redskab i karantæne, marken prøver igen — testet i Task 1 (karantæne i `pairFor`) og Task 4 (fejlvej). - ---- - -### Task 1: `adPool.lua` — ren planlægning - -**Files:** -- Create: `FS25_ADSmartPickup/adPool.lua` -- Test: `tests/test_adPool.lua` - -**Interfaces:** -- Consumes: `ADFieldFleet.roleFromSpecs(specs)` (adFieldFleet.lua:27), `ADFieldModel.checkWidth(role, width, requiredWidth)` (bruges via `accept`), `ADHitch.parseMarker(name)` (adHitch.lua). -- Produces: - - `ADPool.roleOf(specs) -> role|nil` (specs = `ADCpBridge.getSpecs`-tabel; `unloader` når `specs.spec_trailer and specs.spec_dischargeable` og ingen anden rolle). - - `ADPool.numberOf(value) -> number|nil` - - `ADPool.powerOk(tractorHp, neededHp) -> bool` - - `ADPool.connectFor(parkName, markers) -> marker|nil` (markers = `{{name, id}}`) - - `ADPool.pairFor(role, fieldX, fieldZ, implements, tractors, blocked, accept) -> pair | nil, why, args` - - implement = `{id, name, role, roles?, x, z, width, neededHp, connectId, parkId}` - - tractor = `{id, name, x, z, hp, parkId}` - - blocked = `{[id] = true}` (reserverede + karantæne, både redskab- og traktor-id) - - pair = `{implement = implement, tractor = tractor}` - - why ∈ `noPoolImplement` / `noPoolTractor` / `tooWeak` (args = `{neededHp}`) - - `ADPool.queueOrder(infos, waitingSince) -> infos (ny tabel)` - - `ADPool.reserve(owner, ids)`, `ADPool.release(owner)`, `ADPool.quarantine(id, nowMs)`, `ADPool.blocked(nowMs) -> {[id]=true}` - -- [ ] **Step 1: Write the failing test** - -Create `tests/test_adPool.lua`: - -```lua --- Kør: luajit tests/test_adPool.lua (fra repo-roden) -dofile("FS25_ADSmartPickup/adFieldFleet.lua") -dofile("FS25_ADSmartPickup/adHitch.lua") -dofile("FS25_ADSmartPickup/adPool.lua") -local P = ADPool -local failures = 0 -local function check(name, actual, expected) - if actual == expected then print("OK " .. name) else - failures = failures + 1 - print(string.format("FAIL %s: forventede %s, fik %s", name, tostring(expected), tostring(actual))) - end -end --- rolle ud fra redskabets egne specs -check("rolle: plov", P.roleOf({spec_plow = true}), "plow") -check("rolle: kultivator", P.roleOf({spec_cultivator = true}), "cultivator") -check("rolle: vogn med tip", P.roleOf({spec_trailer = true, spec_dischargeable = true}), "unloader") -check("rolle: vogn der også er læssevogn er læssevogn", P.roleOf({spec_trailer = true, spec_dischargeable = true, spec_forageWagon = true}), "collector") -check("rolle: intet", P.roleOf({}), nil) --- hk fra butiksdata: tal eller tabel (konfigurationer) -check("tal", P.numberOf(180), 180) -check("tabel: største", P.numberOf({base = 120, config = 150}), 150) -check("tom tabel", P.numberOf({}), nil) -check("nil", P.numberOf(nil), nil) -check("hk: nok", P.powerOk(200, 180), true) -check("hk: for lidt", P.powerOk(150, 180), false) -check("hk: redskab uden data", P.powerOk(150, nil), true) -check("hk: traktor uden data", P.powerOk(nil, 180), true) --- Connect-markør til en parkering -local markers = {{name = "Start", id = 1}, {name = "Bredal", id = 4}, {name = "Connect Bredal", id = 9}, {name = "connect plov 1", id = 12}} -check("connect: Bredal", P.connectFor("Bredal", markers).id, 9) -check("connect: små bogstaver", P.connectFor("Plov 1", markers).id, 12) -check("connect: ingen", P.connectFor("Start", markers), nil) -check("connect: uden parkering", P.connectFor(nil, markers), nil) --- parring: redskab nærmest marken, så traktor nærmest redskabet -local implements = { - {id = "plovFar", name = "Plov far", role = "plow", x = 500, z = 0, connectId = 20, parkId = 21}, - {id = "plovNear", name = "Plov nær", role = "plow", x = 100, z = 0, neededHp = 180, connectId = 30, parkId = 31}, - {id = "kult", name = "Kultivator", role = "cultivator", x = 0, z = 0, connectId = 40, parkId = 41}, -} -local tractors = { - {id = "small", name = "Lille", x = 110, z = 0, hp = 120, parkId = 2}, - {id = "big", name = "Stor", x = 300, z = 0, hp = 250, parkId = 3}, - {id = "mid", name = "Mellem", x = 150, z = 0, hp = 190, parkId = 5}, -} -local pair = P.pairFor("plow", 0, 0, implements, tractors, {}) -check("par: nærmeste plov til marken", pair.implement.id, "plovNear") -check("par: nærmeste stærke traktor til ploven", pair.tractor.id, "mid") -local blockedPair = P.pairFor("plow", 0, 0, implements, tractors, {plovNear = true}) -check("par: reserveret plov springes over", blockedPair.implement.id, "plovFar") -check("par: plov uden hk-krav tager nærmeste traktor", blockedPair.tractor.id, "big") -local none, why = P.pairFor("seeder", 0, 0, implements, tractors, {}) -check("par: ingen såmaskine", none, nil); check("par: grund", why, "noPoolImplement") -local weak, weakWhy, weakArgs = P.pairFor("plow", 0, 0, {implements[2]}, {tractors[1]}, {}) -check("par: for svag", weak, nil); check("par: grund tooWeak", weakWhy, "tooWeak"); check("par: hk i args", weakArgs[1], 180) -local noTractor, noTractorWhy = P.pairFor("plow", 0, 0, implements, {}, {}) -check("par: ingen traktor", noTractor, nil); check("par: grund noPoolTractor", noTractorWhy, "noPoolTractor") -local rejected, rejectedWhy = P.pairFor("plow", 0, 0, implements, tractors, {}, function() return false end) -check("par: accept afviser alle", rejected, nil); check("par: grund", rejectedWhy, "noPoolImplement") -local multi = P.pairFor("fertilizer", 0, 0, {{id = "spreder", role = "fertilizer", roles = {fertilizer = true, liming = true}, x = 0, z = 0}}, tractors, {}) -check("par: redskab med flere roller", multi.implement.id, "spreder") --- reservationer og karantæne -P.reserve("F12", {"plovNear", "mid"}) -check("reserveret: redskab", P.blocked(0).plovNear, true) -check("reserveret: traktor", P.blocked(0).mid, true) -P.release("F12") -check("frigivet", P.blocked(0).plovNear, nil) -P.quarantine("plovNear", 1000) -check("karantæne: aktiv", P.blocked(1000 + P.QUARANTINE_MS - 1).plovNear, true) -check("karantæne: udløbet", P.blocked(1000 + P.QUARANTINE_MS).plovNear, nil) --- kø: længst ventende først, lige -> lavest mark-id, ikke-ventende bagerst i oprindelig rækkefølge -local infos = {{id = 5}, {id = 3}, {id = 9}, {id = 1}} -local ordered = P.queueOrder(infos, {[9] = 100, [3] = 50, [1] = 50}) -check("kø: 1.", ordered[1].id, 1); check("kø: 2.", ordered[2].id, 3); check("kø: 3.", ordered[3].id, 9); check("kø: 4.", ordered[4].id, 5) -check("kø: rører ikke input", infos[1].id, 5) -print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) -os.exit(failures == 0 and 0 or 1) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `luajit tests/test_adPool.lua` -Expected: fejl `cannot open FS25_ADSmartPickup/adPool.lua` - -- [ ] **Step 3: Write minimal implementation** - -Create `FS25_ADSmartPickup/adPool.lua`: - -```lua --- AD Pool --- Maskinpulje: plove, kultivatorer, såmaskiner … holder frakoblet på deres AD-parkering "X", og en ledig --- solo-traktor henter dem via markøren "Connect X", når markarbejdet mangler en færdig kombination. --- Ren logik øverst (testes med luajit), spil-adapter nederst. - -ADPool = {} -ADPool.LOG_PREFIX = "ADSmartPickup: pulje: " -ADPool.QUARANTINE_MS = 600000 -- redskab der ikke kunne kobles på, springes over så længe - --- ---- ren logik ---- - --- Rolle for et frakoblet redskab ud fra dets egne specs (samme prioritet som markflåden); en ren vogn med --- tip/snegl er tømmevogn. -function ADPool.roleOf(specs) - local role = ADFieldFleet.roleFromSpecs(specs) - if role ~= nil and role ~= "unloader" then return role end - if specs.spec_trailer and specs.spec_dischargeable then return "unloader" end - return role -end - --- Butiksdata er enten et tal eller en tabel pr. konfiguration: største tal, ellers nil. -function ADPool.numberOf(value) - if type(value) == "number" then return value end - if type(value) ~= "table" then return nil end - local best = nil - for _, entry in pairs(value) do - if type(entry) == "number" and (best == nil or entry > best) then best = entry end - end - return best -end - --- Mangler et af tallene, tillades det (intet må gå i stå på manglende data). -function ADPool.powerOk(tractorHp, neededHp) - if tractorHp == nil or neededHp == nil or tractorHp <= 0 or neededHp <= 0 then return true end - return tractorHp >= neededHp -end - --- Markøren "Connect " for en parkering (navneregel). -function ADPool.connectFor(parkName, markers) - if type(parkName) ~= "string" then return nil end - local wanted = parkName:lower() - for _, marker in pairs(markers or {}) do - local action, target = ADHitch.parseMarker(marker.name) - if action == "attach" and target ~= nil and target:lower() == wanted then return marker end - end - return nil -end - -local function hasRole(item, role) - return item.role == role or (item.roles ~= nil and item.roles[role] == true) -end - -local function distanceSq(ax, az, bx, bz) - return (ax - bx) ^ 2 + (az - bz) ^ 2 -end - -local function nearest(items, x, z, keep) - local best, bestDistance = nil, math.huge - for _, item in ipairs(items) do - if keep(item) then - local distance = distanceSq(item.x, item.z, x, z) - if distance < bestDistance then best, bestDistance = item, distance end - end - end - return best -end - --- Redskab med rollen nærmest marken, derefter nærmeste ledige traktor med nok hk til redskabet. --- blocked = {[id] = true} (reserverede og redskaber i karantæne). accept (valgfri): implement -> bool. -function ADPool.pairFor(role, fieldX, fieldZ, implements, tractors, blocked, accept) - blocked = blocked or {} - local free = {} - for _, tractor in ipairs(tractors or {}) do - if not blocked[tractor.id] then table.insert(free, tractor) end - end - local candidates = {} - for _, implement in ipairs(implements or {}) do - if hasRole(implement, role) and not blocked[implement.id] and (accept == nil or accept(implement)) then - table.insert(candidates, implement) - end - end - if #candidates == 0 then return nil, "noPoolImplement" end - if #free == 0 then return nil, "noPoolTractor" end - table.sort(candidates, function(a, b) - return distanceSq(a.x, a.z, fieldX, fieldZ) < distanceSq(b.x, b.z, fieldX, fieldZ) - end) - local weakest = nil - for _, implement in ipairs(candidates) do - local tractor = nearest(free, implement.x, implement.z, function(t) return ADPool.powerOk(t.hp, implement.neededHp) end) - if tractor ~= nil then return {implement = implement, tractor = tractor} end - if weakest == nil or (implement.neededHp or 0) < weakest then weakest = implement.neededHp end - end - return nil, "tooWeak", {weakest or 0} -end - --- Ventende marker først (længst ventende først, lige -> lavest id); resten bagefter i oprindelig rækkefølge. -function ADPool.queueOrder(infos, waitingSince) - local waiting, rest = {}, {} - for _, info in ipairs(infos or {}) do - if waitingSince ~= nil and waitingSince[info.id] ~= nil then table.insert(waiting, info) else table.insert(rest, info) end - end - table.sort(waiting, function(a, b) - local wa, wb = waitingSince[a.id], waitingSince[b.id] - if wa ~= wb then return wa < wb end - return a.id < b.id - end) - for _, info in ipairs(rest) do table.insert(waiting, info) end - return waiting -end - --- Reservationer (i hukommelsen): owner -> {id, ...}. Karantæne: id -> udløbstid. -local reservations, quarantined = {}, {} - -function ADPool.reserve(owner, ids) - reservations[owner] = ids -end - -function ADPool.release(owner) - reservations[owner] = nil -end - -function ADPool.quarantine(id, nowMs) - quarantined[id] = nowMs + ADPool.QUARANTINE_MS -end - -function ADPool.blocked(nowMs) - local result = {} - for _, ids in pairs(reservations) do - for _, id in ipairs(ids) do result[id] = true end - end - for id, untilMs in pairs(quarantined) do - if nowMs < untilMs then result[id] = true end - end - return result -end -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `luajit tests/test_adPool.lua` -Expected: sidste linje `ALLE TESTS OK` - -- [ ] **Step 5: Commit** - -```bash -git add FS25_ADSmartPickup/adPool.lua tests/test_adPool.lua -git commit -m "feat(pulje): ren planlægning — rolle, hk-tjek, Connect-navneregel, parring, kø, reservation/karantæne" -``` - ---- - -### Task 2: `adPool.lua` — spil-adapter (indsamling af puljen) - -**Files:** -- Modify: `FS25_ADSmartPickup/adPool.lua` (tilføj nederst) -- Modify: `FS25_ADSmartPickup/modDesc.xml` (registrér `adPool.lua` efter `adHitch.lua`) - -**Interfaces:** -- Consumes: `ADCpBridge.getSpecs(vehicle)` (adCpBridge.lua:153), `ADCpBridge.getWorkWidth(vehicle)` (:363), `ADRunsController.getFarmVehicles(adEnv)`, `ADRunsController.getRunForVehicle(vehicle)`, `ADRunsController.getVehicleId(vehicle)`, `ADRuns.vehicleLabel(vehicle)`, `ADFieldFleet.isEnabled(mode, fleet, id)`, `ADFieldJobs.state`, `ADHitch.rearImplementsOf(vehicle)` (Task 3), `ADPool.isReturning(vehicle)` (Task 3), `adEnv.ADGraphManager:getMapMarkers()`, `:getMapMarkerById(index)`, `g_currentMission.vehicleSystem.vehicles`. -- Produces: `ADPool.collect(adEnv, isBusy) -> implements, tractors` (formater som Task 1; implement har også `.vehicle`, tractor har `.vehicle`); `isBusy(vehicle) -> bool` gives af kalderen (markarbejdets optaget-regel). - -- [ ] **Step 1: Tilføj adapteren** - -Tilføj nederst i `FS25_ADSmartPickup/adPool.lua`: - -```lua --- ---- spil ---- - -local function parkMarker(adEnv, vehicle) - local index = vehicle.advd ~= nil and vehicle.advd.parkDestination or nil - if type(index) ~= "number" or index < 1 then return nil end - return adEnv.ADGraphManager:getMapMarkerById(index) -end - -local function positionOf(vehicle) - local x, _, z = getWorldTranslation(vehicle.rootNode) - return x, z -end - --- Butiksdata: redskabets krævede effekt (hk). specs indlæses først, når butikken har vist varen. -local function neededHpOf(vehicle) - if g_storeManager == nil or vehicle.configFileName == nil then return nil end - local item = g_storeManager:getItemByXMLFilename(vehicle.configFileName) - if item == nil then return nil end - if item.specs == nil and StoreItemUtil ~= nil and StoreItemUtil.loadSpecsFromXML ~= nil then - pcall(StoreItemUtil.loadSpecsFromXML, item) - end - return item.specs ~= nil and ADPool.numberOf(item.specs.neededPower) or nil -end - --- Traktorens effekt i hk: motorens topeffekt (kW) omregnet; ellers butiksdata. -local function tractorHpOf(vehicle) - local motor = vehicle.spec_motorized ~= nil and vehicle.spec_motorized.motor or nil - local kw = motor ~= nil and motor.peakMotorPower or nil - if type(kw) == "number" and kw > 0 then return kw * 1.35962 end - if g_storeManager == nil or vehicle.configFileName == nil then return nil end - local item = g_storeManager:getItemByXMLFilename(vehicle.configFileName) - return item ~= nil and item.specs ~= nil and ADPool.numberOf(item.specs.power) or nil -end - -local function isAttached(vehicle) - return vehicle.getAttacherVehicle ~= nil and vehicle:getAttacherVehicle() ~= nil -end - --- Puljeredskaber (frakoblet, parkering "X", markør "Connect X") og puljetraktorer. --- isBusy(vehicle): markarbejdets optaget-regel (opgave, AD/CP kører, lånt af marksilo, på hjemtur). -function ADPool.collect(adEnv, isBusy) - local markers = adEnv.ADGraphManager:getMapMarkers() - local farmId = g_currentMission:getFarmId() - local implements, tractors = {}, {} - -- spillets egen liste: frakoblede redskaber er ikke nødvendigvis i AutoDrive's køretøjsliste - for _, vehicle in ipairs(g_currentMission.vehicleSystem.vehicles) do - local owned = vehicle.getOwnerFarmId ~= nil and vehicle:getOwnerFarmId() == farmId - if owned and vehicle.spec_motorized == nil and vehicle.spec_attachable ~= nil and not isAttached(vehicle) then - local park = parkMarker(adEnv, vehicle) - local connect = park ~= nil and ADPool.connectFor(park.name, markers) or nil - local specs = connect ~= nil and ADCpBridge.getSpecs(vehicle) or nil - local role = specs ~= nil and ADPool.roleOf(specs) or nil - if role ~= nil then - local x, z = positionOf(vehicle) - table.insert(implements, { - id = ADRunsController.getVehicleId(vehicle), vehicle = vehicle, name = ADRuns.vehicleLabel(vehicle), - role = role, roles = specs.sprayRoles, x = x, z = z, width = ADCpBridge.getWorkWidth(vehicle), - neededHp = neededHpOf(vehicle), connectId = connect.id, parkId = park.id, - }) - end - end - end - local state = ADFieldJobs.state - for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do - local id = ADRunsController.getVehicleId(vehicle) - local park = parkMarker(adEnv, vehicle) - local controlled = vehicle.getIsControlled ~= nil and vehicle:getIsControlled() - if id ~= nil and park ~= nil and vehicle.spec_attacherJoints ~= nil and #ADHitch.rearImplementsOf(vehicle) == 0 - and ADRunsController.getRunForVehicle(vehicle) == nil and not controlled and not isBusy(vehicle) - and not ADPool.isReturning(vehicle) and ADFieldFleet.isEnabled(state.fleetMode, state.fleet, id) then - local x, z = positionOf(vehicle) - table.insert(tractors, {id = id, vehicle = vehicle, name = ADRuns.vehicleLabel(vehicle), x = x, z = z, - hp = tractorHpOf(vehicle), parkId = park.id}) - end - end - return implements, tractors -end -``` - -- [ ] **Step 2: Registrér modulet** - -I `FS25_ADSmartPickup/modDesc.xml` efter linjen ``: - -```xml - -``` - -- [ ] **Step 3: Syntakstjek og tests** - -Run: `luajit -bl FS25_ADSmartPickup/adPool.lua >/dev/null && echo syntaks-ok && luajit tests/test_adPool.lua | tail -1` -Expected: `syntaks-ok` og `ALLE TESTS OK` - -- [ ] **Step 4: Commit** - -```bash -git add FS25_ADSmartPickup/adPool.lua FS25_ADSmartPickup/modDesc.xml -git commit -m "feat(pulje): spil-adapter — frakoblede redskaber med parkering+Connect og ledige solo-traktorer med hk" -``` - ---- - -### Task 3: `adHitch.lua` — hjemtur og "på hjemtur"-status - -**Files:** -- Modify: `FS25_ADSmartPickup/adHitch.lua` -- Modify: `FS25_ADSmartPickup/adPool.lua` (isReturning-register) -- Test: `tests/test_adHitch.lua`, `tests/test_adPool.lua` - -**Interfaces:** -- Consumes: `ADHitch.route(adEnv, vehicle, markerIds, onDone)` (findes), `attachedImplements`/`rearImplements` (findes i adHitch.lua). -- Produces: - - `ADHitch.homeRoute(implementParkIds, tractorParkId) -> {id, ...}` (ren) - - `ADHitch.rearImplementsOf(vehicle) -> {{object, z, name}}` (offentlig udgave af den lokale) - - `ADHitch.returnHome(adEnv, tractor, onDone) -> bool` (false = intet puljeredskab bag / ingen parkering) - - `ADPool.setReturning(vehicle, isOn)`, `ADPool.isReturning(vehicle) -> bool` - -- [ ] **Step 1: Write the failing tests** - -Tilføj i `tests/test_adHitch.lua` før `print(failures == 0`: - -```lua --- hjemtur: hvert puljeredskabs parkering (afkobles der), så traktorens egen parkering -local home = H.homeRoute({4}, 2) -check("hjem: to trin", #home, 2); check("hjem: redskab først", home[1], 4); check("hjem: traktor sidst", home[2], 2) -check("hjem: uden redskab kun traktor", #H.homeRoute({}, 2), 1) -check("hjem: samme parkering to gange tæller én", #H.homeRoute({4, 4}, 2), 2) -check("hjem: uden traktorparkering", #H.homeRoute({4}, nil), 1) -``` - -Tilføj i `tests/test_adPool.lua` før `print(failures == 0`: - -```lua --- på hjemtur: optaget indtil hjemme -local tractorObject = {} -check("hjemtur: ikke fra start", P.isReturning(tractorObject), false) -P.setReturning(tractorObject, true) -check("hjemtur: markeret", P.isReturning(tractorObject), true) -P.setReturning(tractorObject, false) -check("hjemtur: hjemme", P.isReturning(tractorObject), false) -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `luajit tests/test_adHitch.lua | tail -3; luajit tests/test_adPool.lua | tail -3` -Expected: fejl `attempt to call field 'homeRoute' (a nil value)` og `attempt to call field 'isReturning'` - -- [ ] **Step 3: Implement** - -I `FS25_ADSmartPickup/adPool.lua`, efter `ADPool.blocked`: - -```lua --- Traktorer på hjemtur (sætter redskab af, kører hjem): optaget for markarbejdet til de er hjemme. -local returning = setmetatable({}, {__mode = "k"}) - -function ADPool.setReturning(vehicle, isOn) - returning[vehicle] = isOn and true or nil -end - -function ADPool.isReturning(vehicle) - return returning[vehicle] == true -end -``` - -I `FS25_ADSmartPickup/adHitch.lua`, i den rene del (efter `ADHitch.waitAfter`): - -```lua --- Hjemtur: hvert puljeredskabs parkering (der sættes det af), så traktorens egen parkering. -function ADHitch.homeRoute(implementParkIds, tractorParkId) - local route, seen = {}, {} - for _, id in ipairs(implementParkIds or {}) do - if not seen[id] then seen[id] = true table.insert(route, id) end - end - if tractorParkId ~= nil then table.insert(route, tractorParkId) end - return route -end -``` - -I spil-delen af `adHitch.lua`, lige efter `local function isImplementPark(...) ... end`: - -```lua -function ADHitch.rearImplementsOf(vehicle) - return ADHitch.rearImplements(attachedImplements(vehicle)) -end - -local function parkIdOf(adEnv, vehicle) - local index = vehicle.advd ~= nil and vehicle.advd.parkDestination or nil - local marker = type(index) == "number" and index >= 1 and adEnv.ADGraphManager:getMapMarkerById(index) or nil - return marker ~= nil and marker.id or nil -end -``` - -Efter `function ADHitch.route(...) ... end`: - -```lua --- Puljeredskaber bag traktoren sættes af på deres parkering, og traktoren kører hjem til sin egen. --- Kun redskaber med en Connect-markør til deres parkering er puljeredskaber; andre bliver siddende. -function ADHitch.returnHome(adEnv, tractor, onDone) - local markers = adEnv.ADGraphManager:getMapMarkers() - local parkIds = {} - for _, implement in ipairs(ADHitch.rearImplementsOf(tractor)) do - local parkId = parkIdOf(adEnv, implement.object) - local park = parkId ~= nil and adEnv.ADGraphManager:getMapMarkerByWayPointId(parkId) or nil - if park ~= nil and ADPool.connectFor(park.name, markers) ~= nil then table.insert(parkIds, parkId) end - end - if #parkIds == 0 then return false end - local route = ADHitch.homeRoute(parkIds, parkIdOf(adEnv, tractor)) - ADPool.setReturning(tractor, true) - ADHitch.route(adEnv, tractor, route, function(vehicle, ok, detail) - ADPool.setReturning(vehicle, false) - if onDone ~= nil then onDone(vehicle, ok, detail) end - end) - return true -end -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `luajit tests/test_adHitch.lua | tail -1; luajit tests/test_adPool.lua | tail -1; luajit -bl FS25_ADSmartPickup/adHitch.lua >/dev/null && echo syntaks-ok` -Expected: `ALLE TESTS OK` to gange og `syntaks-ok` - -- [ ] **Step 5: Commit** - -```bash -git add FS25_ADSmartPickup/adHitch.lua FS25_ADSmartPickup/adPool.lua tests/test_adHitch.lua tests/test_adPool.lua -git commit -m "feat(pulje): hjemtur — puljeredskaber sættes af på deres parkering, traktoren kører til sin egen; optaget imens" -``` - ---- - -### Task 4: Markarbejde — puljen som fallback og fasen `fetching` - -**Files:** -- Modify: `FS25_ADSmartPickup/adFieldWork.lua` (`getRigs` :132-170, `dispatch` :382-482, `follow` :1083-1143, `release` :278-283) -- Modify: `FS25_ADSmartPickup/l10n/l10n_da.xml`, `FS25_ADSmartPickup/l10n/l10n_en.xml` - -**Interfaces:** -- Consumes: `ADCpBridge.getSeedFruits/hasSeeds/isMechanicalWeeder(vehicle)`, `ADPool.collect(adEnv, isBusy)`, `ADPool.pairFor(...)`, `ADPool.blocked(nowMs)`, `ADPool.reserve/release/quarantine`, `ADPool.isReturning`, `ADHitch.fetch(adEnv, vehicle, markerId, onDone)`, `ADHitch.returnHome`. -- Produces: assignment-felter `pool = {implementId, implementName, connectId}`, `phase = "fetching"`, `hitch = {done=bool, ok=bool, detail=any}`; field-årsager `spu_field_reason_noPoolTractor`, `spu_field_reason_fetching` (tooWeak som fritekst). - -- [ ] **Step 1: Optaget-regel genbrugelig + hjemtur tæller som optaget** - -I `adFieldWork.lua` før `function ADFieldWork.getRigs`: - -```lua --- Optaget for markarbejdet: vores egen opgave, AutoDrive/Courseplay kører den, lånt af marksilo-flowet, --- eller på hjemtur fra puljen (sætter redskab af). -local function isBusyVehicle(vehicle, id) - return ADFieldJobs.state.assignments[id] ~= nil or isAdActive(vehicle) or ADCpBridge.isWorking(vehicle) - or (ADFieldSilos ~= nil and ADFieldSilos.isLoaned(vehicle)) or (ADPool ~= nil and ADPool.isReturning(vehicle)) -end -``` - -I `getRigs` erstat `busy = state.assignments[id] ~= nil or isAdActive(vehicle) or ADCpBridge.isWorking(vehicle)\n or (ADFieldSilos ~= nil and ADFieldSilos.isLoaned(vehicle)),` med: - -```lua - busy = isBusyVehicle(vehicle, id), -``` - -og tilføj som sidste alternativ i `busyWhy`-kæden (før `or nil`): - -```lua - or (ADPool ~= nil and ADPool.isReturning(vehicle) and "sætter redskab af") or nil, -``` - -- [ ] **Step 2: Fallback i dispatch** - -I `ADFieldWork.dispatch`, lige efter `if rigs == nil then` og FØR `if #offNetwork > 0 ...`, indsæt: - -```lua - -- ingen færdig kombination: en ledig solo-traktor henter et puljeredskab (kun hovedopgaver, ikke tømmevogne) - if not decision.helper and (decision.count or 1) == 1 and ADPool ~= nil - and (why == "noRig" or why == "allBusy" or why == "cannotDo") then - local pooled, poolWhy, poolArgs = ADFieldWork.dispatchPool(adEnv, field, info, role, roleAccept) - if pooled then return end - if poolWhy == "tooWeak" or (poolWhy == "noPoolTractor" and why ~= "allBusy") then - why, args = poolWhy, poolArgs - end - end -``` - -Tilføj før `function ADFieldWork.dispatch` en ny funktion: - -```lua --- Puljekombination: nærmeste puljeredskab til marken + nærmeste ledige traktor med nok hk. Traktoren kører til --- "Connect X", kobler på og fortsætter så som en almindelig udsendelse (fase fetching -> driving). -function ADFieldWork.dispatchPool(adEnv, field, info, role, roleAccept) - local implements, tractors = ADPool.collect(adEnv, function(vehicle) - return isBusyVehicle(vehicle, ADRunsController.getVehicleId(vehicle)) - end) - local accept = function(implement) - -- samme felter som getRigs giver sæt, så markens krav (frø, afgrøde, ukrudt) tjekkes på redskabet selv - local candidate = {role = implement.role, roles = implement.roles, vehicle = implement.vehicle} - if role == "seeder" then - candidate.seedFruits = ADCpBridge.getSeedFruits(implement.vehicle) - candidate.hasSeeds = ADCpBridge.hasSeeds(implement.vehicle) - end - if role == "weeder" then candidate.mechanical = ADCpBridge.isMechanicalWeeder(implement.vehicle) end - if roleAccept ~= nil and not roleAccept(candidate) then return false end - local required = ADFieldModel.requiredWidth(field, role) - return (implement.width or 0) <= 0 or ADFieldModel.checkWidth(role, implement.width, required) - end - local pair, why, args = ADPool.pairFor(role, info.x, info.z, implements, tractors, ADPool.blocked(now()), accept) - if pair == nil then return false, why, args end - local tractor, implement = pair.tractor, pair.implement - local assignment = { - vehicleId = tractor.id, vehicle = tractor.vehicle, fieldId = field.id, role = role, stage = field.stage, - helper = false, phase = "fetching", since = now(), width = implement.width, groupSize = 1, leader = true, - startX = tractor.x, startZ = tractor.z, - pool = {implementId = implement.id, implementName = implement.name, connectId = implement.connectId}, - hitch = {done = false}, - } - ADFieldJobs.state.assignments[tractor.id] = assignment - ADPool.reserve(assignment, {implement.id, tractor.id}) - ADHitch.fetch(adEnv, tractor.vehicle, implement.connectId, function(_, ok, detail) - assignment.hitch = {done = true, ok = ok, detail = detail} - end) - note(field.id, {reason = {key = "spu_field_reason_fetching", name = implement.name}}) - log(field.id, "%s: '%s' henter '%s' (%s)", role, tractor.name, implement.name, "pulje") - return true -end -``` - -- [ ] **Step 3: Frigiv reservation ved release** - -I `ADFieldWork.release`, før `ADFieldJobs.state.assignments[assignment.vehicleId] = nil`: - -```lua - if ADPool ~= nil and assignment.pool ~= nil then ADPool.release(assignment) end -``` - -- [ ] **Step 4: Fasen fetching i follow** - -I `ADFieldWork.follow`, efter linjen `wake(vehicle)` og før `if assignment.phase == "driving" then`, gør `if` til en kæde ved at indsætte: - -```lua - if assignment.phase == "fetching" then - local hitch = assignment.hitch - if not hitch.done then return end - ADPool.release(assignment) - if not hitch.ok then - -- koblingen fejlede: redskabet i karantæne, traktoren hjem, marken prøver igen - ADPool.quarantine(assignment.pool.implementId, now()) - ADFieldWork.release(assignment) - note(assignment.fieldId, {retryAt = now() + ADFieldWork.NO_RIG_RETRY_MS, - reason = {text = string.format("kunne ikke koble '%s' på (%s) — prøver et andet om lidt", - tostring(assignment.pool.implementName), tostring(hitch.detail))}}) - local park = parkMarkerId(adEnv, vehicle) - if park ~= nil then startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, park, nil) end - return - end - -- koblet på: bredden med redskabet på (for bred -> hjem igen) - local width = ADCpBridge.getWorkWidth(vehicle) - local okWidth, widthReason, widthArgs = ADFieldModel.checkWidth(assignment.role, width, ADFieldModel.requiredWidth(field, assignment.role)) - if not okWidth then - ADFieldWork.release(assignment) - note(assignment.fieldId, {retryAt = now() + ADFieldModel.RETRY_MS, reason = {key = "spu_field_reason_" .. tostring(widthReason), role = assignment.role, args = widthArgs}}) - ADHitch.returnHome(adEnv, vehicle) - return - end - assignment.width, assignment.phase, assignment.since = width, "driving", now() - assignment.startX, assignment.startZ = vehiclePosition(vehicle) - startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, info.markerId, nil) - assignment.drive = {mode = adEnv.AutoDrive.MODE_DRIVETO, first = info.markerId} - note(assignment.fieldId, {reason = false}) - log(assignment.fieldId, "%s '%s' har '%s' på — kører til %s (%.1f m)", assignment.role, ADRuns.vehicleLabel(vehicle), - tostring(assignment.pool.implementName), tostring(info.markerName), width or 0) - elseif assignment.phase == "driving" then -``` - -(og fjern det oprindelige `if assignment.phase == "driving" then`, som nu er `elseif`.) - -Tilføj også `fetching` til spiller-reglen øverst i `follow`, så AD's kørsel under hentning ikke tolkes som spillerens: `steeredByAi` er allerede sand, mens AD kører; ingen ændring nødvendig. - -- [ ] **Step 5: Tekster** - -`l10n/l10n_da.xml` (ved de andre `spu_field_reason_`): - -```xml - - - - -``` - -`l10n/l10n_en.xml`: - -```xml - - - - -``` - -`tooWeak` bruger fritekst (hk står før rollen, og `fieldReasonText` sætter rollen først): i Step 2 erstattes linjen -`why, args = poolWhy, poolArgs` med: - -```lua - if poolWhy == "tooWeak" then - note(field.id, {reason = {text = string.format("ingen ledig traktor med ≥%.0f hk til %s-redskabet", - (poolArgs or {})[1] or 0, tostring(role))}, retryAt = now() + ADFieldWork.NO_RIG_RETRY_MS}) - return - end - why, args = poolWhy, poolArgs -``` - -- [ ] **Step 6: Syntakstjek og alle tests** - -Run: `luajit -bl FS25_ADSmartPickup/adFieldWork.lua >/dev/null && echo syntaks-ok; for f in tests/test_*.lua; do luajit $f >/dev/null 2>&1 || echo "FEJL $f"; done; echo færdig` -Expected: `syntaks-ok`, ingen `FEJL`-linjer, `færdig` - -- [ ] **Step 7: Commit** - -```bash -git add FS25_ADSmartPickup/adFieldWork.lua FS25_ADSmartPickup/l10n/l10n_da.xml FS25_ADSmartPickup/l10n/l10n_en.xml -git commit -m "feat(pulje): markarbejdet sender en solo-traktor efter et puljeredskab når ingen færdig kombination er ledig (fase fetching)" -``` - ---- - -### Task 5: Markarbejde — hjemtur når opgaven slutter - -**Files:** -- Modify: `FS25_ADSmartPickup/adFieldWork.lua` (`finishAssignment` :834-862, fejlvejen i `follow` "nåede ikke" :1115-1120) - -**Interfaces:** -- Consumes: `ADHitch.returnHome(adEnv, tractor, onDone) -> bool`. -- Produces: ingen nye navne. - -- [ ] **Step 1: finishAssignment** - -Erstat i `finishAssignment`: - -```lua - local park = not assignment.adHandlesEnd and not assignment.manual and parkMarkerId(adEnv, vehicle) or nil - if park ~= nil then startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, park, nil) end -``` - -med: - -```lua - -- puljeredskab bag traktoren: sæt det af på sin parkering, traktoren hjem (afhænger kun af hvad der hænger - -- bag, så det virker også efter genindlæsning); ellers som før - local pooled = not assignment.adHandlesEnd and not assignment.manual and ADHitch ~= nil - and ADHitch.returnHome(adEnv, vehicle, function(_, ok, detail) - if not ok then log(assignment.fieldId, "'%s' kom ikke hjem fra puljen: %s", ADRuns.vehicleLabel(vehicle), tostring(detail)) end - end) - local park = not pooled and not assignment.adHandlesEnd and not assignment.manual and parkMarkerId(adEnv, vehicle) or nil - if park ~= nil then startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, park, nil) end - if pooled then log(assignment.fieldId, "'%s' sætter redskabet af og kører hjem", ADRuns.vehicleLabel(vehicle)) end -``` - -- [ ] **Step 2: "nåede ikke frem" og "tom" — også hjem med redskabet** - -I `follow`, i grenen der slutter med - -```lua - local park = parkMarkerId(adEnv, vehicle) - if park ~= nil then startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, park, nil) end -``` - -erstat de to linjer med: - -```lua - if not (ADHitch ~= nil and ADHitch.returnHome(adEnv, vehicle)) then - local park = parkMarkerId(adEnv, vehicle) - if park ~= nil then startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, park, nil) end - end -``` - -I `finishAssignment`, i "tom såmaskine/sprøjte"-grenen efter `ADFieldWork.release(assignment)`, indsæt: - -```lua - if ADHitch ~= nil then ADHitch.returnHome(adEnv, vehicle) end -``` - -- [ ] **Step 3: Syntakstjek og alle tests** - -Run: `luajit -bl FS25_ADSmartPickup/adFieldWork.lua >/dev/null && echo syntaks-ok; for f in tests/test_*.lua; do luajit $f >/dev/null 2>&1 || echo "FEJL $f"; done; echo færdig` -Expected: `syntaks-ok`, ingen `FEJL`, `færdig` - -- [ ] **Step 4: Commit** - -```bash -git add FS25_ADSmartPickup/adFieldWork.lua -git commit -m "feat(pulje): når opgaven slutter (eller fejler) sættes puljeredskabet af på sin parkering og traktoren kører hjem" -``` - ---- - -### Task 6: Kø — længst ventende mark først - -**Files:** -- Modify: `FS25_ADSmartPickup/adFieldJobs.lua` (`stepField` dispatch-gren ~:421-426, `tick` løkke ~:486) -- Test: `tests/test_adPool.lua` (dækket af `queueOrder`-tests i Task 1) - -**Interfaces:** -- Consumes: `ADPool.queueOrder(infos, waitingSince)`. -- Produces: `ADFieldJobs.waitingSince = {[fieldId] = nowMs}`. - -- [ ] **Step 1: Registrér ventetid** - -I `adFieldJobs.lua` ved siden af `ADFieldJobs.lastDecision = {}`: - -```lua --- Hvornår en mark begyndte at vente på en maskine (kø: længst ventende får næste ledige traktor). -ADFieldJobs.waitingSince = {} -``` - -I `stepField`, erstat dispatch-grenen: - -```lua - local before = ADFieldJobs.assignmentCount() - ADFieldWork.dispatch(adEnv, field, info, decision) - if ADFieldJobs.assignmentCount() > before then ADFieldJobs.lastDispatchAt = ADFieldJobs.now end -``` - -med: - -```lua - local before = ADFieldJobs.assignmentCount() - ADFieldWork.dispatch(adEnv, field, info, decision) - if ADFieldJobs.assignmentCount() > before then - ADFieldJobs.lastDispatchAt = ADFieldJobs.now - ADFieldJobs.waitingSince[info.id] = nil - elseif ADFieldJobs.waitingSince[info.id] == nil then - ADFieldJobs.waitingSince[info.id] = ADFieldJobs.now - end -``` - -I samme funktion, i grenen `decision.action == "advance"` efter `ADFieldJobs.setField(nextField)`: - -```lua - ADFieldJobs.waitingSince[info.id] = nil -``` - -- [ ] **Step 2: Kørsel i kø-rækkefølge** - -I `ADFieldJobs.tick` erstat `for index, info in ipairs(ADFields.list()) do` med: - -```lua - local infos = ADFields.list() - if ADPool ~= nil then infos = ADPool.queueOrder(infos, ADFieldJobs.waitingSince) end - for index, info in ipairs(infos) do -``` - -- [ ] **Step 3: Tests** - -Run: `for f in tests/test_*.lua; do luajit $f >/dev/null 2>&1 || echo "FEJL $f"; done; luajit -bl FS25_ADSmartPickup/adFieldJobs.lua >/dev/null && echo syntaks-ok` -Expected: ingen `FEJL`, `syntaks-ok` - -- [ ] **Step 4: Commit** - -```bash -git add FS25_ADSmartPickup/adFieldJobs.lua -git commit -m "feat(pulje): marker der venter på en maskine betjenes længst-ventende først" -``` - ---- - -### Task 7: Version, build, deploy og in-game test - -**Files:** -- Modify: `FS25_ADSmartPickup/modDesc.xml` (`1.47.4.0` → `1.48.0.0`) - -- [ ] **Step 1: Bump version, alle tests, build, deploy** - -```bash -cd ~/fs25-adsmartpickup -sed -i 's#1\.47\.4\.0#1.48.0.0#' FS25_ADSmartPickup/modDesc.xml -for f in tests/test_*.lua; do luajit $f >/dev/null 2>&1 || echo "FEJL $f"; done -rm -f FS25_ADSmartPickup.zip; (cd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip .) -cp FS25_ADSmartPickup.zip ~/FS25-data/mods/.FS25_ADSmartPickup.new && mv ~/FS25-data/mods/.FS25_ADSmartPickup.new ~/FS25-data/mods/FS25_ADSmartPickup.zip -unzip -p ~/FS25-data/mods/FS25_ADSmartPickup.zip modDesc.xml | grep -m1 '' -``` - -Expected: ingen `FEJL`, `1.48.0.0` - -- [ ] **Step 2: Commit og push** - -```bash -git add FS25_ADSmartPickup/modDesc.xml -git commit -m "chore: v1.48.0.0 — maskinpulje etape 1 (markarbejde)" -TOKEN=$(grep -A15 -i "^## Gitea" ~/credentials-vault.md | grep -oE "[a-f0-9]{40}" | head -1); git -c http.extraHeader="Authorization: Basic $(printf 'masterdraco:%s' "$TOKEN" | base64 -w0)" push -q gitea main -``` - -- [ ] **Step 3: In-game test (savegame4, Golden Lands) — spilleren udfører, vi læser loggen** - -Forudsætning: et redskab til markarbejde (fx kultivator/plov) med AD-parkering "X" og markør "Connect X" (bakkespor), Challengeren solo med egen AD-parkering, og en mark med markør der skal kultiveres/pløjes (plan auto eller Start). - -Forventet i `log.txt` (grep `ADSmartPickup`): -1. `F: cultivator: 'Challenger MT635' henter '' (pulje)` -2. `kobling: 'Challenger MT635' koblede '' på ved 'Connect X'` -3. `F: cultivator 'Challenger MT635' har '' på — kører til ` -4. CP arbejder; `F: cultivator '…' færdig` -5. `F: '…' sætter redskabet af og kører hjem`, `kobling: … koblede '' af ved 'X'`, `… er færdig med turen (2 trin)` - -Fejlscenarie: flyt redskabet 3 m væk fra sin plads → `kunne ikke koble … på` efter 8 s, traktoren kører hjem, fanen viser årsagen, redskabet springes over i 10 min. diff --git a/docs/superpowers/plans/2026-09-29-flow-field-list-edit-panel.md b/docs/superpowers/plans/2026-09-29-flow-field-list-edit-panel.md deleted file mode 100644 index ce950e2..0000000 --- a/docs/superpowers/plans/2026-09-29-flow-field-list-edit-panel.md +++ /dev/null @@ -1,690 +0,0 @@ -# Flows/Fields: farvet liste + redigeringsvindue — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Flows- og Fields-fanen viser kun en farvet liste med Start/Stop pr. række; klik på en række åbner et redigeringsvindue (panel i samme frame) med de nuværende redigeringslinjer. - -**Architecture:** Rene statusfunktioner (`ADFlowView.flowStatus`, `ADFieldView.fieldStatus`) afgør niveau/farve og testes med luajit. GUI: rækkehjælperne får et mål-layout (`self.rowTarget`), så den eksisterende resumé-/guide-kode kan tegne i vinduets egen `ScrollingLayout`. Vinduet er et skjult overlay i `SmartPickupFrame.xml`, styret af en ny mixin `gui/SmartPickupEditPanel.lua`. - -**Tech Stack:** FS25 Lua (GIANTS GUI: Bitmap/Text/ScrollingLayout, TabbedMenuFrameElement menuButtonInfo), luajit-tests i `tests/`. - -**Spec:** `docs/superpowers/specs/2026-09-29-flow-field-list-edit-panel-design.md` - -## Global Constraints - -- Ingen ændring af flow-/mark-logik, guiderne eller Oversigt-tavlen/flåde-visningen. -- Rækker er ≈835 px brede (som lagervisningen). -- Farver: grøn `{0.30, 0.75, 0.35, 1}` = running, gul `{0.95, 0.80, 0.25, 1}` = waiting, rød `{0.90, 0.35, 0.30, 1}` = error, grå `{0.55, 0.55, 0.55, 1}` = stopped. -- Al GUI-kode i `self:safe(...)`; ingen utilsigtede globals (`tests/test_noStrayGlobals.lua`). -- Byg kun når alle tests er grønne: `for t in tests/test_*.lua; do luajit $t >/dev/null || echo FAIL $t; done`. Zip: `cd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip .`, kopi til `~/FS25-data/mods/`, commit, push remote `gitea` main (Basic-auth header med token fra `~/credentials-vault.md` sektion Gitea, linje med "API-token"). -- l10n: nye tekster i både `l10n/l10n_da.xml` og `l10n/l10n_en.xml`, præfiks `spu_`. - -## Review Focus - -- Klik på listen bag et åbent vindue: skal intet gøre (vinduet ejer klik) — pinned af `makeRowClickable`-regel i Task 2 (klik tæller kun inde i rækkens eget layout, og listen ignorerer klik når vinduet er åbent). -- ESC med vindue åbent: lukker vinduet, ikke ESC-menuen; med et [Edit]-trin åbent: lukker trinnet først — Task 3. -- Flow/mark slettet mens vinduet er åbent: vinduet lukker sig med besked — Task 3 (`refreshEditPanel`). -- Start/Stop på en række for et andet flow end det valgte: må ikke påvirke det valgte — Task 4 (handling bærer sit eget id). -- Flow startet men alle traktorer afvist ("ingen vogn"): rød, ikke grå — pinned af test i Task 1. - ---- - -### Task 1: Rene statusfunktioner for flows (+ fælles farver) - -**Files:** -- Modify: `FS25_ADSmartPickup/adFlowView.lua` (tilføj i bunden) -- Test: `tests/test_flowStatus.lua` (ny) - -**Interfaces:** -- Produces: `ADFlowView.LEVEL_COLORS = {running={...}, waiting={...}, error={...}, stopped={...}}`; - `ADFlowView.flowStatus(s) -> level` hvor `s = {running=int, total=int, stopped=bool, canStop=bool, warning=string|nil, reason=string|nil, lastStartFailed=bool}` og `level ∈ {"running","waiting","error","stopped"}`. - -- [ ] **Step 1: Write the failing test** — `tests/test_flowStatus.lua`: - -```lua --- Kør: luajit tests/test_flowStatus.lua (fra repo-roden) -dofile("FS25_ADSmartPickup/adFlowView.lua") -local V = ADFlowView -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 -check("kører", V.flowStatus({running = 3, total = 4, canStop = true}), "running") -check("kører men holder (begrundelse)", V.flowStatus({running = 2, total = 2, canStop = true, reason = "intet at hente"}), "waiting") -check("startet, venter på vogn", V.flowStatus({running = 0, total = 2, canStop = true}), "waiting") -check("advarsel", V.flowStatus({running = 1, total = 2, canStop = true, warning = "mål mangler"}), "error") -check("start afvist (ingen vogn)", V.flowStatus({running = 0, total = 2, lastStartFailed = true}), "error") -check("stoppet", V.flowStatus({running = 0, total = 2, stopped = true}), "stopped") -check("aldrig startet", V.flowStatus({running = 0, total = 2}), "stopped") -check("stoppet vinder over gammel afvisning", V.flowStatus({running = 0, total = 2, stopped = true, lastStartFailed = true}), "stopped") -check("farver findes", V.LEVEL_COLORS.waiting ~= nil and V.LEVEL_COLORS.stopped ~= nil, true) -print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) -os.exit(failures == 0 and 0 or 1) -``` - -- [ ] **Step 2: Run** `luajit tests/test_flowStatus.lua` — Expected: FAIL (flowStatus nil). - -- [ ] **Step 3: Implement** — append to `adFlowView.lua`: - -```lua --- ---- status pr. flow (Flows-listen) ---- - -ADFlowView.LEVEL_COLORS = { - running = {0.30, 0.75, 0.35, 1}, - waiting = {0.95, 0.80, 0.25, 1}, - error = {0.90, 0.35, 0.30, 1}, - stopped = {0.55, 0.55, 0.55, 1}, -} - --- s = {running, total, stopped, canStop, warning, reason, lastStartFailed} -> "running"|"waiting"|"error"|"stopped" -function ADFlowView.flowStatus(s) - if s.stopped then return "stopped" end - if s.warning ~= nil then return "error" end - if (s.running or 0) == 0 and s.lastStartFailed then return "error" end - if (s.running or 0) > 0 and s.reason == nil then return "running" end - if (s.running or 0) > 0 or s.canStop then return "waiting" end - return "stopped" -end -``` - -- [ ] **Step 4: Run** all tests — Expected: `ALLE TESTS OK` in test_flowStatus, no FAIL elsewhere. -- [ ] **Step 5: Commit** `git add tests/test_flowStatus.lua FS25_ADSmartPickup/adFlowView.lua && git commit -m "feat(flows): ren statusfunktion + farver til Flows-listen"` - ---- - -### Task 2: Rækker kan tegnes i et andet layout - -**Files:** -- Modify: `FS25_ADSmartPickup/gui/SmartPickupFrame.lua` (`new`: felter; `addRow`; `clearRows`; `colorRows`) -- Modify: `FS25_ADSmartPickup/gui/SmartPickupFlowTab.lua` (`makeRowClickable`) - -**Interfaces:** -- Produces: `self.rowTarget` (standard `nil` = `self.boxLayout`); `SmartPickupFrame:withRowTarget(layout, fn)` kører `fn(self)` med rækker klonet ind i `layout`; `self.panelRows` (kloner i vinduet) adskilt fra `self.clonedRows`; `SmartPickupFrame:clearPanelRows()`; `SmartPickupFrame:colorLayout(layout)`. Hver klon får `row.spuLayout = layout`. - -- [ ] **Step 1: `addRow`** — replace body: - -```lua -function SmartPickupFrame:addRow(prefab, data) - local layout = self.rowTarget or self.boxLayout - local row = prefab:clone(layout) - row.spuLayout = layout - FocusManager:loadElementFromCustomValues(row) - table.insert(layout == self.boxLayout and self.clonedRows or self.panelRows, 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 - --- Rækker klonet ind i et andet layout (redigeringsvinduet) mens fn kører. -function SmartPickupFrame:withRowTarget(layout, fn) - local previous = self.rowTarget - self.rowTarget = layout - local ok, err = pcall(fn, self) - self.rowTarget = previous - if not ok then error(err, 0) end -end - -function SmartPickupFrame:clearPanelRows() - for _, row in ipairs(self.panelRows or {}) do - FocusManager:removeElement(row) - row:delete() - end - self.panelRows = {} -end -``` - -- [ ] **Step 2: `new`** — next to `clonedRows = {}` initialisation add `self.panelRows = {}` (find `clonedRows` in `SmartPickupFrame.new` and add on the same pattern). -- [ ] **Step 3: `colorRows`** — split into `colorLayout(layout)` and keep `colorRows()` calling `self:colorLayout(self.boxLayout)`: - -```lua -function SmartPickupFrame:colorLayout(layout) - local colors = InGameMenuSettingsFrame ~= nil and InGameMenuSettingsFrame.COLOR_ALTERNATING or nil - if colors == nil or layout == nil then return end - local unpackFn = table.unpack or unpack - local isEvenRow = false - for _, element in ipairs(layout.elements) do - if element.setImageColor ~= nil and colors[isEvenRow] ~= nil then - element:setImageColor(nil, unpackFn(colors[isEvenRow])) - end - isEvenRow = not isEvenRow - end -end - -function SmartPickupFrame:colorRows() - self:colorLayout(self.boxLayout) -end -``` - -- [ ] **Step 4: `makeRowClickable`** — clicks count inside the row's own layout, and list rows ignore clicks while the panel is open: - -```lua - local layout = element.spuLayout or frame.boxLayout - local blockedByPanel = frame.editPanelOpen and layout == frame.boxLayout - if not used and not eventUsed and not blockedByPanel and isUp and button == MOUSE_LEFT and (element.getIsVisible == nil or element:getIsVisible()) - and isInside(element, posX, posY) and isInside(layout, posX, posY) then -``` -(replace the existing `if not used and not eventUsed and isUp ...` condition lines; keep the body.) - -- [ ] **Step 5: Run all tests** — no FAIL (behaviour unchanged: `rowTarget` nil everywhere). -- [ ] **Step 6: Commit** `git commit -am "refactor(gui): rækker kan klones ind i et andet layout (redigeringsvindue)"` - ---- - -### Task 3: Redigeringsvinduet (overlay + mixin) - -**Files:** -- Modify: `FS25_ADSmartPickup/gui/SmartPickupFrame.xml` (overlay efter `boxLayout`) -- Modify: `FS25_ADSmartPickup/gui/guiProfiles.xml` (profiler) -- Create: `FS25_ADSmartPickup/gui/SmartPickupEditPanel.lua` -- Modify: `FS25_ADSmartPickup/adSmartPickup.lua` (source efter `SmartPickupFlowGuide.lua`) -- Modify: `FS25_ADSmartPickup/gui/SmartPickupFrame.lua` (`rebuild`: fyld vinduet; `refreshButtons`: vinduets knapper; `refreshLive`: `refreshEditPanel`; `onFrameClose`: luk vinduet) -- Modify: `l10n/l10n_da.xml`, `l10n/l10n_en.xml` - -**Interfaces:** -- Consumes: Task 2 (`withRowTarget`, `clearPanelRows`, `colorLayout`, `editPanelOpen`). -- Produces: `SmartPickupFrame:openEditPanel(kind, id)` (`kind ∈ {"flow","field"}`), `closeEditPanel()`, `fillEditPanel(adEnv)`, `refreshEditPanel(adEnv)`, felterne `self.editPanelOpen`, `self.editPanelKind`, `self.editPanelId`; hook-tabel `SmartPickupFrame.EDIT_PANEL_KINDS[kind] = {title(frame,id)->string, exists(frame,id)->bool, fill(frame,adEnv,id), status(frame,adEnv,id)->{text,level}, startStop(frame,id), startStopText(frame,adEnv,id)->string, canDelete=bool, delete(frame,id)}` — Task 4/7 registrerer "flow"/"field". - -- [ ] **Step 1: Profiles** — add to `guiProfiles.xml` (before ``): - -```xml - - - - - - - - - - - - - - -``` - -- [ ] **Step 2: XML overlay** — directly after `` of `boxLayout`: - -```xml - - - - - - - - - -``` - -- [ ] **Step 3: Mixin** — create `gui/SmartPickupEditPanel.lua`: - -```lua --- Smart Pickup — redigeringsvindue (v1.58) --- Klik på en række i Flows/Fields åbner et panel oven på listen. Indholdet tegnes af den eksisterende --- resumé-/guide-kode i vinduets egen ScrollingLayout (SmartPickupFrame:withRowTarget). Knapper i bunden via --- menuButtonInfo: Luk (MENU_BACK = ESC), Start/Stop, Slet. Hver art registrerer sig i EDIT_PANEL_KINDS. --- Funktionerne lægges på SmartPickupFrame (indlæses efter SmartPickupFrame.lua). - -SmartPickupFrame.EDIT_PANEL_KINDS = SmartPickupFrame.EDIT_PANEL_KINDS or {} -SmartPickupFrame.EDIT_PANEL_WIDTH, SmartPickupFrame.EDIT_PANEL_HEIGHT = 0.78, 0.86 -- andel af listens flade - -function SmartPickupFrame:initEditPanelButtons(i18n) - self.panelCloseButtonInfo = {inputAction = InputAction.MENU_BACK, text = i18n:getText("spu_panel_close"), callback = function() self:onClickPanelClose() end} - self.panelStartButtonInfo = {inputAction = InputAction.MENU_ACTIVATE, text = i18n:getText("spu_button_start"), callback = function() self:onClickPanelStartStop() end} - self.panelDeleteButtonInfo = {inputAction = InputAction.MENU_EXTRA_2, text = i18n:getText("spu_button_delete"), callback = function() self:onClickPanelDelete() end} - self.panelButtons = {self.panelCloseButtonInfo, self.panelStartButtonInfo, self.panelDeleteButtonInfo} -end - -function SmartPickupFrame:editPanelKind() - return self.editPanelOpen and SmartPickupFrame.EDIT_PANEL_KINDS[self.editPanelKind] or nil -end - --- Vinduet dækker listens flade; panelet er centreret i den. -function SmartPickupFrame:layoutEditPanel() - local box = self.boxLayout - local x, y, w, h = box.position[1], box.position[2], box.size[1], box.size[2] - self.editBackdrop:setPosition(x, y) - self.editBackdrop:setSize(w, h) - local pw, ph = w * SmartPickupFrame.EDIT_PANEL_WIDTH, h * SmartPickupFrame.EDIT_PANEL_HEIGHT - self.editPanel:setPosition(x + (w - pw) / 2, y - (h - ph) / 2) - self.editPanel:setSize(pw, ph) - local top = 80 * g_pixelSizeScaledY - self.editLayout:setSize(pw, ph - top) -end - -function SmartPickupFrame:openEditPanel(kind, id) - if SmartPickupFrame.EDIT_PANEL_KINDS[kind] == nil then return end - self.editPanelOpen, self.editPanelKind, self.editPanelId = true, kind, id - self:closeGuide() - self.editOverlay:setVisible(true) - self:layoutEditPanel() - self:requestRebuild() -end - -function SmartPickupFrame:closeEditPanel() - if not self.editPanelOpen then return end - self.editPanelOpen, self.editPanelKind, self.editPanelId = false, nil, nil - self:closeGuide() - self:clearPanelRows() - if self.editOverlay ~= nil then self.editOverlay:setVisible(false) end - self:requestRebuild() -end - --- Kaldes fra rebuild (efter listen): vinduets rækker genopbygges i editLayout. -function SmartPickupFrame:fillEditPanel(adEnv) - self:clearPanelRows() - local hooks = self:editPanelKind() - if hooks == nil then return end - if not hooks.exists(self, self.editPanelId) then - self.statusOverride = self.i18n:getText("spu_panel_gone") - self.statusOverrideTicks = SmartPickupFrame.STATUS_OVERRIDE_TICKS - self.editPanelOpen, self.editPanelKind, self.editPanelId = false, nil, nil - self.editOverlay:setVisible(false) - return - end - self.editTitle:setText(hooks.title(self, self.editPanelId)) - self:withRowTarget(self.editLayout, function(frame) - if frame.guide ~= nil then - frame:fillGuideRows(adEnv) - else - hooks.fill(frame, adEnv, frame.editPanelId) - end - end) - self:colorLayout(self.editLayout) - self.editLayout:invalidateLayout() - self:refreshEditPanel(adEnv) -end - --- Hvert sekund: status + knaptekster. -function SmartPickupFrame:refreshEditPanel(adEnv) - local hooks = self:editPanelKind() - if hooks == nil or adEnv == nil then return end - if not hooks.exists(self, self.editPanelId) then self:closeEditPanel() return end - local status = hooks.status(self, adEnv, self.editPanelId) - self.editStatus:setText(status.text or "") - local color = ADFlowView.LEVEL_COLORS[status.level] or ADFlowView.LEVEL_COLORS.stopped - if self.editStatus.setTextColor ~= nil then self.editStatus:setTextColor(color[1], color[2], color[3], color[4]) end - self.panelStartButtonInfo.text = hooks.startStopText(self, adEnv, self.editPanelId) - self.panelDeleteButtonInfo.disabled = not hooks.canDelete -end - -function SmartPickupFrame:onClickPanelClose() - self:safe(function(frame) - -- et åbent [Edit]-trin lukkes først (kladden kasseres som ved Annullér) - if frame.guide ~= nil then - frame:closeGuide() - frame:requestRebuild() - return - end - frame:closeEditPanel() - end) -end - -function SmartPickupFrame:onClickPanelStartStop() - self:safe(function(frame) - local hooks = frame:editPanelKind() - if hooks ~= nil then hooks.startStop(frame, frame.editPanelId) end - frame:requestRebuild() - end) -end - -function SmartPickupFrame:onClickPanelDelete() - self:safe(function(frame) - local hooks = frame:editPanelKind() - if hooks ~= nil and hooks.canDelete then hooks.delete(frame, frame.editPanelId) end - end) -end -``` - -- [ ] **Step 4: Wire into frame** — - - `adSmartPickup.lua`: after `source(ADSmartPickup.dir .. "gui/SmartPickupFlowGuide.lua")` add `source(ADSmartPickup.dir .. "gui/SmartPickupEditPanel.lua")`. - - `SmartPickupFrame.new`: where `initFlowButtons(i18n)` is called, also call `self:initEditPanelButtons(i18n)`. - - `rebuild`: in the Flows branch and the Fields branch, after `self.boxLayout:invalidateLayout()` add `if self.editPanelOpen then self:fillEditPanel(adEnv) end`. - - `refreshButtons`: first branch: `if self.editPanelOpen then wanted = self.guide ~= nil and self:getGuideButtons() or self.panelButtons elseif self:hasGuideOn(...)` — and in the guide-in-panel case replace the guide list's `self.backButtonInfo` by `self.panelCloseButtonInfo` (build `self.panelGuideButtons` once: copy of `getGuideButtons()` with index 1 swapped). - - `refreshLive`: at the end `if self.editPanelOpen then self:refreshEditPanel(adEnv) end`. - - `onFrameClose`: before `closeGuide()` add `self.editPanelOpen, self.editPanelKind, self.editPanelId = false, nil, nil; if self.editOverlay ~= nil then self.editOverlay:setVisible(false) end; self:clearPanelRows()`. - - `switchTab`: `if self.editPanelOpen then self:closeEditPanel() end` at the top. - - Guide rendering while panel is open: `fillFlowTabRows`/`fillFieldRows` start with `if self:hasGuideOn(TAB) and self:fillGuideRows(adEnv) then return end` — guard with `not self.editPanelOpen and` so the guide draws only in the panel. -- [ ] **Step 5: l10n** — add `spu_panel_close` ("Luk"/"Close"), `spu_panel_gone` ("Posten findes ikke længere — vinduet er lukket"/"That item no longer exists — window closed"), `spu_button_delete` if not present (check `grep spu_button_delete l10n/l10n_da.xml` first; reuse existing delete text key if it exists). -- [ ] **Step 6: Run all tests** (noStrayGlobals must pass) and validate XML: `python3 -c "import xml.dom.minidom as m; [m.parse(f) for f in ('FS25_ADSmartPickup/gui/SmartPickupFrame.xml','FS25_ADSmartPickup/gui/guiProfiles.xml','FS25_ADSmartPickup/l10n/l10n_da.xml','FS25_ADSmartPickup/l10n/l10n_en.xml')]"`. -- [ ] **Step 7: Commit** `git commit -am "feat(gui): redigeringsvindue (overlay) med Luk/ESC, Start/Stop, Slet"` - ---- - -### Task 4: Flows-fanen som farvet liste + "flow" i vinduet - -**Files:** -- Modify: `FS25_ADSmartPickup/gui/SmartPickupFrame.xml` (ny `flowListPrefab`, fjernes ved `initialize`) -- Modify: `FS25_ADSmartPickup/gui/SmartPickupFrame.lua` (`initialize`-listen: `"flowListPrefab"`; `onClickStartStop` husker afvisning) -- Modify: `FS25_ADSmartPickup/gui/SmartPickupFlowGuide.lua` (`fillFlowTabRows`, `refreshFlowTab`, `initFlowButtons`, registrér `EDIT_PANEL_KINDS.flow`) -- Modify: `l10n/*.xml` - -**Interfaces:** -- Consumes: `ADFlowView.flowStatus`, `ADFlowView.LEVEL_COLORS` (Task 1); `openEditPanel`, `EDIT_PANEL_KINDS` (Task 3); `makeRowClickable` (Task 2). -- Produces: `SmartPickupFrame:flowLevel(adEnv, run) -> level`, `self.lastStartFailed[runId] = bool`, `SmartPickupFrame:toggleRun(adEnv, runId)`. - -- [ ] **Step 1: Prefab** — in the XML after `flowPrefab`: - -```xml - - - - - - - - - -``` - -- [ ] **Step 2: Toggle + afvisning** — in `SmartPickupFlowGuide.lua` add: - -```lua --- Start/Stop for ét flow (rækkens knap og vinduets knap). Husker om sidste start blev helt afvist (rød). -function SmartPickupFrame:toggleRun(adEnv, runId) - local run = ADRuns.getRun(ADRunsController.store, runId) - if adEnv == nil or run == nil then return end - self.lastStartFailed = self.lastStartFailed or {} - if ADRunsController.getRunStatus(adEnv, run).canStop then - ADRunsController.stopRun(adEnv, run.id) - self.lastStartFailed[run.id] = nil - self.statusOverride = nil - else - local started, skipped, reasons = self:summarizeStartResults(ADRunsController.startRun(adEnv, run.id)) - self.lastStartFailed[run.id] = started == 0 and skipped > 0 - local text = string.format(self.i18n:getText("spu_status_started"), started, skipped) - self:showOverride(text, reasons) - if skipped > 0 and ADFieldJobs ~= nil and ADFieldJobs.notify ~= nil then - ADFieldJobs.notify(string.format("%s: %s — %s", tostring(run.name), text, table.concat(reasons, "; ")), started == 0) - end - end - ADRunsController.save() -end - -function SmartPickupFrame:flowLevel(adEnv, run) - local row = ADRunsController.getFlowRow(adEnv, run) - local status = ADRunsController.getRunStatus(adEnv, run) - return ADFlowView.flowStatus({running = row.running, total = row.total, stopped = run.stopped == true, - canStop = status.canStop == true, warning = row.warning, reason = row.reason, - lastStartFailed = (self.lastStartFailed or {})[run.id] == true}) -end -``` - And make `onClickStartStop` (Frame.lua) call `frame:toggleRun(adEnv, run.id)` instead of its inline start/stop body (keep `ADRunsController.save()` + `requestRebuild()` there). - -- [ ] **Step 3: List** — replace `fillFlowTabRows` body after the guide guard: - -```lua -function SmartPickupFrame:fillFlowTabRows(adEnv) - if not self.editPanelOpen and self:hasGuideOn(SmartPickupFrame.TAB_FLOWS) and self:fillGuideRows(adEnv) then return end - self.flowListRows = {} - local i18n = self.i18n - if #ADRunsController.store.runs == 0 then - self:addTextRow(i18n:getText("spu_noRuns")) - return - end - self:addTextRow(i18n:getText("spu_flowList_hint")) - for _, group in ipairs(ADFlowView.boardGroups(ADRunsController.store.runs)) do - self:addTextRow(i18n:getText(group.kind == "outbound" and "spu_board_outbound" or "spu_board_supply")) - for _, run in ipairs(group.runs) do - local row = self:addRow(self.flowListPrefab, {kind = "flowList"}) - local entry = {runId = run.id, row = row, dot = row:getDescendantByName("dot"), - cells = {row:getDescendantByName("c1"), row:getDescendantByName("c2"), row:getDescendantByName("c3"), - row:getDescendantByName("c4"), row:getDescendantByName("c5")}} - local runId = run.id - local actionCell = entry.cells[5] - self:makeRowClickable(row, function(frame) - -- klik på [Start]/[Stop]-feltet = handling; ellers åbn vinduet - if frame.lastClickX ~= nil and actionCell.absPosition ~= nil and frame.lastClickX >= actionCell.absPosition[1] then - frame:toggleRun(frame.getAdEnv(), runId) - frame:requestRebuild() - else - frame.selectedRunId = runId - frame:openEditPanel("flow", runId) - end - end) - table.insert(self.flowListRows, entry) - end - end - self:refreshFlowListRows(adEnv) -end - -function SmartPickupFrame:refreshFlowListRows(adEnv) - local unpackFn = table.unpack or unpack - for _, entry in ipairs(self.flowListRows or {}) do - local run = ADRuns.getRun(ADRunsController.store, entry.runId) - if run ~= nil then - local cells = self:flowCells(adEnv, run) - local level = self:flowLevel(adEnv, run) - local color = ADFlowView.LEVEL_COLORS[level] - local canStop = ADRunsController.getRunStatus(adEnv, run).canStop - entry.cells[1]:setText(tostring(run.name)) - entry.cells[2]:setText(tostring(cells[2])) - entry.cells[3]:setText(tostring(cells[5])) - entry.cells[4]:setText(tostring(cells[6])) - entry.cells[5]:setText(self.i18n:getText(canStop and "spu_flowList_stop" or "spu_flowList_start")) - if entry.dot ~= nil and entry.dot.setImageColor ~= nil then entry.dot:setImageColor(nil, unpackFn(color)) end - if entry.cells[4].setTextColor ~= nil then entry.cells[4]:setTextColor(unpackFn(color)) end - end - end -end -``` - In `makeRowClickable` (Task 2's function) store the click x before setting `pendingClick`: `frame.lastClickX = posX`. - -- [ ] **Step 4: `refreshFlowTab`** — replace body: - -```lua -function SmartPickupFrame:refreshFlowTab(adEnv) - if not self.editPanelOpen and self:hasGuideOn(SmartPickupFrame.TAB_FLOWS) then - self:setStatusText(self.statusOverride or "") - return - end - self:refreshFlowListRows(adEnv) - local running, total = 0, 0 - for _, run in ipairs(ADRunsController.store.runs) do - total = total + 1 - if ADRunsController.getRunStatus(adEnv, run).running > 0 then running = running + 1 end - end - self:setStatusText(self.statusOverride or string.format(self.i18n:getText("spu_flowList_status"), running, total)) -end -``` - -- [ ] **Step 5: Buttons + panel kind** — in `initFlowButtons`: `self.flowButtons = {self.backButtonInfo, self.flowNewButtonInfo}`. Register: - -```lua -SmartPickupFrame.EDIT_PANEL_KINDS.flow = { - canDelete = true, - exists = function(frame, id) return ADRuns.getRun(ADRunsController.store, id) ~= nil end, - title = function(frame, id) - local run = ADRuns.getRun(ADRunsController.store, id) - return string.format(frame.i18n:getText("spu_panel_flowTitle"), tostring(run ~= nil and run.name or "?")) - end, - fill = function(frame, adEnv, id) - local run = ADRuns.getRun(ADRunsController.store, id) - if run ~= nil then frame:fillFlowSummary(adEnv, run) end - end, - status = function(frame, adEnv, id) - local run = ADRuns.getRun(ADRunsController.store, id) - return {text = tostring(frame:flowCells(adEnv, run)[6]), level = frame:flowLevel(adEnv, run)} - end, - startStopText = function(frame, adEnv, id) - local run = ADRuns.getRun(ADRunsController.store, id) - return frame.i18n:getText(ADRunsController.getRunStatus(adEnv, run).canStop and "spu_button_stop" or "spu_button_start") - end, - startStop = function(frame, id) frame:toggleRun(frame.getAdEnv(), id) end, - delete = function(frame, id) frame.selectedRunId = id; frame:onClickDelete() end, -} -``` - In `fillFlowSummary` drop the first heading row (the panel title shows the name): remove the `spu_guide_flow_section` line. Add "Opsæt flow" as a last link row in the summary: `self:addLinkRow(i18n:getText("spu_guide_flow_open"), function(frame) frame.selectedRunId = runId; frame:onClickFlowSetup() end)`. - After a delete (`onClickDelete`'s YesNo callback path) the run no longer exists → `refreshEditPanel` closes the panel (Review Focus). - -- [ ] **Step 6: l10n** — `spu_flowList_hint` ("Klik på et flow for at rette det. [Start]/[Stop] styrer flowet direkte." / en), `spu_flowList_start` ("[Start]"), `spu_flowList_stop` ("[Stop]"), `spu_flowList_status` ("Flows i gang: %d af %d" / "Flows running: %d of %d"), `spu_panel_flowTitle` ("Rediger flow: %s" / "Edit flow: %s"). -- [ ] **Step 7: Run all tests + XML check.** -- [ ] **Step 8: Commit** `git commit -am "feat(flows): Flows-fanen som farvet liste med Start/Stop pr. række; klik åbner redigeringsvinduet"` - ---- - -### Task 5: Etape 1 build + in-game test - -- [ ] **Step 1:** bump `modDesc.xml` `` to `1.58.0.0`; build zip; copy to `~/FS25-data/mods/`; `unzip -p ~/FS25-data/mods/FS25_ADSmartPickup.zip modDesc.xml | grep -o '[^<]*'` shows 1.58.0.0. -- [ ] **Step 2:** commit + push gitea main. -- [ ] **Step 3: In-game checklist (brugeren):** liste med prikker/farver; [Start]/[Stop] på en række virker og rører ikke andre flows; klik på navnet åbner vinduet; [Edit] → Gem inde i vinduet; Luk og ESC lukker vinduet (ESC med åbent trin lukker trinnet først); klik på listen bagved gør intet; Slet flow fra vinduet lukker det; log uden `GUI-fejl`. Ret layout-afvigelser (størrelser/placering) efter screenshot før Task 6. - ---- - -### Task 6: Ren statusfunktion for marker - -**Files:** -- Modify: `FS25_ADSmartPickup/adFieldView.lua` -- Test: `tests/test_fieldStatus.lua` (ny) - -**Interfaces:** -- Produces: `ADFieldView.fieldStatus(s) -> level` hvor `s = {hasMarker=bool, working=bool, awaiting=bool, startRequested=bool, reasonKey=string|nil}`; `ADFieldView.ERROR_REASONS` (sæt af reason-nøgler). - -- [ ] **Step 1: Test** — `tests/test_fieldStatus.lua`: - -```lua --- Kør: luajit tests/test_fieldStatus.lua (fra repo-roden) -dofile("FS25_ADSmartPickup/adFieldView.lua") -local V = ADFieldView -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 -check("mangler markør", V.fieldStatus({hasMarker = false}), "error") -check("arbejder", V.fieldStatus({hasMarker = true, working = true}), "running") -check("arbejder men venter på maskine", V.fieldStatus({hasMarker = true, working = true, reasonKey = "spu_field_reason_allBusy"}), "waiting") -check("for bred", V.fieldStatus({hasMarker = true, working = true, reasonKey = "spu_field_reason_tooWide"}), "error") -check("ingen maskine", V.fieldStatus({hasMarker = true, reasonKey = "spu_field_reason_noRig"}), "error") -check("regn", V.fieldStatus({hasMarker = true, working = true, reasonKey = "spu_field_reason_rain"}), "waiting") -check("venter på spilleren (høster)", V.fieldStatus({hasMarker = true, awaiting = true}), "waiting") -check("start bestilt", V.fieldStatus({hasMarker = true, startRequested = true}), "waiting") -check("i ro", V.fieldStatus({hasMarker = true}), "stopped") -print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) -os.exit(failures == 0 and 0 or 1) -``` - (If `adFieldView.lua` needs other modules loaded, add the same `dofile`s as `tests/test_adFieldView.lua` uses.) -- [ ] **Step 2: Run** — FAIL (fieldStatus nil). -- [ ] **Step 3: Implement** — append to `adFieldView.lua`: - -```lua --- ---- status pr. mark (Fields-listen) ---- - --- Årsager der betyder "kan ikke" (rød) — resten er "venter" (gul). -ADFieldView.ERROR_REASONS = { - spu_field_reason_noRig = true, spu_field_reason_cannotDo = true, spu_field_reason_tooWide = true, - spu_field_reason_tooNarrow = true, spu_field_reason_widthUnknown = true, spu_field_reason_noSowFruit = true, -} - --- s = {hasMarker, working, awaiting, startRequested, reasonKey} -> "running"|"waiting"|"error"|"stopped" -function ADFieldView.fieldStatus(s) - if not s.hasMarker then return "error" end - if s.reasonKey ~= nil and ADFieldView.ERROR_REASONS[s.reasonKey] then return "error" end - if s.awaiting or s.startRequested then return "waiting" end - if s.working then return s.reasonKey ~= nil and "waiting" or "running" end - if s.reasonKey ~= nil then return "waiting" end - return "stopped" -end -``` -- [ ] **Step 4: Run all tests** — green. -- [ ] **Step 5: Commit** `git commit -am "feat(fields): ren statusfunktion til Fields-listen"` - ---- - -### Task 7: Fields-fanen som farvet liste + "field" i vinduet - -**Files:** -- Modify: `FS25_ADSmartPickup/gui/SmartPickupFrame.xml` (`fieldPrefab`: dot forrest, c7 = handling; kolonner forskydes) -- Modify: `FS25_ADSmartPickup/gui/SmartPickupFieldTab.lua` (`fieldCells`, `fillFieldRows`, `refreshFieldRows`, `onClickFieldStartStop` tager id, register `EDIT_PANEL_KINDS.field`) -- Modify: `FS25_ADSmartPickup/gui/SmartPickupFieldGuide.lua` (`getFieldButtons` uden Start/Stop når listen vises) -- Modify: `l10n/*.xml` - -**Interfaces:** -- Consumes: `ADFieldView.fieldStatus` (Task 6); `openEditPanel`/`EDIT_PANEL_KINDS` (Task 3); `makeRowClickable` + `frame.lastClickX` (Task 2/4); `ADFlowView.LEVEL_COLORS` (Task 1). -- Produces: `SmartPickupFrame:fieldLevel(id) -> level`; `SmartPickupFrame:toggleField(id)`. - -- [ ] **Step 1: Prefab** — `fieldPrefab` becomes (≈835 px): - -```xml - - - - - - - - - - -``` - The header row (column titles) passes a 7th title `""`. -- [ ] **Step 2: Toggle + level** — refactor `onClickFieldStartStop` into `toggleField(id)` (same body, `id` parameter instead of `frame.selectedFieldId`); `onClickFieldStartStop` calls `self:toggleField(self.selectedFieldId)`. Add: - -```lua -function SmartPickupFrame:fieldLevel(id) - local field, info = ADFieldJobs.getField(id), ADFields.getInfo(id) - if field == nil or info == nil then return "stopped" end - local reason = type(field.reason) == "table" and field.reason.key or nil - return ADFieldView.fieldStatus({hasMarker = info.markerId ~= nil, working = ADFieldModel.isWorking(field), - awaiting = ADFieldJobs.awaitingPlayer(id) ~= nil, startRequested = ADFieldJobs.isStartRequested ~= nil and ADFieldJobs.isStartRequested(id), - reasonKey = reason}) -end - -function SmartPickupFrame:fieldActionText(id) - local field = ADFieldJobs.getField(id) - if ADFieldJobs.awaitingPlayer(id) ~= nil then return self.i18n:getText("spu_field_button_takeover") end - return self.i18n:getText(field ~= nil and ADFieldModel.isWorking(field) and "spu_flowList_stop" or "spu_flowList_start") -end -``` - (If `ADFieldJobs.isStartRequested` does not exist, leave the guarded call — it evaluates to false.) -- [ ] **Step 3: List rows** — in `fillFieldRows`: guide guard gets `not self.editPanelOpen and`; `fieldCells` appends `self:fieldActionText(info.id)` as 7th cell and no longer prefixes `"> "`; row click: - -```lua - self:makeRowClickable(cells[1].parent, function(frame) - if frame.lastClickX ~= nil and cells[7] ~= nil and cells[7].absPosition ~= nil and frame.lastClickX >= cells[7].absPosition[1] then - frame:toggleField(fieldId) - else - frame.selectedFieldId = fieldId - frame:openEditPanel("field", fieldId) - end - end) -``` - Remove the trailing `fillFieldOverview` call from `fillFieldRows` (it now lives in the panel). In `refreshFieldRows` after `setCells` color the dot and c5 by `self:fieldLevel(entry.id)` (same code as `refreshFlowListRows`; `entry.cells[1].parent:getDescendantByName("dot")`). -- [ ] **Step 4: Panel kind** — - -```lua -SmartPickupFrame.EDIT_PANEL_KINDS.field = { - canDelete = false, - exists = function(frame, id) return ADFields.getInfo(id) ~= nil end, - title = function(frame, id) return string.format(frame.i18n:getText("spu_panel_fieldTitle"), id) end, - fill = function(frame, adEnv, id) frame:fillFieldOverview(adEnv, ADFields.getInfo(id)) end, - status = function(frame, adEnv, id) - return {text = frame:fieldStatusText(ADFieldJobs.getField(id), ADFields.getInfo(id), false), level = frame:fieldLevel(id)} - end, - startStopText = function(frame, adEnv, id) return frame:fieldActionText(id) end, - startStop = function(frame, id) frame:toggleField(id) end, - delete = function() end, -} -``` - `fillFieldOverview`'s own heading row (`spu_field_section`) is dropped when drawn in the panel: guard it with `if not self.editPanelOpen then ... end`. - `getFieldButtons`: list view keeps Back, Opsæt mark (needs selected field → move "Opsæt mark" into the panel as a link row like flows), Flåde; Start/Stop leaves the bottom bar. -- [ ] **Step 5: l10n** — `spu_panel_fieldTitle` ("Mark F%d" / "Field F%d"). -- [ ] **Step 6: Run all tests + XML check.** -- [ ] **Step 7: Commit** `git commit -am "feat(fields): Fields-fanen som farvet liste med Start/Stop pr. række; klik åbner redigeringsvinduet"` - ---- - -### Task 8: Etape 2 build + in-game test - -- [ ] **Step 1:** bump to `1.59.0.0`; build; deploy; verify version in zip; commit + push. -- [ ] **Step 2: In-game checklist:** Fields-liste med prikker/farver; [Start]/[Stop]/[Overtag] pr. række; klik åbner vinduet med markens linjer; [Ret] → Gem i vinduet; Flåde-visning uændret; Luk/ESC. -- [ ] **Step 3:** Update memory `project_fs25_adsmartpickup.md` with the new GUI structure (EditPanel mixin, rowTarget) and the Obsidian note `projekter/FS25 silo-patches + AD Smart Pickup.md` with a line about v1.58/1.59. diff --git a/docs/superpowers/specs/2026-09-21-behovsstyret-forsyning-design.md b/docs/superpowers/specs/2026-09-21-behovsstyret-forsyning-design.md deleted file mode 100644 index d1e24e9..0000000 --- a/docs/superpowers/specs/2026-09-21-behovsstyret-forsyning-design.md +++ /dev/null @@ -1,73 +0,0 @@ -# Behovsstyret forsyning ("Feed the factory") — design - -Dato: 2026-09-21 · Status: godkendt af bruger - -## Problem - -En produktion med flere input (fx TMR-blander: hø, græs, ensilage, sukkerroer) kræver i dag -én AutoDrive-traktor pr. vare, fordi AD's Pickup&Deliver kun kører én vare fra ét sted. -Varerne ligger spredt over mange lokationer på kortet. - -## Løsning - -Én traktor holder alle input fyldt. For hver tur vælger moden den ingrediens aflæsningsmålet -har mest brug for, finder en silo der har den, skifter AD's vare og sender traktoren derhen. - -### Betjening (ingen ny GUI, ingen config-fil) - -1. Aflæsningsmål = produktionens markør. -2. Multi-vælg ingredienserne i AD's vare-dropdown (>1 vare = funktionen er aktiv). -3. "Brug mapper" + "Rotér mål: pålæsning" slået til (krævet for at AD kalder `getNextPickup`). -4. Start Pickup&Deliver. AD's loop-tæller virker uændret (N læs → parkér, 0 = uendeligt). - -Med præcis én valgt vare er opførslen uændret (eksisterende mappe-rotation, mindst fyldte først). - -### Logik pr. tur (i `getNextPickup`-wrapperen) - -1. **Ingrediensliste pr. køretøj.** Ved >1 valgt vare gemmes listen i modens egen tilstand - (`vehicle → {ingredients, narrowedTo}`). Derefter indsnævres AD's `selectedFillTypes` til den - valgte vare, fordi AD ellers selv roterer vare ved triggeren - (`ADStateModule:selectPreferredFillTypeFromFillLevels`). - Ved næste kald: er AD's valg stadig `{narrowedTo}` → brug den gemte liste. Er det noget andet → - brugeren har ændret valget: ny multi-liste erstatter, enkelt-valg sletter tilstanden. -2. **Behov.** Find `UnloadingStation` nærmest anden markør (≤ 40 m). For hver ingrediens summeres - `level`, `capacity` over `station.targetStorages`; `free = capacity - level`. - Ingredienser stationen ikke understøtter, eller med `free < MIN_FREE_LITERS`, springes over. -3. **Rangering.** `ADSupplyPlanner.scoreNeed(level, capacity, freeSpace)` → højeste score først. -4. **Kilde.** For ingredienserne i behovs-rækkefølge: blandt *alle* AD-markører (ikke kun én mappe) - findes markører med farm-tilgængelig `LoadingStation` ≤ 40 m der har varen. Aflæsningsmålets - egen placeable udelukkes som kilde. Rangering som eksisterende: eget lager før nabo-lån, - mindst fyldte først. Første ingrediens med en kilde vinder. -5. **Effekt.** `setFillType(vare)`, `selectedFillTypes = {vare}`, dirty-flag, returnér markørens - `markerIndex`. Logges som `ADSmartPickup: '' -> (, behov …)`. -6. **Fallback.** Intet behov / ingen kilde / ingen station / fejl (`pcall`) → AutoDrives - originale `getNextPickup` (ved fejl: `Logging.warning`). Aldrig crash. - -## Filer - -| Fil | Ansvar | -|---|---| -| `FS25_ADSmartPickup/adSupplyPlanner.lua` (ny) | Rene funktioner: `scoreNeed`, `rankNeeds`, `resolveIngredients` (tilstandslogik). Ingen spil-API. | -| `FS25_ADSmartPickup/adSmartPickup.lua` | Spil-/AD-adaptere (station ved markør, lagerniveauer, kilde-søgning) + hook. Kilde-søgning generaliseres til at tage en markørliste. | -| `FS25_ADSmartPickup/modDesc.xml` | Ny sourceFile før `adSmartPickup.lua`, version 1.1.0.0, beskrivelse. | -| `tests/test_adSupplyPlanner.lua` (ny) | Enhedstests af de rene funktioner. | -| `tests/test_adSmartPickup.lua` | Integration gennem hook'en med mocks; eksisterende 11 tests skal forblive grønne. | - -## Tests (luajit, mocks) - -- mest trængende ingrediens vælges; vare og AD-valg skiftes; rigtig markør returneres -- ingrediens uden plads i målet springes over -- ingrediens uden kilde springes over → næste i rækken -- kilde i anden mappe end første markør findes -- aflæsningsmålets egen station bruges ikke som kilde -- gemt liste genbruges når AD's valg er indsnævret; brugerændring nulstiller/erstatter -- én vare valgt → eksisterende opførsel uændret -- intet behov / ingen aflæsningsstation / API-fejl → `ORIGINAL` (+ WARN ved fejl) - -## Kendte begrænsninger - -- AD læsser fuld vogn; er der mindre plads end ét læs, venter traktoren ved aflæsning (AD-standard). -- Ingredienslisten lever kun i hukommelsen — efter savegame-reload multi-vælges varerne igen. -- Forudsætter at målet er en placeable med aflæsningstrigger (produktionspunkt/silo/stald), - ikke en blandevogn. Bekræftes ved første in-game test via log.txt. -- Server-side logik; MP-klienter ser vareskiftet via AD's eget dirty-flag. diff --git a/docs/superpowers/specs/2026-09-22-runs-ui-design.md b/docs/superpowers/specs/2026-09-22-runs-ui-design.md deleted file mode 100644 index 952801c..0000000 --- a/docs/superpowers/specs/2026-09-22-runs-ui-design.md +++ /dev/null @@ -1,156 +0,0 @@ -# Runs-UI ("Cow Feed") — design - -Dato: 2026-09-22 · Status: godkendt af bruger (sektion 1–3) · Version: v1.8 - -## Problem - -Al opsætning sker i dag pr. traktor i AutoDrives HUD: mål, multi-valg af varer (tændknap for -behovsstyring), mapper + rotation, loop-tæller. Med fire ens "Fastrac 2135" til to stalde og flere -Wait-puljer på vej (Cow/Pig/Sheep Wait) mangler overblik og central kontrol: *hvad* kører *hvor* -med *hvilke varer*, og hvor venter de. - -## Løsning - -Et **run** er den centrale enhed: navn, aflæsningsmål, varer, Wait-pulje, tildelte traktorer, -loop-tæller. En ESC-menu-fane ("Smart Pickup") viser og redigerer runs og starter/stopper dem. -"Start" skriver run'et ind i hver traktors AutoDrive-tilstand og kalder AD's start — alt det -eksisterende (behovsstyring, fabriks-inputs, Wait-punkter) virker uændret ovenpå, fordi det -læser præcis den tilstand. Traktorer uden run kører som i dag. - -Valgt tilgang: **run-lag oven på AutoDrives tilstand** (fravalgt: egen dispatcher der kalder AD's -lav-niveau-opgaver — genopfinder P&D og knækker ved AD-opdateringer; konfig-fil uden UI — ikke det -brugeren bad om). - -## 1. Datamodel og lagring - -``` -Run - id løbenummer (stabilt, unikt i savegame) - name "Cow Feed" - targetWayPointId AD-waypoint-id for mål-markøren (stald/fabrik); vises som markørnavn - ingredientMode "auto" | "manual" - ingredients liste af fillType-NAVNE (kun ved manual), fx {"STRAW","FORAGE"} - waitPoolGroup AD-mappenavn (fx "Cow Wait"); tom streng = alle Wait-mapper, nærmeste ledige - loops 0 = uendeligt, N = antal ture (AD's loop-tæller) - vehicleIds liste af køretøjs-referencer (se nedenfor) - -Runtime (ikke gemt): - isRunning afledt: mindst én tildelt traktor har AD aktiv og run-medlemskab - runMembership vehicle -> run.id (svagt nøglet tabel) -``` - -- **Køretøjs-reference:** AD's `uniqueId` (streng) når den findes på køretøjet, ellers savegame-id - (`vehicle.currentSavegameId`). Navne bruges aldrig som nøgle (fire hedder "Fastrac 2135 4WS"). -- **WaitPool** er afledt, ikke gemt: alle AD-mapper med ordet "Wait" i navnet (`ADSupplyPlanner.isWaitMarker`). - Omdøbning sker i AutoDrive; vi læser kun. -- **Fil:** `savegameN/ADSmartPickup_runs.xml`, samme mappe som `AutoDrive_config.xml`. Skrives i - spillets gem-hook (som AD), læses ved `loadMap`. Ukendte køretøjs-referencer droppes med loglinje; - run'et beholdes. Runs der kørte ved gem genstartes IKKE ved load (AD gendanner selv aktive køretøjer). -- **Regler:** en traktor kan kun være i ét run (tildeles den et andet, flyttes den — UI'et siger det). - Singleplayer først; multiplayer kræver events og er uden for scope. - -XML-format: - -```xml - - - - - - -``` - -## 2. UI — ESC-fane "Smart Pickup" - -Forbillede: FarmOperationsDashboard (`TabbedMenuFrameElement`, XML-layout med `SmoothList`, -`MultiTextOption`, `Button`; registreret via `g_gui:loadGui` + indsættelse i `InGameMenu.pagingElement`). - -``` -┌ Smart Pickup ─────────────────────────────────────────────────────────────┐ -│ [Runs] [Wait-puljer] (under-faner) │ -├──────────────────┬────────────────────────────────────────────────────────┤ -│ RUNS │ RUN: Cow Feed ● Kører (2/2) │ -│ ▶ Cow Feed 2/2 │ Mål: [Cow 1 Food ▼] (AD-markør) │ -│ Cow 2 Feed 1/2 │ Varer: (•) Auto: alt målet tager / fabrikken │ -│ TMR Fabrik 1/1 │ ( ) Vælg: [x] Halm [x] TMR [ ] Vand │ -│ │ Wait-pulje: [Cow Wait ▼] (6 punkter)│ -│ [+ Nyt run] │ Ture (loop): [0 = uendeligt ◄►] │ -│ [Slet run] │ Traktorer: │ -│ │ Fastrac 2135 (#1) vogn: 1.000.000 l halm+TMR ✔ │ -│ │ Fastrac 2135 (#2) vogn: — INGEN VOGN ✖ │ -│ │ [+ Tilføj traktor ▼] [Fjern] │ -│ │ [▶ Start run] [■ Stop run] Status: 1 advarsel │ -└──────────────────┴────────────────────────────────────────────────────────┘ -``` - -- **Runs-liste (venstre):** alle runs med "kører X af Y". Valg → detaljer til højre. `+ Nyt run` - opretter "Run N" (navn redigeres via AD-lignende tekstdialog). `Slet run` spørger ja/nej. -- **Mål:** dropdown over AD-markører der ligger ved en aflæsningsstation (`getUnloadStationAtMarker` - ≠ nil) — ikke alle markører. -- **Varer:** *Auto* = det eksisterende: fabrik → ønskede produktioners inputs (v1.6); stald → alle - varer stalden tager (`getHusbandryIsFillTypeSupported`); andet mål → stationens understøttede varer. - *Vælg* = afkrydsning blandt målets mulige varer. -- **Wait-pulje:** dropdown "Alle (nærmeste ledige)" + hver mappe med "Wait" i navnet (antal punkter). -- **Ture:** 0–99. -- **Traktorer:** dropdown over farmens køretøjer med `vehicle.ad` (navn + `#løbenummer`). Pr. række: - vogn-kapacitet (AD `getAllFillLevels`), hvilke af run'ets varer vognen kan bære, og ✔/✖ med tekst: - "ingen vogn", "kan ikke bære: TMR", "N m fra vejnettet", "i run X (flyttes)". -- **Start/Stop** gælder hele run'et; ✖-traktorer springes over med besked i rækken. -- **Wait-puljer-fanen:** liste over Wait-mapper: antal punkter, hvilke traktorer holder hvor lige nu - (fra `adUnloadWait`'s reservationer). Kun visning. -- Live-opdatering hvert sekund mens siden er åben. Dansk + engelsk l10n. Ingen nye tastaturgenveje. - -## 3. Start/Stop, integration, fejl og test - -**Start run** (pr. tildelt traktor): -1. Validér: vogn tilkoblet og kan bære ≥1 af run'ets varer (`AutoDrive.getSupportedFillTypesOfAllUnitsAlphabetically`); - ≤ 30 m fra vejnettet (`ADGraphManager:getDistanceFromNetwork`); mål-markør findes. Fejl → ✖ på rækken, - traktoren springes over, run'et fortsætter med de andre. -2. Skriv AD-tilstand: `stateModule:setMode(AutoDrive.MODE_PICKUPANDDELIVER)`; - `setSecondMarkerByWayPointId(target)`; `setFirstMarkerByWayPointId(nærmeste markør med læssestation)` - (kun en gyldig start — moden vælger reel kilde pr. tur); `selectedFillTypes` = run'ets varer - (Auto: udledt liste) + `raiseDirtyFlag`; loop-tæller; per-køretøj `vehicle.ad.settings.useFolders` - = til og `rotateTargets` = kun pålæsning (`current` og `new`). -3. `vehicle:startAutoDrive()`. -4. `runMembership[vehicle] = run.id`. - -**Stop run:** `vehicle:stopAutoDrive()` pr. tildelt traktor; medlemskab bevares (konfigureret, ikke kørende). - -**Integration:** de eksisterende hooks læser AD-tilstanden uændret. Run'et læses to steder: -(a) `adUnloadWait`: Wait-kandidater begrænses til run'ets mappe, hvis sat; (b) `choosePickup`: -for run-traktorer tages ingredienslisten fra run'et (Auto/manual) i stedet for "≥2 valgt i dropdown", -så tændknappen ikke længere kræves. Traktorer uden run: præcis som i dag. - -**Fejlhåndtering:** al UI-kode og Start/Stop i `pcall`; fejl vises i status-linjen og logges med -`ADSmartPickup:`-præfiks; aldrig crash. Manglende AD-API → fanen viser "AutoDrive-API ikke fundet", -resten af moden upåvirket. - -**Test:** rene funktioner i luajit (run-model: opret/omdøb/tildel/flyt; validering; Auto-vareliste; -XML ↔ tabel; kandidat-filtrering pr. Wait-pulje). GUI og AD-skrivning verificeres in-game efter -tjekliste: opret run → tildel 2 traktorer (én uden vogn) → Start (én ✖) → Stop → gem/load → run bevaret. - -**Afgrænsning (YAGNI):** singleplayer; ingen kilde-begrænsning pr. run; ingen omdøbning af -Wait-mapper; ingen auto-genstart ved load; ingen genvejstast-dialog. - -## Filer (forventet) - -- `FS25_ADSmartPickup/adRuns.lua` — run-model + XML (rene funktioner hvor muligt) -- `FS25_ADSmartPickup/adRunsController.lua` — Start/Stop, validering mod AD/spil-API, medlemskab -- `FS25_ADSmartPickup/gui/SmartPickupFrame.lua` + `gui/SmartPickupFrame.xml` + `gui/guiProfiles.xml` -- `FS25_ADSmartPickup/l10n/l10n_da.xml`, `l10n_en.xml` -- `tests/test_adRuns.lua` -- Tilpasninger i `adSmartPickup.lua` (ingredienser fra run) og `adUnloadWait.lua` (pulje-filter) - -## Efterfølgende (ikke i v1.8): læs-bytte fra Wait - -In-game 2026-09-22 21:55: begge stalde fulde af halm, mangler TMR; alle fire traktorer venter ved -Cow Wait med vognene fulde af halm → ingen kan hente TMR (deadlock). - -Design: mens en traktor venter, tjekkes målets behov. Hvis målet er fuldt for vognens vare, og en -ANDEN vare i traktorens ingrediensliste mangler så meget at et fuldt læs passer (hysterese), og -vognen kan bære den: find en silo (lager-placeable med aflæsningstrigger ved en AD-markør, fri plads -≥ rest, rute til/fra) — foretræk den moden ellers henter varen fra — og omdiriger aflæsningsopgaven -dertil. AD læsser af, vognen er tom, næste tur vælger moden den manglende vare efter behov. Log: -"bytter læs: N l STRAW tilbage i , målet mangler FORAGE". Ingen silo → bliv ved Wait. -Kræver at traktoren har begge varer i sin liste (run eller multi-valg). diff --git a/docs/superpowers/specs/2026-09-23-flow-tavle-design.md b/docs/superpowers/specs/2026-09-23-flow-tavle-design.md deleted file mode 100644 index 97e4f02..0000000 --- a/docs/superpowers/specs/2026-09-23-flow-tavle-design.md +++ /dev/null @@ -1,92 +0,0 @@ -# Flow-tavle — design - -Dato: 2026-09-23 · Status: godkendt af bruger (grundform valgt: flow-tavle) · Version: v1.13 - -## Problem - -Opsætningen er markør-baseret: brugeren vælger AD-markører som kilder og mål, og moden gætter -bygningen ud fra markørens geometri (trigger bag næsen, 40 m, 12 m). Fejl viser sig som -"kilde-markør 10873 er ikke …" i stedet for "Kostald 1", først når run'et starter. Forsyning og -Udkørsel er to faner med hver sin lange rulleside, Oversigt er en tredje. Brugeren: "det er noget rod". - -## Løsning - -Én tavle med ét flow pr. række: **Vare · Fra · Til · Regel · Traktorer · Status**. Fra og Til vælges -som **bygninger**; moden finder selv markøren ved bygningens læsse-/aflæsningstrigger og viser den. -Redigering sker i en detail-blok under tavlen for det valgte flow. Faner: **Flows** · **Lagre**. -Motoren (planlægger, hooks, XML-model) er uændret: et flow ER et run; bygninger oversættes til -markører når run'et startes og planlægges. - -Fravalgt: "Bygninger først" (runs implicit → svært at se hvad der kører) og "Sætnings-run" -(mindst ændring, men fanerne forbliver adskilte). - -## 1. Bygninger (`adBuildings.lua`, spil-adapter) - -`ADBuildings.list(adEnv, farmId)` → liste af bygninger gården kan bruge: - -``` -{ id, name, kind = "factory"|"silo"|"husbandry"|"objectStorage"|"sell", - placeable, loadMarker = {id, name, distance} | nil, unloadMarker = {…} | nil, - canBeSource = bool, canBeTarget = bool, canSell = bool } -``` - -- Kilde: fabrik, silo, stald (læssestation), objektlager. Mål (forsyning): stald, fabrik. - Salg: SellingStation der er et rigtigt salgssted (ikke fabrikkers/staldes indleveringer). - Siloer og palleskure er automatiske lagermål (som i dag) og vælges ikke. -- `id` = `placeable.uniqueId`; mangler det, `"pos:" .. afrundet x/z`. -- Markør-opslag: for hver AD-markør (Wait-markører udelukket) afstanden til bygningens - læsse-triggere (loadTriggers.triggerNode) og aflæsnings-triggere (unloadTriggers exactFillRootNode/ - triggerNode/aiNode) samt objektlagerets/pallespawnerens node; nærmeste ≤ 40 m vinder. - Siloer bruger det strenge opslag (bag næsen) for læssemarkøren, som i dag. -- Én scanning pr. sekund højst (cache), da fanen spørger ved hver opdatering. -- `ADBuildings.find(adEnv, farmId, id)`, `ADBuildings.byMarker(adEnv, farmId, wayPointId)` (omvendt - opslag til migration). - -## 2. Model - -Run får `sourceBuildingIds` og `targetBuildingIds` (lister af bygnings-id). De eksisterende -`sourceWayPointIds`/`targetWayPointIds` bevares som **afledte** felter: controlleren udfylder dem fra -bygningerne ved Start og ved hver plan (`ADRunsController.resolveMarkers(adEnv, run)`), så -planlægger og hooks er uændrede. XML-version 3 skriver `#sourceBuildings`/`#targetBuildings`; -version 2-filer læses, og bygninger udledes fra markørerne ved første indlæsning -(`ADBuildings.byMarker`); kan en markør ikke oversættes, beholdes den som rå markør. - -Forsyning: Fra = "alle siloer" (ingen valg; sources er alle markører som i dag). Til = bygninger. -Udkørsel: Fra = bygninger. Til = politik pr. vare (Lager/Salg/Salg, ellers lager) — målet vælges -automatisk pr. tur som i dag. - -## 3. Fanen Flows - -**Tavle** (øverst, tekstrækker, én pr. run, den valgte markeret med "▶"): -`Mælk · Kostald 1 + Kostald 2 → Mejeri (salg) · pris ≥ 90 % · Virtus · venter: 42 % af top` -- Vare: run'ets varer (forsyning auto = "behov"), Fra/Til = bygningsnavne (+ "!" hvis en bygning - mangler markør), Regel = kort tekst, Traktorer = navne, Status = én linje (som Oversigt i dag: - kører/holder/venter/begrundelse/advarsel). -- Under tavlen: Wait-puljer (som i dag) og en linje "Vælg flow ovenfor for at rette". - -**Detail-blok for valgt flow** (efter tavlen; run-vælgeren `Flow:` er en MultiTextOption): -- *Retning*: Ind til bygning / Ud fra bygning (klonet Ja/Nej-række). -- *Fra* (kun Ud): afkrydsning pr. bygning der kan være kilde: `Kostald 1 (stald) — markør 'cow 1 milk' 12 m` eller `— INGEN MARKØR inden for 40 m`. -- *Til* (kun Ind): afkrydsning pr. stald/fabrik med samme markørtekst. -- *Varer*: Ind: Auto/Vælg + afkrydsning; Ud: politik pr. vare med mængde og pris-% (som i dag). -- *Regler*: Ind: ture, kør hjem under; Ud: sælg fra, lager over, saml op. -- *Vent ved*: Wait-pulje. -- *Traktorer*: som i dag (låst når vognen ikke passer). -- Knapper: Nyt flow / Omdøb / Slet / Start-Stop (som i dag). Nyt flow spørger navn og starter som "Ind". - -Fanen **Lagre** uændret. Oversigt og de to gamle faner udgår. - -## 4. Validering og fejl - -- Bygning uden markør: rækken siger det, tavlen viser "!" ved navnet, Start afviser med - `noMarker:` i statuslinjen ("Kostald 2 mangler en AD-markør ved mælketanken"). -- Bygning der er forsvundet (solgt): udgår af listen; run'et beholder id'et og viser "(mangler)". -- Alle eksisterende afvisningsgrunde uændrede. - -## 5. Tests - -- `adBuildings`: kind pr. placeable, markør-opslag (nærmeste ≤ 40 m, Wait udelukket, silo strengt), - byMarker, id-fallback. -- model: v3 roundtrip, v2-migration med byMarker-oversættelse, rå markør bevares. -- controller: resolveMarkers udfylder waypoint-lister; noMarker-validering; Start med bygninger. -- GUI in-game (savegame 3 og 2). diff --git a/docs/superpowers/specs/2026-09-23-udkoersel-design.md b/docs/superpowers/specs/2026-09-23-udkoersel-design.md deleted file mode 100644 index 7335f1c..0000000 --- a/docs/superpowers/specs/2026-09-23-udkoersel-design.md +++ /dev/null @@ -1,201 +0,0 @@ -# Udkørsel af produkter — design - -Dato: 2026-09-23 · Status: godkendt af bruger (afsnit 1–4) · Version: v1.12 - -## Problem - -Modden styrer i dag kun forsyning *ind* til stalde og fabrikker. Fabrikkernes output (sukker, -TMR, halmvarer på paller) og varer der står på lager skal køres *ud*: til en silo som -mellemlager, eller til et salgssted — men kun når prisen er god, så man ikke sælger i bund. -Uden det løber fabrikkernes udgangslager fuldt, og produktionen stopper. Paller ligger ofte i -objektlagre (fx HVF-pakkens palle-/ballelager) hvor man normalt klikker "tag ud" manuelt. - -## Løsning - -Et run får en **retning**: *Forsyning* (som i dag) eller *Udkørsel*. Et udkørsels-run har en -liste af **kilder** (fabrik, silo eller objektlager), en **vareliste** med politik pr. vare -(Lager / Salg / Salg, ellers lager / Fra), en **pristærskel** (% af årets toppris), en -**lagertærskel** (% fyldning) og valget **opsamlingsrunde**. Før hver tur vurderer en ren -planlægger om en tur er berettiget, vælger vare(r) + mål, og sætter AutoDrives tilstand; -AutoDrive kører, læsser (også paller via Universal Autoload) og læsser af. Ingen tur → traktoren -holder ved et Wait-punkt og prøver igen hvert 5. sekund. - -Valgt tilgang: nyt modul der wrapper AutoDrives læsseopgave (samme mønster som Wait-modulet -wrapper aflæsseopgaven). Fravalgt: AutoDrives egen "automatisk aflæsningsmål" (ingen pristærskel, -ingen lager-mulighed, ingen ventelogik) og egen kørselsstyring med DriveTo-opgaver (genopfinder -AutoDrives læsse-/pallehåndtering). - -## 1. Faner og datamodel - -### Faner i ESC-siden - -1. **Oversigt** (som i dag): alle runs med status, traktorernes aktivitet, behovsrække. Udkørsels- - runs vises med prislinje i stedet for behov: `SUGAR 412 kr (78 % af top) → venter`. -2. **Forsyning**: de nuværende runs, samme indhold, grupperet i blokke med overskrift: - *Run* (vælger + Nyt/Omdøb/Slet) · *Mål og varer* · *Ventepunkter og ture* · *Traktorer* · - *Status* (+ Start/Stop). -3. **Udkørsel**: *Run* · *Kilder* (liste af markører, tilføj/fjern) · *Varer* (én række pr. vare: - navn, politik, aktuel pris i % af top) · *Regler* (pristærskel 50–100, standard 90; - lagertærskel 50–95, standard 80; opsamlingsrunde Ja/Nej) · *Ventepunkter* · *Traktorer* · - *Status* (+ Start/Stop). - -Run-vælger, Wait-pulje, traktorliste, knapper og statuslinje er fælles byggeklodser; hver fane -viser kun runs med sin egen `kind`. - -### Kilder - -Kilde = AD-markør. Kildetypen genkendes ved markøren (nærmeste inden for 40 m, som triggere i -dag): - -- **Fabrik** (produktionspunkt): tilgængeligt pr. vare = udgangslager + paller af varen der - allerede står ved pallespawneren (inden for 25 m af spawner-noden). -- **Silo** (spec_silo med læssestation): tilgængeligt = lagerniveau for varen. -- **Objektlager** (spec_objectStorage): tilgængeligt = antal × fyldning pr. palle for varen - (`objectInfos[*].objects[*].palletAttributes`). Udtag sker automatisk, se afsnit 3. - -Varelisten for et run = alle varer kilderne rummer eller producerer, som mindst én tildelt vogn -kan bære. Bulk-varer kræver tipvogn/tank; pallevarer kræver Universal Autoload. - -### Datamodel (adRuns.lua, rene funktioner, ny store ved hver ændring) - -``` -Run (fælles) - id, name, waitPoolGroup, vehicleIds, kind = "supply" | "outbound" -Run (supply, uændret) - targetWayPointId, targetWayPointIds, ingredientMode, ingredients, loops, returnBelowPercent -Run (outbound) - sourceWayPointIds liste af AD-waypoint-id'er (1..n) - products liste af {fillType = NAVN, policy = "store"|"sell"|"sellElseStore"|"off"} - sellAtPercent 50..100, standard 90 - storeAbovePercent 50..95, standard 80 - collectRound bool, standard false -``` - -XML-version 2 i samme `ADSmartPickup_runs.xml`. Version 1-filer læses og får `kind="supply"`. -Ukendte varer i `products` (mod fjernet) ignoreres ved indlæsning med én log-linje. - -## 2. Turvurdering og prisregel - -`adOutboundPlanner.lua` er rent Lua (ingen spil-API). Input: run, vognens kapacitet (liter) og -om den har UAL, kildestatus pr. vare `{available, level, capacity}` pr. kilde, prisstatus pr. -vare `{percent, stationId, reachable}`. Output: en tur eller `nil` + begrundelse. - -Pr. vare: - -- **Tilgængeligt**: sum over kilderne. -- **Fuldt læs**: tilgængeligt ≥ vognens kapacitet. -- **Lager højt**: mindst én kilde har `level/capacity ≥ storeAbovePercent/100` for varen. -- **Pris-%**: bedst betalende *nåelige* salgssted der tager varen (adPrices). Toppris = - stationens aktuelle grundpris for varen × (årets højeste periodefaktor ÷ nuværende periodes - faktor). Pris-% = aktuel effektiv pris ÷ toppris. Stor efterspørgsel giver > 100 % og opfylder - altid reglen. Mangler stationens grundpris, bruges `fillType.pricePerLiter × højeste faktor × - sværhedsgradens multiplikator` som toppris. - -Beslutning, i rækkefølge: - -1. **Salgskandidater**: politik ∈ {sell, sellElseStore}, pris-% ≥ sellAtPercent, tilgængeligt > 0. - Kører **uanset mængde**. Rangering: højeste pris-% først, derefter størst tilgængeligt. -2. **Lagerkandidater**: politik ∈ {store, sellElseStore} og (fuldt læs ELLER lager højt). - Rangering: højeste fyldningsgrad først, derefter størst tilgængeligt. -3. Ingen kandidat → hold ved Wait. Log én gang pr. ændret begrundelse: - `venter: SUGAR 78 % af top (kræver 90), lager 41 %`. - -**Opsamlingsrunde** (`collectRound`): - -- Bulk-vogn: én vare, men fra flere kilder efter tur indtil vognen er fuld (afsnit 3). -- UAL-vogn: alle kandidater med samme mål på én tur; vognens vareliste sættes til dem alle, og - AutoDrive skifter selv vare når der ikke kommer flere af den første. - -**Mål**: - -- Salg: det salgssted der betaler bedst for hovedvaren (første i rangeringen), tager alle rundens - varer, og kan nås begge veje. -- Lager: nærmeste silo-markør med læsse+aflæsningsstation der tager varen, fri plads ≥ hele læsset, - ikke selv kilde, fabrik eller stald, rute begge veje (samme regler som læs-byttet). - -Prisen tjekkes kun ved turens start. - -## 3. Kørsel, hold og pallelæsning - -`adOutbound.lua` med to hooks: - -**Start (controller).** Som forsyning: PickupAndDeliver, rotation "kun pålæsning", mapper til. -1. markør = første kilde, 2. markør = første kilde (pladsholder, sættes pr. tur). Turen -registreres i `activePickups`, så "stå og læs"-grebet og 30 s-sikkerhedsventilen gælder. - -**Hook 1 — næste pålæsning** (det eksisterende `getNextPickup`-wrap): udkørsels-traktor → -planlæggeren spørges. Tur → vognens `selectedFillTypes` = turens varer, 2. markør = målet, -kilden returneres. Ingen tur → ledigt Wait-punkt fra run'ets pulje returneres (reservation + -rutetjek via adWaitPool), traktoren markeres "holder". - -**Hook 2 — læsseopgaven** (prepend på `LoadAtDestinationTask.update`): - -- *Holder ved Wait:* traktoren stoppes ved punktet; planlæggeren spørges hvert 5. sekund. Tur → - opgaven omdirigeres til kilden (`redirect`), varer + 2. markør sættes, "holder" slippes. -- *Ved objektlager:* når markøren er nået, traktoren står stille og AutoDrive har aktiveret UAL: - kald `placeable:removeAbstractObjectsFromStorage(objectInfoIndex, 1, nil)` for turens vare; - vent til lagerets antal falder OG UAL's antal læssede stiger; gentag. Stop når - `ualIsFull()`, lageret er tomt for varen, eller intet er sket i 30 s → `task:finished()`. - Ved runde med flere pallevarer tages varerne efter tur. `connection = nil` så - AutomaticBaleStorage-moddens kø ikke aktiveres. -- *Kør uanset mængde:* stiger vognens fyldning ikke i 15 s og vognen ikke er tom → `task:finished()`. -- *Runde, bulk:* når læsning stopper ved kilde A med fri plads ≥ 10 % og en anden kilde i listen - har samme vare, omdirigeres opgaven til kilde B i stedet for at afslutte. Hver kilde højst én - gang pr. tur. - -**Aflæsning** er AutoDrives egen (tip i silo; tip eller UAL-aflæsning ved salgssted). -`adUnloadWait` springer udkørsels-traktorer over (run'ets `kind`). Efter aflæsning går AutoDrive -selv til næste pålæsning → hook 1 igen. Stop som i dag. - -`adWaitPool.lua` udskilles fra `adUnloadWait.lua`: reservationer (markør-id → køretøj), -`hasRouteTo`/`hasRouteBothWays`, `redirect`, kandidatliste for en pulje. Begge moduler bruger den. - -## 4. Fejl, advarsler og tests - -**Uopnåeligt salgssted / ingen silo.** Kan det bedst betalende salgssted ikke nås, tages det -næstbedste nåelige. Kan intet nås (eller ingen silo med plads ved Lager), vises en rød -AutoDrive-notifikation på skærmen -(`AutoDriveMessageEvent.sendMessageOrNotification(vehicle, ERROR, …)`): -`ADSmartPickup: 'MT635' kan ikke nå salgssted 'Grain Elevator' for SUGAR – tjek vejnettet`. -Samme tekst i AutoDrives notifikations-historik, log.txt (WARN) og run'ets statuslinje. -Traktoren holder ved Wait og prøver igen hvert 5. sekund. Gentages først når traktor, station -eller vare ændrer sig. - -**Fejlhåndtering:** spil-API-kald i pcall; fejl → hold ved Wait + WARN én gang, aldrig -AutoDrive-stop. Validering ved Start (nye grunde): `noSource` (markør uden genkendelig kilde), -`needsAutoload` (pallevare uden UAL), `noProducts` (ingen vare med politik ≠ Fra). Objektlager -der ikke spawner → 30 s-vagt afslutter læsningen med det der er. - -**Tests (luajit, uden spil):** - -- planlægger: prisregel, tærskler, alle fire politikker, rangering, runde bulk/UAL, uanset mængde -- priser: toppris, pris-%, stor efterspørgsel > 100 %, fallback uden stationspris -- model + XML v2 + migration fra v1 -- objektlager-udtag som tilstandsmaskine mod fake-lager (fuld, tom, stall) -- kilde-genkendelse mod fake-markører (fabrik/silo/objektlager, paller ved spawner) -- controller-validering (noSource, needsAutoload, noProducts) - -**In-game (savegame 3):** sukker → salgssted ved høj pris; TMR → Farma-silo; halmkurve fra -fabrik og fra objektlager → halm-salgsstedet (UAL); uopnåeligt salgssted → advarsel på skærm. - -## Filer (forventet) - -``` -FS25_ADSmartPickup/ - adRuns.lua kind, sourceWayPointIds, products, tærskler, collectRound; XML v2 - adRunsStorage.lua v2 læs/skriv, v1-migration - adOutboundPlanner.lua NY, ren - adPrices.lua NY, ren del + spiladapter - adSources.lua NY, kilde-genkendelse + lagerstatus - adOutbound.lua NY, hooks, objektlager-udtag, hold, runde - adWaitPool.lua NY, udskilt fra adUnloadWait.lua - adUnloadWait.lua bruger adWaitPool; springer outbound over - adRunsController.lua start/validering for outbound - adSmartPickup.lua source() af nye filer; getNextPickup-wrap kalder adOutbound - gui/SmartPickupFrame.lua faner + fælles blokke - gui/SupplyTab.lua NY - gui/OutboundTab.lua NY - l10n/l10n_da.xml, l10n_en.xml, README.md -tests/test_adOutboundPlanner.lua, test_adPrices.lua, test_adSources.lua, -tests/test_adOutbound.lua, test_adRuns.lua (v2), test_adRunsController.lua (outbound) -``` diff --git a/docs/superpowers/specs/2026-09-25-markarbejde-etape1-design.md b/docs/superpowers/specs/2026-09-25-markarbejde-etape1-design.md deleted file mode 100644 index d7bcec7..0000000 --- a/docs/superpowers/specs/2026-09-25-markarbejde-etape1-design.md +++ /dev/null @@ -1,151 +0,0 @@ -# Markarbejde, etape 1: slåafgrøder (græs, kløver, lucerne) - -Dato: 2026-09-25 · Mod: FS25_ADSmartPickup · Branch: `feature/markarbejde` - -## Mål - -Når en græs-, kløver- eller lucernemark er klar, sender Smart Pickup selv maskinerne ud. Slåmaskinen slår -marken via Courseplay. Ved **ensilage** samler en læssevogn skårene op og kører dem til den rigtige silo. -Ved **hø** vender en vender først græsset (og en river river det evt. sammen), og derefter samles høet op. -Alt kørsel på vej sker med AutoDrive. Alt arbejde på marken sker med Courseplay. - -Succes: en mark sat til *Auto* går fra "klar" til "færdig" med græsset i en silo, uden at spilleren rører -noget, og menuen viser hele tiden, hvad der sker og hvorfor. - -Ikke med i etape 1: mejetærskerafgrøder (etape 2), roer (etape 3), jordbehandling og såning, baller. - -## Arbejdsdeling - -| Del | Ansvar | -|---|---| -| **Smart Pickup (vores)** | Hvilken mark, hvilket trin, hvilken maskine, hvilken silo. Holder status og viser den | -| **AutoDrive** | Kørsel på vej til markens `F`-markør og til silo; overtager ved "vogn fuld" og genstarter Courseplay | -| **Courseplay** | Markgrænse, kursus, selve arbejdet på marken, "fuld"/"færdig"-signaler | - -## Beslutninger (med begrundelse) - -1. **Faste sæt.** Redskabet sidder fast på traktoren (eller er selvkørende, fx Krone BiG M 450). Mod'en - kobler aldrig redskaber på. *Begrundelse:* auto-påkobling var det, der aldrig blev pålideligt i AI-Bonden. -2. **Markflåde med forslag og flueben.** Mod'en foreslår sæt ud fra redskabets spec (`spec_mower`, - `spec_tedder`, `spec_windrower`, `spec_forageWagon`); kun sæt med flueben bliver sendt ud. - *Begrundelse:* mod'en må aldrig tage en maskine, spilleren selv bruger. Et sæt, spilleren sidder i - (`getIsControlled`/`getIsEntered`), springes altid over. -3. **Staging-markør pr. mark = AutoDrive-markør med navnet `F`** (fx `F109`), stort/småt - ligegyldigt, evt. med mellemrum (`F 109`). Mangler den, vises marken med "!" og sendes ikke ud. -4. **Auto/Manuel pr. mark.** Auto starter selv ved "klar"; Manuel venter på *Start nu*. Standard: Manuel - (intet sker uventet efter opdatering). -5. **Plan pr. mark: Ensilage eller Hø.** Standard: Ensilage. **Riv** er et valg pr. mark, standard *fra*: - læssevognene i Unrealistic Pack kan køre 40 m bredt og samler spredt hø op. -6. **Bredde.** Maskiner kører med deres *aktuelle* bredde (Unrealistic Packs `variableWorkWidth`, - `aiKeepCurrentWidth`). Courseplay kan højst 50 m → et sæt over 50 m sendes ikke ud (advarsel). - Marken husker **slåbredden**; opsamling/vending/rivning kræver bredde ≥ slåbredden. - **Revideret efter in-game test 25/9:** et nyt Courseplay-kursus til læssevognen lå forskudt i forhold til - skårene (anden start/retning/forager). Derfor genbruger vender, river og læssevogn **slåmaskinens - kursus** (`vehicle:cpCopyCourse`). Kurset gemmes pr. mark; efter genindlæsning hentes det fra - slåmaskinen (id gemt som `mowVehicle`), som Courseplay selv gemmer kurset på. Uden kendt slåkursus - laves et nyt som reserve (logges). - **Revideret igen 25/9 (smalle standardmaskiner):** vender og river kører hele marken i egen bredde med - eget kursus (intet breddekrav). Læssevognen følger skårene: uden riv slåmaskinens kursus og bredde ≥ - slåbredde; med riv rivens kursus (skåret ligger midt i banen) og intet breddekrav. Riv kan vælges til - både hø og ensilage. - **Sideriver (25/9):** mens riven arbejder, måles nedlægningsområdets midte (runtime-felt - `dropWindrowWorkAreaIndex`, ellers riverens XML) i traktorens ramme minus rivens CP-forskydning = - skårets sideforskydning (gemt pr. mark). Læssevognen på rivens kursus får `toolOffsetX = egen − - skårets` (±10 m) og får sin egen værdi tilbage bagefter. -7. **Silovalg** genbruger Smart Pickups motor (`ADLoadSwap`). *Tving lager* pr. mark (som "Lager til" i - flows). **Revideret 25/9:** ensilage går først til egne **gæringssiloer** (produktion skår -> SILAGE, - fx FermentingSiloPack/GrassSilageSilo), nærmest marken, ellers nærmeste silo med plads; hø kun til - almindelige siloer (gæringssiloer laver også hø om til ensilage). -8. **Marksilo.** En silo, hvis afstand til markens polygon er ≤ 150 m, er et mellemlager. Den må gerne - vælges (korte ture). Oversigten viser "!" ved marken, hvis intet Smart Pickup-udkørselsflow har - siloen som kilde — hjemkørslen klares af de eksisterende flows, ikke af markdelen. -9. **"Klar"** = ≥ 90 % af markens prøvepunkter står i høstvinduet for markens slåafgrøde - (`FSDensityMapUtil.getFruitArea`, samme metode som Arbejdsseddel, bevist i spillet). `field.fieldState` - bruges ikke (frossen NPC-plan for spillerejede marker). -10. **Slåafgrøde** afgøres ud fra spillets konvertere: `MOWER` giver et skår, og `TEDDER` gør skåret til en - `DRY…`-type (græs, lucerne, kløver, kløver2). Rug/bønner (→ halm) er udelukket. Ensilage-varen er - skåret (`GRASS_WINDROW` …), hø-varen er tørskåret (`DRYGRASS_WINDROW` …). -11. **Én maskine, én mark.** Flere marker kan køre samtidig. Højst 2 læssevogne pr. mark. -12. **Rækkefølge.** Næste trin starter først, når det forrige er *færdigt* på hele marken (ingen vender - bag slåmaskinen). Enklere og uden kollisioner mellem Courseplay-kurser. -13. **Fejl stopper ikke alt.** Kan Courseplay ikke lave kursus, prøves igen efter 5 min; imens står marken - med grunden. Andre marker kører videre. -14. **Genindlæsning.** Markernes trin gemmes i `ADSmartPickup_fields.xml` i savegame-mappen. Efter - indlæsning genoptages trinnet: sæt, der ikke længere kører, bliver sendt ud igen. - -## Opbygning - -Rene moduler (ingen spil-API, testes med `luajit`): - -| Fil | Ansvar | -|---|---| -| `adFieldModel.lua` | Mark-plan og -status som data: plan, tilstand, overgange (`nextStage`), bredde-regler, gem/indlæs-records | -| `adFieldFleet.lua` | Rolle ud fra redskabs-specs, forslag, valg af ledigt sæt (nærmest markøren) | -| `adFieldView.lua` | Rækker og tekster til fanen Marker (sortering, filtre, statusord) | - -Spil-moduler (tynde, pcall'ede): - -| Fil | Ansvar | -|---|---| -| `adFields.lua` | Ejede marker, polygon/prøvepunkter, afgrøde, klar-andel, `F`-markør, marksiloer | -| `adCpBridge.lua` | Start Courseplay på en mark (grænse → kursus → start, async med annullering), læs aktiv/færdig | -| `adFieldJobs.lua` | Tick hvert sekund: opdag klar, send sæt ud, følg ankomst → CP → færdig, næste trin, gem | -| `gui/SmartPickupFieldTab.lua` | Fanen **Marker**: tabel, detalje, markflåde | - -### Forløb pr. mark - -``` -Ensilage: idle → ready → mowing → collecting → done -Hø: idle → ready → mowing → tedding → [raking] → collecting → done -done → idle, når marken ikke længere er slået (vokser igen) -``` - -Hvert arbejdstrin har undertilstande pr. sæt: `driving` (AutoDrive til `F`) → `starting` (CP laver -kursus) → `working` (CP kører) → `returning` (AutoDrive til parkering). Trinnet er færdigt, når Courseplay -meldte færdig for mindst ét sæt, og intet sæt på marken stadig arbejder. - -**Slå/vende/rive:** AutoDrive `DRIVETO` til `F`-markøren. Ved stop inden for 30 m starter -`adCpBridge` Courseplay med `fieldPosition` = et prøvepunkt inde i marken. Når CP er færdig: AutoDrive -`DRIVETO` til sættets parkering (sættets AD-parkeringsmarkør, ellers bliver det stående ved `F`). - -**Opsamling:** Før CP startes sættes AutoDrive til `PICKUPANDDELIVER`, første markør = `F`, anden = -silo, `setStartHelper(true)` med hjælper Courseplay. Så klarer AutoDrive og Courseplay selv -fuld → aflæs → tilbage → `restartCP`. Ved CP færdig: AutoDrive tømmer resten (`enableParkAtJobFinished`) -og parkerer. - -## Fanen Marker - -- Tabel: **Mark · Afgrøde · Plan · Auto/Man. · Status · Maskiner**. Kun slåafgrøde-marker som standard - (*Vis alle marker* viser resten). Klik på en række → detalje. -- Detalje: Plan (Ensilage/Hø), Auto/Manuel, Riv, Tving lager (liste som "Lager til"), **Start nu**/**Stop**, - status med grund, markør og slåbredde. -- Markflåde: foreslåede sæt med flueben, rolle og aktuel bredde; advarsel over 50 m. -- Knapper på fanen: Start nu / Stop på valgt mark (samme placering som flows). -- Statusord: *Venter · Klar · Slås · Vendes · Rives · Samles op · Færdig* + grund ("venter på - slåmaskine", "! mangler markør F109", "læssevogn for smal: 20 m < 40,8 m", "Courseplay: intet kursus"). - -## Fejlhåndtering - -| Situation | Hvad sker | -|---|---| -| Ingen `F`-markør | "!" i tabellen, intet sendes | -| Intet ledigt sæt med rollen | Status "venter på …", prøver hvert 5 s | -| Sæt for bredt (> 50 m) eller for smalt (< slåbredde) | Sættet springes over med grund | -| AutoDrive kan ikke finde vej | Sættet frigives, status med grund, andet sæt prøves | -| CP-grænse/kursus fejler | 5 min pause for marken, grund vises | -| Spilleren sætter sig i et sæt | Sættet frigives fra marken (arbejdet fortsætter ikke automatisk) | -| Genindlæsning | Trin genoptages fra gemt fil | - -## Test - -- `tests/test_adFieldModel.lua`, `tests/test_adFieldFleet.lua`, `tests/test_adFieldView.lua`: alle - overgange, bredde-regler, forslag, sortering/filtre, gem/indlæs. -- In-game testprotokol (manualen + denne spec): én mark Manuel → Start nu → slå → saml op → silo, derefter - Hø, derefter Auto. Loglinjer: `ADSmartPickup: F109 …`. - -## Senere udvidelser (noter) - -- **Flere maskiner om samme mark, maks 5** (Courseplays multi-tool-grænse, 2026-09-25): slå/vende/rive med - op til 5 sæt på én mark samtidig via Courseplays konvoj (multi-tool), og op til 5 læssevogne. Kræver at - `ADFieldModel.maxRigs` bliver en indstilling pr. mark (1–5) og at sættene får samme CP-kursus med - hver sin position i konvojen. diff --git a/docs/superpowers/specs/2026-09-26-guider-design.md b/docs/superpowers/specs/2026-09-26-guider-design.md deleted file mode 100644 index cc784f0..0000000 --- a/docs/superpowers/specs/2026-09-26-guider-design.md +++ /dev/null @@ -1,145 +0,0 @@ -# Guider til opsætning: marker og flows — design - -Dato: 2026-09-26 · Status: udkast til godkendelse · Mod: FS25_ADSmartPickup (v1.30.4.0 → v1.31/v1.32) - -Leveres i to etaper, der hver kan testes for sig: -- **Etape A (v1.31):** fælles guide-ramme + Fields-fanen ("Opsæt mark"). -- **Etape B (v1.32):** Flows-fanen på samme ramme ("Opsæt flow"), så de to faner er ens at bruge. - -## Formål - -Fields-fanen er blevet en lang liste af valg, der er svær at overskue (brugerens ord: "uoverskueligt"). -Målet er, at man sætter en mark op med **ét spørgsmål ad gangen**, og at alt, der kan afgøres automatisk, -aldrig bliver spurgt om. En eksisterende opsætning skal kunne rettes uden at gå hele guiden igennem. - -Succes: brugeren kan sætte F22 op fra bunden på under et minut uden at scrolle, og kan rette ét valg -(fx lager) med to tryk. - -## Hvad brugeren ser - -### Oversigt (fanens standardvisning) - -1. Marktabellen som i dag (Mark · Afgrøde · Plan · Kørsel · Status (kort) · Maskiner). -2. Når en mark er valgt: et **resumé** på én linje pr. emne, hver med sin egen række man kan klikke for at - rette det ene trin ("Ret"): - - Afgrøde: `Behold det der gror` / `-> Hvede` - - Afgrødens valg: `Ensilage, river` / `Halm hakkes` - - Klargøring: `Automatisk` / `Plov, kalk` - - Pleje: `Gødning + ukrudt` - - Maskiner: `3 faste` / `Ledige maskiner` · `1 ad gangen, 1 aflæsningsvogn` - - Lager: `Marksilo` / `Automatisk` / `F22 Silo` - - Status (hele teksten, ombrudt) -3. Knapper i bundlinjen (når en mark er valgt): **[Opsæt mark]**, **[Start nu / Stop]**, **[Markflåde]**. - Uden valgt mark: **[Markflåde]**. -4. Den lange detaljeliste og den fælles markflåde nederst forsvinder fra oversigten. - -### Guide (erstatter fanens indhold, mens den er åben) - -Ét trin pr. skærm: overskrift ("Trin 3 af 7 — Græs: ensilage eller hø?"), en kort forklaring, valgene som -rækker, og bundknapperne **[Næste]** (eller **[Gem]** på sidste trin), **[Tilbage]**, **[Annullér]**. -Trin der ikke er relevante for marken springes over, og tællingen følger de viste trin. - -| # | Trin | Vises når | Valg | Standard | -|---|------|-----------|------|----------| -| 1 | Mark | guiden er åbnet uden valgt mark | liste over marker (nr, afgrøde, status) | — | -| 2 | Afgrøde | altid | "Behold det der gror" + afgrøder der kan sås | nuværende plan | -| 3 | Afgrødens valg | slåafgrøde (nu eller mål): ensilage/hø + river ja/nej. Høstafgrøde med halm: hakkes/samles/presses | se "Vises når" | nuværende plan | -| 4 | Klargøring | altid (efter høst / bar mark) | "Automatisk (anbefalet)" / "Vælg selv" → plov, kultivering, kalk, tromle, mulch | Automatisk | -| 5 | Pleje | afgrøde der gror | gødning ja/nej, ukrudt ja/nej | ja/ja | -| 6 | Maskiner | altid | "Faste maskiner til marken?" ja/nej; ja → liste (kun ledige + dem der allerede er faste her); antal ad gangen (1–5); aflæsningsvogne ved høst (1–5) | nej / 1 / 1 | -| 7 | Lager | høst/slåning | marksilo findes: "Brug marksiloen?" ja/nej; ellers/nej: "Automatisk (anbefalet)" / "Vælg selv" → de 8 nærmeste siloer | Automatisk | -| 8 | Oversigt | altid | resumé med [Ret] pr. linje; [Kør automatisk] (Auto) / [Gem uden at starte] (Manual) | — | - -### Redigering - -- **[Opsæt mark]** på en opsat mark åbner guiden med de nuværende valg udfyldt. -- **[Ret]** på en resumé-linje (oversigt eller trin 8) åbner kun det ene trin; **[Gem]** der gemmer og går - tilbage dit man kom fra. -- **[Annullér]** kasserer ændringer i guiden (intet gemmes før [Gem]/[Kør automatisk]). - -### Markflåde - -Egen visning (knap **[Markflåde]**): "Brug alle ledige maskiner" + listen med flueben, "fast på F22", -"i gang på F108", bredde-advarsler. Samme indhold som i dag, flyttet ud af markens detaljer. - -## Hvad der ALDRIG spørges om (automatisk) - -Scanning af marken og markøren, hvornår der høstes/slås/gødes/sprøjtes, påfyldning (eget lager før køb), -tømning ved forkert vare, ventetid ved regn, bredde-kontrol, valg af nærmeste maskine, ruter, gæringssilo ved -ensilage, marksilo-tømning (advarsel hvis intet flow tømmer den). - -## Etape B: Flows-fanen på samme måde - -Brugerens ønske: "det samme for flow opsætningen så det bliver ensrettet". - -**Oversigt:** flow-tavlen som i dag (én linje pr. flow med status). Valgt flow → resumé med [Ret]-linjer -(Retning · Fra · Til/Lager · Varer · Regler · Vent ved · Traktorer · Status) og bundknapperne -**[Opsæt flow]**, **[Start/Stop]**, **[Nyt flow]**, **[Omdøb]**, **[Slet]**, **[Start alle / Stop alle]**. -Den lange detaljeliste forsvinder. - -**Guiden "Opsæt flow"** — samme trin-skærm, knapper og [Ret]/[Annullér] som markguiden: - -| # | Trin | Vises når | Valg | Standard | -|---|------|-----------|------|----------| -| 1 | Navn | nyt flow | tekst (FS25-tekstdialog) | "Flow N" | -| 2 | Retning | altid | Ind til bygning (forsyning) / Ud fra bygning (udkørsel) | nuværende | -| 3 | Fra / Til | altid | bygninger med markør, fast rækkefølge (navn, markør), "Vis alle" | nuværende | -| 4 | Varer | altid | forsyning: "Automatisk (det bygningen mangler)" / "Vælg selv" → varer; udkørsel: pr. vare Sælg / Lager / Begge / Fra | Automatisk / nuværende | -| 5 | Lager | udkørsel med Lager | "Automatisk (nærmeste med plads)" / "Vælg selv" → lagerbygninger | Automatisk | -| 6 | Regler | altid | udkørsel: sælg ved pris-%, lager over %, samlerunde ja/nej; forsyning: antal ture, retur fra Wait under % | nuværende / standard | -| 7 | Vent ved | altid | Wait-puljer | nuværende | -| 8 | Traktorer | altid | ledige traktorer (+ dem i flowet); traktorer i andre flows skjult med linje "N i andre flows" | nuværende | -| 9 | Oversigt | altid | resumé med [Ret]; [Start flow] / [Gem uden at starte] | — | - -Automatisk (spørges aldrig): markør ved bygningen, rutetjek, bedste foder først/parallelt foder, læs-bytte, -kildevalg (mindst/fyldeste først), fordeling på flere mål, hvile af tomme kilder, traktornavne efter flowet. - -## Arkitektur - -- **`adGuide.lua` (ny, ren Lua, testet):** den fælles guide-motor, uafhængig af marker/flows: - trin-liste, aktuelt trin, kladde, næste/tilbage/spring (til [Ret]), "returnér til oversigt", tælling - "Trin 3 af 7" over de synlige trin. Både markguiden og flowguiden er en trin-definition oven på den. -- **`gui/SmartPickupGuide.lua` (ny):** den fælles trin-skærm i menuen (overskrift, forklaring, valgrækker, - bundknapper Næste/Tilbage/Annullér/Gem) — ét sted, så de to guider ser og opfører sig ens. -- **`adFlowGuide.lua` (ny, etape B, ren Lua, testet):** flowguidens trin, valg, standarder og resumé; gemmer - via `ADRunsController.updateRun`/`assignVehicle` som i dag. -- **`adFieldGuide.lua` (ny, ren Lua, testet):** markguidens trin (oven på `ADGuide`). - - `ADFieldGuide.open(field, info, context)` → guide-tilstand `{fieldId, draft, steps, index, returnTo}`; - `draft` er en kopi af markens plan (+ faste maskiner), så intet ændres før gem. - - `ADFieldGuide.steps(draft, info, context)` → listen af synlige trin (tabellen ovenfor). - - `ADFieldGuide.choices(step, draft, info, context)` → rækkerne for trinnet (tekstnøgler + værdier). - - `ADFieldGuide.choose(guide, key, value)` → ny guide med ændret draft (immutabelt). - - `ADFieldGuide.next/back/jump(guide, step)`; `ADFieldGuide.result(guide)` → `{plan, rigIds}` til gem. - - `ADFieldGuide.summary(field, info, context)` → resumé-linjerne `{step, key, args}`. - - `context` = det spil-afhængige: marksilo findes?, siloer i nærheden, maskiner (ledige/faste), afgrøder - der kan sås. Hentes af GUI'et, så modellen kan testes uden spillet. -- **`gui/SmartPickupFieldTab.lua`:** oversigt (tabel + resumé) og guide-visning; bygger rækker ud fra - `ADFieldGuide`, gemmer via `ADFieldJobs.setPlan` og `ADFieldFleet.dedicate`. Fil-størrelse: guide-visningen - lægges i `gui/SmartPickupFieldGuide.lua` (ny), så ingen fil passerer ~800 linjer. -- **`gui/SmartPickupFrame.lua`:** bundknapper for fanen (Opsæt mark, Start/Stop, Markflåde, Næste/Tilbage/ - Annullér/Gem i guiden) og routing af klik. -- **Uændret:** `ADFieldModel` (plan-felter og lagring), `ADFieldJobs`, `ADFieldWork`, gemt XML. Guiden er et - nyt ansigt på de samme data — eksisterende opsætninger virker videre. - -## Fejl og kanttilfælde - -- Mark uden markør: guiden kan køres, men oversigten siger "sæt markøren F22" og [Kør automatisk] gemmer som - Manual indtil markøren findes (samme regel som i dag). -- Maskine der er fast på en anden mark vises ikke i trin 6 (som v1.30.3); linje "N maskiner er faste på - andre marker". -- Marken forsvinder (solgt/bygget over) mens guiden er åben: guiden lukkes, oversigten vises. -- Guiden er åben og menuen lukkes: kladden kasseres. - -## Test - -- `tests/test_adGuide.lua`: motoren — næste/tilbage over skjulte trin, spring+retur, tælling, annullér. -- `tests/test_adFlowGuide.lua` (etape B): trin pr. retning, varer auto/vælg, lager-trin kun ved Lager, resumé. -- `tests/test_adFieldGuide.lua`: synlige trin pr. marktype (slåafgrøde, høst med halm, bar mark, mål-afgrøde), - standardvalg, udfyldning fra eksisterende plan, `choose` ændrer kun kladden, `result` giver plan + rigIds, - [Ret]-spring og tilbage, "Automatisk" vs "Vælg selv" i klargøring og lager, marksilo-spørgsmålet kun når en - findes, resumé-linjerne. -- Hele suiten skal være grøn; GUI-routing verificeres in-game (testplan-punkt). - -## Uden for scope - -Vinmarker (egne roller), genoptagelse af Courseplay efter genstart, fanerne Factories/Silos/Barns/Pallet sheds. diff --git a/docs/superpowers/specs/2026-09-26-markarbejde-etape2-3-design.md b/docs/superpowers/specs/2026-09-26-markarbejde-etape2-3-design.md deleted file mode 100644 index 48da847..0000000 --- a/docs/superpowers/specs/2026-09-26-markarbejde-etape2-3-design.md +++ /dev/null @@ -1,52 +0,0 @@ -# Markarbejde, etape 2–3 + multi-maskine: høst, roer, jordbehandling, såning - -Dato: 2026-09-26 (nat) · Bygger på `2026-09-25-markarbejde-etape1-design.md` · Branch `feature/markarbejde-2` - -Brugeren gav fuldt mandat ("byg resten, ALLE PUNKTER, du træffer selv gode valg") og tester i spillet dagen -efter. Beslutningerne herunder er truffet uden dialog og skal læses igennem. - -## Mål - -Alle gårdens marker kan køres af Smart Pickup: slåafgrøder (etape 1) og nu **høstafgrøder** (korn, raps, -majs, roer, kartofler … alt der høstes med mejetærsker/optager), inkl. **aflæsningsvogne**, **jordbehandling** -efter høst og **såning**. Hvert trin kan køres med **1–5 maskiner** på samme mark (Courseplays multi-tool). - -## Beslutninger - -1. **Høst er én pipeline for alle høstafgrøder.** Roer og korn adskiller sig kun ved hvilken høster der - kan tage afgrøden. Høsteren matches på afgrøden: skærebordets/optagerens frugttyper (`spec_cutter`) - skal omfatte markens afgrøde. Finsnittere (cutter med `fruitTypeConverters`) bruges ikke til høst. -2. **Aflæsning under høst = Courseplays combine-unloader** (`COMBINE_UNLOADER_CP`, bevist i AI-Bonden): - vognen følger høsteren, tømmer den og kører til en **station**. Stationen vælges af Smart Pickups - silovalg (nærmeste silo med plads der tager afgrøden, eller *Tving lager*); findes den ikke i - Courseplays stationsliste, bruges nærmeste station. Antal vogne pr. mark: 1–3 (plan). -3. **Høsteren sendes hjem** (AD-parkering) når Courseplay er færdig og tanken er tømt. Aflæsningsvogne - stoppes når høsten er færdig; har de rest, kører AutoDrive den til lageret (samme reserve som - læssevognen), ellers parkering. -4. **Efter høst** (valg pr. mark): *Ingenting*, *Kultivér* eller *Pløj*, og derefter evt. **Så**. - Såmaskinen sættes til planens afgrøde (standard: samme afgrøde som blev høstet). Kan afgrøden ikke - sås i den nuværende periode, venter marken med grund. Tom for frø -> sættet springes over med grund - (moden fylder ikke frø på; det gør brugeren/AutoDrive-refill). -5. **Multi-maskine (1–5 pr. trin, plan pr. mark).** Trin med eget kursus (slå, vend, riv, høst, - kultivér, pløj, så) laver ét Courseplay-kursus med `multiTools = n` på første sæt; de øvrige kopierer det, - og hver får sin bane (`laneOffset`: 2 -> −1,1 · 3 -> −1,0,1 · 4 -> −2,−1,1,2 · 5 -> −2…2). Et multi-trin - startes kun når **alle n** sæt er ledige (ellers kører én bane ikke). Læssevognen på et multi-kursus - kræver også n vogne, én pr. bane. -6. **Rolle-rækkefølge** når et køretøj har flere redskaber: høster > læssevogn > river > vender > - slåmaskine > såmaskine > kultivator > plov > aflæsningsvogn (traktor+vogn som Courseplay kan bruge som - combine-unloader). -7. **Afgrødetype pr. mark:** slåafgrøde (etape 1) eller høstafgrøde (alt andet med en høstbar frugt). Plan - *Høst* er standard for høstafgrøder. Marker uden afgrøde (pløjet/stub) kan få *Start nu* for - jordbehandling/såning alene. -8. **Stadig ikke med:** gødning/kalk/sprøjtning, baller, halm efter mejetærsker, auto-påfyldning af frø. - -## Opbygning (ændringer) - -| Fil | Ændring | -|---|---| -| `adFieldModel.lua` | Planer `harvest`, jordbehandling, såning, `rigs`/`unloaders`; trin `harvesting/cultivating/plowing/sowing`; hjælperoller pr. trin; `lanePositions(n)` | -| `adFieldFleet.lua` | Nye roller; `pick` tager afgrøde (høster skal kunne tage den); `pickMany` til multi-trin | -| `adCpBridge.lua` | `startUnloader`, `canHarvest`, frø-valg (`setSeedFruit`, `isPlantable`, `hasSeeds`), multi-tool kursus + lane | -| `adFields.lua` | Afgrødetype (slå/høst), sidst høstede afgrøde | -| `adFieldWork.lua` (ny) | Udsendelse og opfølgning af sæt (AD/CP) — flyttet ud af `adFieldJobs.lua` der blev for stor | -| `gui/SmartPickupFieldTab.lua` | Plan-rækker pr. afgrødetype, maskiner pr. trin, aflæsningsvogne, efter høst, så + afgrøde | diff --git a/docs/superpowers/specs/2026-09-27-marksilo-flow-design.md b/docs/superpowers/specs/2026-09-27-marksilo-flow-design.md deleted file mode 100644 index 6d5c862..0000000 --- a/docs/superpowers/specs/2026-09-27-marksilo-flow-design.md +++ /dev/null @@ -1,103 +0,0 @@ -# Marksilo-flow — design - -Dato: 2026-09-27 · Status: godkendt i chat, afventer review af denne spec - -## Formål - -Høsten lægges ofte i siloer ude ved markerne (mellemlager). I dag skal spilleren selv huske at køre varerne -hjem. Marksilo-flowet kigger jævnligt alle marksiloer igennem og sender selv op til **x** ledige vogne ud for at -hente varerne hjem til gårdens lagre. - -**Succes:** står der varer i en marksilo, som gården kan tage, bliver de kørt hjem uden at spilleren gør noget, -med højst x vogne ude ad gangen, og uden at tage vogne fra markarbejde, andre flows eller spilleren. - -## Brugerens valg (fra brainstorm) - -| Spørgsmål | Valg | -|---|---| -| Hvor er "hjem"? | Nærmeste af de gårdlagre, spilleren vælger i flowet, der tager varen og har plads | -| Hvad er "x vogne"? | Lånte ledige vogne fra hele gården — ingen fast tilknytning — højst x ude samtidig | -| Hvilke vogne må lånes? | Samme regel og flueben som markarbejdet (`fleetMode` "all"/"ticked", `ADFieldJobs.state.fleet`) | - -## Begreber - -- **Marksilo:** bygning af typen silo inden for `ADFields.FIELD_SILO_DISTANCE` (150 m) af en marks polygon - (`ADFields.fieldSilos`), samlet over alle marker, uden dubletter. En bygning, der er valgt som gårdlager i - flowet, er aldrig marksilo. Det samme gælder siloer, der er kilde, mål eller lager i et flow (tilføjet efter review - 2026-09-27: ellers tømte flowet gårdsiloer, som forsyningsflows bruger, fordi de ligger under 150 m fra en mark). -- **Gårdlager:** bygning spilleren vælger i flowet (samme bygningsvælger som udkørslens lagre, `storeBuildings`). -- **Lån:** en vogn, flowet har sendt på én tur. Lån ligger kun i hukommelsen. - -## Flowet - -Marksilo-flowet er ÉN fast opsætning pr. savegame — ikke en ny run-type. Beslutning (2026-09-27, under -planlægning): run-editoren (`SmartPickupFrame`) er bygget til præcis to typer (forsyning/udkørsel) mange steder, -og runs har faste vogne, Start/Stop pr. vogn og Wait-puljer, som intet af det passer til lånte vogne. En egen -underfane + egen gemmefil giver langt færre indgreb i eksisterende kode. - -Opsætning (`ADSmartPickup_fieldsilos.xml` i savegame-mappen, gemmes sammen med runs): -- `enabled` — til/fra, standard fra -- `storeIds` — gårdlagre (bygnings-id'er fra `ADBuildings`, kun `kind == "silo"`) -- `maxVehicles` — x, 1–10, standard 2 -- `minLiters` — tærskel pr. silo og vare: 5000 / 10000 / 25000 / 50000, standard 10000 - -## Planlægning (ren logik, nyt modul `adFieldSiloPlanner.lua`, testes med luajit) - -Input: marksiloer `{id, markerId|nil, stock = {[fillType] = liters}}`, gårdlagre `{id, markerId, accepts = -{[fillType] = freeLiters}, x, z}`, aktive lån `{siloId, fillType, liters}`, ledige vogne `{id, x, z, capacity, -carries = {[fillType] = true}}`, `maxVehicles`, `minLiters`. - -1. **Kvalificerede opgaver:** pr. (silo, vare) hvor silo har markør, `liters >= minLiters`, og mindst ét - gårdlager har `freeLiters >= minLiters` for varen. Resten = liters minus det aktive lån på samme (silo, vare) - allerede er på vej med; kun opgaver med rest `>= minLiters` tæller. - Siloer uden markør → status `noMarker`; varer intet gårdlager tager → status `noFarmStore`. -2. **Rækkefølge:** fuldeste rest først; lige → lavest silo-id (stabilt). -3. **Vogne:** ledige pladser = `maxVehicles - #aktive lån`. For hver opgave i rækkefølge, så længe der er - pladser: nærmeste ledige vogn (til siloen), der kan bære varen; resten reduceres med vognens kapacitet; - samme opgave får flere vogne, så længe resten stadig er `>= minLiters`. -4. **Gårdlager pr. lån:** nærmeste gårdlager (til siloen) der tager varen med `freeLiters >= min(vognens - kapacitet, rest)`; ellers det med mest plads; ellers intet (opgaven springes over denne runde). - -Output: `{dispatch = {{vehicleId, siloId, siloMarkerId, fillType, storeMarkerId, liters}}, status = {...}}`. - -## Udførelse (`adFieldSilos.lua`) - -- **Takt:** planlæg hvert 60. s (spiltid via `dt`), kun når flowet kører. Lagertal læses som i dag - (`ADSmartPickup.getStationLevelAndCapacity` / silo-lagre). -- **Ledige vogne:** kun vogne med AD-parkeringspunkt (ellers bliver de holdende på lagerets markør); `ADFieldWork.getRigs(adEnv)` filtreret til `role == "unloader"` (traktor + vogn, som Courseplay - godkender som tømmevogn — ikke sprøjter/høstere med tank), `enabled and not busy and not controlled`, og - vogne der har aflæsbare enheder (`AutoDrive.getAllDischargeableUnits`), der understøtter varen. - `getRigs.busy` udvides med `ADFieldSilos.isLoaned(vehicle)`, så markarbejdet ikke tager en lånt vogn - (flows tager allerede kun deres egne tilknyttede vogne). -- **Afsend:** AutoDrive "Hent og aflever": første markør = silomarkør, anden = gårdlagerets markør, vare = - lånets vare, `loopCounter = 1`, parkering ved job slut slået til (`enableParkAtJobFinished`); start som - markarbejdet (`startAd` → mode:start). Log: `marksilo: '' henter l i → `. -- **Frigivelse:** lånet slettes når AD ikke længere er aktivt for vognen (turen og parkeringen er færdig), - når spilleren sætter sig i den, eller når flowet stoppes (stop = AD stoppes på lånte vogne). -- **Vagt:** er vognen ikke kommet i gang (ingen stigning i fyldning og står stille) efter 120 s, stoppes AD, - lånet slettes, og der logges én advarsel pr. (silo, vare), så samme opgave ikke fejler i ring hvert minut - (opgaven får 10 min pause). -- **Genindlæsning:** lån gemmes ikke. Ved indlæsning er alle vogne frie for flowet; en vogn AutoDrive selv - genstarter, kører sin tur færdig som almindelig AD-tur. - -## GUI - -Ny underfane "Marksiloer" efter "Måned". Indstillinger: gårdlagre (eksisterende vælger), -"Maks. vogne ude" (1–10), "Mindste mængde" (5.000 / 10.000 / 25.000 / 50.000 l), Start/Stop. -Status: `x/y vogne ude · n siloer venter`, og én række pr. marksilo med indhold: silo, vare, liter, på vej, -status (`ok` / `mangler markør` / `ingen gård tager `). Tekster i `l10n_da` + `l10n_en` (`spu_fs_*`). - -## Uden for scope - -- Salg direkte fra marksilo (kan senere bruge udkørslens prisregel). -- Faste vogne i flowet (valg 1 i brainstorm) — fravalgt. -- Lån der overlever genindlæsning. - -## Test - -- `tests/test_adFieldSiloPlanner.lua`: kvalificering (tærskel, markør, gård tager varen), fuldeste først, - x-loft inkl. aktive lån, flere vogne til én stor silo, vogn der ikke kan bære varen springes over, nærmeste - gårdlager med plads, fallback til mest plads, intet lager → ingen afsendelse. -- `tests/test_adFieldSiloConfig.lua`: standarder, grænser (x 1–10, tærskel kun de fire værdier), til/fra af gårdlager, records frem og tilbage. -- In-game: silo ved en mark med 50.000 l hvede, gårdlager = Farma-silo, x = 2 → to vogne kører, afleverer, - parkerer; markarbejdet tager ikke de lånte vogne imens. diff --git a/docs/superpowers/specs/2026-09-28-flow-vogne-design.md b/docs/superpowers/specs/2026-09-28-flow-vogne-design.md deleted file mode 100644 index 2b4f02f..0000000 --- a/docs/superpowers/specs/2026-09-28-flow-vogne-design.md +++ /dev/null @@ -1,75 +0,0 @@ -# Flow-vogne — design - -Dato: 2026-09-28 · Status: godkendt i chat ("1 vognen hører til flowet", "1 automatisk", "ja alle tre", "når du har -det hele klar"). Bygger på maskinpuljen (`2026-09-28-maskinpulje-design.md`). - -## Formål - -Alle påhæng har nu egen AD-parkering "P X" og en markør "Connect P X". Flows (fodring, mælk, slurry …) skal ikke -længere have traktor og vogn låst sammen: et flow henter **sin egen** vogn, når det starter, og når flowet ikke har -noget job længere, køres vognen på plads og traktoren hjem, så begge bliver ledige i puljen. - -## Brugerens valg - -| Spørgsmål | Valg | -|---|---| -| Hvem ejer en flow-vogn? | Flowet. Puljen må kun låne den, når flowet er stoppet/slettet/uden bygning | -| Hvordan kendes flowets vogn? | Automatisk: vognen der hænger på flowets traktor huskes pr. traktor | -| Hvornår "intet job"? | Alle tre: bygning væk, flow slettet, flow stoppet | -| Mejetærskere/skærebord | Uændret — skærebord er aldrig i puljen; mejetærsker henter ikke redskaber | - -## Begreber - -- **Flowets vogn:** `run.wagons = {[traktorId] = vognId}`. Opdateres automatisk: hænger der et redskab bagpå en af - flowets traktorer (mens flowet ikke er stoppet), huskes det som traktorens vogn. Gemmes i `ADSmartPickup_runs.xml` - (`wagons="traktorId=vognId;…"`). -- **Stoppet flow:** `run.stopped = true`, gemt. Sættes af Stop på flowet, "Stop alle"/"Stop alt og parkér" og - "bygning væk"; ryddes af Start / "Start alle". Ikke udledt af om AD kører (en traktor ved et Wait-punkt kan holde - stille uden at flowet er stoppet). -- **Bygning væk:** en bygning i flowets kilde-/mål-/lagerliste findes ikke længere som placeable (uniqueId) i to - tjek i træk (60 s mellem tjek). - -## Adfærd - -1. **Husk vogn** (hvert 10. s): for hvert ikke-stoppet flow og hver traktor med et bagerste redskab → `wagons[traktor] - = redskab` (ændringer gemmes med næste gem). -2. **Start** (`startVehicle`): er traktoren solo og har den en husket vogn: - - vognen frakoblet og ikke reserveret/lånt → `ADHitch.fetch` til "Connect ", derefter startes - traktoren i flowet som i dag. Fanen/loggen: "henter sin vogn". - - vognen er lånt ud / koblet på en anden → traktoren venter ("venter på sin vogn"), der prøves igen hvert 10. s. - - vognen findes ikke eller har ingen Connect-markør → startes som i dag (solo; valideringen afgør). -3. **Stop / "Stop alle" / "Stop alt og parkér"**: `stopped = true`; hver traktor stoppes og køres hjem med - `ADHitch.returnHome` (vogn af på sin parkering, traktor til sin egen). Vognen forbliver flowets. -4. **Slet flow**: som stop; derefter er vognen ikke længere flowets (flowet findes ikke). -5. **Bygning væk**: flowet stoppes som i 3, markeres i loggen og med en besked: "Flow 'X': bygning findes ikke længere - — stoppet, traktorer kørt hjem". - -## Puljen og flows - -- `ADPool.collect`: traktor i et flow er kun puljetraktor, når flowet er **stoppet**. -- Puljeredskab: en vogn, der er husket af et **ikke-stoppet** flow, er aldrig puljeredskab. -- `ADFieldWork.getRigs`: uændret for kørende flows; traktorer i stoppede flows må indgå. - -## Komponenter - -- `adFlowWagons.lua` (nyt): ren logik (serialisering af `wagons`, beslutning ved start: fetch/wait/start, "bygning - væk"-debounce, ejer-opslag) + spil-del (tick: husk vogne, ventende starter, bygningstjek; `fetchThenStart`, - `sendHome(run)`). -- `adRuns.lua`/`adRunsStorage.lua`: felterne `stopped`, `wagons` i records. -- `adRunsController.lua`: kroge i `startVehicle`, `stopRun`, `deleteRun`, `stopAllRuns`, `stopAllAndPark`, - `startRun`/`startAllRuns`/`queueStartAll` (ryd `stopped`). -- `adPool.lua`: udelukker vogne ejet af ikke-stoppede flows; traktorer i stoppede flows tæller. -- `adSmartPickup.lua`: `ADFlowWagons.tick` i update-løkken. - -## Test - -- `tests/test_adFlowWagons.lua`: serialisering frem/tilbage, startbeslutning (fetch/wait/start), debounce af - bygning væk, ejer-opslag (stoppet vs kørende). -- `tests/test_adRuns.lua`: records med `stopped` og `wagons`. -- In-game: savegame2 — stop et fodringsflow → vogne sættes på plads, traktorer hjem; start det → traktorerne henter - deres egne vogne og kører. - -## Uden for scope - -- Valg af vogn i GUI (automatisk er valgt). -- At et flow tager en anden vogn end sin egen. diff --git a/docs/superpowers/specs/2026-09-28-maskinpulje-design.md b/docs/superpowers/specs/2026-09-28-maskinpulje-design.md deleted file mode 100644 index 304210d..0000000 --- a/docs/superpowers/specs/2026-09-28-maskinpulje-design.md +++ /dev/null @@ -1,149 +0,0 @@ -# Maskinpulje — design - -Dato: 2026-09-28 · Status: godkendt i chat (afsnit 1–4), afventer review af denne spec - -## Formål - -Plove, harver, kultivatorer, såmaskiner, tømmevogne m.m. bruges kun en gang imellem. I dag skal hvert af dem have -en traktor koblet fast på hele året, fordi markarbejdet kun kan sende en traktor, der allerede har det rigtige -redskab bag sig. Maskinpuljen gør traktorer og redskaber til fælles puljer: et job venter i kø, til en traktor er -ledig; traktoren henter det redskab, jobbet kræver, udfører arbejdet, parkerer redskabet på dets plads og kører -selv hjem til sin egen parkering. - -**Succes:** med fx 3 ledige traktorer, 2 plove og 1 kultivator bliver markernes arbejde udført uden at nogen -traktor står låst til et redskab — færre køretøjer at købe og vedligeholde. Flows, der kører hele tiden (fodring, -slurry, mælk …), påvirkes ikke. - -## Brugerens valg (fra brainstorm) - -| Spørgsmål | Valg | -|---|---| -| Hvordan findes redskabets Connect-markør? | Navneregel: AD-parkering "X" ↔ markør "Connect X" | -| Hvem er i puljen? | Automatisk ud fra opsætningen — ingen afkrydsning | -| Hvilken traktor henter? | Nærmeste ledige med nok hk | -| Omfang | Markarbejde + tømmevogne ved høst + marksilo-flowet. Flows er undtaget | -| Tømmevogne ved høst | Også fra puljen, men dedikeret til marken, til høsten dér er færdig | -| Arkitektur | Udvid markarbejdets eget maskinvalg (ikke en separat puljemanager) | - -## Begreber - -- **Puljeredskab:** et redskab eller en vogn, der holder **frakoblet**, har en AD-parkering (`advd.parkDestination` - → markør "X"), og hvor der findes en markør "Connect X" (`ADHitch.parseMarker` = attach, navn = X; store/små - bogstaver ligegyldigt). Uden Connect-markør er redskabet ikke i puljen — det er spillerens måde at holde det - udenfor. -- **Puljevogn:** puljeredskab med rollen `unloader` (vogn med tip/snegl, `spec_trailer` + `spec_dischargeable`). -- **Puljetraktor:** motoriseret køretøj uden noget koblet bag på (`ADHitch.rearImplements` tom), ikke i et flow - (`ADRunsController.getRunForVehicle == nil`), ikke optaget (samme `busy`-regel som `getRigs`), ikke styret af - spilleren, med egen AD-parkering (`vehicle.advd.parkDestination >= 1`) og flueben i markflåden - (`ADFieldFleet.isEnabled`). -- **Færdig kombination:** det markarbejdet bruger i dag — traktor med redskabet allerede koblet på (`getRigs`). -- **Puljekombination:** {traktor, redskab, Connect-markør-id, redskabets parkering-id, traktorens parkering-id}. - -AD-parkering: hver maskine har sin egen `advd.parkDestination`; AD bruger det valgte redskabs, når et er koblet på -(`AutoDriveVehicleData.lua:88-95`). Traktorens egen parkering læses derfor altid på traktoren selv. - -## Komponenter - -### `adPool.lua` (nyt, ren logik + tynd spil-adapter) - -Ren logik (testes med luajit): -- `ADPool.roleOf(specs)` — rolle for et frakoblet redskab ud fra dets egne specs via `ADFieldFleet.ROLE_SPECS`; - vogn med `spec_trailer` + `spec_dischargeable` og intet andet → `unloader`. -- `ADPool.isPoolImplement(item)` — `item = {attached, parkMarkerName, connectMarkerId}` → frakoblet, med parkering - og Connect-markør. -- `ADPool.powerOk(tractorPower, neededPower)` — true når et af tallene mangler (nil/0), ellers `tractorPower >= - neededPower`. -- `ADPool.pairFor(role, fieldX, fieldZ, implements, tractors, reserved, accept)` — redskab med rollen nærmest marken - (ikke reserveret, `accept(implement)` sand), derefter nærmeste ledige traktor til redskabet med `powerOk`. - Returnerer `pair` eller `nil, why, args` (`noPoolImplement` / `noPoolTractor` / `tooWeak` med hk-tal). -- `ADPool.queueOrder(fields)` — markers ventende rækkefølge: `waitingSince` stigende, lige → lavest mark-id. -- `ADPool.isReturnDue(rearImplements, poolParkIds)` — traktor har et puljeredskab bag sig (dets parkering har en - Connect-markør) → hjemtur skal køres. - -Spil-adapter: -- `ADPool.collect(adEnv)` → `implements, tractors` fra `g_currentMission.vehicleSystem.vehicles` (gårdens), med - position, rolle, specs til `acceptFor`, hk og parkering/Connect-id. -- hk: `g_storeManager:getItemByXMLFilename(vehicle.configFileName).specs` → traktor `power`, redskab - `neededPower`; mangler de, bruges nil (→ tilladt). -- Reservationer: `ADPool.reserve(implementId, tractorId, owner)` / `release(owner)`; i hukommelsen, ryddes ved - genindlæsning (se Genindlæsning). -- Karantæne: redskab, der ikke kunne kobles på, springes over i 10 min (`ADPool.QUARANTINE_MS = 600000`). - -### `adHitch.lua` (findes, v1.47.4 — udvides let) - -Udfører kørslerne: `fetch` (kør til Connect + kobl på), `route` (trin-tur, afkobling ved redskabets parkering). -Ny: `ADHitch.returnHome(adEnv, tractor, onDone)` = `route({redskabets parkering, traktorens parkering})`; ved -parkeringen kobles puljeredskabet af (samme regel som i dag i ruter). - -### Markarbejdet (`adFieldWork.lua`, `adFieldJobs.lua`) - -- `dispatch`: som i dag først færdige kombinationer. Returnerer `pick`/`pickMany` intet (`noRig`/`allBusy`/ - `cannotDo`), forsøges `ADPool.pairFor` for de manglende. Puljekombinationens assignment får `phase = "fetching"`, - `pool = {implementId, connectId, implementParkId, tractorParkId}`, nøgle = traktorens id. -- `follow`: fase `fetching` → når `ADHitch.fetch` melder ok: bredde tjekkes med redskabet på (`checkWidth`; for - bred → hjemtur + årsag `tooWide`), derefter samme vej som i dag (`startAd` til markens markør / påfyldning, - `phase = "driving"`). Fejl → hjemtur, karantæne, markens `retryAt` som ved `noRig`. -- `finishAssignment`: har traktoren et puljeredskab bag sig (`ADPool.isReturnDue`) → `ADHitch.returnHome` i - stedet for den almindelige parkeringstur; fase `returning`; assignment slettes, når hjemturen er færdig (eller - fejler — så med advarsel). -- Kø: `tick` gennemgår ventende marker i `ADPool.queueOrder`. `waitingSince` sættes, når en mark første gang ikke - fik en maskine, og nulstilles ved udsendelse. `DISPATCH_GAP_MS` (10 s) beholdes. -- Fanen viser faserne "henter X" og "sætter X af" og årsagerne `noPoolTractor`/`tooWeak`. - -### Tømning ved høst - -- Helper-dispatch (`role = "unloader"`, `helper = true`) bruger samme fallback: puljevogn + puljetraktor. -- Efter `fetch`: `startUnloader` som i dag. Assignmentet bliver hos marken, til høststadiet er slut (som i dag); - først da `returnHome`. Ingen frigivelse mellem læs. - -### Marksilo-flowet (`adFieldSilos.lua`, `adFieldSiloPlanner.lua`) - -- `collectVehicles` tilføjer puljekombinationer (puljevogn der kan bære varen + nærmeste puljetraktor) som - kandidater med `pool = {...}`; planneren behandler dem som vogne (position = vognens). -- `withoutHarvestReserve` tæller puljevogne med, så høsten går først. -- Lån med `pool`: `ADHitch.fetch` → AD "Hent og aflever" som i dag (`enableParkAtJobFinished`) → når AD er - inaktiv: `returnHome` → lånet slettes, når traktoren er hjemme. Vagten (120 s uden fremgang) starter efter - koblingen. - -## Genindlæsning - -Reservationer og assignments gemmes ikke (som i dag). Efter indlæsning ser en traktor med puljeredskab ud som en -færdig kombination; når markarbejdet eller marksilo-flowet afslutter et job med den, køres `returnHome`. Reglen -afhænger kun af, hvad der hænger bag traktoren, ikke af gemt tilstand. En traktor, som ingen af vores jobs bruger, -køres aldrig hjem af sig selv (spilleren kan have koblet et puljeredskab på for at køre selv). - -## Fejl - -| Situation | Handling | -|---|---| -| Kobling fejler (`noAttachable`, `stoppedAway`) | Traktor hjem, redskab i karantæne 10 min, marken prøver igen (andet redskab eller venter) | -| Redskab for bredt når koblet på | Hjemtur, årsag `tooWide` i fanen | -| Spilleren sætter sig i traktoren | Assignment afbrydes som i dag; reservationer frigives | -| Ingen traktor stærk nok | Årsag `tooWeak` med hk-tal: "ingen traktor med ≥180 hk ledig til 'Plov 1'" | -| Hjemtur fejler | Advarsel i loggen; traktor og redskab frigives (spilleren må ordne det) | - -## Undtaget / uden for scope - -- Flows (runs) — deres køretøjer er aldrig i puljen (uændret regel i `getRigs`). -- Vogne uden Connect-markør — dedikerede som i dag. -- Genbrug af et redskab direkte til næste mark uden at parkere (senere optimering). -- Flere redskaber pr. traktor (front + bag). -- Egen GUI-side for puljen. - -## Etaper - -1. **v1.48.0.0 — markarbejde med pulje:** `adPool.lua`, `returnHome`, dispatch-fallback, fase fetching/returning, - kø-rækkefølge, fane-tekster. In-game: savegame4, mark der skal harves/pløjes, redskab med parkering + Connect, - solo-traktor henter → arbejder → parkerer → hjem. -2. **v1.49.0.0 — tømmevogn ved høst fra puljen.** -3. **v1.50.0.0 — marksilo med puljevogne** (inkl. høst-reserve over puljevogne). - -## Test - -- `tests/test_adPool.lua`: puljeredskab ja/nej (koblet på, uden parkering, uden Connect), `roleOf` inkl. - `unloader`, `powerOk` (nok/for lidt/manglende data), `pairFor` (redskab nærmest marken, traktor nærmest redskabet, - reserveret og karantæne springes over, `tooWeak`/`noPoolTractor`/`noPoolImplement`), `queueOrder`, - `isReturnDue`. -- `tests/test_adHitch.lua`: `returnHome`-rutens trin. -- `tests/test_adFieldJobs.lua`: færdig kombination foretrækkes; fallback til pulje; kø længst ventende først. -- `tests/test_adFieldSiloPlanner.lua` (etape 3): puljekandidater og høst-reserve med puljevogne. diff --git a/docs/superpowers/specs/2026-09-29-flow-field-list-edit-panel-design.md b/docs/superpowers/specs/2026-09-29-flow-field-list-edit-panel-design.md deleted file mode 100644 index 564574f..0000000 --- a/docs/superpowers/specs/2026-09-29-flow-field-list-edit-panel-design.md +++ /dev/null @@ -1,131 +0,0 @@ -# Flows og Fields: farvet liste + redigeringsvindue - -Dato: 2026-09-29 · Status: godkendt i chat ("men kør"), afventer review af denne spec - -## Formål - -Flows- og Fields-fanen er i dag én lang rullende side: listen over flows/marker efterfulgt af den valgte -posts redigeringslinjer. Det er svært at overskue (brugerens screenshot 29/9 17:15). Målet: - -- Fanen viser **kun listen**, med farvet status og **Start/Stop pr. række**. -- **Klik på en række** åbner et **redigeringsvindue** (popup) oven på listen med de nuværende linjer. - -Succes = man kan se med ét blik hvad der kører/venter/fejler, starte/stoppe en post uden at vælge den først, -og redigere i et vindue der lukkes med Luk/ESC — uden at noget af flow-/mark-logikken ændres. - -## Ikke-mål - -- Ingen ændring af flow-/mark-logik, guiderne ("Nyt flow", "Opsæt mark") eller deres trin. -- Oversigt-tavlen og Fields' flåde-visning er uændrede. -- Ingen ny FS25-GUI-skærm/dialog (fravalgt: al rækkekode skulle genbygges; fokus/ESC oven på ESC-menuen er - skrøbeligt). "Popup" er et panel inde i samme frame. - -## Brugeroplevelse - -### Flows-fanen (liste) - -Én række pr. flow: `● navn · varer · kørende/i alt · statustekst · [Start]/[Stop]`. - -| Niveau | Prik/statusfarve | Hvornår | -|---|---|---| -| running | grøn | mindst én traktor kører og ingen er blokeret | -| waiting | gul | startet, men venter: ingen varer at hente, venter på vogn, traktor optaget, holder ved Wait | -| error | rød | kan ikke køre: sidste start afvist (fx ingen vogn), advarsel (`!!`) | -| stopped | grå | stoppet / aldrig startet | - -- `[Start]/[Stop]` bruger den eksisterende `ADRunsController.startRun/stopRun` (samme regler som knaplinjen, inkl. - sprunget-over-besked i højre side). -- Klik på rækken (ikke knappen) åbner redigeringsvinduet for flowet. -- Knaplinjen (i dag Tilbage, Start/Stop, Opsæt flow, Nyt flow, Slet) bliver: Tilbage, Nyt flow. Start/Stop sidder på - rækkerne; "Opsæt flow" og "Slet" flyttes ind i vinduet. - -### Fields-fanen (liste) - -Nuværende kolonner (Mark, Afgrøde, Plan, Kørsel, Status, Traktorer) + `●` forrest og `[Start]/[Stop]` bagerst. - -| Niveau | Hvornår | -|---|---| -| running (grøn) | marken har aktive opgaver der arbejder | -| waiting (gul) | startet men venter: på maskine, vejr, påfyldning, "startes når en maskine er ledig" | -| error (rød) | mangler markør, arbejdsbredde for bred/ukendt, ingen egnet maskine | -| stopped (grå) | ingen plan / stoppet | - -- `[Start]/[Stop]` genbruger `onClickFieldStartStop` (inkl. "Overtag høster"). -- Klik på rækken åbner vinduet med markens resumé og [Ret]-linjer. - -### Redigeringsvinduet (fælles) - -- Mørk halvgennemsigtig baggrund over listen; centreret panel (~70 % bredde, ~80 % højde). -- Top: titel ("Rediger: Cow Food" / "Mark F12"), statuslinje i niveau-farve. -- Midte: egen rullende liste med præcis det indhold, der i dag vises under listen: - flow → `fillFlowSummary`-linjerne; mark → `fillFieldOverview`-linjerne. [Edit]/[Ret] åbner trinnet - (guiden i enkelt-trin-tilstand) **inde i vinduet** med Gem/Tilbage som i dag. -- Bund: `Luk` · `Start/Stop` · `Slet` (kun flow). ESC/Tilbage lukker vinduet (ikke menuen); står et trin åbent, - går ESC først tilbage til resuméet. -- Mens vinduet er åbent, opdateres statuslinjen og listen bagved hvert sekund som i dag. - -## Arkitektur - -### Rene funktioner (testbare) - -- `ADFlowView.flowStatus(s)` → `{level, text}` hvor `s = {running, total, canStop, lastStartSkipped, warning, - waitReason, holdReason, stopped}`. Afgør niveau efter tabellen ovenfor. -- `ADFieldView.fieldStatus(s)` → `{level, text}` hvor `s = {working, awaiting, starting, missingMarker, - widthProblem, noMachine, hasPlan}`. -- `ADFlowView.LEVEL_COLORS` (grøn/gul/rød/grå) — flyttes/udleveres fra `SmartPickupStorageView`s lokale tabel, - som også eksporterer `setTextColor` til genbrug. - -### GUI - -- **XML** (`SmartPickupFrame.xml`): efter `boxLayout` et `GuiElement id="editOverlay"` (skjult) med - `Bitmap id="editBackdrop"` (fuld flade, sort ~60 %), `Bitmap id="editPanel"` (centreret, baggrund som - fs25-paneler) med `Text id="editTitle"`, `Text id="editStatus"`, `ScrollingLayout id="editLayout"` (+ slider) og - en knaplinje med tre `Button`s (`editClose`, `editStartStop`, `editDelete`). -- **Rækkeprefabs**: `flowPrefab` og `fieldPrefab` får en `Bitmap name="dot"` (profil `spuDot`) forrest og en - `Button name="rowAction"` bagerst. Kolonnebredder justeres så rækken stadig er ≈835 px. -- **Mål-layout for rækker**: `addRow`/`addTextRow`/`addCellRow`/`addLinkRow`/`addGuideRow` kloner i dag altid ind i - `self.boxLayout`. Der indføres `self.rowTarget` (standard `boxLayout`); vinduet sætter det til `editLayout` - mens det fyldes. `clearRows` holder klonerne pr. layout, så listen og vinduet kan genopbygges hver for sig. -- **Ny fil** `gui/SmartPickupEditPanel.lua` (mixin på `SmartPickupFrame`, `source()`s som de andre): - `openEditPanel(kind, id)`, `closeEditPanel()`, `fillEditPanel(adEnv)`, `refreshEditPanel(adEnv)`, - knap-callbacks. `kind ∈ {"flow","field"}`. -- **Input**: `backButtonInfo`-callback og ESC tjekker først `self.editPanelOpen` (luk trin → luk vindue → - normal Tilbage). Klik på backdrop gør intet (blokerer klik til listen bagved via `mouseEvent` der returnerer - "brugt"). -- **Guide i vinduet**: `openFlowStep`/`openFieldStep` sætter `returnTo = "panel"`, så Gem/Tilbage vender tilbage til - vinduets resumé i stedet for at lukke guiden til fanen. - -### Dataflow - -Klik på række → `openEditPanel(kind, id)` → `selectedRunId`/`selectedFieldId` sættes (eksisterende felter) → -`requestRebuild` → `rebuild` tegner listen i `boxLayout` og, når vinduet er åbent, vinduets indhold i -`editLayout`. Start/Stop i række eller vindue kalder de eksisterende controller-funktioner og `requestRebuild`. - -## Fejlhåndtering - -- Alt nyt GUI-arbejde kører i `self:safe(...)` som resten af frame'n; fejl vises i statuslinjen og logges. -- Forsvinder flowet/marken mens vinduet er åbent (slettet), lukkes vinduet og statuslinjen siger hvorfor. -- `rowAction`-knappen er deaktiveret når handlingen ikke er mulig (fx mark uden markør → ingen Start). - -## Test - -- luajit: `tests/test_flowStatus.lua` og `tests/test_fieldStatus.lua` for alle niveauer i tabellerne + - grænsetilfælde (0 traktorer, stoppet med advarsel, venter + afvist). -- Eksisterende tests skal forblive grønne (`test_noStrayGlobals` dækker den nye fil). -- In-game (brugeren): liste med farver, Start/Stop pr. række, åbn/luk vindue med klik/Luk/ESC, [Edit] → Gem inde i - vinduet, Slet flow fra vinduet, klik på listen bag vinduet gør intet, mark "Overtag høster" virker fra rækken. - -## Levering - -To etaper, hver sin version og in-game test: - -1. **Flows**: rene statusfunktioner + fælles vindue + Flows-liste. -2. **Fields**: mark-status + Fields-liste genbruger vinduet. - -## Risici - -- **Klik gennem backdrop** til listen: afbødes ved at backdrop'en fanger `mouseEvent`; fallback: skjul listens rækker - (`setVisible(false)`) mens vinduet er åbent. -- **Musehjul/rulning** i to ScrollingLayouts: hver får sin egen slider; FS25 ruller det layout musen er over. -- **Knapper i rækkeprefabs** (Button i en klonet Bitmap) er ikke brugt før i modden: onClick sættes efter - `clone` pr. række (som `makeRowClickable`), ikke via XML-callback.