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