128 KiB
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 medALLE 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>; INGENCo-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.getSettingreturnerersetting.values[setting.current]; loop-tæller sættes medstateModule.loopCounter = n; stateModule:raiseDirtyFlag();stateModule:setLoopsDone(0); køretøjs-listeAutoDrive.getAllVehicles();ADGraphManager:getDistanceFromNetwork(vehicle);ADGraphManager:getMapMarkerByWayPointId(id); savegame-stig_currentMission.missionInfo.savegameDirectory; AD gemmer viaItemSystem.save = Utils.prependedFunction(ItemSystem.save, ...); køretøjer harvehicle.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() -> storehvorstore = {nextId = number, runs = {run, ...}},run = {id, name, targetWayPointId|nil, ingredientMode = "auto"|"manual", ingredients = {"STRAW", ...}, waitPoolGroup = "", loops = 0, vehicleIds = {"vehicle…", ...}}ADRuns.createRun(store, name) -> newStore, runADRuns.getRun(store, runId) -> run|nilADRuns.deleteRun(store, runId) -> newStoreADRuns.updateRun(store, runId, changes) -> newStore(changesdelmængde af{name, targetWayPointId, ingredientMode, ingredients, waitPoolGroup, loops};targetWayPointId = falserydder målet; loops klippes til 0..99)ADRuns.findRunByVehicle(store, vehicleId) -> run|nilADRuns.assignVehicle(store, runId, vehicleId) -> newStore, previousRunId|nilADRuns.unassignVehicle(store, vehicleId) -> newStoreADRuns.toRecords(store) -> {{id, name, target, ingredientMode, ingredients = "STRAW FORAGE", waitPool, loops, vehicleIds}, ...}ADRuns.fromRecords(records) -> storeADRuns.parseIngredients(text) -> list,ADRuns.serializeIngredients(list) -> textADRuns.MAX_LOOPS = 99,ADRuns.XML_VERSION = 1
-
Step 1: Skriv de fejlende tests
tests/test_adRuns.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:
-- 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
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ørprint(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|nilADRunsStorage.writeRecords(xmlFile, records),ADRunsStorage.readRecords(xmlFile) -> recordsADRunsStorage.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 ...):
-- ==== 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:
-- 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
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|nilADSmartPickup.getUnloadStationAtWayPointId(adEnv, wayPointId, farmId) -> station|nilADSmartPickup.getStationLevelAndCapacity(station, fillType, farmId) -> level, capacity | nilADSmartPickup.getOwnStorageLevel(station, fillTypes) -> literADSmartPickup.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:
-- ==== 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:
- Erstat
local function getFarmId(vehicle)medfunction ADSmartPickup.getFarmId(vehicle)og tilføj lige under funktionen:local getFarmId = ADSmartPickup.getFarmId. - Erstat
local function getOwnStorageLevel(station, fillTypes)medfunction ADSmartPickup.getOwnStorageLevel(station, fillTypes)og tilføj under den:local getOwnStorageLevel = ADSmartPickup.getOwnStorageLevel. - Erstat
local function getTargetLevelAndCapacity(station, fillType, farmId)medfunction ADSmartPickup.getStationLevelAndCapacity(station, fillType, farmId)og tilføj under den:local getTargetLevelAndCapacity = ADSmartPickup.getStationLevelAndCapacity. - Tilføj lige efter
getUnloadStationAtMarker:
-- 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
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 = 30ADRunsController.getVehicleId(vehicle) -> string|nilADRunsController.getFarmVehicles(adEnv) -> {vehicle, ...}(farmens køretøjer medvehicle.ad)ADRunsController.findVehicleById(adEnv, id) -> vehicle|nilADRunsController.getRunForVehicle(vehicle) -> run|nilADRunsController.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 stoppedeADRunsController.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å navnADRunsController.createRun(name) -> run,.deleteRun(runId),.updateRun(runId, changes),.assignVehicle(runId, vehicle) -> previousRunId|nil,.unassignVehicle(vehicle)— wrapper der erstatterADRunsController.storeADRunsController.load(),ADRunsController.save(),ADRunsController.installSaveHook()
-
Step 1: Skriv de fejlende tests
tests/test_adRunsController.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:
-- 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
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, + nyloadMap) - Modify:
FS25_ADSmartPickup/adUnloadWait.lua:42-53(getWaitCandidates),:109-113(kaldet istartWaiting) - 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:
-- ==== 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:
-- 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):
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:
-- 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
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(nygetClaims) - 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:
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 ...):
-- 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:):
-- 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:
-- 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:
-- 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
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 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 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 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 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:
-- 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 = {}):
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):
-- 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
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
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
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
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)
- Start spillet, indlæs savegame 2. Log skal vise
ADSmartPickup: aktiv (...)og INGENError:/LUA-linjer medADSmartPickup/SmartPickupFrame. - ESC → fanen "Smart Pickup" findes; Runs-listen er tom; "+ Nyt run" opretter "Run 1".
- Mål-dropdown viser "Cow 1 Food"/"Cow 2 Food"/"TMR Unload" (markører med aflæsningsstation), ikke siloer.
- Varer: Auto viser målets varer; Vælg viser afkrydsning.
- Wait-pulje-dropdown viser "Alle (nærmeste ledige)" + "Cow Wait (6)".
- Tilføj 2 Fastrac + én traktor uden vogn → uden vogn får ✖ "ingen vogn".
- 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)". - Stop run → begge stopper; status "(0/3)".
- Gem, afslut, indlæs → run'et og traktorerne er der stadig;
savegame2/ADSmartPickup_runs.xmlfindes. - 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(beforeUpdatefårdt),:180-187(installsenderdt) - Modify:
FS25_ADSmartPickup/modDesc.xml(adLoadSwap.luaføradUnloadWait.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 = 5000ADLoadSwap.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|nilADLoadSwap.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:
-- ==== 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:
-- 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:
updateWaiting(adEnv, task, wait)→updateWaiting(adEnv, task, wait, dt). Erstat blokken
local free, capacity = ADSmartPickup.getTargetFreeCapacity(adEnv, vehicle, fillType)
if free == nil or not ADSupplyPlanner.canUnloadRest(free, rest, capacity) then
return
end
med
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.)
-
ADUnloadWait.beforeUpdate(adEnv, task)→ADUnloadWait.beforeUpdate(adEnv, task, dt)og kaldupdateWaiting(adEnv, task, wait, dt). Iinstall:pcall(ADUnloadWait.beforeUpdate, adEnv, self, dt). -
I
release(vehicle): tilføjif ADLoadSwap ~= nil then ADLoadSwap.clearWarning(vehicle) end(ny vente-periode = ny chance for advarsel). -
modDesc.xml:adLoadSwap.luaindsættes FØRadUnloadWait.luaog EFTERadRunsController.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
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
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
- 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.
- 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 FORAGEog traktoren kører til siloen og læsser af. - Når vognen er tom, kører den til TMR-kilden (log
-> TMR Load (FORAGE: kilde …)). - 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.