chore: interne design-/plannoter og bench ud af repoet (kun lokalt)

This commit is contained in:
2026-09-29 21:31:33 +02:00
parent f8864e26c3
commit 4e8e9aa350
24 changed files with 3 additions and 9505 deletions
+3
View File
@@ -1 +1,4 @@
*.zip
docs/superpowers/
bench/
.superpowers/
-1
View File
@@ -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/`
---
-10
View File
@@ -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 <commit>; 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.
-103
View File
@@ -1,103 +0,0 @@
-- Simulering af "hak hvert 5. sekund" (kør: luajit bench/bench_hak.lua <mappe med modulerne>)
-- 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))
@@ -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/<fil>.lua`. Commits uden Co-Authored-By.
- Verificerede AD-fakta: `selectedFillTypes` er et array-felt på stateModule og gemmes i savegame; `setFillType(ft)` bevarer valget hvis `ft` er i det; `isActive()`, `getSecondMarker()`, `raiseDirtyFlag()` findes; `ADGraphManager:getMapMarkers()` returnerer array indekseret på `markerIndex`.
---
### Task 1: `adSupplyPlanner.lua` — rene funktioner
**Files:**
- Create: `FS25_ADSmartPickup/adSupplyPlanner.lua`
- Test: `tests/test_adSupplyPlanner.lua`
**Interfaces:**
- Produces (global tabel `ADSupplyPlanner`):
- `MIN_FREE_LITERS` (number)
- `scoreNeed(level, capacity, freeSpace) -> number` (højere = mere trængende)
- `rankNeeds(needs) -> ranked` hvor `needs = {{fillType, level, capacity}, …}` og `ranked = {{fillType, level, capacity, freeSpace, score}, …}` sorteret mest trængende først, uden dem med `freeSpace < MIN_FREE_LITERS`
- `resolveIngredients(state, selected) -> ingredients|nil` hvor `state = nil | {ingredients = {ft…}, narrowedTo = ft}`
- `copyList(list) -> ny liste`
- [ ] **Step 1: Skriv fejlende test** — `tests/test_adSupplyPlanner.lua`:
```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`, `<sourceFile filename="adSupplyPlanner.lua"/>` FØR `adSmartPickup.lua`, beskrivelse), `README.md`
- [ ] **Step 1:** Opdatér modDesc + README (betjening: multi-vælg varer; begrænsninger fra spec).
- [ ] **Step 2:** Byg: `cd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip . && cd ..`; verificér med `unzip -l` at begge .lua + modDesc + icon er med.
- [ ] **Step 3:** Deploy atomisk (spillet kan køre): `cp FS25_ADSmartPickup.zip ~/FS25-data/mods/.FS25_ADSmartPickup.zip.tmp && mv ~/FS25-data/mods/.FS25_ADSmartPickup.zip.tmp ~/FS25-data/mods/FS25_ADSmartPickup.zip`.
- [ ] **Step 4:** Code-review (ecc:code-reviewer) af diff; ret CRITICAL/HIGH.
- [ ] **Step 5:** Commit + `git push gitea`. Opdatér memory + Obsidian-noten `projekter/FS25 silo-patches + AD Smart Pickup.md`.
File diff suppressed because it is too large Load Diff
@@ -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.
@@ -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/<fil>.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.
File diff suppressed because it is too large Load Diff
-174
View File
@@ -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.
File diff suppressed because it is too large Load Diff
@@ -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_<navn>.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 <parkering>" 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 `<sourceFile filename="adHitch.lua"/>`:
```xml
<sourceFile filename="adPool.lua"/>
```
- [ ] **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
<text name="spu_field_reason_noPoolTractor" text="ingen ledig traktor til at hente et redskab til %s"/>
<text name="spu_field_reasonShort_noPoolTractor" text="venter på traktor"/>
<text name="spu_field_reason_fetching" text="henter '%s'"/>
<text name="spu_field_reasonShort_fetching" text="henter redskab"/>
```
`l10n/l10n_en.xml`:
```xml
<text name="spu_field_reason_noPoolTractor" text="no free tractor to fetch an implement for %s"/>
<text name="spu_field_reasonShort_noPoolTractor" text="waiting for tractor"/>
<text name="spu_field_reason_fetching" text="fetching '%s'"/>
<text name="spu_field_reasonShort_fetching" text="fetching implement"/>
```
`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` (`<version>1.47.4.0</version>` → `1.48.0.0`)
- [ ] **Step 1: Bump version, alle tests, build, deploy**
```bash
cd ~/fs25-adsmartpickup
sed -i 's#<version>1\.47\.4\.0</version>#<version>1.48.0.0</version>#' 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 '<version>'
```
Expected: ingen `FEJL`, `<version>1.48.0.0</version>`
- [ ] **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<n>: cultivator: 'Challenger MT635' henter '<redskab>' (pulje)`
2. `kobling: 'Challenger MT635' koblede '<redskab>' på ved 'Connect X'`
3. `F<n>: cultivator 'Challenger MT635' har '<redskab>' på — kører til <markør>`
4. CP arbejder; `F<n>: cultivator '…' færdig`
5. `F<n>: '…' sætter redskabet af og kører hjem`, `kobling: … koblede '<redskab>' 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.
@@ -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 `</Profiles>`):
```xml
<!-- redigeringsvindue (v1.58): mørk flade + panel; størrelser sættes i Lua ud fra boxLayout -->
<Profile name="spuOverlayBackdrop" extends="spuBar">
<imageColor value="0 0 0 0.65"/>
<positionOrigin value="topLeft"/>
<size value="100px 100px"/>
</Profile>
<Profile name="spuOverlayPanel" extends="spuBar">
<imageColor value="0.09 0.09 0.09 0.97"/>
<positionOrigin value="topLeft"/>
<size value="100px 100px"/>
</Profile>
<Profile name="spuPanelTitle" extends="spuCellBold">
<textSize value="20px"/>
</Profile>
```
- [ ] **Step 2: XML overlay** — directly after `</ScrollingLayout>` of `boxLayout`:
```xml
<!-- redigeringsvindue (skjult; placeres/størrelse sættes i SmartPickupEditPanel.lua) -->
<GuiElement profile="baseReference" id="editOverlay" visible="false">
<Bitmap profile="spuOverlayBackdrop" id="editBackdrop"/>
<Bitmap profile="spuOverlayPanel" id="editPanel">
<Text profile="spuPanelTitle" id="editTitle" position="20px -16px"/>
<Text profile="spuCell" id="editStatus" position="20px -46px"/>
<ScrollingLayout profile="fs25_settingsLayout" id="editLayout" position="0px -80px"/>
</Bitmap>
</GuiElement>
```
- [ ] **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
<!-- Flows-listen (v1.58): ● · navn · varer · kører/i alt · status · [Start]/[Stop]. ~835 px. -->
<Bitmap profile="fs25_multiTextOptionContainer" id="flowListPrefab">
<Bitmap profile="spuDot" name="dot" position="20px 0px"/>
<Text profile="spuCellBold" name="c1" position="38px 0px" size="200px 40px" textMaxWidth="192px"/>
<Text profile="spuCell" name="c2" position="238px 0px" size="170px 40px" textMaxWidth="162px"/>
<Text profile="spuCell" name="c3" position="408px 0px" size="70px 40px" textMaxWidth="62px"/>
<Text profile="spuCell" name="c4" position="478px 0px" size="252px 40px" textMaxWidth="244px"/>
<Text profile="spuCellBold" name="c5" position="730px 0px" size="100px 40px" textMaxWidth="92px"/>
</Bitmap>
```
- [ ] **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` `<version>` to `1.58.0.0`; build zip; copy to `~/FS25-data/mods/`; `unzip -p ~/FS25-data/mods/FS25_ADSmartPickup.zip modDesc.xml | grep -o '<version>[^<]*'` 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
<Bitmap profile="fs25_multiTextOptionContainer" id="fieldPrefab">
<Bitmap profile="spuDot" name="dot" position="20px 0px"/>
<Text profile="spuCellBold" name="c1" position="38px 0px" size="82px 40px" textMaxWidth="76px"/>
<Text profile="spuCell" name="c2" position="120px 0px" size="120px 40px" textMaxWidth="112px"/>
<Text profile="spuCell" name="c3" position="240px 0px" size="100px 40px" textMaxWidth="92px"/>
<Text profile="spuCell" name="c4" position="340px 0px" size="70px 40px" textMaxWidth="62px"/>
<Text profile="spuCell" name="c5" position="410px 0px" size="200px 40px" textMaxWidth="192px"/>
<Text profile="spuCell" name="c6" position="610px 0px" size="120px 40px" textMaxWidth="112px"/>
<Text profile="spuCellBold" name="c7" position="730px 0px" size="100px 40px" textMaxWidth="92px"/>
</Bitmap>
```
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.
@@ -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: '<traktor>' -> <markør> (<vare>, 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.
@@ -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
<ADSmartPickupRuns version="1">
<run id="1" name="Cow Feed" target="11066" ingredientMode="manual" ingredients="STRAW FORAGE"
waitPool="Cow Wait" loops="0">
<vehicle id="..."/>
<vehicle id="..."/>
</run>
</ADSmartPickupRuns>
```
## 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 <silo>, målet mangler FORAGE". Ingen silo → bliv ved Wait.
Kræver at traktoren har begge varer i sin liste (run eller multi-valg).
@@ -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:<bygning>` 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).
@@ -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)
```
@@ -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<marknummer>`** (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.
@@ -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.
@@ -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 |
@@ -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: '<vogn>' henter <liter> l <vare> i <silo> → <lager>`.
- **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 <vare>`). 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.
@@ -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 <vognens parkering>", 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.
@@ -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.
@@ -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.