43 KiB
Udkørsel af produkter — implementeringsplan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Runs med retning "Udkørsel": traktorer henter fabrikkers output og lagervarer (bulk og paller, også fra objektlagre) og kører dem til silo eller salgssted efter politik pr. vare, pristærskel og lagertærskel.
Architecture: Ren planlægger (adOutboundPlanner) + rene prisregler (adPrices) uden spil-API; spiladaptere i adSources/adPrices; AutoDrive-integration i adOutbound via to hooks (næste pålæsning + læsseopgaven), samme mønster som adUnloadWait. Wait-puljen udskilles i adWaitPool og deles. GUI får tre faner (Oversigt · Forsyning · Udkørsel) med fælles byggeklodser.
Tech Stack: FS25 Lua (GIANTS-sandbox), AutoDrive 3.x via FS25_AutoDrive-globalen, Universal Autoload via AD's AL-lag, luajit-tests uden spil.
Spec: docs/superpowers/specs/2026-09-23-udkoersel-design.md
Global Constraints
- Alle spil-API-kald i hooks i
pcall; fejl → hold ved Wait + WARN én gang, aldrig AutoDrive-stop. - Modellen (
adRuns) muterer aldrig: ny store ved hver ændring. - Log-præfiks
ADSmartPickup:i alle moduler; alle run-ændringer logges. - XML-version 2; version 1-filer læses som
kind="supply". - Standarder:
sellAtPercent90 (50–100),storeAbovePercent80 (50–95),collectRoundfalse. - Politikker:
store·sell·sellElseStore(+ "off" = varen udelades afproducts). - Kildetyper: fabrik (produktionspunkt), silo (
spec_silo), objektlager (spec_objectStorage), genkendt inden for 40 m af markøren. - Tests køres fra repo-roden:
luajit tests/<fil>.lua; alle eksisterende tests skal blive grønne. - Version bump til 1.12.0.0 i
modDesc.xml; bygcd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip .; deploy til~/FS25-data/mods/.
Review Focus
- Traktor med UAL-vogn står ved et objektlager hvis spawn sidder fast (
objectSpawn.isActivefor evigt): forventet: 30 s uden fremdrift → læsningen afsluttes med det der er, ingen palle efterlades ud over den ene der evt. er spawnet. (Task 6 test O5) - Bedst betalende salgssted er uopnåeligt, næstbedste kan nås: forventet: næstbedste vælges uden advarsel; kun når intet kan nås, vises rød advarsel én gang. (Task 4 test P6 + Task 6 test O7)
- Vare hvis salgsstation ikke findes på kortet, politik
sell: forventet: varen står som "intet salgssted" i UI og planlæggeren springer den over uden fejl. (Task 4 test P7) - Version 1-runs-fil (uden
kind) indlæses: forventet: alle runs bliver Forsyning, intet går tabt, næste gem skriver version 2. (Task 1 test R-v2) - Bulk-vogn med opsamlingsrunde og to kilder hvor den første giver fuldt læs: forventet: der køres direkte til målet, kilde 2 besøges ikke. (Task 6 test O6)
Task 1: Model + lagring v2
Files:
- Modify:
FS25_ADSmartPickup/adRuns.lua - Modify:
FS25_ADSmartPickup/adRunsStorage.lua - Test:
tests/test_adRuns.lua(tilføj sektion R-v2)
Interfaces:
-
Produces:
ADRuns.KINDS = {supply=true, outbound=true},ADRuns.POLICIES = {store=true, sell=true, sellElseStore=true},ADRuns.DEFAULT_SELL_AT_PERCENT=90,ADRuns.DEFAULT_STORE_ABOVE_PERCENT=80,ADRuns.createRun(store, name, kind), run-felterkind, sourceWayPointIds, products={{fillType="SUGAR", policy="sell"}}, sellAtPercent, storeAbovePercent, collectRound,ADRuns.toggleSource(store, runId, wayPointId),ADRuns.setProductPolicy(store, runId, fillTypeName, policy)(policy "off" fjerner),ADRuns.getProductPolicy(run, fillTypeName)→ policy eller "off",ADRuns.serializeProducts/parseProducts,ADRuns.XML_VERSION = 2. -
Step 1: Skriv fejlende tests — tilføj nederst i
tests/test_adRuns.luafør den afsluttende opsummering:
-- R-v2: udkørsels-run — kind, kilder, varer med politik, tærskler, runde; XML v2 + v1-migration
local o1, out = ADRuns.createRun(ADRuns.newStore(), "Sugar out", "outbound")
check("Rv2 kind", out.kind, "outbound")
check("Rv2 supply som standard", ADRuns.createRun(ADRuns.newStore(), "x").kind == nil and "nil" or select(2, ADRuns.createRun(ADRuns.newStore(), "x")).kind, "supply")
check("Rv2 sellAt 90", out.sellAtPercent, 90)
check("Rv2 storeAbove 80", out.storeAbovePercent, 80)
check("Rv2 runde fra", out.collectRound, false)
local o2 = ADRuns.toggleSource(o1, out.id, 12694)
o2 = ADRuns.toggleSource(o2, out.id, 12386)
check("Rv2 to kilder", table.concat(ADRuns.getRun(o2, out.id).sourceWayPointIds, ","), "12694,12386")
check("Rv2 toggle fjerner", #ADRuns.getRun(ADRuns.toggleSource(o2, out.id, 12694), out.id).sourceWayPointIds, 1)
local o3 = ADRuns.setProductPolicy(o2, out.id, "SUGAR", "sell")
o3 = ADRuns.setProductPolicy(o3, out.id, "FORAGE", "store")
check("Rv2 politik SUGAR", ADRuns.getProductPolicy(ADRuns.getRun(o3, out.id), "SUGAR"), "sell")
check("Rv2 politik ukendt = off", ADRuns.getProductPolicy(ADRuns.getRun(o3, out.id), "STRAW"), "off")
check("Rv2 ugyldig politik ignoreres", ADRuns.getProductPolicy(ADRuns.getRun(ADRuns.setProductPolicy(o3, out.id, "SUGAR", "bogus"), out.id), "SUGAR"), "sell")
check("Rv2 off fjerner", ADRuns.getProductPolicy(ADRuns.getRun(ADRuns.setProductPolicy(o3, out.id, "SUGAR", "off"), out.id), "SUGAR"), "off")
check("Rv2 gammel store urørt", #ADRuns.getRun(o2, out.id).products, 0)
local o4 = ADRuns.updateRun(o3, out.id, {sellAtPercent = 120, storeAbovePercent = 10, collectRound = true})
check("Rv2 sellAt klippet 100", ADRuns.getRun(o4, out.id).sellAtPercent, 100)
check("Rv2 storeAbove klippet 50", ADRuns.getRun(o4, out.id).storeAbovePercent, 50)
check("Rv2 runde til", ADRuns.getRun(o4, out.id).collectRound, true)
check("Rv2 serialiser", ADRuns.serializeProducts(ADRuns.getRun(o3, out.id).products), "SUGAR:sell FORAGE:store")
local parsed = ADRuns.parseProducts("SUGAR:sell FORAGE:store BAD BAD:bogus")
check("Rv2 parse antal", #parsed, 2)
check("Rv2 parse politik", parsed[2].policy, "store")
local rt = ADRuns.fromRecords(ADRuns.toRecords(o4))
local rtRun = ADRuns.getRun(rt, out.id)
check("Rv2 roundtrip kind", rtRun.kind, "outbound")
check("Rv2 roundtrip kilder", table.concat(rtRun.sourceWayPointIds, ","), "12694,12386")
check("Rv2 roundtrip products", ADRuns.serializeProducts(rtRun.products), "SUGAR:sell FORAGE:store")
check("Rv2 roundtrip sellAt", rtRun.sellAtPercent, 100)
check("Rv2 roundtrip runde", rtRun.collectRound, true)
-- v1-post uden kind/sources/products -> supply med standarder
local v1 = ADRuns.fromRecords({{id = 7, name = "Old", target = 94, targets = "94", ingredientMode = "auto", ingredients = "", waitPool = "", loops = 0, returnBelow = 50, vehicleIds = {}}})
check("Rv2 v1 -> supply", ADRuns.getRun(v1, 7).kind, "supply")
check("Rv2 v1 tomme kilder", #ADRuns.getRun(v1, 7).sourceWayPointIds, 0)
check("Rv2 v1 sellAt standard", ADRuns.getRun(v1, 7).sellAtPercent, 90)
-
Step 2: Kør testen —
luajit tests/test_adRuns.lua→ FAIL (toggleSourcenil). -
Step 3: Implementér i
adRuns.lua:
ADRuns.XML_VERSION = 2
ADRuns.KINDS = {supply = true, outbound = true}
ADRuns.POLICIES = {store = true, sell = true, sellElseStore = true}
ADRuns.DEFAULT_SELL_AT_PERCENT, ADRuns.MIN_SELL_AT_PERCENT, ADRuns.MAX_SELL_AT_PERCENT = 90, 50, 100
ADRuns.DEFAULT_STORE_ABOVE_PERCENT, ADRuns.MIN_STORE_ABOVE_PERCENT, ADRuns.MAX_STORE_ABOVE_PERCENT = 80, 50, 95
local function copyProducts(products)
local copy = {}
for index, product in ipairs(products or {}) do
copy[index] = {fillType = product.fillType, policy = product.policy}
end
return copy
end
local function clampRange(value, default, min, max)
local number = tonumber(value) or default
return math.max(min, math.min(max, math.floor(number)))
end
copyRun får kind, sourceWayPointIds = copyList(...), products = copyProducts(...), sellAtPercent, storeAbovePercent, collectRound. createRun(store, name, kind) sætter kind = ADRuns.KINDS[kind] and kind or "supply", sourceWayPointIds = {}, products = {}, sellAtPercent = 90, storeAbovePercent = 80, collectRound = false. updateRun håndterer changes.kind (kun gyldige), sourceWayPointIds (copyList), products (copyProducts), sellAtPercent/storeAbovePercent (clampRange), collectRound (== true).
function ADRuns.toggleSource(store, runId, wayPointId)
local run = ADRuns.getRun(store, runId)
if run == nil then return copyStore(store) end
local sources, found = {}, false
for _, id in ipairs(run.sourceWayPointIds) do
if id == wayPointId then found = true else table.insert(sources, id) end
end
if not found then table.insert(sources, wayPointId) end
return ADRuns.updateRun(store, runId, {sourceWayPointIds = sources})
end
function ADRuns.getProductPolicy(run, fillTypeName)
for _, product in ipairs(run.products or {}) do
if product.fillType == fillTypeName then return product.policy end
end
return "off"
end
-- policy "off" fjerner varen; ukendt politik ignoreres (store uændret).
function ADRuns.setProductPolicy(store, runId, fillTypeName, policy)
local run = ADRuns.getRun(store, runId)
if run == nil or (policy ~= "off" and not ADRuns.POLICIES[policy]) then return copyStore(store) end
local products = {}
for _, product in ipairs(run.products) do
if product.fillType ~= fillTypeName then table.insert(products, product) end
end
if policy ~= "off" then table.insert(products, {fillType = fillTypeName, policy = policy}) end
return ADRuns.updateRun(store, runId, {products = products})
end
function ADRuns.serializeProducts(products)
local parts = {}
for _, product in ipairs(products or {}) do table.insert(parts, product.fillType .. ":" .. product.policy) end
return table.concat(parts, " ")
end
function ADRuns.parseProducts(text)
local products = {}
for token in string.gmatch(tostring(text or ""), "%S+") do
local name, policy = string.match(token, "^([^:]+):(%a+)$")
if name ~= nil and ADRuns.POLICIES[policy] then table.insert(products, {fillType = name, policy = policy}) end
end
return products
end
toRecords tilføjer kind, sources = table.concat(run.sourceWayPointIds, " "), products = serializeProducts, sellAt, storeAbove, collectRound = run.collectRound and 1 or 0. fromRecords læser dem med standarder (kind ugyldig/nil → "supply"; collectRound = tonumber(record.collectRound) == 1).
adRunsStorage.lua: writeRecords skriver #kind (string), #sources, #products, #sellAt (int), #storeAbove (int), #collectRound (int); readRecords læser dem (getString/getInt; manglende attribut → nil → standard i fromRecords).
- Step 4: Kør
luajit tests/test_adRuns.lua && luajit tests/test_adRunsController.lua→ alle OK. - Step 5: Commit
feat: run-model v2 — retning, kilder, varepolitik, tærskler, runde (XML v2 + v1-migration)
Task 2: adWaitPool udskilt fra adUnloadWait
Files:
- Create:
FS25_ADSmartPickup/adWaitPool.lua - Modify:
FS25_ADSmartPickup/adUnloadWait.lua,adLoadSwap.lua(excludedPlaceables),modDesc.xml(sourceFile før adUnloadWait) - Test:
tests/test_adWaitPool.lua(ny), eksisterendetests/test_adSmartPickup.luaskal stadig køre
Interfaces (Produces):
ADWaitPool.getCandidates(adEnv, poolGroup) -- {{marker, x, z}}
ADWaitPool.claim(markerId, vehicle) / ADWaitPool.releaseVehicle(vehicle) / ADWaitPool.getOwner(markerId)
ADWaitPool.pickFree(adEnv, vehicle, poolGroup, isReachable) -- candidate eller nil (nærmeste ledige fra vognens position)
ADWaitPool.hasRouteTo(adEnv, vehicle, wayPointId) / ADWaitPool.hasRouteBothWays(adEnv, vehicle, toId, homeId)
ADWaitPool.redirect(task, destinationID) -- stopper AD's trigger-aktivitet, sætter task.destinationID/state/setPathTo
ADWaitPool.getClaims() -- {{marker, vehicle}} sorteret (til GUI)
ADWaitPool.STATE_DRIVING -- sættes af adUnloadWait.install (task.STATE_DRIVING or 2)
ADLoadSwap.findDumpMarker(adEnv, vehicle, fillType, rest, homeWayPointId, isReachable, excludedPlaceables) -- 7. param: {[placeable]=true} udelukkes
- Step 1: Test
tests/test_adWaitPool.lua: mockFS25_AutoDrive.ADGraphManager(markers i mapperne "Cow Wait" og "silo", waypoints,getPathToder returnerer{ {id=target} }for nåelige),ADSupplyPlannerviadofile. Tjek: (W1)getCandidates(adEnv, "Cow Wait")giver kun Wait-markører i den mappe, tom pulje = alle Wait-mapper; (W2)pickFreereturnerer nærmeste ledige, springer reserverede over, returnerer eget reserverede; (W3)claim/releaseVehicle/getOwner; (W4)pickFreemedisReachableder afviser nærmeste → næste; (W5)getClaimssorteret på mappe. - Step 2: Kør → FAIL (fil mangler).
- Step 3: Implementér
adWaitPool.luaved at flyttegetWaitCandidates,claims,pathReaches,hasRouteTo,hasRouteBothWays,redirect,getClaims-sorteringen fraadUnloadWait.lua(samme kode, offentlige navne som ovenfor;claimsersetmetatable({}, {__mode="v"});getClaimsiterererclaimsdirekte:{marker = ADGraphManager:getMapMarkerByWayPointId(id), vehicle}).adUnloadWait.luakalder dem (claims[...]→ADWaitPool.claim/getOwner/releaseVehicle;pickNearestFreeWaitPoint(getWaitCandidates(...), claims, ...)→ADSupplyPlanner.pickNearestFreeWaitPoint(ADWaitPool.getCandidates(adEnv, pool), ADWaitPool.claims, x, z, vehicle, isReachable));ADUnloadWait.getClaims/hasRouteTo/hasRouteBothWaysbliver delegater.ADLoadSwap.findDumpMarker:if excludedPlaceables ~= nil and excludedPlaceables[station.owningPlaceable] then skip. - Step 4: Kør alle tests:
for t in tests/test_*.lua; do luajit $t | tail -1; done→ grønne. - Step 5: Commit
refactor: Wait-puljen udskilt i adWaitPool (reservationer, rutetjek, redirect) til deling med udkørsel
Task 3: adPrices — toppris og pris-%
Files:
- Create:
FS25_ADSmartPickup/adPrices.lua - Test:
tests/test_adPrices.lua
Interfaces (Produces):
ADPrices.maxFactor(factors) -- højeste værdi i {[period]=factor}; 1 uden faktorer
ADPrices.topPrice(baseNow, factorNow, maxFactor) -- baseNow * maxFactor / factorNow; baseNow når factorNow <= 0
ADPrices.percentOfTop(price, top) -- afrundet heltal; 0 når top <= 0
ADPrices.isSellingStation(station) -- SellingStation, isSellingPoint, ikke hideFromPricesMenu
ADPrices.getQuote(station, fillType) -- {price, top, percent} eller nil (spil-API i pcall)
ADPrices.bestQuote(quotes) -- højeste percent, dernæst price; nil ved tom
- Step 1: Test (P1)
maxFactor({EARLY_SPRING=0.9, LATE_AUTUMN=1.2})= 1.2; (P2)topPrice(500, 1.0, 1.25)= 625;topPrice(500, 0, 1.25)= 500; (P3)percentOfTop(470, 625)= 75;percentOfTop(700, 625)= 112 (stor efterspørgsel > 100);percentOfTop(1, 0)= 0; (P4)getQuotemed fake station{fillTypePrices={[5]=500}, getEffectiveFillTypePrice=function(_,ft) return 470 end, isa=function() return true end, isSellingPoint=true}ogg_fillTypeManager.getFillTypeByIndex→{economy={factors={A=1.0,B=1.25}}, pricePerLiter=400},g_currentMission.environment.currentPeriod="A"→ percent 75; (P5) udenfillTypePricesbrugespricePerLiter × factorNow × economyManager:getPriceMultiplier()(1.0) → top 500, percent 94 (470/500); (P6)bestQuotevælger højeste percent. - Step 2: Kør → FAIL.
- Step 3: Implementér:
ADPrices = {}
ADPrices.LOG_PREFIX = "ADSmartPickup: "
function ADPrices.maxFactor(factors)
local best = nil
for _, factor in pairs(factors or {}) do
if type(factor) == "number" and (best == nil or factor > best) then best = factor end
end
return best or 1
end
function ADPrices.topPrice(baseNow, factorNow, maxFactor)
if baseNow == nil or baseNow <= 0 then return 0 end
if factorNow == nil or factorNow <= 0 or maxFactor == nil or maxFactor <= 0 then return baseNow end
return baseNow * maxFactor / factorNow
end
function ADPrices.percentOfTop(price, top)
if top == nil or top <= 0 or price == nil then return 0 end
return math.floor(100 * price / top + 0.5)
end
function ADPrices.isSellingStation(station)
return station ~= nil and station.isa ~= nil and SellingStation ~= nil and station:isa(SellingStation)
and station.isSellingPoint == true and not station.hideFromPricesMenu
end
local function getCurrentPeriod()
local environment = g_currentMission ~= nil and g_currentMission.environment or nil
return environment ~= nil and environment.currentPeriod or nil
end
function ADPrices.getQuote(station, fillType)
local ok, effective = pcall(station.getEffectiveFillTypePrice, station, fillType)
if not ok or type(effective) ~= "number" then return nil end
local fillTypeDesc = g_fillTypeManager ~= nil and g_fillTypeManager.getFillTypeByIndex ~= nil and g_fillTypeManager:getFillTypeByIndex(fillType) or nil
local factors = fillTypeDesc ~= nil and fillTypeDesc.economy ~= nil and fillTypeDesc.economy.factors or nil
local period = getCurrentPeriod()
local factorNow = factors ~= nil and period ~= nil and tonumber(factors[period]) or 1
local baseNow = station.fillTypePrices ~= nil and tonumber(station.fillTypePrices[fillType]) or nil
if baseNow == nil or baseNow <= 0 then
local multiplier = 1
local economy = g_currentMission ~= nil and g_currentMission.economyManager or nil
if economy ~= nil and economy.getPriceMultiplier ~= nil then
local okMult, value = pcall(economy.getPriceMultiplier, economy)
if okMult and type(value) == "number" then multiplier = value end
end
baseNow = (fillTypeDesc ~= nil and tonumber(fillTypeDesc.pricePerLiter) or 0) * factorNow * multiplier
end
local top = ADPrices.topPrice(baseNow, factorNow, ADPrices.maxFactor(factors))
return {price = effective, top = top, percent = ADPrices.percentOfTop(effective, top)}
end
function ADPrices.bestQuote(quotes)
local best = nil
for _, quote in ipairs(quotes or {}) do
if best == nil or quote.percent > best.percent or (quote.percent == best.percent and quote.price > best.price) then best = quote end
end
return best
end
- Step 4: Kør → OK. Step 5: Commit
feat: adPrices — toppris fra årets prisforløb og pris-% pr. salgssted
Task 4: adOutboundPlanner (ren)
Files:
- Create:
FS25_ADSmartPickup/adOutboundPlanner.lua - Test:
tests/test_adOutboundPlanner.lua
Interfaces (Produces):
-- input:
-- { capacity = liter, hasAutoload = bool, sellAtPercent, storeAbovePercent, collectRound,
-- products = { { fillType = idx, name = "SUGAR", policy = "sell"|"store"|"sellElseStore", isPallet = bool,
-- sources = { {wayPointId, available, level, capacity} ... }, -- kun kilder med available > 0
-- quote = {percent, price, wayPointId, stationName} | nil, -- bedste NÅELIGE salgssted
-- store = {wayPointId, name, free} | nil } ... } }
-- output: trip | nil, reasons
-- trip = { kind = "sell"|"store", fillTypes = {idx...}, names = {...}, sourceIds = {wayPointId...}, targetWayPointId, targetName, percent|nil }
-- reasons = { "SUGAR 78 % af top (kræver 90), lager 41 %", ... }
ADOutboundPlanner.plan(input)
ADOutboundPlanner.evaluate(product, input) -- {available, fullLoad, storageHigh, storagePercent, priceOk, percent}
ADOutboundPlanner.MIN_AVAILABLE_LITERS = 1
ADOutboundPlanner.ROUND_MIN_FREE_FRACTION = 0.1
- Step 1: Tests (
local P = ADOutboundPlannerefterdofile):- P1 salg ved pris over tærskel, uanset mængde: SUGAR sell, sources {{1, 5000, ...}}, quote 92, capacity 20000 → trip.kind "sell", targetWayPointId = quote.wayPointId, sourceIds {1}.
- P2 pris under tærskel, lager lavt → nil, reasons[1] = "SUGAR 78 % af top (kræver 90), lager 41 %".
- P3 sellElseStore, pris lav, lager 85 % → "store" til store.wayPointId.
- P4 store med fuldt læs (available ≥ capacity) og lager 20 % → store.
- P5 rangering: to sell-kandidater 95 % og 91 % → første vælges (højeste %).
- P6 quote nil (intet nåeligt salgssted), policy sell, lager højt → nil, reason "SUGAR: intet nåeligt salgssted".
- P7 store nil ved store-behov → nil, reason "FORAGE: ingen silo med plads".
- P8 runde bulk (collectRound, hasAutoload=false): SUGAR i kilde A (3000) og B (9000), capacity 10000 → sourceIds {B, A} (mest først), ét fillType.
- P9 runde UAL: STRAW_BASKET (sell 95, station S) + STRAW_CRATE (sell 93, station S) + STRAW_BROOM (sell 96, station T) → fillTypes {BROOM} kun (højest, T) — og med BROOM fjernet: {BASKET, CRATE} samme station S.
- P10 uden runde: kun første fillType og kun én kilde (den med mest).
- P11 policy off/ingen sources → nil, reasons tom for varer uden kilder ("SUGAR: intet på lager").
- Step 2: Kør → FAIL. Step 3: Implementér:
ADOutboundPlanner = {}
ADOutboundPlanner.MIN_AVAILABLE_LITERS = 1
ADOutboundPlanner.ROUND_MIN_FREE_FRACTION = 0.1
local function sumAvailable(product)
local total, high, percent = 0, false, 0
for _, source in ipairs(product.sources or {}) do
total = total + (source.available or 0)
if source.capacity ~= nil and source.capacity > 0 then
local p = 100 * (source.level or 0) / source.capacity
percent = math.max(percent, p)
end
end
return total, percent
end
function ADOutboundPlanner.evaluate(product, input)
local available, storagePercent = sumAvailable(product)
local percent = product.quote ~= nil and product.quote.percent or nil
return {
available = available,
fullLoad = input.capacity ~= nil and input.capacity > 0 and available >= input.capacity,
storagePercent = storagePercent,
storageHigh = storagePercent >= (input.storeAbovePercent or 80),
percent = percent,
priceOk = percent ~= nil and percent >= (input.sellAtPercent or 90),
}
end
local function wantsSell(policy) return policy == "sell" or policy == "sellElseStore" end
local function wantsStore(policy) return policy == "store" or policy == "sellElseStore" end
local function reasonFor(product, e, input)
if e.available < ADOutboundPlanner.MIN_AVAILABLE_LITERS then return product.name .. ": intet på lager" end
if wantsSell(product.policy) and product.quote == nil and not (wantsStore(product.policy) and product.store ~= nil) then
return product.name .. ": intet nåeligt salgssted"
end
if wantsStore(product.policy) and product.store == nil and (e.fullLoad or e.storageHigh) and not e.priceOk then
return product.name .. ": ingen silo med plads"
end
local parts = {}
if wantsSell(product.policy) and e.percent ~= nil then
table.insert(parts, string.format("%d %% af top (kræver %d)", e.percent, input.sellAtPercent or 90))
end
table.insert(parts, string.format("lager %d %%", math.floor(e.storagePercent + 0.5)))
return product.name .. " " .. table.concat(parts, ", ")
end
-- kandidater: {product, e, kind, targetWayPointId, targetName, score}
local function collectCandidates(input)
local sell, store, reasons = {}, {}, {}
for _, product in ipairs(input.products or {}) do
local e = ADOutboundPlanner.evaluate(product, input)
local added = false
if e.available >= ADOutboundPlanner.MIN_AVAILABLE_LITERS then
if wantsSell(product.policy) and e.priceOk and product.quote ~= nil then
table.insert(sell, {product = product, e = e, kind = "sell", targetWayPointId = product.quote.wayPointId, targetName = product.quote.stationName, percent = e.percent})
added = true
elseif wantsStore(product.policy) and (e.fullLoad or e.storageHigh) and product.store ~= nil then
table.insert(store, {product = product, e = e, kind = "store", targetWayPointId = product.store.wayPointId, targetName = product.store.name})
added = true
end
end
if not added then table.insert(reasons, reasonFor(product, e, input)) end
end
table.sort(sell, function(a, b)
if a.e.percent ~= b.e.percent then return a.e.percent > b.e.percent end
if a.e.available ~= b.e.available then return a.e.available > b.e.available end
return a.product.name < b.product.name
end)
table.sort(store, function(a, b)
if a.e.storagePercent ~= b.e.storagePercent then return a.e.storagePercent > b.e.storagePercent end
if a.e.available ~= b.e.available then return a.e.available > b.e.available end
return a.product.name < b.product.name
end)
return sell, store, reasons
end
local function sortedSources(product)
local sources = {}
for _, source in ipairs(product.sources or {}) do
if (source.available or 0) >= ADOutboundPlanner.MIN_AVAILABLE_LITERS then table.insert(sources, source) end
end
table.sort(sources, function(a, b) return a.available > b.available end)
return sources
end
local function buildTrip(primary, candidates, input)
local trip = {kind = primary.kind, fillTypes = {primary.product.fillType}, names = {primary.product.name}, sourceIds = {}, targetWayPointId = primary.targetWayPointId, targetName = primary.targetName, percent = primary.percent}
local members = {primary}
if input.collectRound and input.hasAutoload and primary.product.isPallet then
for _, candidate in ipairs(candidates) do
if candidate ~= primary and candidate.kind == primary.kind and candidate.targetWayPointId == primary.targetWayPointId and candidate.product.isPallet then
table.insert(members, candidate)
table.insert(trip.fillTypes, candidate.product.fillType)
table.insert(trip.names, candidate.product.name)
end
end
end
local seen = {}
for _, member in ipairs(members) do
local sources = sortedSources(member.product)
local limit = input.collectRound and #sources or math.min(1, #sources)
for index = 1, limit do
local id = sources[index].wayPointId
if not seen[id] then seen[id] = true; table.insert(trip.sourceIds, id) end
end
end
return trip
end
function ADOutboundPlanner.plan(input)
local sell, store, reasons = collectCandidates(input)
if sell[1] ~= nil then return buildTrip(sell[1], sell, input), reasons end
if store[1] ~= nil then return buildTrip(store[1], store, input), reasons end
return nil, reasons
end
- Step 4: Kør → OK. Step 5: Commit
feat: adOutboundPlanner — turvurdering, prisregel, politikker, opsamlingsrunde (ren)
Task 5: adSources — kilde-genkendelse og lagerstatus
Files:
- Create:
FS25_ADSmartPickup/adSources.lua - Test:
tests/test_adSources.lua
Interfaces (Produces):
ADSources.MAX_DISTANCE = 40; ADSources.PALLET_RADIUS = 25
ADSources.identify(adEnv, wayPointId, farmId) -- {kind="factory"|"silo"|"objectStorage", placeable, station|nil, wayPointId, name} | nil
ADSources.getProducts(adEnv, source, farmId) -- { {fillType, name, available, level, capacity, isPallet} } (kun varer med kapacitet/indhold)
ADSources.isPalletFillType(fillType) -- fillTypeDesc.palletFilename ~= nil
ADSources.getSellMarkers(adEnv, farmId) -- { {marker, station} } salgssteder (ADPrices.isSellingStation)
ADSources.getObjectInfoIndex(source, fillType) -- objectInfo-indeks + antal for varen i et objektlager, eller nil
ADSources.spawnOne(source, objectInfoIndex) -- pcall placeable:removeAbstractObjectsFromStorage(index, 1, nil) -> ok
ADSources.countStored(source, fillType) -- antal objekter af varen i lageret
ADSources.getSourcePlaceables(sources) -- {[placeable]=true} til udelukkelse
Genkendelse: læssestation ved markøren (ADSmartPickup.getLoadStationAtWayPointId) med owningPlaceable.spec_productionPoint → factory; med spec_silo → silo; ellers nærmeste placeable med spec_objectStorage inden for 40 m af waypointet (g_currentMission.placeableSystem.placeables, getWorldTranslation(placeable.rootNode), canFarmAccess) → objectStorage.
Fabrik-produkter: for production in productionPoint.productions: for output in production.outputs: type (unik); level = productionPoint.storage:getFillLevel(ft), capacity = storage:getCapacity(ft) (fallback storage.capacities[ft]); paller i nærheden: g_currentMission.vehicleSystem.vehicles med spec_pallet eller isPallet, ejer = farm, fillUnit-varen = ft, afstand ≤ 25 m fra productionPoint.palletSpawner.spawnPlaces[1].startNode (fallback placeable.rootNode); available = level + Σ palle-fyldning.
Silo: spec_silo.storages[*] fillLevels > 0 → level = getOwnStorageLevel, capacity = storage:getCapacity(ft).
Objektlager: spec_objectStorage.objectInfos[i].objects[1] med palletAttributes.fillType/fillLevel eller baleAttributes.fillType/fillLevel; numObjects → available = numObjects × fillLevel; level = spec.numStoredObjects, capacity = spec.capacity; isPallet = true.
- Step 1: Tests med fakes: (S1) markør ved fabrik → factory med SUGAR output level 150000, capacity 180000, ingen paller → available 150000; (S2) markør ved silo → silo med FORAGE 74500; (S3) markør uden station, objektlager 20 m væk → objectStorage, produkt STRAW_BASKET available 8×1000, isPallet true,
getObjectInfoIndex= 1 og 8; (S4) paller ved spawner: 2 paller à 1000 STRAW_BASKET inden for 25 m + 1 uden for → available = storage + 2000; (S5)getSellMarkersgiver kun markører hvis unload-station er SellingStation med isSellingPoint; (S6)spawnOnekalderremoveAbstractObjectsFromStorage(1, 1, nil)(fake tæller); (S7) markør 60 m fra objektlager → nil. - Step 2–4: implementér som beskrevet, kør, grønne. Step 5: Commit
feat: adSources — fabrik, silo og objektlager som kilder; paller ved spawner; salgssteder
Task 6: adOutbound — hooks, hold, objektlager-udtag, runde, advarsler
Files:
- Create:
FS25_ADSmartPickup/adOutbound.lua - Test:
tests/test_adOutbound.lua
Interfaces (Produces):
ADOutbound.CHECK_INTERVAL_MS = 5000; ADOutbound.NO_PROGRESS_MS = 15000; ADOutbound.SPAWN_STALL_MS = 30000
ADOutbound.isOutboundVehicle(vehicle) -- run for vognen har kind "outbound"
ADOutbound.choosePickup(adEnv, vehicle) -- kaldes fra getNextPickup-wrap; returnerer choice {markerIndex, markerName, group, station|nil, fillTypes, fillType} eller nil
ADOutbound.buildPlanInput(adEnv, run, vehicle) -- samler kilder/priser/lager til planlæggeren (spil-API)
ADOutbound.beforeLoadUpdate(adEnv, task, dt) -- true = håndteret (spring original update over)
ADOutbound.beforeLoadFinished(adEnv, task) -- true = fortsæt runde (spring original finished over)
ADOutbound.releaseInactive()
ADOutbound.getHoldFor(vehicle) -- {markerName, reasons} | nil
ADOutbound.getRunStatus(run) -- {reasons, warning} (seneste)
ADOutbound.install(adEnv) -- wrapper LoadAtDestinationTask.update/finished
Adfærd (fra spec afsnit 3–4):
-
choosePickup:trips[vehicle]=nil; input = buildPlanInput;trip, reasons = ADOutboundPlanner.plan(input); status[run.id] = reasons. Tur →stateModule.selectedFillTypes = trip.fillTypes; setFillType(trip.fillTypes[1]); setSecondMarkerByWayPointId(trip.targetWayPointId); raiseDirtyFlag;trips[vehicle] = {run, trip, queue = copy(trip.sourceIds), visited = {}, current = trip.sourceIds[1]}; log'MT635' udkørsel: 8×STRAW_BASKET fra Straw factory → Halm-salg (95 % af top); returnér choice for første kilde (station = kildens læssestation hvis bulk, ellers nil). Ingen tur →ADWaitPool.pickFree(adEnv, vehicle, run.waitPoolGroup, isReachable=hasRouteTo); fundet → claim,holds[vehicle] = {markerId, reasons}, log én gang pr. ændret begrundelse, returnér choice for Wait-markøren (station nil); intet Wait-punkt → hold ved første kilde (warn én gang) og returnér den. -
buildPlanInput: for hver kilde irun.sourceWayPointIds:ADSources.identify(nil → spring over + warn én gang "kilde markør X ikke genkendt"); products = union over kilder afADSources.getProducts, filtreret tilADRuns.getProductPolicy ~= "off"og til det vognen kan bære (bulk: AutoDrive.getSupportedFillTypesOfAllUnitsAlphabetically, pallet: hasAutoload =AutoDrive:hasAL(trailer)for en afgetAllUnits); pr. produkt med sell-politik: quotes overADSources.getSellMarkershvorstation.acceptedFillTypes[ft](ellergetIsFillTypeSupported) ogADWaitPool.hasRouteBothWays(adEnv, vehicle, marker.id, sourceId)→ADPrices.getQuote; bedste =ADPrices.bestQuote; ingen nåelig men mindst én unåelig → warnOnce(vehicle, "sell:"..ft, "'%s' kan ikke nå salgssted '%s' for %s – tjek vejnettet"); pr. produkt med store-politik:ADLoadSwap.findDumpMarker(adEnv, vehicle, ft, math.min(available, capacity), nil, isReachable, ADSources.getSourcePlaceables(sources))→ store; nil → warnOnce(vehicle, "store:"..ft, "'%s' finder ingen silo med plads til %s (%s)"). -
warnOnce(vehicle, key, text):warned[vehicle][key] ~= text→ Logging.warning + pcalladEnv.AutoDriveMessageEvent.sendMessageOrNotification(vehicle, adEnv.ADMessagesManager.messageTypes.ERROR, "ADSmartPickup: " .. text, 8000); status[run.id].warning = text. Ryddes når nøglens tilstand ændrer sig (ny tekst) eller turen lykkes. -
beforeLoadUpdate: hold → hvistask.destinationID == hold.markerIdogdrivePathModule:isTargetReached():specialDrivingModule:stopVehicle(); specialDrivingModule:update(dt); timer 5 s →choosePickupigen; tur →ADWaitPool.releaseVehicle,holds[vehicle]=nil,ADWaitPool.redirect(task, trip.sourceIds[1]),task.activatedUALLoading=false; returnér true. Ikke fremme → false. Tur aktiv ogisTargetReached(): kilde =state.sources[task.destinationID]; objektlager →updateSpawn(nedenfor); derefter fremdriftsvagt:level = AutoDrive.getAllFillLevels(task.trailers); stiger →noProgressMs=0; ellers+dt;> NO_PROGRESS_MSoglevel > 0og ingen spawn i gang →tryNextSourceOrFinish(task); returnér false (original kører altid, den aktiverer UAL og stopper vognen). -
updateSpawn(state, task, source, dt): første gang:index = ADSources.getObjectInfoIndex(source, currentFillType); hvistask.activatedUALLoadingog vognen står (vehicle.lastSpeedReal < 0.0005): hvis ingen udestående spawn:ualIsFull()på en UAL-vogn → done;countStored == 0→ skift til næste fillType itrip.fillTypes(nyt index) eller done; ellersADSources.spawnOne→pending = {stored = countStored, loaded = countLoaded(trailers), ms = 0}; udestående:countStored < pending.stored and countLoaded > pending.loaded→ pending=nil (næste);pending.ms > SPAWN_STALL_MS→ done + warn "objektlager spawner ikke".countLoaded= antal nøgler ispec_universalAutoload.loadedObjectssummeret over trailers. done →task:finished()(via original finished) og returnér. -
tryNextSourceOrFinish(task): næste id istate.queuesom ikke er besøgt, og fri kapacitet ≥ 10 % (fillFreeCapacity/capacity) →visited[current]=true; current=next; ADWaitPool.redirect(task, next); task.activatedUALLoading=false; activePickups opdateres via ADSmartPickup.setActivePickup(vehicle, station, fillTypes)(ny lille funktion i adSmartPickup); ellerstask:finished(). -
beforeLoadFinished: kaldes fra wrap affinished; hvis der er tur, kilde ikke fuld (ikkefilledToUnload) og en ubesøgt kilde med plads → redirect som ovenfor og returnér true (spring original over); ellerstrips[vehicle]=nilog false. -
releaseInactive: for inaktive køretøjer: trips/holds ryddes,ADWaitPool.releaseVehicle. -
Step 1: Tests (fake AD-miljø som i
test_adRunsController.lua+ fakes forADSources,ADPrices,ADWaitPool,ADRunsController.getRunForVehicle): (O1) tur → selectedFillTypes/2. markør sat, choice = kilde; (O2) ingen tur → Wait-markør claimet, hold registreret, choice = Wait; (O3) hold + 5 s + nu tur → redirect til kilde, hold frigivet; (O4) objektlager: spawn kaldes én ad gangen; stored falder + loaded stiger → næste spawn;ualIsFull→ finished kaldt; (O5) spawn uden fremdrift 30 s → finished + WARN; (O6) runde bulk: kilde A fylder vognen (filledToUnload) → finished kaldt, ingen redirect; kilde A giver 30 % → redirect til B; (O7) intet nåeligt salgssted →sendMessageOrNotificationkaldt én gang med ERROR, anden gang ikke; (O8)releaseInactiverydder hold og claim. -
Step 2–4: implementér, kør, grønne. Step 5: Commit
feat: adOutbound — udkørsels-hooks: turvalg, hold ved Wait, objektlager-udtag, opsamlingsrunde, advarsler på skærm
Task 7: Controller — udkørsels-runs
Files:
- Modify:
FS25_ADSmartPickup/adRunsController.lua - Test:
tests/test_adRunsController.lua(sektion C-out)
Interfaces (Produces):
ADRunsController.createRun(name, kind)
ADRunsController.getSourceMarkers(adEnv, farmId) -- markører der er kilder: {marker, kind}
ADRunsController.getRunProducts(adEnv, run) -- { {fillType, name, title, policy, isPallet, available, percent|nil, stationName|nil} } sorteret på titel
ADRunsController.validateVehicle(adEnv, run, vehicle) -- outbound: reasons noSource | noTrailer | noProducts | needsAutoload | cannotCarry | farFromNetwork
ADRunsController.getOutboundStatus(adEnv, run) -- {products = getRunProducts, reasons, warning} (fra ADOutbound.getRunStatus)
applyRunToVehicle: if run.kind == "outbound": mode P&D, setFirstMarkerByWayPointId(run.sourceWayPointIds[1]), setSecondMarkerByWayPointId(run.sourceWayPointIds[1]), selectedFillTypes = produkt-fillTypes vognen kan bære, setFillType(first), loopCounter 0, useFolders true, rotateTargets ONLYPICKUP; ingen dump-logik. getVehicleActivity: ADOutbound.getHoldFor(vehicle) → {kind="waiting", place=markerName, reason=reasons[1]}. updateRun logger som før. getRunTargets for outbound returnerer {} (mål vælges pr. tur).
- Step 1: Tests (C-out1) createRun kind; (C-out2) validate: ingen kilder → noSource; bulk-vogn og kun pallevarer → needsAutoload; ingen politik → noProducts; ok-tilfælde; (C-out3) startRun sætter 1./2. markør = kilde 1 og selectedFillTypes; (C-out4) getRunProducts slår politik op.
- Step 2–4: implementér, kør alle tests. Step 5: Commit
feat: controller — udkørsels-runs: start, validering, produkter, status
Task 8: Wiring i adSmartPickup, adUnloadWait, modDesc
Files:
- Modify:
adSmartPickup.lua(source() nye gui-filer,installHook: outbound-gren igetNextPickup-wrap,ADOutbound.install(adEnv),ADSmartPickup.setActivePickup,update:ADOutbound.releaseInactive),adUnloadWait.lua(beforeUpdate: return hvisADOutbound.isOutboundVehicle),modDesc.xml(sourceFiles: adPrices, adOutboundPlanner, adSources, adWaitPool, adOutbound før adUnloadWait/adSmartPickup; version 1.12.0.0; beskrivelse EN udvidet med udkørsel).
getNextPickup-wrap, ny gren først:
if ADOutbound ~= nil and ADOutbound.isOutboundVehicle(driver) then
local ok, choice = pcall(ADOutbound.choosePickup, adEnv, driver)
if not ok then
Logging.warning(ADSmartPickup.LOG_PREFIX .. "fejl i udkørsel, bruger AutoDrives rotation: %s", tostring(choice))
activePickups[driver] = nil
return originalGetNextPickup(self, driver, forcedSkip)
end
if choice == nil then activePickups[driver] = nil; return originalGetNextPickup(self, driver, forcedSkip) end
activePickups[driver] = choice.station ~= nil and {station = choice.station, fillTypes = choice.fillTypes, markerName = choice.markerName, fillType = choice.fillType} or nil
self.pickups[choice.group] = self.pickups[choice.group] or {}
self.pickups[choice.group].lastTarget = choice.markerIndex
self.pickups[choice.group].lastVehicle = driver
return choice.markerIndex
end
- Step 1: ret filer. Step 2:
luajit tests/test_adSmartPickup.luagrøn;for t in tests/test_*.lua; do luajit $t | tail -1; done. Step 3: Commitfeat: udkørsel koblet på AutoDrive-hooks; Wait-logik springer udkørsels-traktorer over
Task 9: GUI — tre faner
Files:
- Modify:
gui/SmartPickupFrame.lua(faner, fælles),gui/SmartPickupFrame.xml(nye statiske rækker + productPrefab + sourcePrefab),l10n/l10n_da.xml,l10n/l10n_en.xml - Create:
gui/SmartPickupSupplyTab.lua(flyt: fillTargetRows, fillNeedsRows, refreshNeedsRows, fillIngredientRows, describeTargetNeeds, onClickTarget/IngredientMode/Ingredient/Loops/ReturnBelow,SmartPickupFrame:rebuildSupplyTab(adEnv, run)),gui/SmartPickupOutboundTab.lua(rebuildOutboundTab(adEnv, run): Kilder-rækker (ingredientPrefab med kind="source"), Varer-rækker (productPrefab: MultiTextOption med 4 tilstande Fra/Lager/Salg/Salg, ellers lager + titel "Sukker — 150.0k · 78 % af top (Grain Elevator)"), Regler (sellAtOption 50..100 trin 5, storeAboveOption 50..95 trin 5, collectRoundOption), status-/advarselsrække; callbacks onClickSource/onClickPolicy/onClickSellAt/onClickStoreAbove/onClickCollectRound;describeOutboundOverview(run)til Oversigt). SmartPickupFrame:TAB_OVERVIEW, TAB_SUPPLY, TAB_OUTBOUND = 1, 2, 3,TAB_NAMES = {"spu_tab_overview","spu_tab_supply","spu_tab_outbound"},tabKind = {nil, "supply", "outbound"};fillRunOptionviser kun runs med fanens kind;onNewRunNameopretter med fanens kind (Oversigt → supply);setStaticRowsVisible(tab)viser forsynings-rækker (ingredientMode, waitPool, loops, returnBelow) kun på fane 2, udkørsels-rækker (waitPool, sellAt, storeAbove, collectRound) kun på fane 3 (waitPool er fælles); blok-overskrifter viaaddTextRow("— Mål og varer —" stil: brug tekstnøglernespu_section_*).refreshLiveopdaterer også produktrækkernes titler (pris-%) hvert sekund og udkørsels-status.- l10n (da/en):
spu_tab_supply"Forsyning"/"Supply",spu_tab_outbound"Udkørsel"/"Outbound",spu_sources"Kilder (fabrik, silo eller objektlager)",spu_products"Varer",spu_policy_off/store/sell/sellElseStore"Fra/Lager/Salg/Salg, ellers lager",spu_sellAt"Sælg fra (% af årets toppris)",spu_sellAt_tooltip,spu_storeAbove"Kør til lager over",spu_storeAbove_tooltip,spu_collectRound"Saml op på én tur",spu_collectRound_tooltip,spu_section_run/targets/rules/wait/vehicles/status,spu_reason_noSource"ingen genkendt kilde",spu_reason_noProducts"ingen vare valgt",spu_reason_needsAutoload"pallevarer kræver Universal Autoload på vognen",spu_product_row"%s — %s · %s",spu_price_pct"%d %% af top (%s)",spu_price_none"intet salgssted",spu_outbound_waiting"venter: %s",spu_activity_holding"holder ved %s (%s)". - Step 1: XML + l10n; Step 2: split + ny fane; Step 3:
luajitalle tests (GUI testes in-game); Step 4: Commitfeat(gui): faner Oversigt/Forsyning/Udkørsel; udkørsels-run med kilder, varepolitik, regler
Task 10: README, version, byg, deploy, in-game
- README: nyt afsnit "Udkørsel (v1.12)" (kilder, politikker, prisregel, lagertærskel, runde, objektlager-krav om markørplacering, advarsler). modDesc 1.12.0.0.
cd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip . && cp ../FS25_ADSmartPickup.zip ~/FS25-data/mods/- Commit
chore: v1.12.0.0 — udkørsel af produkter+ push til Gitea (git push gitea mainmed token fra vault). - In-game-test (bruger): savegame 3 — se spec afsnit 4.