225 Commits

Author SHA1 Message Date
masterdraco a3d234a3a2 chore(tools): AD-vejnet-værktøjer (45°-kurver, parkering, reparationer) brugt på savegame2 30/9 2026-09-30 15:32:21 +02:00
masterdraco 68eb26d71d docs: numpad * som tast, samlede kædeposter, markvarer og README-funktioner
Manualer (DA/EN) og ændringslog: statusvinduets tast er numpad *, kædeproblemer
står i én post i Kræver handling, beskeder højst 3 ad gangen, markvarer står
som "fra marker", slukket men nødvendig højst én post med +k. README nævner
statusvinduet og afhængigheds-advarslen.
2026-09-30 15:29:51 +02:00
masterdraco 1c60529dfd fix(status): numpad * som tast og log når vinduet ikke kan tegnes
Standardtasten skifter fra venstre Alt + Insert (kolliderer med
FS25_UniversalAutoload) til numpad *. Mangler info-boksens skalering eller
overlays, logges det én gang. Statusvinduet henter "start afvist" fra den
rigtige ESC-fane, så et flow hvis start fejlede vises rødt som i Flows-listen.
2026-09-30 15:28:56 +02:00
masterdraco b33f2a3b01 fix(kæde): højst 3 beskeder og samlede poster i Kræver handling
notifyProblems sender højst 3 beskeder pr. tick og samler resten i én
("+N manglende led — se fanen Kæder"). Alle kædeproblemer står i én rød post
("Kæder: N manglende led", scoret under rød fuld; klik folder kæderne ud) og
slukkede nødvendige fabrikker i højst én post ("Slukket men nødvendig: X +k").
2026-09-30 15:27:08 +02:00
masterdraco d2a4cf0d23 fix(kæde): markvarer som hø og hakkelse kommer fra markerne
ADChainData leverer data.fieldGoods (vindue-varer, spillets konvertere, tørrede
varianter og kendte markvarer der findes som fill type). En markvare uden
producent/stald får kilden "fra marker" uden afgrødeplan og uden problem, så
fermenteringssiloernes hø/lucerne/kløver/hakkelse ikke meldes som køb.
2026-09-30 15:25:47 +02:00
masterdraco 928299c8ee fix(kæde): kædens egne flows giver ikke dobbelt advarsel
Flows med run.chain (Kæde: X → Y) tæller som kæde-behov i neededOutputs,
så onlyChain forbliver sand, når kun kæden bruger varen, og Task 10 alene
melder det. Et almindeligt flow med samme vare advarer stadig.
2026-09-30 15:14:42 +02:00
masterdraco e9f1251471 fix(kæde): afhængigheds-advarsel uden støj fra auto-flows, alternative opskrifter og kæder
- Auto-flowets mål tæller kun for varer gården forbruger (eller en kæde vil
  have); stald-foder/auto-behov uden kendt forbrug er ikke nødvendigt. Manuel
  flow-vare og kæde-input gælder også uden kendte lagerdage.
- En slukket opskrift springes over, når en anden tændt opskrift i samme
  fabrik laver varen.
- Behov kun fra kæder giver ingen advarsel/post (Task 10 melder kæderne).
2026-09-30 15:13:15 +02:00
masterdraco 9fde03f950 fix(status): farve som Flows-listen, solgte køretøjer skjules og tasten er Alt+Insert
- En flow-traktors linje følger flowets niveau (venter/fejl) som Flows-listen.
- Solgte/slettede køretøjer (isDeleted, ingen/død rootNode) vises ikke under
  stået stille.
- Standardtasten er venstre Alt + Insert (S er spillets gå/bremse-akse).
2026-09-30 15:08:33 +02:00
masterdraco 979b7f72d5 feat(kæde): advarsel når en fabrik gården er afhængig af slukkes
Slukker spilleren en produktion, hvis vare et tændt flow kører (manuel vare
eller målet tager den i auto), en stald æder eller en aktiv kæde bruger,
kommer én besked pr. slukning med brugere og lagerdage, og en rød post
'Slukket men nødvendig' i Kræver handling og statusvinduet så længe den er
slukket og nødvendig. Ingen advarsel når en anden tændt produktion dækker,
lageret rækker over 60 dage, kæden selv slukkede den eller den var slukket
ved indlæsning. Tjekket kører i kædens tick også uden kæder; ren logik i
adDependency.lua.
2026-09-30 15:08:28 +02:00
masterdraco ba2a3e94f1 feat(status): statusvindue på skærmen slås til med en tast 2026-09-30 14:52:35 +02:00
masterdraco 131b1ad10c fix(kæde): stald-mangel forklares rigtigt og beskeder nævner kæden
Køb af en rest efter gårdens stalde giver nu spu_chain_problem_barn (køb resten
eller flere dyr) i stedet for 'byg en fabrik'. Engangsbeskeden får kædens navn
foran, så to kæder der mangler samme vare kan skelnes.
2026-09-30 14:42:53 +02:00
masterdraco 19a10ab67c feat(kæde): manglende led forklares i Kræver handling, fanen og som besked
problemText giver årsag og råd for køb/flaskehals/kredsløb og 'slukket af dig'
(userOff-kopi på ADChain.last); modellens problemer får need. Fanen Kæder viser
teksten som rød tekstrække under varen (foldet ud). Kræver handling får én rød
post pr. kæde (højst 2, score 3500+alvor mellem rød tom og rød fuld); klik åbner
fanen med kæden foldet ud. Engangsbesked pr. nyt led (rod|vare|art) kun fra
tick, ikke i opstartsfristen eller på delvise data/ufærdig markscanning.
Manualer og CHANGELOG opdateret.
2026-09-30 14:40:29 +02:00
masterdraco 1e7b2ec699 docs(kæde): tydelig advarsel om tændte produktioner og manualen opdateret efter slutreview-rettelserne 2026-09-30 14:28:43 +02:00
masterdraco 817fc388a2 fix(kæde): palle-varer får egen status og linjen med frie marker passer i fanen 2026-09-30 14:27:48 +02:00
masterdraco 83394aabfd perf(kæde): priser slås kun op for kandidatmarker, fanen genbruger resultater under 30 s og kæden måles af profileren 2026-09-30 14:27:08 +02:00
masterdraco 9e34a16dfa fix(kæde): højst 3 forslag pr. afgrøde og ét samlet kædepunkt i Kræver handling 2026-09-30 14:26:09 +02:00
masterdraco d2453f78fb fix(kæde): ingen markskift eller forslag før markscanningen har set alle marker 2026-09-30 14:25:14 +02:00
masterdraco 605c4e8d45 fix(kæde): spillerens slukninger huskes, pause beskytter kun sin egen kæde, stoppede flows slippes og forældede nøgler glemmes 2026-09-30 14:24:16 +02:00
masterdraco 9c9c2badeb fix(kæde): spillerens tændte produktioner leverer til andre rødder med fuld kapacitet 2026-09-30 14:21:23 +02:00
masterdraco e394a77833 docs(kæde): produktionskæder i manual, changelog og README (v1.61.0.0) 2026-09-30 14:10:18 +02:00
masterdraco b77bda5334 fix(kæde): pausede kæder kan genoptages, træet folder ud og teksten passer i cellerne 2026-09-30 14:07:37 +02:00
masterdraco f0b8df8ae3 feat(kæde): fanen Kæder og forslag i Kræver handling 2026-09-30 13:59:25 +02:00
masterdraco b389fa3564 fix(kæde): kæden tager aldrig spillerens traktorer, respekterer slukkede produktioner og handler ikke på delvise data 2026-09-30 13:52:15 +02:00
masterdraco 32f2886a55 feat(kæde): kæden kører — skifter frie marker, tænder mellemled, opretter flows 2026-09-30 13:46:01 +02:00
masterdraco 2a7769d090 feat(kæde): læse-adapter for produktioner, stalde, marker og udbytte 2026-09-30 13:37:58 +02:00
masterdraco 49eed51202 feat(kæde): flows kan tilhøre en kæde 2026-09-30 13:34:09 +02:00
masterdraco 6dd1b5cac0 feat(kæde): marker kan markeres fri til kæden; målafgrødens kilde huskes 2026-09-30 13:32:48 +02:00
masterdraco 217498aefa feat(kæde): mellemled og manglende flows 2026-09-30 13:30:54 +02:00
masterdraco 2e7a7105c1 feat(kæde): afgrødeplan vælger marker til manglende afgrøder 2026-09-30 13:28:57 +02:00
masterdraco 27017c0f62 fix(kæde): opbrugt fælles kapacitet meldes som flaskehals 2026-09-30 13:26:51 +02:00
masterdraco f813d4b3d8 feat(kæde): kædemodel sporer produkter baglæns til råvarer 2026-09-30 13:24:04 +02:00
masterdraco 4f676135ac fix(flow-vogne): husk kun redskaber med lastrum som flow-vogn (v1.60.21.0)
Sell Slurry 2 havde ploven husket som gyllevogn. Manualerne: fast sæt/bundet
omfatter nu også flowets vogne.
2026-09-30 12:34:39 +02:00
masterdraco 2a36de0837 docs: engelsk README-afsnit med install-trin 3 og Development 2026-09-30 11:53:12 +02:00
masterdraco c415bdc7a4 fix(marker): vinhøster — lille CP-bredde erstattes af 3 m (v1.60.20.0) 2026-09-29 23:50:34 +02:00
masterdraco a6506fea4e fix(marker): vinhøster uden bredde får vinrækkens 3 m (v1.60.19.0) 2026-09-29 23:40:08 +02:00
masterdraco 8abcbcdcb8 fix(marker): vinhøsterens afgrøde fra inputFruitTypeIndex (FS25) (v1.60.18.0) 2026-09-29 23:34:38 +02:00
masterdraco 3c67591cb3 fix(marker): vinhøstere genkendes via spec_vineCutter (v1.60.17.0) 2026-09-29 23:26:31 +02:00
masterdraco 784144cd4e fix(aflæsning): tipvogn skiftes til en tip-side AD må aflæsse fra
EDK 650 Unrealistic har kun allowAIDischarge på bagudtip; på venstre/højre/
kornluge åbnede AD aflæsningen, men intet løb ud (Chicken Food 2 786.712 l
hvede ved Farma med plads; Disponibel Vogn 3 fuld af løg). CP-genstart logger
kun ved ændring (v1.60.16.0).
2026-09-29 23:23:20 +02:00
masterdraco 13e5dbf188 fix(marker): humle (HOPS) er vinafgrøde — aldrig slåafgrøde (v1.60.15.0) 2026-09-29 23:12:16 +02:00
masterdraco 4de0bfcb46 fix(kobling): afkobling ved egen parkering godtager 8 m for lange redskaber (v1.60.14.0) 2026-09-29 21:41:53 +02:00
masterdraco 53346cc916 chore: tests ud af det offentlige repo (kun lokalt); feedback uden krav om konto 2026-09-29 21:32:19 +02:00
masterdraco 4e8e9aa350 chore: interne design-/plannoter og bench ud af repoet (kun lokalt) 2026-09-29 21:31:33 +02:00
masterdraco f8864e26c3 docs: fjern forældet testplan for markarbejde (v1.28) 2026-09-29 21:30:32 +02:00
masterdraco b33f7682b4 fix(cp): genstart efter indlæsning kun når CP's HUD-job er markarbejde
Moddets markopgaver starter CP med eget job; HUD'en kunne stå på bunkersilo,
og HUD-starten efter indlæsning gav 'CP: No silo found' (v1.60.13.0).
2026-09-29 21:21:18 +02:00
masterdraco d420d5026d fix(flows): Start alle ophæver pausen også for flows hvis traktorer allerede kører
Flows på pause (Stop alt) med traktorer der kørte videre på en gemt AD-tur blev
talt som 'kørte allerede' og forblev stoppede; efter at have hentet vognen så
flowet sig selv som stoppet og kørte vognen hjem igen (v1.60.12.0).
2026-09-29 21:08:07 +02:00
masterdraco 5827143327 docs: manualer opdateret til v1.60.11, README kort + CHANGELOG
Brugermanualen (dansk) er omskrevet til v1.60.11: menuplacering, farvede
Flows/Fields-lister med redigeringsvindue, guiden Opsæt flow med
solo-traktorer og vognvalg, flow-vogne, AD-markørkonventioner (P, Connect,
Afkobl), maskinpulje, markarbejde, Måned, Marksiloer, Stop alt og parkér,
lagerfanernes Kræver handling, navne og konsolkommandoer samt Test og
feedback. USER_MANUAL.md er en tro engelsk oversættelse. Udviklernoterne
er flyttet fra README til CHANGELOG.md, og README er en kort præsentation.
2026-09-29 21:07:00 +02:00
masterdraco 76f51c7be7 fix(flows): udkørselsregler vises kun når en vare bruger dem
Rent lager-flow viste 'sælg fra 90 %' / 'pris >= 90 %' selv om det aldrig
sælger. Guidens Regler-trin, resuméet og statuslinjen viser nu kun
salgsgrænser ved salg og lagergrænsen ved lager (v1.60.11.0).
2026-09-29 21:02:10 +02:00
masterdraco 089fb98782 docs: download og installation i README 2026-09-29 20:52:28 +02:00
masterdraco 1739b01d92 feat: flow-guide tildeler ledige vogne til solo-traktorer
Solo-traktorer (ingen vogn bagpå) er valgbare når en frakoblet vogn med
Connect-markør kan bære flowets varer. Guiden foreslår en vogn (navnematch,
så Disponibel) med vognvalg pr. traktor; flowet husker parret ved gem, så
vognens parkering ikke skal omdøbes.
2026-09-29 20:48:04 +02:00
masterdraco 87b030d492 fix(fields): tydelig linje øverst i Fields når Stop alt-pausen er aktiv (gemmes med savegamet) (v1.60.9.0) 2026-09-29 20:01:52 +02:00
masterdraco fbd3b2c643 fix(fields): gemt produkt (silage/hø) gælder kun slåafgrøder — F22 vinmark fortsatte som 'Silage + så' (v1.60.8.0) 2026-09-29 20:01:33 +02:00
masterdraco 0a4ef9624d fix(pulje): traktor med redskab på sin egen parkering er et parkeret sæt, ikke forældreløs (vinmarks-REX blev skilt ad) (v1.60.7.0) 2026-09-29 19:56:25 +02:00
masterdraco 781c81752c fix(flows): tømning bruger aldrig marksiloer; kan en silo ikke tage imod, prøves den næste (op til 3) før parkering med last (v1.60.6.0) 2026-09-29 19:11:17 +02:00
masterdraco 8b4df6b15e fix(pulje): efter indlæsning følges traktorer med puljeredskab uden ejer som forældreløse — sættes tilbage efter 2 min uden opgave (v1.60.5.0) 2026-09-29 19:04:10 +02:00
masterdraco 1c6cda3c10 fix(pulje/flows): redskab koblet på uden ejer (hentet før genindlæsning) sættes tilbage efter 2 min; flow husker kun vogn når et kørende flow stoppes, ikke ved oprydning efter indlæsning (v1.60.4.0) 2026-09-29 19:03:49 +02:00
masterdraco 02bd9b8fd4 chore(diag): DEBUG-linje også når vognen står ved målet med plads og læsset ikke falder i 30 s (EDK 650 ved fabriks-aflæsning) (v1.60.3.0) 2026-09-29 18:54:41 +02:00
masterdraco 5a4be10996 fix(fields): vin og oliven er aldrig slåafgrøder (et mod satte druer på MOWER-listen → F22 fik 'Silage + så' og en skårlægger) (v1.60.2.0) 2026-09-29 18:51:25 +02:00
masterdraco da9edaaabc feat(gui): Smart Pickup-fanen ligger lige over spillets Indstillinger i ESC-menuen (flyttes på alle tre lister som FarmOperationsDashboard) (v1.60.1.0) 2026-09-29 18:40:40 +02:00
masterdraco 3702073c8c feat(lagre): 'Kræver handling' → flow: løber tør = åbn/opret forsyningsflow (eller 'producér/køb' når et flow ikke hjælper), sælg nu = åbn/opret salgsflow; hint på hver linje (v1.60.0.0) 2026-09-29 18:30:54 +02:00
masterdraco ccd019d32a fix(lagre): statuslinjen viser ikke længere det sidst valgte flows status i lagerfanerne 2026-09-29 18:28:36 +02:00
masterdraco 6486d77cb4 fix(måned): egen række-skabelon med bredere Vindue-kolonne — 'OBS: sidste såmåned' klippes ikke (v1.59.11.0) 2026-09-29 18:24:24 +02:00
masterdraco 9c55859853 fix(marksilo): kolonneoverskrift 'En route' klippes ikke; liter vises kompakt (v1.59.10.0) 2026-09-29 18:23:43 +02:00
masterdraco fd3f002275 fix(fields): markvinduet viser ikke længere marksilo-advarsler (gælder ikke marken; Field Silos-fanen tømmer selv) (v1.59.9.0) 2026-09-29 18:22:34 +02:00
masterdraco f893b5e8ca feat(fields): Pleje har Automatisk/Manuelt som Klargøring — Automatisk = gødning, ukrudt og kalk; resuméet viser 'Automatisk' (v1.59.8.0) 2026-09-29 18:21:29 +02:00
masterdraco fc646ab00b feat(flows): besked på skærmen når solgte traktorer fjernes fra et flow; rød når flowet står uden traktorer (v1.59.7.0) 2026-09-29 18:18:27 +02:00
masterdraco 45f58abcf0 fix(flows): traktorer der ikke findes længere fjernes fra flowet 2 min efter indlæsning (ikke hvis alle mangler); resuméet viser aldrig rå køretøjs-id (v1.59.6.0) 2026-09-29 18:13:30 +02:00
masterdraco 3d11ee9748 fix(ui): redigeringsvinduet i menuens mørke tone (lineær farve 0.016) og så bredt som rækkerne (v1.59.5.0) 2026-09-29 18:10:27 +02:00
masterdraco 985114c9bf fix(ui): tekstrækker op til 90 tegn pr. linje (før 66); 'need' → 'efter behov — laveste først' + forklaring af farver og behov i Flows-listens hint (v1.59.4.0) 2026-09-29 18:08:50 +02:00
masterdraco 0cac934480 fix: Fields under Stop alt-pause vises stoppet med [Start], [Start] genoptager kun den mark; listen skjules bag redigeringsvinduet (tekst skinnede igennem) (v1.59.3.0) 2026-09-29 18:01:44 +02:00
masterdraco 9b5d71b180 fix(flows): flow der ikke er stoppet og har kørende traktorer tæller som kørende efter genindlæsning — listen viste [Start] ved kørende flows (v1.59.2.0) 2026-09-29 17:59:11 +02:00
masterdraco 07dada58a9 chore: v1.59.1.0 — review-rettelser til Flows/Fields-liste og redigeringsvindue 2026-09-29 17:52:32 +02:00
masterdraco f64959e2c3 fix(gui): review — vinduets placering (størrelse før position, genanvendes hvert sekund, profil uden midt-anker), fokus ved sletning af vinduets rækker, ESC via requestClose, [Start] kræver markør, forældet rød status, markvindue uden Slet 2026-09-29 17:52:23 +02:00
masterdraco e892348374 chore: v1.59.0.0 — Fields-liste + redigeringsvindue (etape 2) 2026-09-29 17:45:47 +02:00
masterdraco 19ed87ce0f feat(fields): Fields-fanen som farvet liste med Start/Stop pr. række; klik åbner redigeringsvinduet 2026-09-29 17:45:47 +02:00
masterdraco 44f1204be9 feat(fields): ren statusfunktion til Fields-listen 2026-09-29 17:44:16 +02:00
masterdraco e9fd920f7e chore: v1.58.0.0 — Flows-liste + redigeringsvindue (etape 1) 2026-09-29 17:43:52 +02:00
masterdraco 9435db929d feat(flows): Flows-fanen som farvet liste med Start/Stop pr. række; klik åbner redigeringsvinduet 2026-09-29 17:43:52 +02:00
masterdraco f068c910c7 feat(gui): redigeringsvindue (overlay) med Luk/ESC, Start/Stop, Slet 2026-09-29 17:42:35 +02:00
masterdraco 0cfb983506 refactor(gui): rækker kan klones ind i et andet layout (redigeringsvindue) 2026-09-29 17:40:35 +02:00
masterdraco 345e7303c1 feat(flows): ren statusfunktion + farver til Flows-listen 2026-09-29 17:40:06 +02:00
masterdraco 1ca1a30b0e docs(plan): Flows/Fields liste + redigeringsvindue (8 opgaver, 2 etaper) 2026-09-29 17:37:55 +02:00
masterdraco 21f2e6fee6 docs(spec): Flows/Fields som farvet liste med Start/Stop pr. række + redigeringsvindue 2026-09-29 17:28:30 +02:00
masterdraco 5649c6c59d fix: AutoDrive tælles ikke som Courseplay (flow-traktor med AD aktiv ventede på sig selv ved Start efter indlæsning); venteårsag logges kun ved ændring (v1.57.4.0) 2026-09-29 17:10:07 +02:00
masterdraco 3f7b85ba54 fix(flows): manuel påkobling starter også traktoren i et flow hvis Start blev afvist (ikke stoppet af spilleren) (v1.57.3.0) 2026-09-29 16:04:31 +02:00
masterdraco 6ec6a75f95 fix(udkørsel): 'All'-pulje gemmes som tom streng — ankerpunkt ved kilderne gælder nu også dér (v1.57.2.0) 2026-09-29 15:23:29 +02:00
masterdraco a694d17a67 fix(udkørsel): 'Wait at: All' vælger det ledige Wait-punkt nærmest flowets kilder, ikke nærmest traktoren (v1.57.1.0) 2026-09-29 15:23:14 +02:00
masterdraco 793f792f8b fix(forsyning): mindst fyldte kilde først kun blandt kilder med >= 25 % af et læs (ellers fyldeste) — foder-traktorer hentede 2-10k l i en gæringssilo der fyldes løbende; knappen viser Start for et flow der aldrig kom i gang, selv med vogn på (v1.57.0.0) 2026-09-29 15:20:48 +02:00
masterdraco 2454dd17e7 fix(ui): Start med afviste traktorer viser grunden som besked i højre side; 'ingen vogn' forklarer hvad der skal til (v1.56.5.0) 2026-09-29 15:14:28 +02:00
masterdraco 7922b8d4d0 fix(flows): flowet husker vognen bag traktoren før et stop sætter den af — ellers 'noTrailer' ved næste start (Milk Store) (v1.56.4.0) 2026-09-29 15:13:49 +02:00
masterdraco 3d6a3761e5 feat(kobling): spillerens AD 'Kør til' til et puljeredskabs egen parkering kobler det af (flow-vogne og puljeredskaber; faste sæt røres ikke) (v1.56.3.0) 2026-09-29 15:01:36 +02:00
masterdraco fece920028 feat(flows): traktor i et kørende flow startes når spilleren selv har kørt den til Connect og koblet dens vogn på (v1.56.2.0) 2026-09-29 14:58:10 +02:00
masterdraco be11283ba7 fix(kobling): stoppedAway logger afstanden til markøren; flow prøver én gang mere at hente vognen efter stoppedAway; puljens skyld-regel matcher årsagen med afstand (v1.56.1.0) 2026-09-29 14:54:38 +02:00
masterdraco 3e7e3d588e feat(pulje): bedste pasform — mindste traktor med hk nok vælges; afstand kun inden for ±15 %; over 2,5x redskabets behov kun som nødløsning (v1.56.0.0) 2026-09-29 14:33:43 +02:00
masterdraco 6af89c3034 fix(flows): tømning ved stop giver ikke længere 10 min ved et fuldt mål — 45 s ved målet uden at læsset falder -> silo, ellers parkering med last; Wait-pulje/læs-bytte slået fra under tømning (v1.55.1.0) 2026-09-29 14:18:11 +02:00
masterdraco 5cf093c3f1 feat(stop): redskaber slukkes, løftes og foldes til transport når en mark, et flow eller 'stop alt' stoppes — alle påkoblede, også forrest og bag redskaber (v1.55.0.0) 2026-09-29 14:13:37 +02:00
masterdraco f472f5dd80 fix(pulje): flow-vogne holdes altid af deres flow (også stoppet), stoppet flow-traktor kun ledig solo; markredskaber kun bag traktorer (butikskategori tractors*, ikke lastbil/Unimog); præcis venteårsag i loggen (v1.54.0.0) 2026-09-29 14:09:12 +02:00
masterdraco acbbe04a11 fix(kobling): afkobling bekræftes — først 'koblet af' når intet sidder bag traktoren, ellers nyt forsøg i op til 20 s og turen stoppes; før kørte traktoren videre med vognen (v1.53.2.0) 2026-09-29 14:03:41 +02:00
masterdraco db9fdf2178 fix(flows): tømning ved stop afleverer kun ved flowets egne mål — AD's 2. markør kunne være forældet (æg-markør) eller et Wait-punkt (v1.53.1.0) 2026-09-29 13:46:26 +02:00
masterdraco 417a44841d feat(kobling): rummelig koblingsrækkevidde (3 m) og bakning de sidste 3-8 m hen til vognens trækøje ved Connect-markører; lagervisning: bredere mængde-/rate-kolonner så teksten ikke klippes (v1.53.0.0) 2026-09-29 13:44:21 +02:00
masterdraco a305de25a5 fix(flows): flow startet igen under tømning/hjemtur afbryder hjemturen og kører videre med vognen — før tømte den i op til 10 min ved forkert mål, satte vognen af og kørte i garage (v1.52.1.0) 2026-09-29 13:37:47 +02:00
masterdraco 5b26415d1e feat(lagre): vareoversigt med nøgletal, 'kræver handling' og fyldningsbjælker — klik på en vare folder den ud til bygningerne; bygningstabellen bag 'Vis: Varer | Bygninger' (v1.52.0.0) 2026-09-29 09:23:15 +02:00
masterdraco 82db248775 feat(kursus): nye Courseplay-kurser får altid 2 forager-runder og starter på forageren (ellers ind i træer i hjørnerne); smal mark -> ét forsøg uden; genbrugt kursus uden forager beregnes på ny (v1.51.5.0) 2026-09-29 01:11:38 +02:00
masterdraco a5c52cee09 feat(flows): stoppet flow tømmer vognen før den parkeres — først ved flowets mål, rest til nærmeste silo/egen fabrik der tager varen, så på plads og hjem (v1.51.4.0) 2026-09-29 00:24:18 +02:00
masterdraco a390a05e8c fix(flows): flow-traktor uden husket vogn finder sin vogn på parkeringsnavnet (flowets navn, helst samme nummer) — Cow Food blev sprunget over med noTrailer (v1.51.3.0) 2026-09-29 00:09:27 +02:00
masterdraco 368a4784f7 fix(flows): review v1.51 — start af stoppet flow venter hvis traktoren er lånt af puljen, stop/slet af et stoppet flow rører ikke lånte traktorer, oprydning efter indlæsning springer lånte over, vogn holdt af ET kørende flow, flyttet traktor glemmer sin vogn, Stop-knap også for flows der holder stille, fejlet start sætter vognen tilbage, bygning væk kun for bygninger set i sessionen, spuNameFleet rører kun egne ikke-delte parkeringer (v1.51.2.0) 2026-09-28 23:26:01 +02:00
masterdraco d2ac2fb36d feat: spuNameFleet giver traktorer uden for flows butiksnavne (+ parkering 'P <navn>'); stoppede flows' traktorer der holder med vogn efter indlæsning sætter den på plads (v1.51.1.0) 2026-09-28 23:20:10 +02:00
masterdraco b0d22236b0 feat(flows): flow-vogne — flowets vogn huskes pr. traktor, solo-traktor henter sin vogn ved start (venter hvis den er lånt ud), stop/slet/bygning væk sætter vognen på plads og kører traktoren hjem; stoppede flows er ledige i maskinpuljen (v1.51.0.0) 2026-09-28 23:18:38 +02:00
masterdraco 1759d62600 fix(marksilo): fuld fabrik — lånt vogn med last der holder ved et lager uden plads i 60 s, kører resten til et andet lager (gårdsilo) (v1.50.5.0) 2026-09-28 20:06:03 +02:00
masterdraco e778ab44b4 fix(marksilo): tom silo — lånt vogn med last ved en tom marksilo kører resten til fabrik (plads til hele resten) ellers gårdlager; AD-ture fra marksiloer overtages igen efter genindlæsning (v1.50.4.0) 2026-09-28 20:00:34 +02:00
masterdraco 59e18acc9c fix(markarbejde): kultivering/pløjning måles på jorden — er ≥95 % af marken klargjort/pløjet, går trinnet videre uden at sende maskinen ud igen (også efter genindlæsning; F17 blev kultiveret 3 gange) (v1.50.3.0) 2026-09-28 19:57:02 +02:00
masterdraco 8d33499a37 fix(kobling): plov o.l. foldes ud før afkobling (spillet kræver det) med 20 s frist; foldbare redskaber foldes sammen til vejen efter påkobling (v1.50.2.0) 2026-09-28 19:08:22 +02:00
masterdraco 5a2701fc62 fix(pulje): review v1.50 — egne tømmevogne virkede ikke under høst (global 'implement'), hentning kan afbrydes (høst slut/Stop/spiller), hjemtur ved tømmevogns-fejl, marksilo-lån kan ikke hænge i 'henter' (tidsgrænse, spiller, fejl i start), ingen ekstra parkeringstur med puljevogn, Stop holder en bar mark; regressionstest for fejlagtige globale (v1.50.1.0) 2026-09-28 19:02:28 +02:00
masterdraco 6f46bb543a feat(pulje): etape 3 — marksilo-flowet låner også ledig solo-traktor + puljevogn: henter vognen, kører Hent og aflever, sætter vognen af og kører hjem; høstreserven tæller puljevogne (v1.50.0.0) 2026-09-28 18:56:06 +02:00
masterdraco 473f6110bf feat(pulje): etape 2 — tømmevogne ved høst fra puljen: ledig traktor henter en puljevogn der kan bære afgrøden, følger høsteren til høsten er færdig, sætter vognen af og kører hjem (v1.49.0.0) 2026-09-28 18:54:44 +02:00
masterdraco 760fbac328 fix(pulje): review-minors — mark forsvundet under hentning, hjemtur ved kursus-/CP-fejl og Stop alt, ventende marker på fælles taktslag (kø holder), redskabets navn i 'for svag', hk-cache (v1.48.4.0) 2026-09-28 18:53:34 +02:00
masterdraco 6eff1dcae8 feat(pulje): flådefanen viser puljetraktorer og -redskaber med status og hvorfor de ikke er med; puljetraktorer omdøbes ikke efter lånte redskaber (v1.48.3.0) 2026-09-28 18:52:43 +02:00
masterdraco 7730f68dec fix(flåde): plov før kultivator i rolle-tabellen (FS25-plove har også spec_cultivator); en plov må også tage kultivering (v1.48.2.0) 2026-09-28 18:46:13 +02:00
masterdraco 0e714d2968 feat(markarbejde): bar mark med mål-afgrøde klargøres straks uanset årstid — kun såningen venter på såtiden (v1.48.1.0) 2026-09-28 18:33:52 +02:00
masterdraco 3f19738743 chore: v1.48.0.0 — maskinpulje etape 1 (markarbejde) 2026-09-28 18:12:23 +02:00
masterdraco 2eec23add7 fix(pulje): review — puljeredskab sættes altid af (også påfyldte sæt), kun rolleløse traktorer henter, traktor uden vejnet springes over og får skylden ved stoppedAway, påfyldning efter koblingen, faste maskiner respekteres 2026-09-28 18:12:16 +02:00
masterdraco 3ac53b7d1f feat(pulje): marker der venter på en maskine betjenes længst-ventende først 2026-09-28 18:06:12 +02:00
masterdraco 2dc39b7849 feat(pulje): når opgaven slutter (eller fejler) sættes puljeredskabet af på sin parkering og traktoren kører hjem 2026-09-28 18:05:58 +02:00
masterdraco 2d170a30ff feat(pulje): markarbejdet sender en solo-traktor efter et puljeredskab når ingen færdig kombination er ledig (fase fetching) 2026-09-28 18:05:42 +02:00
masterdraco cdd253ee5f feat(pulje): hjemtur — puljeredskaber sættes af på deres parkering, traktoren kører til sin egen; optaget imens 2026-09-28 18:04:49 +02:00
masterdraco 4072445105 feat(pulje): spil-adapter — frakoblede redskaber med parkering+Connect og ledige solo-traktorer med hk 2026-09-28 18:04:31 +02:00
masterdraco 4760ee5e19 feat(pulje): ren planlægning — rolle, hk-tjek, Connect-navneregel, parring, kø, reservation/karantæne 2026-09-28 18:04:18 +02:00
masterdraco 4863e422ad docs: maskinpulje etape 1 — implementeringsplan (v1.48.0.0) 2026-09-28 18:02:01 +02:00
masterdraco 127f4cc9d9 docs: maskinpulje — design (traktorer og redskaber som fælles pulje, kø, hent/arbejd/parkér/hjem) 2026-09-28 17:56:12 +02:00
masterdraco e7b0e5c8f5 fix(kobling): ankomst måles fra nærmeste led i sættet — bakker AD med vogn, ender vognen på markøren og traktoren et vognslæb væk (stoppedAway ved Bredal) (v1.47.4.0) 2026-09-28 17:23:12 +02:00
masterdraco b8704f1f95 fix(kobling): Connect kobler kun på; i en tur sættes vognen af ved sin egen AD-parkering (advd.parkDestination) — kun i turen, så almindelig AD-parkering ikke afkobler (v1.47.3.0) 2026-09-28 17:18:14 +02:00
masterdraco f2ebc54b25 feat(kobling): Connect-markøren virker begge veje — med redskab bag sig kobles det af på sin parkeringsplads (v1.47.2.0) 2026-09-28 17:16:34 +02:00
masterdraco b57940a933 feat(kobling): spuTrip kører en tur i flere trin — Connect kobler på, Afkobl kobler af, almindelige markører holder 10 s undervejs (v1.47.1.0) 2026-09-28 17:15:49 +02:00
masterdraco be043ac220 feat(kobling): Afkobl-markører kobler redskaber bag traktoren af (frontvægt bliver); 'hent vogn og kør til' som ADHitch.trip + konsol spuTrip/spuDetach (v1.47.0.0) 2026-09-28 17:14:20 +02:00
masterdraco fde3fd4399 feat(kobling): Connect-markører — traktor der kører AD 'Kør til' en 'Connect <navn>'-markør kobler redskabet bag sig på ved ankomst; ADHitch.fetch til flows, konsol spuConnect (v1.46.0.0) 2026-09-28 17:07:07 +02:00
masterdraco 5a715bc3e9 fix(marksilo): høsten først — tømmevogne holdes tilbage til høstere der kører (planlagt antal minus udsendte); fabrik kun med plads til hele læsset (v1.45.1.0) 2026-09-28 00:51:15 +02:00
masterdraco 28d2f2c086 feat(genstart): husk ved hvert gem hvilke maskiner Courseplay kører (uden AD) og start netop dem igen efter indlæsning fra nærmeste waypoint via CP's HUD-start — også CP-jobs uden markrolle (v1.45.0.0) 2026-09-27 23:55:10 +02:00
masterdraco 6aeb8e4179 feat(marksilo): 'Kør overskud til produktion' — foder først: staldenes forbrug × valgt antal døgn bliver på gården, overskud køres til gårdens fabrikker (v1.44.0.0) 2026-09-27 22:19:26 +02:00
masterdraco 5e1d6a3659 fix(markarbejde): CP får markens punkt nærmest traktoren som position (kursets startsted) — F17-kurset startede 280 m væk, og kultivatoren kørte gennem skoven (v1.43.1.0) 2026-09-27 20:06:18 +02:00
masterdraco 5bb1ad6ae0 feat(markarbejde): høsten er færdig men høsteren har korn i tanken -> CP genstartes og venter ved kursets ende, tømmevogn sendes ud, marken går først videre når tanken er tom (én gang pr. mark) (v1.43.0.0) 2026-09-27 19:57:43 +02:00
masterdraco 521dc10df5 fix(tømmevogn): sæt retning på CP-tømmejobbets positioner når CP ikke selv nåede det — 'Angle missing' afviste tømmevognen til F15 i en time (v1.42.6.0) 2026-09-27 19:23:03 +02:00
masterdraco f3db8e4e5c feat(gui): Flows-fanen viser det valgte flow øverst og en kort liste til at vælge et andet — tavlen står kun i Oversigt (v1.42.5.0) 2026-09-27 17:02:31 +02:00
masterdraco df514fb6ec fix(markarbejde): vent op til 5 min mens Courseplay beregner kursus (store marker) i stedet for at opgive efter 60 s — F90 (140 ha) blev opgivet midt i beregningen (v1.42.4.0) 2026-09-27 16:51:39 +02:00
masterdraco 3ec4aaa179 fix(markarbejde): høst er først færdig når under 5 % af hele marken står (standingRatio); klargøring med mere stående afgrøde spoles tilbage — en halvt høstet mark kunne gå til kultivering (v1.42.3.0) 2026-09-27 16:47:57 +02:00
masterdraco 5ceb7da87d fix(markarbejde): højst én maskine sendt ud hvert 10. sekund på tværs af markerne — 6 kørte fra parkeringen samtidig efter Start alle; marksilo-navn uden dobbelt F-nummer (v1.42.2.0) 2026-09-27 16:43:12 +02:00
masterdraco c1060cddf9 fix(parkering): parkeringspunkt findes også på vognen/redskabet (advd.parkDestination) — AD kopierer det først til traktoren ved tilkobling; efter indlæsning manglede 4 af 9 (v1.42.1.0) 2026-09-27 15:32:15 +02:00
masterdraco 9c1c87b8dd feat(oversigt): 'Stop alt og parkér' — flows, markarbejde og marksilo-lån stoppes, køretøjer til AD-parkering (manuelle høstere nævnes), pause der gemmes; 'Start alle' ophæver pausen og starter traktorerne én ad gangen hvert 10. sekund (v1.42.0.0) 2026-09-27 15:20:41 +02:00
masterdraco 9348443f61 fix(navne): navnefilen skrives i spillets gem-hook — FS25 genskriver savegame-mappen og slettede ADSmartPickup_names.xml; startkø (én traktor ad gangen) forberedt (v1.41.1.0) 2026-09-27 15:18:50 +02:00
masterdraco 331d192f78 feat(navne): markmaskiner der stadig har modelnavnet navngives efter rollen (Læssevogn 5, Roeoptager 2, Mejetærsker 1 …); givne navne og flow-traktorer røres ikke (v1.41.0.0) 2026-09-27 15:12:40 +02:00
masterdraco 7d372eb5ab feat(navne): nye bygninger navngives automatisk (stald efter dyretype, gårdsilo/marksilo, palleskur, fabrik efter butiksnavn; numre fortsætter) + Omdøb bygning-knap i bygningsfanerne (v1.40.0.0) 2026-09-27 15:10:24 +02:00
masterdraco 9a284c875e feat(navne): egne bygningsnavne fra ADSmartPickup_names.xml bruges overalt (FS25 kan ikke omdøbe bygninger); marksilo: siloer i samme klynge som et gårdlager er gårdens (v1.39.0.0) 2026-09-27 15:05:14 +02:00
masterdraco 7e0a991ce6 feat(marksilo): marknummer foran silonavnet; statuslinje fortæller hvorfor ingen tømmevogn lånes (uden parkering/optaget/spiller i/ikke i flåden) (v1.38.7.0) 2026-09-27 14:58:10 +02:00
masterdraco 25867e2d88 fix(bygninger): pallespawner får aldrig aflæsningens nærmeste markør — i fårestald 3 og 5 stod spawneren tættest (5 mod 9 m), så Goat Food kun kunne levere til 3 af 5 stalde (v1.38.6.0) 2026-09-27 14:54:54 +02:00
masterdraco 490eecc25e fix(markarbejde): genstart efter indlæsning kun når maskinen står på en mark i gang med dens trin eller ≤30 m fra sit huskede waypoint — to ROPA'er kørte ad vejene mod et gammelt kursus (v1.38.5.0) 2026-09-27 14:47:52 +02:00
masterdraco bcfa237321 fix(markarbejde): CP startes ved sidste punkt (eget kursus + husket waypoint) eller nærmeste punkt (påbegyndt høst/slåning) i stedet for CP's standard 'first' — F111 blev høstet forfra (v1.38.4.0) 2026-09-27 14:43:21 +02:00
masterdraco f1b8b64668 fix(manuel transport): manuel høster springer kun skærebord-tjekket over når intet er monteret — F111 (hvede) valgte roeoptageren (v1.38.3.0) 2026-09-27 14:24:17 +02:00
masterdraco de88846166 fix(markarbejde): 'ingen maskine sendt' logger hver maskine med rollen og hvorfor (spiller i, AD/CP kører, opgave, marksilo-lån, ikke i flåden); manuel-transport-høstere afvises ikke for afstand til vejnettet (v1.38.2.0) 2026-09-27 14:20:46 +02:00
masterdraco 3a1d49ccef fix(gui): 'Kører selv på vejen' Ja/Nej i stedet for 'Manuel transport' — Ja betyder nu må sendes ud, som resten af flåden (v1.38.1.0) 2026-09-27 14:17:17 +02:00
masterdraco 6afcae6fe1 feat(markarbejde): manuel transport af brede høstere — flueben pr. høster i markflåden; moden beder spilleren køre den til marken og montere skærebordet (dialog + påmindelse hvert 5. min), overtager når den står på marken med passende skærebord og spilleren er steget ud (eller ved Start nu), tømmevogne først når høsten kører, ingen AD-parkering bagefter (v1.38.0.0) 2026-09-27 14:05:27 +02:00
masterdraco 12647087ef fix(markarbejde): høsteren sendes ikke selv til lager med rest i tanken (for stor) — CP venter ved kursets ende til tømmevognene har tømt den; kun læssevogne kører selv hjem (v1.37.2.0) 2026-09-27 13:53:59 +02:00
masterdraco a6a0dbf121 fix(markarbejde): genoptaget høster/læssevogn får lager til resten som ved normal start — ellers stod høsteren med korn i tanken efter kurset (v1.37.1.0) 2026-09-27 13:52:51 +02:00
masterdraco 565d7923cd feat(markarbejde): efter genindlæsning genstartes CP-maskiner der var midt i et job fra sidste waypoint (CP's eget fortsæt-job; HUD'ens startpunkt urørt) og meldes ind på marken igen, hvis trinnet passer til rollen (v1.37.0.0) 2026-09-27 13:51:44 +02:00
masterdraco 2b3793eeaa chore(markarbejde): log hvorfor ingen maskine sendes ud (alle optaget/ingen med rollen/…) én gang pr. ændring — før kun i fanen (v1.36.7.0) 2026-09-27 13:44:26 +02:00
masterdraco 29a142d3df feat(flow-guide): listen over traktorer der ikke kan bruges er foldet sammen som standard; linjen med antallet folder ud/ind (v1.36.6.0) 2026-09-27 13:34:23 +02:00
masterdraco a626fcdd95 fix(bygninger): pallespawnere viger for aflæsningen når begge vil have samme markør og aflæsningen står tættere — fårestaldens foderindkast fik 'Sheep Milk' som mål; gælder alle bygningstyper, rigtige læssetriggere beholder forrang (v1.36.5.0) 2026-09-27 13:31:22 +02:00
masterdraco 4ae268376d chore(diag): log pr. stald hvilke markører læsse-/aflæsningspunkterne ligger nærmest — Goat Food fik Sheep Milk-markører som mål (v1.36.4.0) 2026-09-27 13:13:52 +02:00
masterdraco 19893aaf41 feat(runs): ny grund 'AutoLoad er slået til på vognen' når en UAL-aktiv tipvogn afvises i et forsyningsflow (AutoDrive ser den kun som palle-/ballevogn) (v1.36.3.0) 2026-09-27 12:42:11 +02:00
masterdraco 37768c1507 feat(flow-guide): traktorer der ikke kan bruges i flowet vises med navn og grund (før kun et antal); fejl ved opbygning af traktorlisten logges i stedet for at skjule traktoren (v1.36.2.0) 2026-09-27 12:33:45 +02:00
masterdraco d7f4d0b3d3 fix(marksilo): siloer inden for 100 m af et gårdlager er gårdens (Farma-klynge deler lager) — sukkerroer blev kørt fra én Farma-silo til nabosiloen (v1.36.1.0) 2026-09-27 12:19:06 +02:00
masterdraco c2ab20d9aa merge: marksilo-flow (v1.36.0.0) 2026-09-27 11:50:51 +02:00
masterdraco 2e64ad537c docs(spec): marksilo-flow — flow-siloer udelades, vogne skal have parkering (fra review) 2026-09-27 10:50:55 +02:00
masterdraco fca26f5b4d fix(marksilo): review — flerværdi-valg som MultiTextOption, lagerreservation på tværs af runder, vagt kun før start, rutetjek + straks-fejl pauses, rotation fra på lånte vogne, kræver parkering, flow-siloer udelades, slettede vogne og små rester håndteres 2026-09-27 10:50:48 +02:00
masterdraco 31c993c6eb feat(gui): fanen Marksiloer — til/fra, maks. vogne, mindste mængde, gårdlagre, status pr. silo (v1.36.0.0) 2026-09-27 10:42:42 +02:00
masterdraco ccc6b608dc feat(marksilo): låner ledige tømmevogne hvert minut, AD hent-og-aflever til gårdlager, frigiver ved færdig/spiller/2 min stilstand; opsætning gemmes 2026-09-27 10:41:46 +02:00
masterdraco e9272b8b8f feat(marksilo): opsætningsmodel (til/fra, gårdlagre, x 1-10, tærskel) + records 2026-09-27 10:40:24 +02:00
masterdraco 1a8931116b feat(marksilo): ren planlægger — kvalificering, fuldeste først, x-loft, vogn- og lagervalg med reservation 2026-09-27 10:40:12 +02:00
masterdraco 8a0d7f2489 docs(plan): marksilo-flow — 4 opgaver (planlægger, opsætning, spil-lag, fane) 2026-09-27 10:39:48 +02:00
masterdraco f93ae798e3 docs(spec): marksilo-flow — låner op til x ledige vogne og kører marksiloernes varer hjem til nærmeste gårdlager 2026-09-27 10:34:51 +02:00
masterdraco 33b4f754b0 fix(runs): fyldte traktorer registreres 'på vej' ved run-start og efter AutoDrives genstart ved indlæsning — ellers så mål-valget 0 vogne på vej og sendte alle til samme biogas-drop; startloggen viser det valgte mål (v1.35.3.0) 2026-09-27 10:15:34 +02:00
masterdraco 6fbf05273e chore(diag): log hvorfor aktive AutoDrive-køretøjer står stille >20 s (opgave, forhindring, AD-trafik + blokerer, pause, Smart Pickup-hold) — flows kører ikke efter genstart (v1.35.2.0) 2026-09-27 09:32:58 +02:00
masterdraco 2beca0924a fix(måned): fremdrift for høst/slåning ud fra hele arealet — slået græs faldt ud af readyRatio's nævner, så halvt slået F90 viste 0 %; Courseplays kursus-position bruges kun til øvrige trin (v1.35.1.0) 2026-09-27 09:25:34 +02:00
masterdraco 5df4fdc7c3 feat(gui): fanen Måned — marker i gang med fremdrift, næste trin og om høst-/såvinduet lukker ved månedsskiftet; dom 'du kan sove' + marker der kan sås nu (v1.35.0.0) 2026-09-27 09:12:49 +02:00
masterdraco 5274925f2a fix(markarbejde): utoppede kartofler (forberedelige stadier) tæller som høstklare; klargøring på en mark med uhøstet afgrøde spoles tilbage til klar (F16) (v1.34.2.0) 2026-09-27 09:09:08 +02:00
masterdraco 307676004e fix(markarbejde): slåmarker regnes først klar i fuld højde; afbrudt slåning venter på genvækst i stedet for at klippe græs i mellemhøjde (F90) (v1.34.1.0) 2026-09-27 08:57:32 +02:00
masterdraco 50301b61b9 feat(markarbejde): genbrug Courseplay-kurser — markform-signatur + kursus-register pr. maskine; genbrug/kopi i stedet for ny beregning, glemmes ved ændret mark (v1.34.0.0) 2026-09-26 22:27:16 +02:00
masterdraco 39fa72ba0d test: O14 tæller rutetjek markør->markør (efter perf-ændringen i v1.33.4.0) 2026-09-26 22:14:22 +02:00
masterdraco ac3db0d826 perf: rutetjek frem+tilbage går markør->markør og gemmes 5 min (var 30-39 ms pr. kald, genberegnet hver 10 m); bygningsliste 30 s — hak på 44-67 ms (v1.33.4.0) 2026-09-26 22:13:50 +02:00
masterdraco 4aa221820d fix(markarbejde): intet sendes ud de første 2 min efter indlæsning, så Courseplay/AutoDrive kan genoptage egne jobs (kultivatoren blev taget midt i spillerens CP-job) (v1.33.3.0) 2026-09-26 22:04:32 +02:00
masterdraco 6a835b9efb fix(markarbejde): maskiner over 60 m fra AD-vejnettet sendes ikke ud (AutoDrive 'Could not calculate path'); ingen genforsøg når AD stopper uden at flytte sig (v1.33.2.0) 2026-09-26 22:00:45 +02:00
masterdraco f9d875fc3e fix(markarbejde): vinmark — sprøjter/spredere springer 1 vinrække over (arbejder begge sider af gangen) (v1.33.1.0) 2026-09-26 21:40:18 +02:00
masterdraco 954dd25c51 feat(udkørsel): 'Sælg altid når kilden er over X %' (sælger uanset pris, bedste salgssted der kan nås); fix: flydende varer lagres ikke automatisk i en fabriks rist (gyllevogn holdt ved AIO) (v1.33.0.0) 2026-09-26 21:37:20 +02:00
masterdraco 10838779bc fix(markarbejde): vinmark — Courseplays vinkursus lægges til venstre for vinen (i gangen) for sprøjte/spreder/harve, på vinen for høsteren (sprøjten kørte ind i stolperne) (v1.32.5.0) 2026-09-26 21:33:27 +02:00
masterdraco 59b6ac1e47 fix(markarbejde): forkert vare hældes kun ud som lille rest (≤10 %, ≤2000 l); fuld sprøjte/spreder bruges ikke til en anden opgave (100.000 l gødning blev hældt ud) (v1.32.4.0) 2026-09-26 21:28:17 +02:00
masterdraco a04c16552a fix(gui): advarsel øverst i Fields når markflåden står på 'kun med flueben' uden flueben (intet kan sendes ud) (v1.32.3.0) 2026-09-26 21:21:16 +02:00
masterdraco dde6f4b8eb fix(markarbejde): høsteren får lager ved høststart; korn i tanken efter høsten køres af AutoDrive til lageret, ellers tydelig besked (v1.32.2.0) 2026-09-26 21:19:07 +02:00
masterdraco 151954bd37 refactor(gui): fjern død kode fra den gamle flow-detaljeliste (retning/bygninger/varer/kilder-rækker) efter review; marklisten til guidens trin 1 hentes kun når den bruges (v1.32.1.0) 2026-09-26 20:39:23 +02:00
masterdraco 9b51a68731 feat(gui): guiden "Opsæt flow" — Flows-fanen får tavle + resumé (klik = ret ét trin, Navn = omdøb) og trin-for-trin-guide på samme ramme som markguiden; Nyt flow oprettes først ved Gem (v1.32.0.0) 2026-09-26 20:28:12 +02:00
masterdraco 92af260a79 feat(gui): guiden "Opsæt mark" — markfanen får oversigt med resumé (klik på en linje = ret ét trin), trin-for-trin-guide på fælles guide-ramme (ADGuide/ADFieldGuide) og egen markflåde-visning (v1.31.0.0) 2026-09-26 20:20:56 +02:00
masterdraco 7cbd8fb878 docs: spec — guider til opsætning af marker og flows (etape A+B) 2026-09-26 19:33:13 +02:00
masterdraco f174c10da5 fix(gui): klik på markens faste maskiner blev sendt til flowets traktorliste (GUI-fejl adRunsController:29, valget blev aldrig gemt); flowets traktorklik kræver et køretøj (v1.30.4.0) 2026-09-26 19:21:40 +02:00
masterdraco a95cb9e71d fix(gui): maskiner der er faste på en anden mark vises ikke ved den valgte mark (hverken faste maskiner eller markflåden) — 'N maskiner er faste på andre marker (F22)' (v1.30.3.0) 2026-09-26 19:08:21 +02:00
masterdraco 780b6a0daa fix(gui): marktabellens status er kort (trin + kort årsag, fx 'Gødskning: bredde ukendt'); hele forklaringen står i markens detaljer; kortere hint (v1.30.2.0) 2026-09-26 19:01:58 +02:00
masterdraco 61e85e1402 fix(gui): markflåden er tydeligt fælles for alle marker og viser 'fast på F…' / 'i gang på F…'; 50,0 m markeres ikke som over 50 m; rollen hedder gødskning (v1.30.1.0) 2026-09-26 18:45:25 +02:00
masterdraco 4c60fd555f feat(markarbejde): faste maskiner pr. mark (bruges kun dér; marken venter på sin egen); fix: AD-påfyldning får varen sat (kom tom tilbage); AD der stopper undervejs startes igen 2x, ellers hjem til parkering (v1.30.0.0) 2026-09-26 18:09:12 +02:00
masterdraco 9ef1eb6655 fix(markarbejde): påfyldning fra gårdens eget lager før købestation (gylle blev købt trods 7 mio. l ved BGA) (v1.29.6.0) 2026-09-26 18:02:30 +02:00
masterdraco 77253b5d2b feat(gui): markens lagervalg viser markørnavn og afstand, marksilo/gæringssilo først, kun de 8 nærmeste (34 ens Farma-rækker) (v1.29.5.0) 2026-09-26 17:37:10 +02:00
masterdraco 1b0a2c152b feat(gui): lange tekstrækker ombrydes over flere linjer i stedet for at blive afkortet; markdetaljen viser hele statusteksten (v1.29.4.0) 2026-09-26 16:29:21 +02:00
masterdraco 813ee6d0b9 fix(markarbejde): påfyldning også ved købestationer (buyingStation, ingen lagerbeholdning) — 'Liquid Buy Station' blev aldrig fundet; markør inden for 30 m af trigger eller 40 m af bygningen (v1.29.3.0) 2026-09-26 15:45:05 +02:00
masterdraco 455c9c7598 fix(markarbejde): tom sprøjte/spreder/såmaskine fyldes af AutoDrive (LOAD) før marken; uden påfyldning med AD-markør sendes den ikke ud; Courseplay startes aldrig med tom tank (sprøjten kørte F108 tom) (v1.29.2.0) 2026-09-26 15:08:47 +02:00
masterdraco 92e65a6431 fix(markarbejde): sæt med arbejdsbredde under 1,5 m sendes aldrig ud (spreder uden AI-markører gav 0,5 m -> CP-kursus hver halve meter på 72 ha -> spillet frøs); advarsel i markflåden (v1.29.1.0) 2026-09-26 14:52:47 +02:00
masterdraco e02d8af3d5 feat(markarbejde): bygget-over marker (groundType NONE / ingen markjord — stald, biogas, gårdsplads) er ikke med i oversigten eller arbejdet (v1.29.0.0) 2026-09-26 12:59:43 +02:00
masterdraco 5d06c46fa9 perf(markarbejde): markscanning kører op til 8 opgaver pr. frame inden for 0,5 ms — marklisten fyldes hurtigt efter genstart (v1.28.9.0) 2026-09-26 12:51:50 +02:00
masterdraco 6138b6b874 feat(markarbejde): tom spreder/sprøjte kan bruges (roller efter hvad den kan tage; påfyldning med rollens vare); spreder med forkert vare tømmes først og vælges kun hvis ingen anden kan (v1.28.8.0) 2026-09-26 12:51:03 +02:00
masterdraco abbe40da01 fix(markarbejde): afgrøde og vækststadie fra field.fieldState (spillets marktilstand); getFieldDataAtWorldPosition's 2. værdi er jord-bits, ikke vækst — F108 majs = bar jord; høstet mark vises som 'stub (afgrøde)' (v1.28.7.0) 2026-09-26 12:38:25 +02:00
masterdraco 36f381fc36 fix(markarbejde): markens omrids fra getDensityMapPolygon (FS25 har ingen polygonPoints) — flere prøvepunkter, markør-afstand til kanten; F108 majs sås som bar jord og F<nr>-markører 'manglede' på store marker (v1.28.6.0) 2026-09-26 12:28:27 +02:00
masterdraco 9be8fa98f0 fix(markarbejde): traktorer i et almindeligt flow er ikke markflåde (vises ikke og sendes ikke ud) (v1.28.5.0) 2026-09-26 12:15:00 +02:00
masterdraco 08d983800c fix(gui): bygningsrækker i fast rækkefølge (navn, så markør) — et tryk flyttede valgte drops op i toppen; fjernet bygning vises som 'Fjernet bygning' i stedet for rå id (v1.28.4.0) 2026-09-26 12:03:56 +02:00
masterdraco 41e50ccec8 fix: forsyning med flere mål vælger målet med færrest andre vogne på vej før mindst fyldte (3 biogas-vogne i kø ved samme drop) (v1.28.3.0) 2026-09-26 11:54:14 +02:00
masterdraco 725ad7b459 fix: æg-markør genkendes som hønsehus når det er nærmere end en løs læssestation (Pickup 1+2 blev Pigsty); palletildeling måler til hele spawn-området (alle rækker, start-slut) (v1.28.2.0) 2026-09-26 11:52:22 +02:00
masterdraco cb68c81a3f fix: æg-paller tælles kun for nærmeste spawner — tomt hønsehus så naboernes paller (hus 1+2 blev aldrig hentet) (v1.28.1.0) 2026-09-26 11:29:49 +02:00
masterdraco 6fd917caca perf: hak-audit — spredte timere, fælles cache (markør->station, salgssteder, identify, Wait-punkter, lagre, positioner), bygningsliste 10 s, profiler, simulering (v1.28.0.0)
Audit (2 reviewere) + simulering bench/bench_hak.lua: værste frame 124,7 ms -> 0,9 ms.
- Holdende vognes genplanlægning (5 s) spredes tilfældigt over hele intervallet (før: alle i samme frame).
- ADCache: korttids-cache m. spredt udløb (+0-50 %), kun aktiv i spillet; stationsopslag 30 s.
- Bygningsliste 10 s (før 1 s: bygninger x markører hvert sekund). Markarbejde-tick forskudt 0,5 s fra menuen.
- ADProfiler: logger top-6 langsomste indgange pr. minut ved > 2 ms.
2026-09-26 11:24:05 +02:00
masterdraco 8a18187103 feat(perf): indbygget profiler — måler alle indgange (hooks, update, GUI), logger top-6 pr. minut ved > 2 ms 2026-09-26 11:19:35 +02:00
masterdraco 0fca6e8a9a perf+fix: rutetjek gemmes 30 s (hak hvert 5. s); kilde der gav lidt hviler 3 min (æg-vognen til samme hønsehus 3x) (v1.27.3.0)
In-game 26/9: hak ca. hvert 5. s efter 1.27.2 = holdende vognes genplanlægning (ADOutbound/ADLoadSwap hvert
5 s) med AD-stifinding + reserve-BFS gennem 13.550 waypoints pr. kandidat. Rutesvar caches pr. mål og 10 m-
område i 30 s (max 2000 poster); langsomme rutetjek logges. Æg: 'ingen mere last' markerer nu kilden som
hvilende 3 min, så runden roterer mellem hønsehusene.
2026-09-26 11:07:16 +02:00
masterdraco 038cb5be54 docs: kommentar — arbejdskø, ikke coroutine 2026-09-26 10:39:01 +02:00
masterdraco 05832603e4 perf(marker): markscanning som arbejdskø — højst ét prøvepunkt pr. frame (v1.27.2.0)
In-game 26/9: hakket kom sjældnere da scanningen gik fra 1 s til 2 s -> scanningen af én mark i ét frame
var årsagen (opslag gennemgår alle ~40 afgrødetyper pr. punkt). Coroutines findes ikke i FS25 (Giants
fjernede dem, jf. Courseplays HybridAStar), derfor en eksplicit kø. Tidsmåling bruger netGetTime.
Test (test_adFieldsScan) beviser: færdig, korrekt resultat, <= 3 opslag pr. frame.
2026-09-26 10:38:51 +02:00
masterdraco c34a472b1c perf(marker): hak hvert sekund — ingen kopier når intet ændrer sig, passive marker i tiendedele, scan hvert 2. s, cachet markliste/markører, tick-tidsmåling i loggen (v1.27.1.0)
In-game 26/9: lille hak ca. hvert sekund. Markarbejdets tick kopierede alle markers data hvert sekund
(affald -> GC-pause), hentede alle kortets marker og alle AD-markører.
2026-09-26 02:03:31 +02:00
117 changed files with 18162 additions and 13557 deletions
+4
View File
@@ -1 +1,5 @@
*.zip
docs/superpowers/
bench/
.superpowers/
tests/
+445
View File
@@ -0,0 +1,445 @@
# Ændringslog — FS25_ADSmartPickup
Nyeste version øverst. Versionsnummeret står i `FS25_ADSmartPickup/modDesc.xml`. Brugervejledningen
står i [docs/BRUGERMANUAL.md](docs/BRUGERMANUAL.md) (dansk) og [docs/USER_MANUAL.md](docs/USER_MANUAL.md)
(engelsk). De ældste afsnit (v1.1–v1.13) er de oprindelige udviklernoter, der tidligere stod i README.
---
## 1.61 — Produktionskæder
- **1.61.0** Produktionskæder: slå et produkt til i en fabrik, og moden holder det kørende helt tilbage til marken. Kæden regner baglæns gennem dine egne fabrikker og stalde til afgrøderne og ser 12 måneder frem. Ny fane **Kæder** med træ pr. rod-produktion, farvet status, fold ud/ind og **[Pause]**/**[Start]**. Marker markeret **Fri til kæden** skifter afgrøde automatisk (højst 2 i minuttet); andre marker får forslag med **[Godkend]**/**[Afvis]** (højst 3 pr. afgrøde) i fanen og som ét samlet punkt i **Kræver handling**. En afgrøde, du selv har valgt, ændres aldrig. Mellemled tændes og slukkes kun igen, hvis kæden tændte dem. Flows **Kæde: <vare> → <bygning>** oprettes med en ledig pool-traktor og tipvogn (ellers stoppet: *mangler traktor/vogn*; paller altid stoppet) og stoppes, ikke slettes, når kæden slås fra. Dine egne tændte produktioner bruges som kilde først; slukker du en produktion, tænder kæden den ikke igen, før du tænder en ny. Stoppede kædeflows slippes af kæden. Ingen handlinger de første 2 minutter, ingen markskift før alle marker er scannet, og intet slukkes ved delvise læsefejl. Begrænsninger: intet køb, ingen dyrekøb, flerårige afgrøder og græsmarker skiftes aldrig, grundudbytte uden gødningsbonus. **Vigtigt:** hver produktion, du tænder i en fabrik, prøver moden at forsyne — tænd kun det, du vil have produceret; sluk produktionen eller tryk [Pause] for at stoppe kæden. Manglende led forklares i klart sprog med råd (*Ingen af dine fabrikker laver Mel — byg en eller køb*, *Mølle kan kun lave 800 af 1.0k/md*, *Dine stalde leverer ikke nok Mælk …*, *… er slukket af dig*, *Kredsløb omkring …*): som rød tekst under varen i fanen Kæder, som én samlet rød linje i **Kræver handling** (*Kæder: 3 manglende led*, under lagerets røde linjer; klik åbner fanen Kæder med kæderne foldet ud) og som engangsbesked med kædens navn foran, når et nyt led mangler — højst 3 ad gangen, resten samlet i *+N manglende led — se fanen Kæder* (ikke i opstartsfristen eller mens markerne scannes). Markvarer, der ikke er en afgrødes hovedvare (hø, tørret lucerne/kløver, hakkelse, helsæd, halm …), står med kilden *fra marker* uden afgrødeplan eller advarsel, så fermenteringssiloer ikke giver falske *byg en eller køb*. Nyt **statusvindue** på skærmen (i stil med Simple Inspector), som slås til/fra med **numpad \*** (Insert bruges af Universal Autoload; kan ændres under Styring; virker til fods og i køretøjer): øverst til højre under info-boksen vises flow-traktorernes handling og fyld, markarbejde i gang, kæder med manglende led og advarsler (slukkede nødvendige fabrikker, køretøjer der har stået stille over 2 min — solgte vises ikke —, ventende kædeforslag); traktorlinjernes farve følger flowets niveau som Flows-listen — højst 18 linjer, opdateres hvert sekund, skjules ved menu/dialog, skjult HUD og søvn; tændt/slukket gemmes pr. bruger i `modSettings/FS25_ADSmartPickup/status.xml`. **Advarsel når en nødvendig fabrik slukkes** (også uden kæder): slukker du en produktion, hvis vare et tændt flow kører (valgt vare, eller målet tager den i auto og gården forbruger den), en stald æder eller en aktiv kæde bruger sammen med et flow/en stald, kommer én besked pr. slukning (*Advarsel: du slukkede Fermenteringssilo 3 (Ensilage) — Cow Food, Goat Food +1 bruger det. Lageret rækker ca. 12 dage.*) og en rød linje *Slukket men nødvendig: …* i **Kræver handling** (højst én, flere tælles som *+k*; klik åbner fanen Fabrikker) og i statusvinduet, så længe den er slukket og nødvendig. Ingen advarsel når en anden opskrift i samme fabrik laver varen og er tændt, en anden tændt produktion dækker med nok kapacitet, kun kæder bruger varen (meldes som manglende led), lageret rækker over 60 dage, kæden selv slukkede den, eller den allerede var slukket ved indlæsning; produktioner ingen bruger, advarer aldrig. Manual og ordliste opdateret.
## 1.60 — "Kræver handling" → flow, menuplacering, flow-vogne i guiden
- **1.60.21** Flow-vogne: kun et redskab med lastrum (ikke plov, kultivator eller såmaskine) huskes som flowets vogn. Før kunne en plov, der hang bag en flow-traktor, blive gemt som gyllevogn, og gyllen blev aldrig kørt.
- **1.60.20** Vinhøsterens bredde: også en lille bredde fra Courseplay (under 1,5 m) erstattes af vinrækkens 3 m.
- **1.60.19** Vinhøster uden kendt arbejdsbredde får vinrækkens 3 m (før "bredde ukendt", og marken blev ikke startet).
- **1.60.18** Vinhøsterens afgrøde læses fra `inputFruitTypeIndex` (feltnavnet i FS25).
- **1.60.17** Vinhøstere (fx Grégoire GL86) genkendes som høstere af den afgrøde, de er konfigureret til
(spec_vineCutter). Før fik vinmarker "ingen kan" (in-game: F22 hvide druer).
- **1.60.16** Tipvogne skiftes under AD-aflæsning til en tip-side, AutoDrive må aflæsse fra (`allowAIDischarge`).
Farmtech EDK 650 (Unrealistic) tillader kun bagudtip; stod den på venstre, højre eller kornluge, åbnede AD
aflæsningen, men intet løb ud, og vognen holdt med fuldt læs. CP-genstartslisten logges kun, når den ændrer sig.
- **1.60.15** Humle (HOPS, Dakota) behandles som vinafgrøde: aldrig slåafgrøde, og markmaskiner kører på rækkerne som ved druer.
- **1.60.14** Afkobling ved redskabets egen parkering godtager 8 m (før 5 m): lange redskaber som en valse står
med roden over 5 m inde i båsen, så afkoblingen fejlede med "stoppedAway".
- **1.60.13** CP-genstart efter indlæsning starter kun maskiner, hvis valgte CP-job er markarbejde. Moddets
markopgaver starter CP med eget job, så HUD'en kunne stå på fx bunkersilo, og HUD-starten gav "CP: No silo found".
- **1.60.12** "Start alle" ophæver altid pausen: et flow på pause, hvis traktorer stadig kørte en gemt AD-tur,
blev talt som "kørte allerede" og forblev stoppet — traktoren hentede sin vogn og kørte den straks hjem igen.
- **1.60.11** Udkørselsregler vises kun, når en vare bruger dem: salgsgrænserne ("Sælg fra", "Sælg altid
over") kun ved Salg / Salg, ellers lager, og "Kør til lager over" kun ved Lager / Salg, ellers lager.
Gælder Regler-trinnet, resuméet og status (et rent lagerflow viser fx kun "lager > 80 %").
- **1.60.10** Guiden "Opsæt flow" tildeler ledige vogne til solo-traktorer (traktor uden vogn bagpå):
guiden foreslår en vogn (navnematch, ellers "Disponibel …"), vognen kan skiftes pr. traktor på linjen
"↳ Vogn", og flowet husker parret ved Gem.
- **1.60.9** Tydelig linje øverst i Fields, når "Stop alt"-pausen er aktiv (pausen gemmes med savegamet).
- **1.60.8** Gemt produkt (ensilage/hø) gælder kun slåafgrøder.
- **1.60.7** Traktor med redskab på sin egen parkering er et parkeret sæt, ikke en forældreløs påkobling.
- **1.60.6** Tømning ved stop bruger aldrig marksiloer; kan en silo ikke tage imod, prøves den næste
(op til 3), før der parkeres med last.
- **1.60.4–1.60.5** Redskab/vogn der er koblet på uden ejer efter genindlæsning, sættes tilbage efter
2 min uden opgave. Flowet husker kun vognen, når et kørende flow stoppes.
- **1.60.3** Diagnoselinje når vognen står ved målet med plads, uden at læsset falder, i 30 s.
- **1.60.2** Vin og oliven er aldrig slåafgrøder.
- **1.60.1** Fanen Smart Pickup ligger lige over spillets Indstillinger i ESC-menuen.
- **1.60.0** Lagerfanernes "Kræver handling": klik åbner eller opretter et forsyningsflow (løber tør) eller et
salgsflow (sælg nu), eller siger "producér/køb", når et flow ikke hjælper. Statuslinjen i lagerfanerne
viser ikke længere det sidst valgte flows status.
## 1.59 — Fields som farvet liste, Pleje Automatisk/Manuelt
- **1.59.0** Fields-fanen som farvet liste med Start/Stop pr. række; klik åbner redigeringsvinduet.
- **1.59.1–1.59.5** Review-rettelser til listen og redigeringsvinduet (placering, fokus, ESC, [Start]
kræver markør); vinduet i menuens mørke tone; tekstrækker op til 90 tegn; forklaring af farver i hint.
- **1.59.2** Flow der ikke er stoppet og har kørende traktorer, tæller som kørende efter genindlæsning.
- **1.59.3** Under "Stop alt"-pausen vises marker som stoppet med [Start]; [Start] genoptager kun den mark.
- **1.59.6–1.59.7** Traktorer der ikke findes længere (solgt), fjernes fra flowet 2 min efter indlæsning,
med besked på skærmen; rød besked når flowet står uden traktorer.
- **1.59.8** Pleje har Automatisk/Manuelt som Klargøring (Automatisk = gødning, ukrudt og kalk).
- **1.59.9–1.59.11** Markvinduet viser ikke marksilo-advarsler; kolonnebredder i Marksiloer og Måned.
## 1.58 — Flows som farvet liste med redigeringsvindue
- **1.58.0** Flows-fanen som farvet liste med [Start]/[Stop] pr. række; klik åbner et redigeringsvindue
(Luk/ESC, Start/Stop, Slet).
## 1.57 — Forsyning og Wait-valg
- **1.57.0** Mindst fyldte kilde først kun blandt kilder med mindst 25 % af et læs (ellers fyldeste).
- **1.57.1–1.57.2** "Vent ved: Alle" vælger det ledige Wait-punkt nærmest flowets kilder.
- **1.57.3** Manuel påkobling starter traktoren i et flow, hvis Start blev afvist.
- **1.57.4** AutoDrive tælles ikke som Courseplay (flow-traktor ventede på sig selv efter indlæsning).
## 1.56 — Bedste pasform i maskinpuljen
- **1.56.0** Puljen vælger den mindste traktor med hk nok; afstand kun inden for ±15 %; over 2,5× redskabets
behov kun som nødløsning.
- **1.56.1–1.56.5** Flow prøver én gang mere at hente vognen; traktor startes, når spilleren selv har kørt
den til Connect og koblet vognen på; AD "Kør til" et puljeredskabs parkering kobler det af; flowet
husker vognen før et stop; afviste traktorer ved Start vises med grund.
## 1.55 — Redskaber foldes ved stop
- **1.55.0** Redskaber slukkes, løftes og foldes til transport, når en mark, et flow eller "Stop alt" stoppes.
- **1.55.1** Tømning ved stop: 45 s ved målet uden at læsset falder → silo, ellers parkering med last.
## 1.51–1.54 — Flow-vogne og kobling
- **1.51.0** Flow-vogne: flowets vogn huskes pr. traktor, solo-traktor henter sin vogn ved start, stop/slet
sætter vognen på plads og kører traktoren hjem; stoppede flows er ledige i maskinpuljen.
- **1.51.1** Konsolkommandoen `spuNameFleet` giver traktorer uden for flows butiksnavne og parkeringer
"P <navn>".
- **1.51.3–1.51.4** Flow-traktor finder sin vogn på parkeringsnavnet; stoppet flow tømmer vognen før den
parkeres.
- **1.51.5** Nye Courseplay-kurser får altid 2 forager-runder.
- **1.52.0** Lagerfanerne får vareoversigt med nøgletal, "Kræver handling" og fyldningsbjælker.
- **1.52.1** Flow startet igen under tømning/hjemtur kører videre med vognen.
- **1.53.0** Koblingsrækkevidde 3 m og bakning de sidste 3–8 m ved Connect-markører.
- **1.53.1–1.53.2** Tømning ved stop kun ved flowets egne mål; afkobling bekræftes (op til 20 s).
- **1.54.0** Flow-vogne holdes altid af deres flow; markredskaber kun bag traktorer.
## 1.48–1.50 — Maskinpulje
- **1.48.0** Maskinpulje etape 1: markarbejdet sender en solo-traktor efter et puljeredskab (frakoblet, med
parkering + Connect-markør), når ingen færdig kombination er ledig; bagefter sættes redskabet af på sin
parkering, og traktoren kører hjem. Marker der venter, betjenes længst-ventende først.
- **1.48.1–1.48.4** Bar mark med mål-afgrøde klargøres straks (kun såningen venter); plov før kultivator i
rolletabellen; markflåden viser puljetraktorer og -redskaber med status.
- **1.49.0** Etape 2: tømmevogne ved høst fra puljen.
- **1.50.0** Etape 3: marksilo-flowet låner også solo-traktor + puljevogn.
- **1.50.2–1.50.5** Plov foldes ud før afkobling; kultivering/pløjning måles på jorden (≥95 %); tom/fuld
marksilo og fuld fabrik håndteres.
## 1.46–1.47 — Connect- og Afkobl-markører
- **1.46.0** Connect-markører: en traktor der kører AD "Kør til" en "Connect <navn>"-markør, kobler
redskabet på ved ankomst.
- **1.47.0–1.47.4** Afkobl-markører; Connect virker begge veje; ture i flere trin; vognen sættes af ved sin
egen AD-parkering.
## 1.41–1.45 — Navne, Stop alt, Courseplay-genstart
- **1.41.0** Markmaskiner med modelnavn navngives efter rollen (Læssevogn 5, Mejetærsker 1 …).
- **1.41.1** Navnefilen skrives i spillets gem-hook.
- **1.42.0** "Stop alt og parkér" og "Start alle" med pause der gemmes.
- **1.42.1–1.42.5** Parkeringspunkt findes også på vognen/redskabet; højst én maskine sendt ud hvert
10. sekund; vent op til 5 min på Courseplays kursusberegning; Flows-fanen med valgt flow øverst.
- **1.43.0** Høster med korn i tanken efter høsten: tømmevogn sendes ud, før marken går videre.
- **1.44.0** Marksilo: "Kør overskud til produktion" med foderreserve.
- **1.45.0** Maskiner som Courseplay kører, huskes ved hvert gem og startes igen efter indlæsning.
## 1.36–1.40 — Marksilo-flow og bygningsnavne
- **1.36.0** Fanen Marksiloer: låner ledige tømmevogne og kører marksiloernes varer hjem til gårdlagre.
- **1.36.x** Flow-guiden viser ubrugelige traktorer med grund (foldet sammen); grunden "AutoLoad er slået
til på vognen"; pallespawnere viger for aflæsningens markør.
- **1.37.0** CP-maskiner midt i et job genstartes efter indlæsning.
- **1.38.0** Manuel transport af brede høstere ("Kører selv på vejen" Ja/Nej).
- **1.38.x** Rettelser til CP-startpunkt, genstart og marksilo-navne.
- **1.39.0** Egne bygningsnavne fra `ADSmartPickup_names.xml` bruges overalt.
- **1.40.0** Nye bygninger navngives automatisk; knappen "Omdøb bygning" i bygningsfanerne.
## 1.33–1.35 — Courseplay-kursusgenbrug og fanen Måned
- **1.33.0** Udkørsel: "Sælg altid når kilden er over X %".
- **1.33.1–1.33.4** Vinmarker, maskiner langt fra vejnettet sendes ikke ud, intet sendes de første 2 min
efter indlæsning, hurtigere rutetjek.
- **1.34.0** Genbrug af Courseplay-kurser (markform-signatur + kursusregister pr. maskine).
- **1.34.1–1.34.2** Slåmarker klar først i fuld højde; utoppede kartofler tæller som høstklare.
- **1.35.0** Fanen Måned: marker i gang, næste trin, og om høst-/såvinduet lukker ved månedsskiftet.
- **1.35.x** Fremdrift for høst/slåning; diagnose af stillestående AD-køretøjer; fyldte traktorer
registreres "på vej".
## 1.32 — Guiden "Opsæt flow"
- **1.32.0** Flows-fanen med tavle + resumé og trin-for-trin-guide; nyt flow oprettes først ved Gem.
- **1.32.1–1.32.5** Oprydning i gammel GUI-kode; høsterens restkorn; advarsel når markflåden er tom;
forkert vare hældes kun ud som lille rest; vinkursus i gangen.
## 1.31 — Guiden "Opsæt mark"
- Markfanen får resumé (klik på en linje = ret ét trin) og en trin-for-trin-guide.
## 1.21–1.30 — Markarbejde
- **1.21** Fanen Marker: slåafgrøder (slå, vend, riv, saml op) med AutoDrive til F-markøren og Courseplay
på marken; arbejdsbredde fra Courseplay.
- **1.22–1.25** Vender/river/læssevogn genbruger slåmaskinens kursus; smalle maskiner; sideriver; ensilage
til gæringssilo.
- **1.26–1.27** Høst, klargøring, pleje, multi-tool, vejr, påfyldning, mål-afgrøde pr. mark, halm efter høst;
ydelsesforbedringer.
- **1.28** Profiler og hak-audit; æg-paller; forsyning med flere mål vælger målet med færrest vogne på vej.
- **1.29** Bebyggede marker udelades; minimum arbejdsbredde 1,5 m; påfyldning via AD og købestationer;
lange tekstrækker ombrydes.
- **1.30** Faste maskiner pr. mark; markflåden er tydeligt fælles.
## 1.14–1.20 — Forsyning, lagerfaner og Overblik
- **1.14** Bedste foder først (lavere foder kun når truget er mindst 75 % tomt); læs på vej tælles med;
rest under 100 l regnes som tom.
- **1.15–1.16** Lagerfaner pr. bygningstype med statistik pr. vare; første brugermanual.
- **1.17** Traktorer navngives efter deres flow; kilder med fælles lager regnes som ét stop; lagerture til
egne fabrikker; jordbundne kortanlæg regnes som gårdens.
- **1.18** "Lager til" (tvunget aflæsningssted, fodring ved ≥ 10.000 l); siloer der kun kan fyldes; generelle
lagre som kilde; æg/uld-paller fra stalde.
- **1.19** Fanen Overblik; "Lager til" filtreres efter varer; parallelt foder (høns).
- **1.20** Start/Stop alle flows; klik på et flow i Overblik hopper til redigering.
## 1.10–1.11
- **1.10** Overblik i fanen; "Kør hjem fra Wait under X %" (10–90, standard 50); foderprioritering efter
spillets fodervægt.
- **1.11** Flere mål pr. run; faneikon; forkert last læsses af i nærmeste silo; forbedret Wait-logik.
## v1.13 — Flow-tavle (bygningsbaseret opsætning)
ESC-fanen har nu to under-faner: **Flows** og **Lagre**. Flows viser én række pr. flow (run):
`> Mælk: Sukker · Kostald 1 + Kostald 2 → salg · pris ≥ 90 %, lager > 80 % · Virtus · kører (1/1) — SUGAR 78 % af top`
og under tavlen en detail-blok for det valgte flow: **Retning** (ind til bygning / ud fra bygning),
**Fra** (udkørsel) eller **Til** (forsyning) som afkrydsning pr. **bygning** — moden finder selv den
AD-markør der står nærmest bygningens læsse-/aflæsningstrigger (≤ 40 m; siloer via det strenge
opslag) og viser den: `Kostald 1 (stald) — markør 'cow 1 milk' 12 m` eller `INGEN MARKØR inden for
40 m`. Derefter Varer, Regler, Vent ved, Traktorer og Status som før. Forsyning har "Fra = alle
siloer" (som hidtil). Wait-puljerne står nederst.
Gamle runs oversættes automatisk: markører der er en bygnings markør bliver til bygningen; rå
markører uden bygning beholdes og vises som `markør '…'`. Start afviser med `'<bygning>' mangler en
AD-markør ved triggeren`, når en valgt bygning ingen markør har. Runs gemmes som XML version 3.
## v1.12 — Udkørsel af produkter
Et run har nu en **retning**: *Forsyning* (som hidtil) eller *Udkørsel*. ESC-fanen har tre
under-faner: **Oversigt** · **Forsyning** · **Udkørsel**.
Et udkørsels-run består af:
- **Kilder** (én eller flere AD-markører): en **fabrik** (produktionspunkt, dens outputs),
en **silo** (`spec_silo`, dens indhold) eller et **objektlager** (palle-/ballelager som HVF-
pakkens; paller tages ud automatisk). Kilden genkendes inden for 40 m af markøren.
- **Varer** med politik pr. vare: **Fra** · **Lager** · **Salg** · **Salg, ellers lager**.
Bulk-varer kræver tipvogn/tank; pallevarer kræver **Universal Autoload** på vognen.
- **Regler:** *Sælg fra* (% af årets toppris, standard 90), *Kør til lager over* (% fyldning i
kilden, standard 80), *Saml op på én tur* (opsamlingsrunde).
- Wait-pulje og traktorer som i forsynings-runs.
**Turvurdering** (før hver pålæsning, og hvert 5. sekund mens traktoren holder):
1. *Salg*: en vare med Salg-politik, hvis bedst betalende **nåelige** salgssted giver mindst
tærsklen af årets toppris — **kører uanset mængde**. Højeste pris-% først. Toppris = stationens
grundpris nu × (årets højeste periodefaktor ÷ nuværende). Stor efterspørgsel giver > 100 %.
2. *Lager*: en vare med Lager-politik, når der er et **fuldt læs** eller kildens lager er over
tærsklen — til nærmeste silo med plads til hele læsset og vejnet begge veje (aldrig en kilde,
fabrik eller stald). Mest fyldte kilde først.
3. Ingen tur → traktoren holder ved et ledigt Wait-punkt (run'ets pulje) og prøver igen.
Loggen viser hvorfor: `holder ved Wait 3: SUGAR 78 % af top (kræver 90), lager 41 %`.
**Opsamlingsrunde:** tipvogn = samme vare fra flere kilder efter tur til vognen er fuld (næste
kilde kun ved ≥ 10 % fri plads); autoload-vogn = flere pallevarer med samme mål på én tur.
**Objektlager:** markøren skal stå så vognens autoload-zone dækker lagerets spawn-område (som når
man læsser manuelt). Moden beder lageret spawne **én palle ad gangen** og venter til UAL har taget
den, så der aldrig ligger paller tilbage. Sidder lageret fast (kendt spilfejl), afsluttes
læsningen efter 30 s med det der er. Ved en autoload-kilde uden fremdrift i 15 s køres der med
det der er ("uanset mængde").
**Advarsler på skærmen:** kan intet salgssted nås (vejnet), eller findes ingen silo med plads,
vises en rød AutoDrive-notifikation (`'MT635' kan ikke nå salgssted 'Grain Elevator' for SUGAR –
tjek vejnettet`), samme tekst i AD's notifikations-historik, i log.txt (WARN) og i run'ets status.
Traktoren holder ved Wait og kører selv når vejen er koblet på. Advarslen gentages først når
traktor, station eller vare ændrer sig.
**Prisregel (v1.12.1, metode fra Distribution Redux):** pris-% = nuværende periodes faktor ÷ årets
højeste faktor i spillets faste sæsonkurve (samme data som prisgrafen) × stationens egen afvigelse
(effektiv pris ÷ stationens grundpris nu). Sværhedsgraden falder ud; flade kurver (top/bund < 1,02)
regnes som 100 %; stor efterspørgsel giver > 100 %.
**Reserve (v1.12.1):** før salg fratrækkes det gårdens egne fabrikker og stalde skal bruge det næste
døgn (forbrug × 24 t minus det de allerede har, højst deres fri plads; fabrikker = ønskede
produktioners inputs, stalde = foder/halm/vand-rate). Der sælges kun når et helt læs kan tages uden
at røre reserven. Loggen/UI'et siger fx `SUGARBEET: 196.0k reserveret til TMR-fabrik`. Lager-politik
rører ikke reserven (varen bliver på gården).
**Paller til lager (v1.12.1):** Lager-mål for pallevarer = nærmeste objektlager (palleskur) der tager
varen og har plads; AutoDrive læsser UAL-vognen af dér.
**Tætte siloer (AutoDrive-indstilling):** AutoDrive tipper ved den første aflæsningstrigger inden for sin
globale "Max trigger distance" (standard 25 m). Står siloerne tættere end det, kan nabosiloen få læsset —
sæt AD-indstillingen til **10 m**. Moden advarer i loggen første gang et lagermål har en anden
aflæsningsmarkør inden for afstanden.
**Fanen Lagre (v1.12.1):** gårdens beholdning pr. bygning (fabrikker med ▸ ved outputs, stalde,
palleskure som antal paller, siloer), opdateret hvert sekund.
**Opsætning:** Udkørsel-fanen → Nyt run → afkryds kilder → sæt politik pr. vare → Ja på traktorer
→ Start. AD-tilstand pr. traktor: PickupAndDeliver, 1./2. markør = første kilde (pladsholder,
sættes pr. tur), rotation "kun pålæsning". Runs gemmes i `ADSmartPickup_runs.xml` version 2;
ældre filer læses som forsynings-runs.
## v1.9 — læs-bytte fra Wait
Venter en traktor ved et Wait-punkt med en vare målet er fuldt af, mens målet **samtidig mangler
en anden vare** på traktorens liste (run eller AD's multi-valg) — kan der ellers gå i hårdknude:
alle traktorer venter med den ene vare, mens ingen henter den anden. Hvert 5. sekund tjekkes derfor:
1. Er der en anden vare i listen, målet mangler et **fuldt** læs af (og som kan hentes fra en
silo)? Ellers venter traktoren som hidtil.
2. Findes en **ren lager-silo** (ikke stald, ikke fabrik, og ikke målet selv) med en
aflæsningsstation ved en AD-markør, der tager varen og har fri plads ≥ resten, samt vejnet
både til siloen og videre fra siloen til målet? Siloer der **allerede har varen** foretrækkes,
ellers den nærmeste.
3. Findes en silo: Wait-punktet frigives, traktoren kører derhen og læsser resten af. Når vognen
er tom, henter næste tur den vare målet mangler.
4. Findes ingen egnet silo: traktoren bliver ved Wait-punktet (som før v1.9).
Loglinjer: `'<navn>' bytter læs: N l STRAW tilbage i <markør> (markør n), målet mangler FORAGE` ved
bytte, og én gang pr. venteperiode `'<navn>' venter med N l STRAW, og målet mangler FORAGE — ingen
silo med plads og vejnet til/fra tager resten, så den bliver ved Wait-punktet` når ingen silo findes.
Kræver at traktoren har **begge** varer i sin liste (run's varer eller AD's multi-valg) — kun én
vare valgt betyder ingen bytte-mulighed, og traktoren venter som hidtil. Wait-logikken gælder desuden
kun mens aflæsningsopgaven peger på AD's rigtige aflæsningsmål, så en silo-omvej (fx efter et bytte)
aldrig fejlagtigt bliver opfattet som et fuldt mål.
## v1.8 — Runs i ESC-menuen
Et **run** er en navngivet opskrift: mål + varer + Wait-pulje + ture, samt en liste traktorer der skal
køre den. I stedet for at sætte AutoDrive op manuelt på hver traktor, defineres run'et ét sted og
startes/stoppes samlet for alle traktorer i det.
**Oprettelse:** ESC → fanen "Smart Pickup" → "+ Nyt run". Runs vælges i Run-selectoren øverst;
"Omdøb" og "Slet run" findes som knapper nederst sammen med Start/Stop.
**Felter:**
- **Mål** — dropdown med aflæsningsmål (markører med aflæsningsstation, fx en stald eller fabrik).
- **Varer** — Auto (følger målets behov, som ved almindelig behovsstyret forsyning) eller Vælg
(afkrydsning af bestemte varer).
- **Wait-pulje** — hvilken `Wait`-mappe rest-læs skal vente i ("Alle (nærmeste ledige)" eller en
navngivet pulje som fx "Cow Wait").
- **Ture** — loop-tælleren (0–99, 0 = uendeligt), som normalt i AutoDrives Pickup&Deliver.
- **Traktorer** — rækker med Ja/Nej (er traktoren med i run'et), kapacitet og et ✔/✖-ikon. En
**✖-traktor springes over ved Start**, med en grund vist ved siden af (fx "ingen vogn", "vognen
kan ikke bære run'ets varer", "for langt fra vejnettet" eller "intet mål"). Sættes en traktor til
Nej (fjernet fra run'et), rører det IKKE dens AutoDrive-vareliste — run'ets varer forbliver valgt
i traktorens AD-vareliste (synlig/redigerbar i AD's eget HUD) indtil de ændres dér manuelt.
**Start/Stop:** "Start run" sætter AutoDrive op på hver ✔-traktor: mode Pickup&Deliver, målmarkør,
nærmeste pålæsningsmarkør som start, run'ets varer valgt, run'ets tur-tæller, mapper ("Brug mapper")
slået til, og rotation sat til "kun pålæsning" (bemærk: "Brug mapper" er en global
AutoDrive-indstilling og slås til for hele spillet, ikke kun run'ets traktorer). **Dette overskriver
traktorens hidtidige manuelle AutoDrive-indstillinger.** "Stop run" stopper AutoDrive på hver traktor
i run'et (medlemskabet i run'et bevares, så det kan startes igen). Traktorer der ikke er del af noget
run, kører som før — bortset fra at "Brug mapper" kan være blevet slået til globalt af et run.
**Wait-puljer-afsnittet** nederst på siden viser hver Wait-mappe skrivebeskyttet: antal punkter og
hvilken traktor der holder hvor.
**Gemning:** Runs gemmes i `savegameN/ADSmartPickup_runs.xml` (ved gem og ved Start/Stop). De
**genstartes ikke automatisk** ved indlæsning af et savegame — kør "Start run" igen efter load, hvis
traktorerne skal køre videre.
## v1.1–v1.7 — Grundfunktionerne
### Vare-bevidst mappe-rotation (én vare valgt)
Når pålæsningsmål roteres i en AD-mappe, køres der direkte til den silo i mappen, der har den
valgte vare — mindst fyldte først (mælk/gylle: fyldeste først) — i stedet for at prøve markørerne én efter én.
### Behovsstyret forsyning (flere varer valgt)
Én traktor holder alle input i en produktion fyldt — fx hø, græs, ensilage og sukkerroer til en
TMR-blander — i stedet for én traktor pr. vare.
1. Aflæsningsmål = produktionens markør.
2. Multi-vælg ingredienserne i AutoDrives vare-dropdown.
3. Start Pickup&Deliver. Loop-tælleren virker som normalt (N læs → parkér, 0 = uendeligt).
Pr. tur læses målets lager; den ingrediens der mangler mest (relativt til kapacitet) hentes fra en
silo **hvor som helst på kortet** (eget lager før nabo-lån, mindst fyldte først). Ingredienser uden
plads i målet (< 1000 l fri) eller uden kilde springes over. Mens AD kører viser HUD'en kun turens
vare (AutoDrive ville ellers selv rotere vare ved triggeren); når AD stopper, gendannes hele listen.
Slå fra ved at vælge en anden enkelt vare.
"Mest brug for" defineres i `ADSupplyPlanner.scoreNeed` (`adSupplyPlanner.lua`).
### Fabriksstyret (v1.6): målet er et produktionspunkt
Er aflæsningsmålet en fabrik (produktionspunkt), **erstattes** ingredienslisten af inputs fra
fabrikkens ønskede produktioner — slår du fx TMR til i en AIO-fabrik, begynder traktoren at hente
ensilage/hø/halm/roer/mineralfoder af sig selv; slår du den fra, holder den op. Dit multi-valg er kun
tændknappen (og det gendannes ved stop). Listen genudledes hver tur, begrænset til det vognen kan bære.
- **Ønsket produktion** = tændt, ELLER slukket af kortet med status "mangler input" (Dakotas
`ExtendedProductionPoint` slår produktioner fra automatisk, når de løber tør — dem skal vi netop
fodre). Manuelt slukket = ikke ønsket.
- **Prioritet = tid til tom:** `lager / (Σ amount × cyclesPerMinute)` over ønskede produktioner —
den vare der løber tør først hentes først, uanset relativ fyldning. Loggen viser forbrug og minutter.
- Fabrik uden ønskede produktioner, eller inputs vognen ikke kan bære → dit multi-valg som hidtil.
### Kilder: en fabriks inputs stjæles aldrig (v1.6.1)
En fabriks læssestation udleverer også dens **inputs** (halm i en TMR-fabrik). Sådan en vare hentes
aldrig derfra — kun fabrikkens outputs og rigtige siloer er kilder. Gælder både mappe-logik og
behovsstyring. Varer der både er input og output (kæde) må hentes.
### Stalde som mål (v1.6.1)
Kostald m.fl. svarer selv for niveau/kapacitet (`getHusbandryFillLevel/Capacity`), så halm+TMR til en
stald rangeres korrekt, selv om staldens aflæsningsstation kun "kender" foder.
### Wait-punkter: rest-læs ved fuld stald (v1.7)
AutoDrive holder ellers en traktor med rest-læs stille ved aflæsningspunktet, til den er tømt — og
spærrer for de næste i køen. Med Wait-punkter:
1. Læg markører i en **mappe med ordet `Wait` i navnet** (fx mappen `Cow Wait`) — så mange du har
plads til; de er en fælles pulje for alle traktorer. Markørernes egne navne er ligegyldige, så
dine mark-ventepladser ("F91 Wait" i mark-mapperne) rører den ikke.
2. Kan målet ikke tage mere af vognens vare, kører traktoren til det **nærmeste ledige** Wait-punkt
(modden holder styr på reservationer) og venter dér.
3. Når målet igen har plads til **hele** resten — eller er mindst **halvt tomt** (kæmpe vogne kan aldrig
tømme hele resten i en lille stald) — kører den tilbage og læsser af. Derefter normal tur.
4. Wait-punkterne SKAL være forbundet til vejnettet både fra målets udkørsel og tilbage til målet.
Modden tjekker ruten begge veje før den omdirigerer; uden rute prøves næste punkt (AutoDrive
ville ellers vise "cannot reach" og slukke sig selv).
Kun den aktuelle aflæsningsopgaves destination ændres — mål, rotation og loop-tæller røres ikke.
Ventende traktorer ser rotationen som slået fra, så AutoDrive ikke kører videre fra Wait-punktet.
Ingen ledige Wait-punkter → AutoDrive venter ved målet som i dag (én advarsel i loggen).
Seks markører må gerne hedde det samme (fx `Cow Wait`); loggen viser markørnummeret.
**Læs der passer, foretrækkes (v1.7):** ved valg af næste tur går varer, hvor et fuldt læs kan komme af
i målet, forud; passer intet fuldt læs, tages den mest trængende alligevel (og Wait-punktet bruges).
**Opsætning med flere stalde:** én traktor (eller flere) pr. stald med *Rotér mål: kun pålæsning*.
Brug IKKE "pålæsning og aflæsning": AutoDrive vælger næste pålæsning FØR den roterer aflæsningsmålet,
så behovet ville blive regnet for den forkerte stald. Pålæsningsmarkøren må ikke være fast på en
fabrik ("kun aflæsning") — så kalder AutoDrive aldrig modden, og alt valgt læsses dér.
### Fælles tekniske noter (v1.2–v1.5)
- Wrapper `ADMultipleTargetsManager.getNextPickup` (via den globale `FS25_AutoDrive`-tabel); rører ikke AutoDrive-zip'en.
- Siloens eget lager rangeres før nabo-lån (udvidelses-siloer deler lager inden for `storageRadius`).
- Intet fund, intet behov eller fejl i et spil-API → AutoDrives originale rotation. Valg logges som `ADSmartPickup:` i log.txt.
- Kræver i AutoDrive: "Brug mapper" (global AutoDrive-indstilling, gælder hele spillet) +
"Rotér mål: pålæsning" på køretøjet (pr. køretøj).
- **Markør ↔ silo (v1.3):** markøren er dér hvor trækkerens næse stopper; vognen holder et vogntog
bagved. Markørens silo er derfor nærmeste trigger **bag næsen** set i ankomstretningen (forrige
waypoint), højst 2 m foran. I tætte rækker (15 m mellem siloerne) er nabosiloens trigger ellers
nærmere end markørens egen. Ingen trigger bag næsen/ukendt retning → nærmeste trigger som før.
- **Kildestrategi (v1.5):** lagervarer hentes fra den **mindst fyldte** silo først (rester ryddes,
siloer frigives). Varer hvis tanke *løber fulde* — mælk, gylle, digestat — hentes fra den **fyldeste**
kilde først, så produktionen ikke går i stå. Listen er `ADSupplyPlanner.FULLEST_FIRST_FILL_TYPES`
(`adSupplyPlanner.lua`). En trigger foran næsen tæller aldrig som markørens egen.
- **Spor-regel (v1.4):** triggeren skal ligge i trækkerens spor (højst 6 m til siden). Kendes retningen,
men ligger intet i sporet (kurve ind til markøren), accepteres kun en trigger inden for 12 m.
- **Kun fremme ved markøren (v1.4):** rotationen skjules først når målet er nået — undervejs ruller
vognen gennem nabosiloers triggere, og dér skal AutoDrive køre videre ved tom trigger.
- **Sikkerhedsventil (v1.4):** 30 s ved markøren uden at vognen fyldes → grebet slippes (advarsel i
log), og AutoDrive kører videre som med almindelig rotation.
- **Står og læsser (v1.2):** når moden har valgt siloen, ser AutoDrive under
`LoadAtDestinationTask.update` rotationen som slået fra for netop det køretøj, så den holder ved
markøren og læsser som uden rotation. Løber kilden tør, slippes grebet og turen fortsætter med det der
er i vognen.
+97 -4
View File
@@ -10,7 +10,8 @@ ADBuildings.LOG_PREFIX = "ADSmartPickup: "
ADBuildings.MAX_MARKER_DISTANCE = 40
-- en separat drop-markør (≠ bygningens læssemarkør) må højst stå så langt fra aflæsningstriggeren
ADBuildings.MAX_DROP_DISTANCE = 25
ADBuildings.CACHE_MS = 1000
-- bygninger ændrer sig sjældent; genopbygningen (bygninger x markører) er dyr — 1 s gav et hak hvert sekund
ADBuildings.CACHE_MS = 30000
local cache = {list = nil, farmId = nil, time = -math.huge}
@@ -30,6 +31,8 @@ local function getNodePosition(node)
end
local function getName(placeable, fallback)
local own = ADNames ~= nil and ADNames.forPlaceable(placeable) or nil
if own ~= nil then return own end
if placeable ~= nil and placeable.getName ~= nil then
local ok, name = pcall(placeable.getName, placeable)
if ok and name ~= nil then return tostring(name) end
@@ -118,7 +121,10 @@ local function collectNodes(placeable, loadStations, unloadStations)
for _, place in ipairs(spawner.spawnPlaces or {}) do
if place.startNode ~= nil then table.insert(nodes, place.startNode) end
end
if #nodes > 0 then table.insert(loadGroups, nodes) end
if #nodes > 0 then
nodes.isSpawner = true
table.insert(loadGroups, nodes)
end
end
-- stald uden aflæsningsnoder i stationen (hønsehus: kun foder-triggeren) -> søg i staldens foderdata
if #unloadNodes == 0 and placeable.getHusbandryFillLevel ~= nil then
@@ -139,7 +145,10 @@ local function collectNodes(placeable, loadStations, unloadStations)
for _, place in ipairs(palletSpawner.spawnPlaces or {}) do
if place.startNode ~= nil then table.insert(nodes, place.startNode) end
end
if #nodes > 0 then table.insert(loadGroups, nodes) end
if #nodes > 0 then
nodes.isSpawner = true
table.insert(loadGroups, nodes)
end
end
end
if placeable.spec_objectStorage ~= nil and placeable.rootNode ~= nil then
@@ -218,6 +227,82 @@ end
-- Aflæsningsmarkør: nærmeste ikke-Wait-markør ≤ MAX_MARKER_DISTANCE til aflæsningsnoderne — men ikke
-- bygningens egne LÆSSEmarkører, når der findes en anden (Sugarbeet Crusher: pickup står tættere på
-- aflæsningstriggeren end drop). Kun én fælles markør (AIO Load / Unload) bruges til begge.
-- Alle ikke-Wait-markører ≤ MAX_MARKER_DISTANCE til nodes, nærmeste først: {{id, name, distance}}.
local function markerCandidates(adEnv, nodes)
local positions, found = {}, {}
for _, node in ipairs(nodes) do
local x, z = getNodePosition(node)
if x ~= nil then table.insert(positions, {x = x, z = z}) end
end
for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do
local wayPoint = not ADSupplyPlanner.isWaitMarker(marker) and adEnv.ADGraphManager:getWayPointById(marker.id) or nil
if wayPoint ~= nil then
local nearest = nil
for _, position in ipairs(positions) do
local distance = math.sqrt((wayPoint.x - position.x) ^ 2 + (wayPoint.z - position.z) ^ 2)
if nearest == nil or distance < nearest then nearest = distance end
end
if nearest ~= nil and nearest < ADBuildings.MAX_MARKER_DISTANCE then
table.insert(found, {id = marker.id, name = tostring(marker.name), distance = nearest})
end
end
end
table.sort(found, function(a, b) return a.distance < b.distance end)
return found
end
-- Læssemarkør for én læssegruppe. En pallespawner (upræcist startpunkt) får aldrig aflæsningens nærmeste markør —
-- den tager sin næstnærmeste. Rigtige læssetriggere beholder markøren (Sugarbeet Crusher: pickup står tættere på
-- aflæsningstriggeren end drop). Gælder alle bygningstyper. (in-game 27/9 fårestald: spawner 9 m fra "Sheep Food",
-- foderindkast 5 m -> Goat Food fik "Sheep Milk"; i stald 3 og 5 stod spawneren endda tættest, 5 mod 9 m)
function ADBuildings.pickLoadMarker(candidates, unloadBest, yieldsToUnload)
for _, candidate in ipairs(candidates or {}) do
local isUnloads = yieldsToUnload and unloadBest ~= nil and candidate.id == unloadBest.id
if not isUnloads then
return {id = candidate.id, name = candidate.name, distance = math.floor(candidate.distance + 0.5)}
end
end
return nil
end
-- Diagnose (én gang pr. stald pr. session): hvilke markører ligger nærmest læsse- og aflæsningspunkterne.
-- (in-game 27/9: Goat Food fik "Sheep Milk"-markørerne som mål og Goat Milk Pickup "Sheep Food" som kilde)
local diagnosed = {}
local function closestMarkers(adEnv, nodes, count)
local positions, found = {}, {}
for _, node in ipairs(nodes) do
local x, z = getNodePosition(node)
if x ~= nil then table.insert(positions, {x = x, z = z}) end
end
for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do
local wayPoint = not ADSupplyPlanner.isWaitMarker(marker) and adEnv.ADGraphManager:getWayPointById(marker.id) or nil
if wayPoint ~= nil then
local nearest = nil
for _, position in ipairs(positions) do
local distance = math.sqrt((wayPoint.x - position.x) ^ 2 + (wayPoint.z - position.z) ^ 2)
if nearest == nil or distance < nearest then nearest = distance end
end
if nearest ~= nil and nearest < ADBuildings.MAX_MARKER_DISTANCE then table.insert(found, {name = tostring(marker.name), distance = nearest}) end
end
end
table.sort(found, function(a, b) return a.distance < b.distance end)
local parts = {}
for index = 1, math.min(count, #found) do table.insert(parts, string.format("%s %.0f m", found[index].name, found[index].distance)) end
return #parts > 0 and table.concat(parts, ", ") or "ingen markør"
end
local function diagnoseHusbandry(adEnv, id, name, loadGroups, unloadNodes, loadMarkers, unloadMarker)
if diagnosed[id] then return end
diagnosed[id] = true
local groups = {}
for index, nodes in ipairs(loadGroups) do table.insert(groups, string.format("læs%d: %s", index, closestMarkers(adEnv, nodes, 2))) end
local loads = {}
for _, marker in ipairs(loadMarkers) do table.insert(loads, tostring(marker.name)) end
Logging.info("ADSmartPickup: stald '%s': %s | aflæsning nærmest: %s | valgt læs=[%s] aflæs=%s", tostring(name),
#groups > 0 and table.concat(groups, "; ") or "ingen læssegrupper", closestMarkers(adEnv, unloadNodes, 3),
table.concat(loads, ", "), unloadMarker ~= nil and tostring(unloadMarker.name) or "-")
end
local function unloadMarkerFor(adEnv, nodes, loadMarkers, allowSkipLoad)
if #nodes == 0 then
return nil
@@ -332,8 +417,9 @@ local function build(adEnv, farmId)
local marker = siloLoadMarker(adEnv, farmId, placeable, loadStations)
if marker ~= nil then table.insert(loadMarkers, marker) end
else
local unloadBest = markerCandidates(adEnv, unloadNodes)[1]
for _, nodes in ipairs(loadGroups) do
local marker = nearestMarker(adEnv, nodes)
local marker = ADBuildings.pickLoadMarker(markerCandidates(adEnv, nodes), unloadBest, nodes.isSpawner == true)
if marker ~= nil and not seen[marker.id] then
seen[marker.id] = true
table.insert(loadMarkers, marker)
@@ -343,6 +429,7 @@ local function build(adEnv, farmId)
end
-- siloer og palleskure læsser og aflæsser samme sted: altid nærmeste markør
local unloadMarker = unloadMarkerFor(adEnv, unloadNodes, loadMarkers, kind ~= "silo" and kind ~= "objectStorage")
if kind == "husbandry" then pcall(diagnoseHusbandry, adEnv, id, getName(placeable, id), loadGroups, unloadNodes, loadMarkers, unloadMarker) end
-- silo der kun kan FYLDES (fx drive-on-bunker der fodrer en biogas-fermentor): aflæsning,
-- ingen læssestation -> et forsyningsmål, aldrig en kilde
local isFillOnlySilo = kind == "silo" and loadMarkers[1] == nil and #loadGroups == 0 and unloadMarker ~= nil
@@ -363,6 +450,7 @@ local function build(adEnv, farmId)
end
function ADBuildings.invalidate()
if ADCache ~= nil then ADCache.clear() end
cache.list, cache.time = nil, -math.huge
end
@@ -372,6 +460,11 @@ function ADBuildings.list(adEnv, farmId)
return cache.list
end
cache.list, cache.farmId, cache.time = build(adEnv, farmId), farmId, time
-- nye bygninger uden eget navn navngives (tilføjes navnefilen; eksisterende navne røres ikke)
if ADNames ~= nil and ADNames.autoName ~= nil then
local ok, err = pcall(ADNames.autoName, adEnv, cache.list)
if not ok then Logging.warning("ADSmartPickup: automatiske bygningsnavne fejlede: %s", tostring(err)) end
end
return cache.list
end
+53
View File
@@ -0,0 +1,53 @@
-- AD Cache
-- Fælles korttids-cache for dyre opslag (markør -> station, salgssteder, identify, Wait-punkter, lagre).
-- Holdende vogne genplanlægger hvert 5. s og slog de samme ting op igen og igen (markører x stationer x
-- triggere) -> hak (in-game 26/9). Verden ændrer sig ikke så hurtigt. Uden spillets tid (tests) caches
-- intet, så adfærden er som før. "Intet fundet" gemmes også.
ADCache = {}
ADCache.buckets = {}
-- tændes af ADSmartPickup når hooksene er installeret (i spillet); tests kører uden cache
ADCache.enabled = false
local NOTHING = {}
-- Til moduler der indlæses uden adCache (tests): kald fn direkte.
function ADCacheGet(bucket, key, ttlMs, fn)
if ADCache == nil or not ADCache.enabled then return fn() end
return ADCache.get(bucket, key, ttlMs, fn)
end
local function now()
local mission = g_currentMission
if mission ~= nil and type(mission.time) == "number" then return mission.time end
return nil
end
-- Resultatet af fn() for (bucket, key), genbrugt i ttlMs.
function ADCache.get(bucket, key, ttlMs, fn)
local time = now()
if not ADCache.enabled or time == nil or key == nil then return fn() end
local store = ADCache.buckets[bucket]
if store == nil then
store = {size = 0, entries = {}}
ADCache.buckets[bucket] = store
end
local entry = store.entries[key]
if entry ~= nil and time - entry.at < ttlMs then
if entry.value == NOTHING then return nil end
return entry.value
end
local value = fn()
if entry == nil then
store.size = store.size + 1
if store.size > 5000 then store.entries, store.size = {}, 1 end
end
-- spredt udløb (levetid +0-50 %): poster der blev lavet i samme kald, udløber ikke i samme frame
-- (simulering 26/9: 600 markør-opslag udløb samtidig -> hak hvert 10. s)
store.entries[key] = {value = value == nil and NOTHING or value, at = time + math.random() * ttlMs * 0.5}
return value
end
function ADCache.clear(bucket)
if bucket == nil then ADCache.buckets = {} else ADCache.buckets[bucket] = nil end
end
+573
View File
@@ -0,0 +1,573 @@
-- AD Chain (kæde-kørsel)
-- Hvert minut (efter opstartsfristen): læs gården (ADChainData), byg kæden (ADChainModel), find manglende
-- afgrøder (ADCropPlan) og lad ADChainRun skifte frie marker, tænde/slukke mellemled og oprette/stoppe flows.
-- Samme tick advarer (ADDependency), når spilleren slukker en produktion gården er afhængig af — også uden kæder.
-- Resultatet gemmes i ADChain.last til Kæder-fanen. Tilstanden (byChain, pausede rødder, afviste forslag,
-- userOff og det sidst kendte sæt af rødder) gemmes i ADSmartPickup_chain.xml under spillets gem.
-- Spiladgang kun inde i funktioner (filen dofile's i test).
ADChain = {}
ADChain.FILE_NAME = "ADSmartPickup_chain.xml"
ADChain.ROOT = "ADSmartPickupChain"
ADChain.TICK_MS = 60000
ADChain.STARTUP_GRACE_MS = 120000
ADChain.MAX_FIELD_SWITCHES = 2
ADChain.MAX_NEW_FLOWS = 2
ADChain.LOG_PREFIX = "ADSmartPickup: kæde: "
-- Et afvist forslag vises igen, når manglen for afgrøden har ændret sig mere end dette.
ADChain.DISMISS_CHANGE = 0.25
ADChain.state = {byChain = {}, paused = {}, dismissed = {}, userOff = {}}
ADChain.last = nil
-- {[nøgle] = true}: produktioner (ikke kædens) der var tændt ved sidste tick — til at se spillerens slukninger
ADChain.prevEnabled = nil
-- true fra indlæsning til første tick: kædens produktioner der er slukket, glemmes (spillet blev ikke gemt)
ADChain.afterLoad = false
-- run-id -> "noVehicle"|"pallet" (true = "noVehicle"): flows kæden oprettede stoppet, fordi der ikke var
-- traktor/vogn eller varen er på paller (kun i hukommelsen)
ADChain.noVehicle = {}
ADChain.switchCount = 0
ADChain.now = 0
ADChain.timer = 0
ADChain.collectFailed = false
-- {[rodnøgle|vare|art] = true}: manglende led der allerede er meldt som besked (kun i hukommelsen; nulstilles ved indlæsning)
ADChain.problemKeys = {}
-- {[nøgle] = true}: produktioner (ikke kædens) der var tændt ved sidste afhængighedstjek (kun kædens tick, kun på
-- hele data). nil efter indlæsning: det der allerede er slukket ved første tjek, advares der ikke om.
ADChain.depPrevEnabled = nil
local function contains(list, value)
for _, item in ipairs(list or {}) do if item == value then return true end end
return false
end
local function sortedKeys(map)
local keys = {}
for key in pairs(map or {}) do table.insert(keys, key) end
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
return keys
end
-- ---- rene hjælpere ----
-- userOff = {[recipeKey] = true}: produktioner spilleren slukkede selv — kæden tænder dem ikke igen.
-- roots = {[recipeKey] = true} rødderne ved sidste tick (nil = ukendt, fx gem fra en ældre version).
function ADChain.newState()
return {byChain = {}, paused = {}, dismissed = {}, userOff = {}, roots = nil}
end
function ADChain.stateToRecords(state)
local records = {byChain = sortedKeys(state.byChain), paused = sortedKeys(state.paused), userOff = sortedKeys(state.userOff), dismissed = {},
roots = state.roots ~= nil and sortedKeys(state.roots) or nil}
local ids = {}
for id in pairs(state.dismissed or {}) do table.insert(ids, id) end
table.sort(ids)
for _, id in ipairs(ids) do
local entry = state.dismissed[id]
table.insert(records.dismissed, {id = id, fruit = entry.fruit, missingHa = entry.missingHa})
end
return records
end
function ADChain.stateFromRecords(records)
local state = ADChain.newState()
if records == nil then return state end
for _, key in ipairs(records.byChain or {}) do state.byChain[key] = true end
for _, key in ipairs(records.paused or {}) do state.paused[key] = true end
for _, key in ipairs(records.userOff or {}) do state.userOff[key] = true end
if records.roots ~= nil then
state.roots = {}
for _, key in ipairs(records.roots) do state.roots[key] = true end
end
for _, entry in ipairs(records.dismissed or {}) do
local id = tonumber(entry.id)
if id ~= nil and entry.fruit ~= nil and entry.fruit ~= "" then
state.dismissed[id] = {fruit = entry.fruit, missingHa = tonumber(entry.missingHa) or 0}
end
end
return state
end
-- Afviste forslag beholdes kun mens manglen for afgrøden findes og har ændret sig højst 25 %.
function ADChain.pruneDismissed(dismissed, shortages)
local byFruit = {}
for _, shortage in ipairs(shortages or {}) do byFruit[shortage.fruit] = shortage.missingHa end
local kept = {}
for id, entry in pairs(dismissed or {}) do
local now = byFruit[entry.fruit]
local before = entry.missingHa or 0
if now ~= nil and math.abs(now - before) <= math.abs(before) * ADChain.DISMISS_CHANGE then
kept[id] = {fruit = entry.fruit, missingHa = before}
end
end
return kept
end
-- {[mark] = {fruit, missingHa}} -> {[mark] = afgrøde} (ADCropPlan.choose's dismissed)
function ADChain.dismissedFruits(dismissed)
local result = {}
for id, entry in pairs(dismissed or {}) do result[id] = entry.fruit end
return result
end
function ADChain.unionFruits(usedFruits, fieldDemand)
local result = {}
for fruit in pairs(usedFruits or {}) do result[fruit] = true end
for fruit in pairs(fieldDemand or {}) do result[fruit] = true end
return result
end
-- Modellens kopi af opskrifterne: pausede rødder markeres paused (kører for sig selv, men skaber intet nyt), og
-- slukkede userOff-produktioner får 0 cyklusser (de dækker intet behov). Originalerne røres ikke.
function ADChain.modelRecipes(recipes, paused, userOff)
local result = {}
for index, recipe in ipairs(recipes or {}) do
local copy = {}
for key, value in pairs(recipe) do copy[key] = value end
if (paused or {})[recipe.key] then copy.paused = true end
if (userOff or {})[recipe.key] and not recipe.enabled then copy.cyclesPerMonth = 0 end
result[index] = copy
end
return result
end
local function copySet(map)
local result = {}
for key, value in pairs(map or {}) do result[key] = value end
return result
end
-- Tændte produktioner der ikke er kædens (= rødderne, inkl. pausede).
function ADChain.enabledKeys(recipes)
local result = {}
for _, recipe in ipairs(recipes or {}) do
if recipe.enabled and not recipe.byChain then result[recipe.key] = true end
end
return result
end
-- Spillerens valg og forældede nøgler, før kæden regner (rent: state røres ikke).
-- opts = {prevEnabled, isPartial, firstEval}. -> {userOff, byChain, paused, roots, added, forgotten, droppedPaused, cleared}
-- * kædens produktion er slukket: af spilleren -> userOff (added); første tick efter indlæsning -> glemt (forgotten)
-- * en produktion (ikke kædens) der var tændt sidste tick, er slukket -> userOff (added)
-- * userOff ryddes kun når der kommer en ny rod (spilleren tænder noget) — ikke når noget "ikke længere bruges"
-- (det gav tænd/sluk-løkker); nye slukninger i samme tick beholdes
-- * pausede nøgler for produktioner der er væk eller slukket, droppes (droppedPaused)
-- Delvise data: userOff ryddes ikke, rødderne opdateres ikke og pausede droppes ikke.
function ADChain.reconcile(state, recipes, opts)
opts = opts or {}
local roots = ADChain.enabledKeys(recipes)
local rootAdded = false
if state.roots ~= nil then
for key in pairs(roots) do if not state.roots[key] then rootAdded = true end end
end
local result = {userOff = copySet(state.userOff), byChain = copySet(state.byChain), paused = copySet(state.paused),
roots = opts.isPartial and state.roots or roots, added = {}, forgotten = {}, droppedPaused = {}, cleared = false}
if rootAdded and not opts.isPartial and next(result.userOff) ~= nil then
result.userOff, result.cleared = {}, true
end
local present = {}
for _, recipe in ipairs(recipes or {}) do
present[recipe.key] = recipe
if not recipe.enabled and recipe.byChain then
result.byChain[recipe.key] = nil
if opts.firstEval then
table.insert(result.forgotten, recipe.key)
elseif not result.userOff[recipe.key] then
result.userOff[recipe.key] = true
table.insert(result.added, recipe.key)
end
elseif not recipe.enabled and (opts.prevEnabled or {})[recipe.key] and not result.userOff[recipe.key] then
result.userOff[recipe.key] = true
table.insert(result.added, recipe.key)
end
end
if not opts.isPartial then
for key in pairs(state.paused or {}) do
if present[key] == nil or not present[key].enabled then
result.paused[key] = nil
table.insert(result.droppedPaused, key)
end
end
end
table.sort(result.added); table.sort(result.forgotten); table.sort(result.droppedPaused)
return result
end
-- Rødder hvis flows ikke må stoppes: kædens aktuelle rødder + pausede rødder (pause rører intet).
function ADChain.activeRoots(chain, paused)
local result = {}
for _, root in ipairs((chain or {}).roots or {}) do result[root.key] = true end
for key in pairs(paused or {}) do result[key] = true end
return result
end
-- Mest brugte Wait-pulje blandt forsyningsflows til bygningen, ellers "".
function ADChain.waitPoolFor(runs, building)
local counts, best, bestCount = {}, "", 0
for _, run in ipairs(runs or {}) do
local group = run.waitPoolGroup
if run.kind == "supply" and group ~= nil and group ~= "" and contains(run.targetBuildingIds, building) then
counts[group] = (counts[group] or 0) + 1
end
end
for _, group in ipairs(sortedKeys(counts)) do
if counts[group] > bestCount then best, bestCount = group, counts[group] end
end
return best
end
function ADChain.flowName(fillTitle, buildingName)
return "Kæde: " .. tostring(fillTitle) .. " → " .. tostring(buildingName)
end
local function covers(run, edge)
return contains(run.targetBuildingIds, edge.consumer)
and (run.ingredientMode ~= "manual" or contains(run.ingredients, edge.fillType))
end
-- Bruger en af de aktuelle kanter stadig flowet? -> kantens rodnøgle (flowet flyttes dertil), ellers nil.
function ADChain.stillNeeded(runView, edges)
for _, edge in ipairs(edges or {}) do
if covers(runView, edge) then return edge.rootKey end
end
return nil
end
-- {[forbruger|vare] = "ok"|"missing"|"stopped"|"noVehicle"|"pallet"} for hver kant.
function ADChain.flowStatus(edges, runs, noVehicle)
local result = {}
for _, edge in ipairs(edges or {}) do
local status = "missing"
for _, run in ipairs(runs or {}) do
if run.kind == "supply" and covers(run, edge) then
if not run.stopped then status = "ok"; break end
local reason = (noVehicle or {})[run.id]
if reason == "pallet" then status = "pallet"
elseif reason then status = "noVehicle"
elseif status == "missing" then status = "stopped" end
end
end
result[tostring(edge.consumer) .. "|" .. tostring(edge.fillType)] = status
end
return result
end
-- Flows i det format ADChainActions regner på.
function ADChain.runsView(runs)
local list = {}
for _, run in ipairs(runs or {}) do
table.insert(list, {id = run.id, name = run.name, kind = run.kind, targetBuildingIds = run.targetBuildingIds,
ingredientMode = run.ingredientMode, ingredients = run.ingredients, stopped = run.stopped == true,
chain = run.chain, waitPoolGroup = run.waitPoolGroup})
end
return list
end
-- Manglende led der ikke var der sidst (nøgle = rodnøgle|vare|art). -> nye problemer (hver nøgle én gang), nøglerne nu.
-- Et problem der forsvinder, glemmes; kommer det igen, er det nyt. prevKeys røres ikke.
function ADChain.problemKey(problem)
return tostring(problem.rootKey) .. "|" .. tostring(problem.fillType) .. "|" .. tostring(problem.kind)
end
function ADChain.newProblems(prevKeys, problems)
local fresh, keys = {}, {}
for _, problem in ipairs(problems or {}) do
local key = ADChain.problemKey(problem)
if not keys[key] then
keys[key] = true
if not (prevKeys or {})[key] then table.insert(fresh, problem) end
end
end
return fresh, keys
end
-- ---- evaluering ----
local function farmId()
return g_currentMission ~= nil and g_currentMission.getFarmId ~= nil and g_currentMission:getFarmId() or 1
end
local function plan(data, state)
local recipes = ADChain.modelRecipes(data.recipes, state.paused, state.userOff)
local chain = ADChainModel.build(recipes, data.husbandry, data.crops, data.fieldGoods)
-- pausede rødder: det de bruger, er beskyttet mod slukning (modellen regnet som om de ikke var pauset)
local protected = {}
if next(state.paused) ~= nil then
protected = ADChainModel.build(ADChain.modelRecipes(data.recipes, {}, state.userOff), data.husbandry, data.crops, data.fieldGoods).needRecipes
end
local used = ADChain.unionFruits(data.usedFruits, chain.fieldDemand)
-- ufuldstændig markliste: udbuddet ville være undervurderet -> ingen mangler, forslag eller markskift endnu
local shortages, actions = {}, {}
if not data.fieldsIncomplete then
shortages = ADCropPlan.shortages(chain.fieldDemand, data.stock, data.fields, data.yields)
state.dismissed = ADChain.pruneDismissed(state.dismissed, shortages)
actions = ADCropPlan.choose(shortages, data.fields, used, data.yields, data.priceOf or data.prices, ADChain.dismissedFruits(state.dismissed))
end
-- de ægte opskrifter (ikke kopien): en pauset rod er stadig tændt og må ikke "tændes" og overtages af kæden
local prod = ADChainActions.productions(data.recipes, chain.needRecipes, protected)
return {chain = chain, shortages = shortages, actions = actions, prod = prod, edges = ADChainActions.edges(chain)}
end
local function logInfo(fmt, ...) Logging.info(ADChain.LOG_PREFIX .. fmt, ...) end
-- Spillerens valg og forældede nøgler ind i tilstanden (kun ved tick, før der regnes).
local function applyReconcile(state, data)
local result = ADChain.reconcile(state, data.recipes, {prevEnabled = ADChain.prevEnabled, isPartial = data.partial == true,
firstEval = ADChain.afterLoad})
if result.cleared then logInfo("ny produktion tændt — slukkede produktioner må bruges igen") end
for _, key in ipairs(result.added) do logInfo("spilleren slukkede %s — kæden tænder den ikke igen", key) end
for _, key in ipairs(result.forgotten) do logInfo("kæden glemmer %s (ikke tændt efter indlæsning)", key) end
for _, key in ipairs(result.droppedPaused) do logInfo("pause for %s fjernet: produktionen er væk eller slukket", key) end
state.userOff, state.byChain, state.paused, state.roots = result.userOff, result.byChain, result.paused, result.roots
ADChain.prevEnabled = ADChain.enabledKeys(data.recipes)
ADChain.afterLoad = false
end
-- Afhængighedstjek (spec afsnit 8): nye slukninger (kun act) og "Slukket men nødvendig"-posterne. Uafhængigt af
-- kædens rødder og af markscanningen; springes over ved læsefejl (så beholdes forrige poster, og slukninger i
-- mellemtiden meldes ved næste hele tjek).
-- Produktioner kæden selv er ved at tænde (plan.prod.enable), er ikke "slukket men nødvendig".
local function dependencyCheck(data, plan, runs, act, previous)
if ADDependency == nil or ADChainData.dependencyInputs == nil then return nil end
if data.readFailed then
return previous ~= nil and {warnings = {}, offNeeded = previous.offNeeded or {}} or nil
end
local inputs = ADChainData.dependencyInputs(data, plan.chain, runs)
local enabling, recipes = {}, {}
for _, key in ipairs(plan.prod ~= nil and plan.prod.enable or {}) do enabling[key] = true end
for _, recipe in ipairs(data.recipes or {}) do
if not enabling[recipe.key] then table.insert(recipes, recipe) end
end
local prev = act and ADChain.depPrevEnabled or nil
local result = {
warnings = ADDependency.warnings(prev, recipes, inputs.needed, inputs.stockDays, inputs.capacity, inputs.demand),
offNeeded = ADDependency.offNeeded(recipes, inputs.needed, inputs.stockDays, inputs.capacity, inputs.demand),
}
if act then ADChain.depPrevEnabled = ADDependency.enabledKeys(data.recipes) end
return result
end
-- act = true: udfør handlingerne (tick). Uden act kun en frisk visning (GUI).
function ADChain.evaluate(adEnv, act)
local state = ADChain.state
local ok, data = pcall(ADChainData.collect, adEnv, farmId(), state.byChain)
if not ok then
if not ADChain.collectFailed then Logging.warning(ADChain.LOG_PREFIX .. "gården kunne ikke læses: %s", tostring(data)) end
ADChain.collectFailed = true
return ADChain.last
end
ADChain.collectFailed = false
if act then
local okState, errState = pcall(applyReconcile, state, data)
if not okState then Logging.warning(ADChain.LOG_PREFIX .. "tilstanden kunne ikke opdateres: %s", tostring(errState)) end
end
local okPlan, result = pcall(plan, data, state)
if not okPlan then
Logging.warning(ADChain.LOG_PREFIX .. "kæden kunne ikke beregnes: %s", tostring(result))
return ADChain.last
end
if act and ADChainRun ~= nil then ADChainRun.apply(adEnv, data, result) end
local runs = ADChain.runsView(ADRunsController.store.runs)
local okDep, dependency = pcall(dependencyCheck, data, result, runs, act, ADChain.last ~= nil and ADChain.last.dependency or nil)
if not okDep then
Logging.warning(ADChain.LOG_PREFIX .. "afhængighedstjek fejlede: %s", tostring(dependency))
dependency = nil
end
local paused = {}
for key in pairs(state.paused) do paused[key] = true end
ADChain.last = {chain = result.chain, shortages = result.shortages, actions = result.actions, edges = result.edges,
missingFlows = ADChainActions.missingFlows(result.edges, runs), flowStatus = ADChain.flowStatus(result.edges, runs, ADChain.noVehicle),
paused = paused, userOff = copySet(state.userOff), data = data, fields = data.fields, fieldsIncomplete = data.fieldsIncomplete == true, titles = data.fillTitles, at = ADChain.now, switchCount = ADChain.switchCount,
dependency = dependency}
return ADChain.last
end
-- Alder (ms, spillets ur) af det sidste resultat; math.huge når der intet er. Fanen genbruger et friskt resultat.
function ADChain.lastAge()
if ADChain.last == nil then return math.huge end
return ADChain.now - (ADChain.last.at or 0)
end
-- Engangsbesked pr. nyt manglende led (kun fra tick). Ikke i opstartsfristen og ikke mens gården er læst delvist
-- eller markscanningen kører (ellers falske alarmer lige efter indlæsning) — så huskes intet, og det meldes senere.
-- Højst MAX_PROBLEM_NOTES beskeder pr. tick; resten samles i én besked (alle logges stadig).
ADChain.MAX_PROBLEM_NOTES = 3
function ADChain.notifyProblems(last)
if last == nil or ADChain.now < ADChain.STARTUP_GRACE_MS then return 0 end
if last.fieldsIncomplete or (last.data ~= nil and last.data.partial) then return 0 end
local fresh, keys = ADChain.newProblems(ADChain.problemKeys, last.chain ~= nil and last.chain.problems or {})
ADChain.problemKeys = keys
if #fresh == 0 or ADFieldJobs == nil or ADFieldJobs.notify == nil then return 0 end
local textOf = function(key) return g_i18n ~= nil and g_i18n:getText(key) or key end
local fmt = SmartPickupFrame ~= nil and SmartPickupFrame.formatCompact or nil
local rootNames = {}
for _, root in ipairs(last.chain ~= nil and last.chain.roots or {}) do
rootNames[root.key] = tostring((last.titles or {})[root.name] or root.name)
end
for index, problem in ipairs(fresh) do
-- kædens navn foran, så to kæder der mangler samme vare kan skelnes
local text = tostring(rootNames[problem.rootKey] or problem.rootKey) .. ": " .. ADChainView.problemText(problem, last, textOf, nil, fmt)
Logging.info(ADChain.LOG_PREFIX .. "manglende led: %s", text)
if index <= ADChain.MAX_PROBLEM_NOTES then ADFieldJobs.notify(text, true) end
end
-- resten samles i én besked (fx mange fermenteringssiloer efter indlæsning); alle står i fanen Kæder
if #fresh > ADChain.MAX_PROBLEM_NOTES then
local ok, text = pcall(string.format, textOf("spu_chain_notifyMore"), #fresh - ADChain.MAX_PROBLEM_NOTES)
ADFieldJobs.notify(ok and text or ("+" .. tostring(#fresh - ADChain.MAX_PROBLEM_NOTES)), true)
end
return #fresh
end
-- Én besked pr. slukning af en nødvendig produktion (kun fra tick; advarslerne tømmes, når de er meldt).
function ADChain.notifyDependencies(last)
local dependency = last ~= nil and last.dependency or nil
if dependency == nil or #(dependency.warnings or {}) == 0 or ADChain.now < ADChain.STARTUP_GRACE_MS then return 0 end
local warnings = dependency.warnings
dependency.warnings = {}
if ADFieldJobs == nil or ADFieldJobs.notify == nil then return 0 end
local textOf = function(key) return g_i18n ~= nil and g_i18n:getText(key) or key end
local titleOf = function(name) return tostring((last.titles or {})[name] or name) end
for _, warning in ipairs(warnings) do
local text = ADDependency.notifyText(warning, textOf, titleOf)
Logging.info(ADChain.LOG_PREFIX .. "nødvendig produktion slukket: %s", text)
ADFieldJobs.notify(text, true)
end
return #warnings
end
function ADChain.tick(adEnv, dt)
if adEnv == nil then return end
ADChain.now = ADChain.now + (dt or 0)
ADChain.timer = ADChain.timer + (dt or 0)
if ADChain.now < ADChain.STARTUP_GRACE_MS or ADChain.timer < ADChain.TICK_MS then return end
ADChain.timer = 0
local last = ADChain.evaluate(adEnv, true)
local ok, err = pcall(ADChain.notifyProblems, last)
if not ok then Logging.warning(ADChain.LOG_PREFIX .. "besked om manglende led fejlede: %s", tostring(err)) end
local okDep, errDep = pcall(ADChain.notifyDependencies, last)
if not okDep then Logging.warning(ADChain.LOG_PREFIX .. "advarsel om slukket produktion fejlede: %s", tostring(errDep)) end
end
-- ---- GUI-handlinger ----
local function suggestionFor(fieldId)
for _, action in ipairs(ADChain.last ~= nil and ADChain.last.actions or {}) do
if action.fieldId == fieldId then return action end
end
return nil
end
-- Godkend forslag: marken får kædens afgrøde som mål (kilde "chain").
function ADChain.approve(fieldId)
local action = suggestionFor(fieldId)
if action == nil then return false end
local ok, err = pcall(function()
-- et auto-skift må aldrig overskrive en afgrøde spilleren har sat siden; et forslag godkender spilleren selv
if action.mode == "auto" then
local plan = ADFieldJobs.getField(fieldId).plan or {}
if plan.targetSource == "user" then error("spilleren har selv valgt afgrøde") end
end
ADFieldJobs.setTarget(fieldId, action.to, "chain")
end)
if not ok then
Logging.warning(ADChain.LOG_PREFIX .. "F%s: forslag kunne ikke godkendes: %s", tostring(fieldId), tostring(err))
return false
end
Logging.info(ADChain.LOG_PREFIX .. "F%s: forslag godkendt: %s -> %s", tostring(fieldId), tostring(action.from), tostring(action.to))
return true
end
-- Afvis forslag: vises ikke igen, før manglen for afgrøden ændrer sig > 25 %.
function ADChain.dismiss(fieldId)
local action = suggestionFor(fieldId)
if action == nil then return false end
local missingHa = action.hectares or 0
for _, shortage in ipairs(ADChain.last.shortages or {}) do
if shortage.fruit == action.to then missingHa = shortage.missingHa end
end
ADChain.state.dismissed[fieldId] = {fruit = action.to, missingHa = missingHa}
Logging.info(ADChain.LOG_PREFIX .. "F%s: forslag %s afvist", tostring(fieldId), tostring(action.to))
return true
end
function ADChain.setPaused(rootKey, isPaused)
if rootKey == nil then return end
ADChain.state.paused[rootKey] = isPaused and true or nil
Logging.info(ADChain.LOG_PREFIX .. "%s %s", tostring(rootKey), isPaused and "på pause" or "genoptaget")
end
-- ---- gem / indlæs (samme sti-opslag som runs; skrives kun fra ADRunsController.save) ----
local function getPath()
local path = ADRunsStorage ~= nil and ADRunsStorage.getPath() or nil
if path == nil then return nil end
return (path:gsub(ADRunsStorage.FILE_NAME .. "$", ADChain.FILE_NAME))
end
function ADChain.save()
local path = getPath()
if path == nil then return false end
local xmlFile = XMLFile.create("ADSmartPickupChain", path, ADChain.ROOT)
if xmlFile == nil then return false end
local records = ADChain.stateToRecords(ADChain.state)
for index, key in ipairs(records.byChain) do
xmlFile:setString(string.format("%s.byChain.recipe(%d)#key", ADChain.ROOT, index - 1), key)
end
for index, key in ipairs(records.paused) do
xmlFile:setString(string.format("%s.paused.root(%d)#key", ADChain.ROOT, index - 1), key)
end
for index, key in ipairs(records.userOff) do
xmlFile:setString(string.format("%s.userOff.recipe(%d)#key", ADChain.ROOT, index - 1), key)
end
if records.roots ~= nil then
xmlFile:setBool(ADChain.ROOT .. ".roots#known", true)
for index, key in ipairs(records.roots) do
xmlFile:setString(string.format("%s.roots.recipe(%d)#key", ADChain.ROOT, index - 1), key)
end
end
for index, entry in ipairs(records.dismissed) do
local key = string.format("%s.dismissed.field(%d)", ADChain.ROOT, index - 1)
xmlFile:setInt(key .. "#id", entry.id)
xmlFile:setString(key .. "#fruit", entry.fruit)
xmlFile:setString(key .. "#missingHa", string.format("%.2f", entry.missingHa or 0))
end
xmlFile:save()
xmlFile:delete()
return true
end
function ADChain.load()
ADChain.state = ADChain.newState()
ADChain.noVehicle = {}
ADChain.prevEnabled, ADChain.afterLoad = nil, true
ADChain.problemKeys = {}
ADChain.depPrevEnabled = nil
local path = getPath()
if path == nil or not fileExists(path) then return end
local xmlFile = XMLFile.load("ADSmartPickupChain", path)
if xmlFile == nil then return end
local records = {byChain = {}, paused = {}, userOff = {}, dismissed = {}}
xmlFile:iterate(ADChain.ROOT .. ".byChain.recipe", function(_, key)
table.insert(records.byChain, xmlFile:getString(key .. "#key"))
end)
xmlFile:iterate(ADChain.ROOT .. ".paused.root", function(_, key)
table.insert(records.paused, xmlFile:getString(key .. "#key"))
end)
xmlFile:iterate(ADChain.ROOT .. ".userOff.recipe", function(_, key)
table.insert(records.userOff, xmlFile:getString(key .. "#key"))
end)
if xmlFile:getBool(ADChain.ROOT .. ".roots#known", false) then
records.roots = {}
xmlFile:iterate(ADChain.ROOT .. ".roots.recipe", function(_, key)
table.insert(records.roots, xmlFile:getString(key .. "#key"))
end)
end
xmlFile:iterate(ADChain.ROOT .. ".dismissed.field", function(_, key)
table.insert(records.dismissed, {id = xmlFile:getInt(key .. "#id"), fruit = xmlFile:getString(key .. "#fruit"),
missingHa = tonumber(xmlFile:getString(key .. "#missingHa"))})
end)
xmlFile:delete()
ADChain.state = ADChain.stateFromRecords(records)
Logging.info(ADChain.LOG_PREFIX .. "%d produktioner tændt af kæden, %d pausede kæder indlæst", #records.byChain, #records.paused)
end
+67
View File
@@ -0,0 +1,67 @@
-- AD Chain Actions (ren logik): mellemled der skal tændes/slukkes, flows der mangler eller skal stoppes.
ADChainActions = {}
-- protected = {[nøgle] = cyklusser}: behov fra pausede rødder — slukkes ikke, men tændes heller ikke.
function ADChainActions.productions(recipes, needRecipes, protected)
local result = {enable = {}, disable = {}}
for _, recipe in ipairs(recipes or {}) do
local needed = ((needRecipes or {})[recipe.key] or 0) > 0
local kept = ((protected or {})[recipe.key] or 0) > 0
if needed and not recipe.enabled then table.insert(result.enable, recipe.key) end
if not needed and not kept and recipe.enabled and recipe.byChain then table.insert(result.disable, recipe.key) end
end
table.sort(result.enable); table.sort(result.disable)
return result
end
local SUPPLIED = {production = true, husbandry = true, field = true}
function ADChainActions.edges(chain)
local list, seen = {}, {}
local function walk(nodes, rootKey)
for _, node in ipairs(nodes or {}) do
local id = tostring(node.consumer) .. "|" .. tostring(node.fillType)
if SUPPLIED[node.source] and node.consumer ~= nil and not seen[id] then
seen[id] = true
table.insert(list, {fillType = node.fillType, consumer = node.consumer, consumerName = node.consumerName, rootKey = rootKey})
end
walk(node.children, rootKey)
end
end
for _, root in ipairs((chain or {}).roots or {}) do walk(root.children, root.key) end
return list
end
local function contains(list, value)
for _, item in ipairs(list or {}) do if item == value then return true end end
return false
end
function ADChainActions.isCovered(edge, runs)
for _, run in ipairs(runs or {}) do
if run.kind == "supply" and contains(run.targetBuildingIds, edge.consumer)
and (run.ingredientMode ~= "manual" or contains(run.ingredients, edge.fillType)) then
return true
end
end
return false
end
function ADChainActions.missingFlows(edges, runs)
local list = {}
for _, edge in ipairs(edges or {}) do
if not ADChainActions.isCovered(edge, runs) then table.insert(list, edge) end
end
return list
end
function ADChainActions.flowsToStop(runs, activeRootKeys)
local list = {}
for _, run in ipairs(runs or {}) do
if run.chain ~= nil and run.chain ~= "" and not run.stopped and not (activeRootKeys or {})[run.chain] then
table.insert(list, run)
end
end
return list
end
+453
View File
@@ -0,0 +1,453 @@
-- AD Chain Data (læse-adapter)
-- Omsætter spillets tilstand (gårdens fabrikker, stalde, afgrøder, lager og marker) til de rene data som
-- ADChainModel og ADCropPlan regner på. Varer og afgrøder er NAVNE; mængder er liter pr. måned (spillets
-- rå rater: én 24-timers dag pr. måned, som lagerfanen). Al spiladgang sker inde i funktionerne, så filen kan
-- dofile's uden spillet. Hver bygning/mark/afgrøde læses i pcall; en fejl logges én gang pr. nøgle.
ADChainData = {}
ADChainData.LOG_PREFIX = "ADSmartPickup: kæde: "
ADChainData.PERMANENT = {GRAPE = true, GRAPEB = true, OLIVE = true, POPLAR = true}
ADChainData.warned = {}
-- Varige afgrøder (vin, oliven, poppel) skiftes aldrig; mod-vine hedder også GRAPE…
function ADChainData.isPermanent(fruitName)
if type(fruitName) ~= "string" or fruitName == "" then return false end
return ADChainData.PERMANENT[fruitName] == true or fruitName:sub(1, 5) == "GRAPE"
end
function ADChainData.recipeKey(placeableUid, productionId)
return tostring(placeableUid) .. ":" .. tostring(productionId)
end
-- Samme omregning som lagerfanen (adSources addFactoryRates): cyklusser pr. 24-timers dag = pr. måned.
function ADChainData.cyclesPerMonth(production)
return (production.cyclesPerMinute or ((production.cyclesPerHour or 0) / 60)) * 1440
end
function ADChainData.warnOnce(key, fmt, ...)
if ADChainData.warned[key] then return end
ADChainData.warned[key] = true
if Logging ~= nil and Logging.warning ~= nil then Logging.warning(ADChainData.LOG_PREFIX .. fmt, ...) end
end
-- En læsefejl i dette kald: data.partial = true (også når advarslen allerede er logget), så kæden ikke
-- slukker eller stopper noget på et ufuldstændigt billede af gården.
-- data.readFailed skelner en ægte læsefejl fra en ufærdig markscanning (afhængighedstjekket bruger ikke markerne).
local function failed(data, key, fmt, ...)
data.partial, data.readFailed = true, true
ADChainData.warnOnce(key, fmt, ...)
end
-- ---- hjælpere ----
-- Navn for et fillType-indeks; husker indeks og titel til GUI og vognvalg.
local function nameResolver(data)
return function(index)
if index == nil or g_fillTypeManager == nil then return nil end
local name = g_fillTypeManager:getFillTypeNameByIndex(index)
if name == nil then return nil end
if data.fillIndex[name] == nil then
data.fillIndex[name] = index
local desc = g_fillTypeManager.getFillTypeByIndex ~= nil and g_fillTypeManager:getFillTypeByIndex(index) or nil
data.fillTitles[name] = tostring(desc ~= nil and desc.title or name)
end
return name
end
end
-- {{type, amount}} -> {[navn] = mængde pr. cyklus}
local function amountsByName(list, nameOf)
local result = {}
for _, entry in ipairs(list or {}) do
local name = nameOf(entry.type)
if name ~= nil and (entry.amount or 0) > 0 then result[name] = (result[name] or 0) + entry.amount end
end
return result
end
local function placeableId(placeable)
if placeable.uniqueId ~= nil then return tostring(placeable.uniqueId) end
return ADBuildings ~= nil and ADBuildings.getId ~= nil and ADBuildings.getId(placeable) or nil
end
-- Spillerens eget navn (ADNames) før spillets; samme rækkefølge som adSources/adBuildings.
local function placeableName(placeable, fallback)
local own = ADNames ~= nil and ADNames.forPlaceable ~= nil and ADNames.forPlaceable(placeable) or nil
if own ~= nil then return own end
if placeable.getName ~= nil then
local ok, name = pcall(placeable.getName, placeable)
if ok and name ~= nil then return tostring(name) end
end
return fallback
end
local function isEnabled(point, production)
if point.getIsProductionEnabled ~= nil then return point:getIsProductionEnabled(production.id) == true end
for _, active in ipairs(point.activeProductions or {}) do
if active == production then return true end
end
return false
end
-- ---- læsning pr. del ----
-- En fabriks produktioner -> opskrifter + punkter (samles først, så en fejl midtvejs ikke efterlader halve).
local function readFactory(placeable, point, byChain, nameOf)
local uid = placeableId(placeable)
if uid == nil then return {}, {} end
local buildingName = placeableName(placeable, uid)
local recipes, points = {}, {}
for _, production in ipairs(point.productions or {}) do
local key = ADChainData.recipeKey(uid, production.id)
table.insert(recipes, {key = key, name = tostring(production.name or production.id), building = uid,
buildingName = buildingName, enabled = isEnabled(point, production), byChain = (byChain or {})[key] == true,
cyclesPerMonth = ADChainData.cyclesPerMonth(production),
inputs = amountsByName(production.inputs, nameOf), outputs = amountsByName(production.outputs, nameOf)})
points[key] = {point = point, productionId = production.id, placeable = placeable}
end
return recipes, points
end
local function readHerd(placeable, nameOf)
local uid = placeableId(placeable)
local outputs = {}
for index, perMonth in pairs(ADSources.getHusbandryOutputs(placeable) or {}) do
local name = nameOf(index)
if name ~= nil then outputs[name] = (outputs[name] or 0) + perMonth end
end
if uid == nil or next(outputs) == nil then return nil end
return {building = uid, buildingName = placeableName(placeable, uid), outputs = outputs}
end
-- Stald-foder: {[vare] = {staldnavn...}} for det truget æder nu (forbrug > 0) — til afhængighedstjekket.
local function readBarnFood(data, placeable, nameOf)
if ADSources.getHusbandryConsumption == nil then return end
local barnName
for index, perDay in pairs(ADSources.getHusbandryConsumption(placeable) or {}) do
local name = nameOf(index)
if name ~= nil and (perDay or 0) > 0 then
barnName = barnName or placeableName(placeable, placeableId(placeable) or "?")
data.barnFood[name] = data.barnFood[name] or {}
table.insert(data.barnFood[name], barnName)
end
end
end
local function readPlaceables(data, farmId, byChain, nameOf)
local placeables = g_currentMission ~= nil and g_currentMission.placeableSystem ~= nil and g_currentMission.placeableSystem.placeables or {}
for index, placeable in pairs(placeables) do
local ok, err = pcall(function()
if not ADPrices.isOwnedBy(placeable, farmId) then return end
local uid = placeableId(placeable)
if uid ~= nil then data.placeables[uid] = placeable end
local point = placeable.spec_productionPoint ~= nil and placeable.spec_productionPoint.productionPoint or nil
if point ~= nil then
local recipes, points = readFactory(placeable, point, byChain, nameOf)
for _, recipe in ipairs(recipes) do table.insert(data.recipes, recipe) end
for key, entry in pairs(points) do data.points[key] = entry end
elseif placeable.spec_husbandry ~= nil or placeable.getHusbandryFillLevel ~= nil then
local herd = readHerd(placeable, nameOf)
if herd ~= nil then table.insert(data.husbandry, herd) end
readBarnFood(data, placeable, nameOf)
end
end)
if not ok then
local key = tostring(placeable ~= nil and placeable.uniqueId or index)
failed(data, "placeable:" .. key, "bygning %s kunne ikke læses: %s", key, tostring(err))
end
end
table.sort(data.recipes, function(a, b) return a.key < b.key end)
end
-- Markedspris for en afgrøde, først når kæden spørger (kun kandidatmarkernes afgrøder, kun ved mangel);
-- hver afgrøde slås højst op én gang pr. læsning. Fejl -> 0 (logget én gang).
local function priceResolver(data)
local cache = {}
return function(fruitName)
if cache[fruitName] ~= nil then return cache[fruitName] end
local index = data.fruitFillIndex[fruitName]
local price = 0
if index ~= nil then
local ok, quote = pcall(ADSources.getMarketQuote, index)
if ok then
price = quote ~= nil and quote.price or 0
else
ADChainData.warnOnce("price:" .. tostring(fruitName), "pris for %s kunne ikke læses: %s", tostring(fruitName), tostring(quote))
end
end
cache[fruitName] = price
return price
end
end
local function readCrops(data, nameOf)
local fruits = g_fruitTypeManager ~= nil and g_fruitTypeManager.fruitTypes or {}
for _, fruit in ipairs(fruits) do
if fruit.fillType ~= nil and fruit.allowsSeeding ~= false and fruit.name ~= nil then
local ok, err = pcall(function()
local fillName = fruit.fillType.name or nameOf(fruit.fillType.index)
if fillName == nil then return end
if fruit.fillType.index ~= nil then nameOf(fruit.fillType.index) end
data.crops[fillName] = fruit.name
data.yields[fruit.name] = (tonumber(fruit.literPerSqm) or 0) * 10000
data.fruitFillIndex[fruit.name] = fruit.fillType.index
end)
if not ok then failed(data, "fruit:" .. tostring(fruit.name), "afgrøde %s kunne ikke læses: %s", tostring(fruit.name), tostring(err)) end
end
end
end
-- ---- markvarer (hø, hakkelse, halm …) ----
-- Varer der høstes/laves på marken uden at være en afgrødes hovedvare. Spillets konvertere hedder forskelligt
-- mellem versioner/kort (FORAGEHARVESTER/FORAGE_HARVESTER), så alle navne prøves; et ukendt navn giver nil.
ADChainData.FIELD_CONVERTERS = {"FORAGEHARVESTER", "FORAGE_HARVESTER", "MOWER", "COMBINE", "CUTTER"}
-- Kendte markvarer (basisspil, FermentingSiloPack, Dakota); kun dem der findes som fill type bruges.
ADChainData.KNOWN_FIELD_GOODS = {"STRAW", "CHAFF", "CROP_WINDROW", "GRASS_WINDROW", "ALFALFA_WINDROW", "CLOVER_WINDROW",
"DRYGRASS_WINDROW", "DRYALFALFA_WINDROW", "DRYCLOVER_WINDROW", "CCM", "CCMRAW", "WCS"}
-- fruit.windrowFillType kan være et indeks eller en fill type-beskrivelse
local function windrowIndex(fruit)
local windrow = fruit.windrowFillType
if type(windrow) == "number" then return windrow end
if type(windrow) == "table" then
if windrow.index ~= nil then return windrow.index end
if windrow.name ~= nil and g_fillTypeManager.getFillTypeIndexByName ~= nil then
return g_fillTypeManager:getFillTypeIndexByName(windrow.name)
end
end
return nil
end
-- Én konverters mål-indekser ({[fra] = {targetFillTypeIndex|fillTypeIndex}}, samme form som adFields.buildMowable)
local function converterTargets(converter, add)
for _, entry in pairs(converter or {}) do
if type(entry) == "table" then add(entry.targetFillTypeIndex or entry.fillTypeIndex) end
end
end
-- data.fieldGoods = {[navn] = true}. Kun eksisterende fill types; fejl logges én gang og gør ikke læsningen delvis
-- (en ændret spil-API må ikke blokere kæden for altid) — værst tænkeligt meldes en markvare som "køb".
local function readFieldGoods(data, nameOf)
local indices = {}
local function add(index) if type(index) == "number" then indices[index] = true end end
local function guarded(key, fn)
local ok, err = pcall(fn)
if not ok then ADChainData.warnOnce("fieldGoods:" .. key, "markvarer (%s) kunne ikke læses: %s", key, tostring(err)) end
end
if g_fillTypeManager == nil then return end
local fruitManager = g_fruitTypeManager
for _, fruit in ipairs(fruitManager ~= nil and fruitManager.fruitTypes or {}) do
guarded("windrow:" .. tostring(fruit.name), function() add(windrowIndex(fruit)) end)
end
if fruitManager ~= nil and fruitManager.getConverterDataByName ~= nil then
for _, name in ipairs(ADChainData.FIELD_CONVERTERS) do
guarded(name, function() converterTargets(fruitManager:getConverterDataByName(name), add) end)
end
end
if g_fillTypeManager.getFillTypeIndexByName ~= nil then
for _, name in ipairs(ADChainData.KNOWN_FIELD_GOODS) do
guarded(name, function() add(g_fillTypeManager:getFillTypeIndexByName(name)) end)
end
end
-- tørrede varianter (vender: græs-vindue -> hø)
if g_fillTypeManager.getConverterDataByName ~= nil then
guarded("TEDDER", function()
local tedder = g_fillTypeManager:getConverterDataByName("TEDDER") or {}
local windrows = {}
for index in pairs(indices) do table.insert(windrows, index) end
for _, index in ipairs(windrows) do
local entry = tedder[index]
if type(entry) == "table" then add(entry.targetFillTypeIndex or entry.fillTypeIndex) end
end
end)
end
for index in pairs(indices) do
local ok, name = pcall(nameOf, index)
if ok and name ~= nil and data.crops[name] == nil then data.fieldGoods[name] = true end
end
end
local function readStock(data, adEnv, farmId, nameOf)
local ok, err = pcall(function()
for index, liters in pairs(ADStorageStats.farmTotals(ADSources.getFarmInventory(adEnv, farmId))) do
local name = nameOf(index)
if name ~= nil then data.stockByFill[name] = (data.stockByFill[name] or 0) + liters end
local fruit = data.crops[name or ""]
if fruit ~= nil then data.stock[fruit] = (data.stock[fruit] or 0) + liters end
end
end)
if not ok then failed(data, "stock", "lager kunne ikke læses: %s", tostring(err)) end
end
-- Gårdens forbrug og produktion pr. døgn (fabrikker der kører + stalde) pr. varenavn og spillets tidsfaktor
-- (rå rater pr. måned × faktor = pr. døgn) — til lagerdage og dækning i afhængighedstjekket.
local function readRates(data, adEnv, farmId, nameOf)
local environment = g_currentMission ~= nil and g_currentMission.environment or nil
local adjustment = environment ~= nil and tonumber(environment.timeAdjustment) or nil
data.timeAdjustment = adjustment ~= nil and adjustment > 0 and adjustment or 1
local ok, err = pcall(function()
for index, rate in pairs(ADSources.getFarmRates(adEnv, farmId, false) or {}) do
local name = nameOf(index)
if name ~= nil then data.rates[name] = {consumption = rate.consumption or 0, production = rate.production or 0} end
end
end)
if not ok then failed(data, "allRates", "gårdens forbrug kunne ikke læses: %s", tostring(err)) end
end
-- Afgrøder gården selv bruger: input i en tændt produktion eller foder/halm i en stald (kun staldenes forbrug
-- læses: feedOnly springer fabrikkernes rater over — dem dækker opskrifterne allerede).
local function readUsedFruits(data, adEnv, farmId, nameOf)
for _, recipe in ipairs(data.recipes) do
if recipe.enabled then
for name in pairs(recipe.inputs) do
if data.crops[name] ~= nil then data.usedFruits[data.crops[name]] = true end
end
end
end
local ok, err = pcall(function()
for index, rate in pairs(ADSources.getFarmRates(adEnv, farmId, true) or {}) do
local fruit = data.crops[nameOf(index) or ""]
if fruit ~= nil and (rate.consumption or 0) > 0 then data.usedFruits[fruit] = true end
end
end)
if not ok then failed(data, "rates", "staldforbrug kunne ikke læses: %s", tostring(err)) end
end
-- Markscanningen har ikke nået alle marker endnu (efter indlæsning ~én mark pr. 2 s): marklisten er ufuldstændig,
-- så udbud undervurderes -> fieldsIncomplete (kæden rører ingen marker) og partial (intet slukkes/stoppes).
local function readFields(data)
if ADFields.isScanComplete ~= nil and not ADFields.isScanComplete() then
data.partial, data.fieldsIncomplete = true, true
end
for _, info in ipairs(ADFields.list() or {}) do
local ok, entry = pcall(function()
local field = ADFieldJobs.getField(info.id)
local plan = field.plan or {}
local fruit = info.growing and info.fruitName or field.lastFruit
return {id = info.id, areaHa = ADFields.polygonAreaHa(info.verts), fruit = fruit,
targetFruit = plan.targetFruit, targetSource = plan.targetSource, chainFree = plan.chainFree == true,
permanent = ADChainData.isPermanent(info.fruitName or field.lastFruit), excluded = info.kind == "mowable"}
end)
if ok then
table.insert(data.fields, entry)
else
failed(data, "field:" .. tostring(info.id), "mark %s kunne ikke læses: %s", tostring(info.id), tostring(entry))
end
end
end
-- ---- afhængighedstjek (spec afsnit 8) ----
local function unloadStationsOf(placeable)
local storage = g_currentMission ~= nil and g_currentMission.storageSystem or nil
local result = {}
if storage == nil or storage.getUnloadingStations == nil then return result end
for _, station in pairs(storage:getUnloadingStations() or {}) do
if station.owningPlaceable == placeable then table.insert(result, station) end
end
return result
end
-- Hvad auto-flowenes mål tager af varerne `goods` (navne): {[bygnings-id] = {[vare] = true}}.
-- Kun tændte forsyningsflows i auto-tilstand; hvert mål slås op én gang.
function ADChainData.acceptsMap(data, flows, goods)
local result = {}
for _, flow in ipairs(flows or {}) do
if flow.kind ~= "outbound" and not flow.stopped and flow.ingredientMode ~= "manual" then
for _, id in ipairs(flow.targetBuildingIds or {}) do
local placeable = data.placeables[id]
if result[id] == nil and placeable ~= nil then
result[id] = {}
local ok, err = pcall(function()
local stations = unloadStationsOf(placeable)
for _, name in ipairs(goods) do
local index = data.fillIndex[name]
for _, station in ipairs(stations) do
if index ~= nil and ADSources.stationAccepts(station, index) then result[id][name] = true; break end
end
end
end)
if not ok then ADChainData.warnOnce("accepts:" .. tostring(id), "mål %s kunne ikke læses: %s", tostring(id), tostring(err)) end
end
end
end
end
return result
end
-- Kædens input-varer (alle varer i de aktive kæders træer) -> {[vare] = {kædenavn...}}
function ADChainData.chainInputs(chain, titles)
local result = {}
local function walk(nodes, rootTitle)
for _, node in ipairs(nodes or {}) do
result[node.fillType] = result[node.fillType] or {}
local list = result[node.fillType]
if list[#list] ~= rootTitle then table.insert(list, rootTitle) end
walk(node.children, rootTitle)
end
end
for _, root in ipairs(chain ~= nil and chain.roots or {}) do
walk(root.children, tostring((titles or {})[root.name] or root.name))
end
return result
end
-- Alt afhængighedstjekket regner på, i liter pr. døgn: {needed, stockDays, capacity, demand}.
-- flows = ADChain.runsView(...); chain = kædemodellen (kan være uden rødder).
function ADChainData.dependencyInputs(data, chain, flows)
local goods, seen = {}, {}
for _, recipe in ipairs(data.recipes or {}) do
if not recipe.enabled and not recipe.byChain then
for name in pairs(recipe.outputs or {}) do
if not seen[name] then seen[name] = true; table.insert(goods, name) end
end
end
end
table.sort(goods)
-- auto-flowets mål tager mange varer (fx biogas: korn, ensilage, gylle): kun varer gården faktisk forbruger
-- (stald eller fabrik) eller en kæde vil have, tæller
local consumed = {}
for _, name in ipairs(goods) do
local rate = (data.rates or {})[name]
if (rate ~= nil and (rate.consumption or 0) > 0) or ((chain ~= nil and chain.demand or {})[name] or 0) > 0 then
table.insert(consumed, name)
end
end
local needed = ADDependency.neededOutputs(flows, data.barnFood, ADChainData.chainInputs(chain, data.fillTitles),
#consumed > 0 and ADChainData.acceptsMap(data, flows, consumed) or {})
local adjustment = data.timeAdjustment or 1
local capacity, demand, stockDays = {}, {}, {}
for name, perMonth in pairs(ADDependency.capacity(data.recipes)) do capacity[name] = perMonth * adjustment end
for _, name in ipairs(goods) do
local rate = (data.rates or {})[name] or {}
local consumption, production = rate.consumption or 0, rate.production or 0
local chainDemand = ((chain ~= nil and chain.demand or {})[name] or 0) * adjustment
demand[name] = math.max(consumption, chainDemand)
if consumption > production then
stockDays[name] = ((data.stockByFill or {})[name] or 0) / (consumption - production)
elseif consumption > 0 then
stockDays[name] = math.huge
end
end
return {needed = needed, stockDays = stockDays, capacity = capacity, demand = demand}
end
-- ---- samlet ----
-- byChain = {[recipeKey] = true} for produktioner kæden selv har tændt (ikke rødder).
-- data.partial = true når noget ikke kunne læses i dette kald (data.readFailed) eller markscanningen kører; data.fieldsIncomplete = true mens markscanningen
-- ikke har nået alle marker.
function ADChainData.collect(adEnv, farmId, byChain)
local data = {recipes = {}, husbandry = {}, crops = {}, stock = {}, fields = {}, yields = {}, fruitFillIndex = {},
usedFruits = {}, points = {}, fillTitles = {}, fillIndex = {}, placeables = {}, barnFood = {}, stockByFill = {}, rates = {}, fieldGoods = {}}
local nameOf = nameResolver(data)
data.priceOf = priceResolver(data)
readPlaceables(data, farmId, byChain, nameOf)
readCrops(data, nameOf)
readFieldGoods(data, nameOf)
readStock(data, adEnv, farmId, nameOf)
readRates(data, adEnv, farmId, nameOf)
readUsedFruits(data, adEnv, farmId, nameOf)
readFields(data)
return data
end
+167
View File
@@ -0,0 +1,167 @@
-- AD Chain Model (ren logik)
-- Sporer baglæns fra de produktioner spilleren selv har tændt (rødder) gennem gårdens egne fabrikker og stalde
-- til råvarerne. Alle mængder er liter pr. måned; varer og afgrøder er NAVNE. Se spec 2026-09-30-produktionskaeder.
ADChainModel = {}
ADChainModel.MAX_DEPTH = 8
local function byKey(a, b) return tostring(a.key) < tostring(b.key) end
-- producenter pr. vare: tændte først, så stabil rækkefølge
local function indexProducers(recipes)
local index = {}
for _, recipe in ipairs(recipes or {}) do
for name, amount in pairs(recipe.outputs or {}) do
if (amount or 0) > 0 and (recipe.cyclesPerMonth or 0) > 0 then
index[name] = index[name] or {}
table.insert(index[name], recipe)
end
end
end
for _, list in pairs(index) do
table.sort(list, function(a, b)
if (a.enabled == true) ~= (b.enabled == true) then return a.enabled == true end
return byKey(a, b)
end)
end
return index
end
local function indexHerds(husbandry)
local index = {}
for _, herd in ipairs(husbandry or {}) do
for name, perMonth in pairs(herd.outputs or {}) do
if (perMonth or 0) > 0 then
index[name] = index[name] or {}
table.insert(index[name], {building = herd.building, buildingName = herd.buildingName, left = perMonth})
end
end
end
return index
end
local function sortedNames(map)
local names = {}
for name in pairs(map or {}) do table.insert(names, name) end
table.sort(names)
return names
end
-- fieldGoods = {[vare] = true}: varer der høstes/laves på marken uden at være en afgrødes hovedvare (hø, hakkelse,
-- halm …). Uden producent/stald er kilden "field" uden afgrøde: intet markbehov og intet problem.
function ADChainModel.build(recipes, husbandry, crops, fieldGoods)
local chain = {roots = {}, demand = {}, fieldDemand = {}, needRecipes = {}, problems = {}}
local producers, herds = indexProducers(recipes), indexHerds(husbandry)
-- Rødderne kører for sig selv med fuld fart: deres egne input kræves af deres eget træ. Som leverandør til en
-- anden rod har de derfor fuld kapacitet, men deres input regnes ikke med igen (standalone = ingen rekursion).
-- En pauset rod (recipe.paused) kører også for sig selv, men er ikke rod: kæden skaber intet nyt for den.
local capacity, roots, standalone = {}, {}, {}
for _, recipe in ipairs(recipes or {}) do
capacity[recipe.key] = recipe.cyclesPerMonth or 0
if recipe.enabled and not recipe.byChain then
if not recipe.paused then table.insert(roots, recipe) end
standalone[recipe.key] = true
end
end
table.sort(roots, byKey)
-- detail = manglende liter/md (flaskehals/køb), need = varens behov pr. måned i den gren
local function problem(kind, fillType, rootKey, detail, need)
table.insert(chain.problems, {kind = kind, fillType = fillType, rootKey = rootKey, detail = detail, need = need})
end
local need
local function inputsOf(recipe, cycles, depth, path, rootKey)
local children = {}
for _, name in ipairs(sortedNames(recipe.inputs)) do
local amount = recipe.inputs[name] or 0
if amount > 0 and cycles > 0 then
local child = need(name, amount * cycles, depth + 1, path, rootKey)
child.consumer, child.consumerName = recipe.building, recipe.buildingName
table.insert(children, child)
end
end
return children
end
need = function(name, perMonth, depth, path, rootKey)
local node = {fillType = name, perMonth = perMonth, coveredPerMonth = 0, producers = {}, herds = {}, children = {}}
chain.demand[name] = (chain.demand[name] or 0) + perMonth
if depth > ADChainModel.MAX_DEPTH or path[name] then
node.source = "cycle"
problem("cycle", name, rootKey, nil, perMonth)
return node
end
path[name] = true
local rest = perMonth
-- a) gårdens fabrikker
local makers = producers[name]
if makers ~= nil then
node.source = "production"
for _, recipe in ipairs(makers) do
local perCycle = recipe.outputs[name]
local cycles = math.min(rest / perCycle, capacity[recipe.key] or 0)
if cycles > 0 then
capacity[recipe.key] = capacity[recipe.key] - cycles
rest = rest - cycles * perCycle
table.insert(node.producers, {key = recipe.key, building = recipe.building, buildingName = recipe.buildingName, cycles = cycles})
if not standalone[recipe.key] then
chain.needRecipes[recipe.key] = (chain.needRecipes[recipe.key] or 0) + cycles
for _, child in ipairs(inputsOf(recipe, cycles, depth, path, rootKey)) do table.insert(node.children, child) end
end
end
if rest <= 1e-9 then break end
end
if rest > 1e-9 and #node.producers > 0 then
problem("bottleneck", name, rootKey, rest, perMonth)
node.shortPerMonth = rest
end
if rest > 1e-9 and #node.producers == 0 then
node.source = nil
end
end
-- b) stalde
if rest > 1e-9 and #node.producers == 0 and herds[name] ~= nil then
node.source = "husbandry"
for _, herd in ipairs(herds[name]) do
local take = math.min(rest, herd.left)
if take > 0 then
herd.left = herd.left - take
rest = rest - take
table.insert(node.herds, {building = herd.building, buildingName = herd.buildingName, perMonth = take})
end
end
end
-- c) mark
if rest > 1e-9 and #node.producers == 0 and #node.herds == 0 and crops ~= nil and crops[name] ~= nil then
node.source, node.fruit = "field", crops[name]
chain.fieldDemand[node.fruit] = (chain.fieldDemand[node.fruit] or 0) + rest
rest = 0
end
-- c2) markvare uden egen afgrøde (fx hø, hakkelse): kommer fra markerne, ingen afgrødeplan
if rest > 1e-9 and #node.producers == 0 and #node.herds == 0 and fieldGoods ~= nil and fieldGoods[name] then
node.source, node.fruit = "field", nil
rest = 0
end
-- d) ingen kilde (eller rest efter stald), eller exhausted producers
if rest > 1e-9 and makers ~= nil and #node.producers == 0 and #node.herds == 0 then
node.source = "production"
node.shortPerMonth = rest
problem("bottleneck", name, rootKey, rest, perMonth)
elseif rest > 1e-9 and (node.source == nil or node.source == "husbandry") then
if node.source == nil then node.source = "buy" end
node.shortPerMonth = rest
problem("buy", name, rootKey, rest, perMonth)
end
node.coveredPerMonth = perMonth - (node.shortPerMonth or 0)
path[name] = nil
return node
end
for _, root in ipairs(roots) do
local cycles = root.cyclesPerMonth or 0
table.insert(chain.roots, {key = root.key, name = root.name, building = root.building, buildingName = root.buildingName,
children = inputsOf(root, cycles, 0, {}, root.key)})
end
return chain
end
+217
View File
@@ -0,0 +1,217 @@
-- AD Chain Run (kædens handlinger i spillet)
-- Udfører ADChain's plan: skifter frie marker (kun "auto", højst MAX_FIELD_SWITCHES pr. tick), tænder/slukker
-- mellemled, opretter manglende forsyningsflows (højst MAX_NEW_FLOWS pr. tick) og stopper flows for rødder der
-- er slået fra. Hver del kører i pcall, så en fejl ikke stopper resten. Spilkald kun inde i funktioner.
ADChainRun = {}
local function log(fmt, ...) Logging.info(ADChain.LOG_PREFIX .. fmt, ...) end
local function warn(fmt, ...) Logging.warning(ADChain.LOG_PREFIX .. fmt, ...) end
local UNLOADER = "unloader"
-- Samme rolletjek som ADPool.pairAll("unloader", …) (hasRole er lokal i adPool).
local function isUnloader(implement)
return implement.role == UNLOADER or (implement.roles ~= nil and implement.roles[UNLOADER] == true)
end
-- Traktor i et flow (også et STOPPET): ADPool.collect regner den ledig til korte lån, men ADRuns.assignVehicle
-- ville flytte den permanent ud af spillerens flow — kæden tager den aldrig.
local function belongsToFlow(tractor)
return tractor.vehicle ~= nil and ADRunsController.getRunForVehicle(tractor.vehicle) ~= nil
end
-- Første puljetømmevogn der kan bære varen + den bedst egnede ledige traktor (maskinpuljens regler: hk og
-- kategori via ADPool.bestFit). used = {[id] = true}: brugt i dette tick; blocked = ADPool.blocked(nu).
function ADChainRun.choosePair(implements, tractors, fillTypeIndex, used, blocked)
if fillTypeIndex == nil then return nil end
used, blocked = used or {}, blocked or {}
local free = {}
for _, tractor in ipairs(tractors or {}) do
if not used[tractor.id] and not blocked[tractor.id] and not tractor.offNetwork and not belongsToFlow(tractor) then
table.insert(free, tractor)
end
end
for _, implement in ipairs(implements or {}) do
if not used[implement.id] and not blocked[implement.id] and isUnloader(implement) and ADPool.canCarry(implement, fillTypeIndex) then
local tractor = ADPool.bestFit(free, implement, "unloader")
if tractor ~= nil then return {implement = implement, tractor = tractor} end
end
end
return nil
end
-- ---- marker ----
local function fieldById(fields, id)
for _, field in ipairs(fields or {}) do
if field.id == id then return field end
end
return nil
end
function ADChainRun.switchFields(data, actions)
local switched = 0
for _, action in ipairs(actions or {}) do
if switched >= ADChain.MAX_FIELD_SWITCHES then break end
local field = fieldById(data.fields, action.fieldId)
-- brugerens egen målafgrøde røres aldrig (dobbelttjek oven i ADCropPlan)
if action.mode == "auto" and field ~= nil and field.targetSource ~= "user" then
local ok, err = pcall(ADFieldJobs.setTarget, action.fieldId, action.to, "chain")
if ok then
switched = switched + 1
ADChain.switchCount = ADChain.switchCount + 1
local text = string.format("F%s: %s -> %s (%.1f ha) til %s", tostring(action.fieldId), tostring(action.from),
tostring(action.to), action.hectares or 0, tostring(action.reason))
log("%s", text)
pcall(ADFieldJobs.notify, text, false)
else
warn("F%s kunne ikke skiftes: %s", tostring(action.fieldId), tostring(err))
end
end
end
end
-- ---- mellemled ----
local function setProduction(data, key, isOn)
local entry = data.points[key]
if entry == nil or entry.point == nil or entry.point.setProductionState == nil then
warn("produktion %s findes ikke (længere)", tostring(key))
return false
end
local ok, err = pcall(entry.point.setProductionState, entry.point, entry.productionId, isOn)
if not ok then warn("produktion %s kunne ikke %s: %s", tostring(key), isOn and "tændes" or "slukkes", tostring(err)) end
return ok
end
-- Spillerens slukninger er allerede lagt i state.userOff (ADChain.reconcile før planen): de tændes ikke igen.
-- Delvise data: intet slukkes (en rod der ikke kunne læses, ligner en rod der er slået fra).
function ADChainRun.toggleProductions(data, prod)
local state = ADChain.state
local byChain = state.byChain
for _, key in ipairs(prod.enable or {}) do
if not state.userOff[key] and setProduction(data, key, true) then
byChain[key] = true
log("tænder %s (mellemled i kæden)", key)
end
end
if data.partial then return end
for _, key in ipairs(prod.disable or {}) do
if setProduction(data, key, false) then
byChain[key] = nil
log("slukker %s (kæden bruger den ikke længere)", key)
end
end
end
-- ---- flows ----
local function collectPool(adEnv)
local ok, implements, tractors = pcall(ADPool.collect, adEnv, function(v)
return ADFieldWork.isBusyVehicle(v, ADRunsController.getVehicleId(v))
end)
if not ok then
warn("maskinpuljen kunne ikke læses: %s", tostring(implements))
return {}, {}
end
return implements or {}, tractors or {}
end
-- kind = "noVehicle"|"pallet": vises som status på kanten i fanen Kæder
local function createStopped(run, kind, reason, ...)
run.stopped = true
ADChain.noVehicle[run.id] = kind
log("flow '%s' oprettet stoppet: " .. reason, tostring(run.name), ...)
end
local function createFlow(adEnv, data, edge, pool, used)
local title = data.fillTitles[edge.fillType] or edge.fillType
local created = ADRunsController.createRun(ADChain.flowName(title, edge.consumerName or edge.consumer), "supply")
local runId = created.id
ADRunsController.updateRun(runId, {targetBuildingIds = {edge.consumer}, ingredientMode = "manual", ingredients = {edge.fillType},
waitPoolGroup = ADChain.waitPoolFor(ADRunsController.store.runs, edge.consumer), chain = edge.rootKey})
-- updateRun erstatter store'en: slå altid run'et op igen
local run = ADRuns.getRun(ADRunsController.store, runId)
local fillIndex = data.fillIndex[edge.fillType]
if fillIndex ~= nil and ADSources.isPalletFillType(fillIndex) then
createStopped(run, "pallet", "palle-vare: vælg vogn med Universal Autoload (%s)", tostring(title))
return
end
if pool.implements == nil then pool.implements, pool.tractors = collectPool(adEnv) end
if pool.blocked == nil then pool.blocked = ADPool.blocked(ADFieldJobs.now or 0) end
local pair = ADChainRun.choosePair(pool.implements, pool.tractors, fillIndex, used, pool.blocked)
if pair == nil then
createStopped(run, "noVehicle", "ingen ledig traktor/vogn til %s", tostring(title))
return
end
used[pair.tractor.id], used[pair.implement.id] = true, true
ADRunsController.assignVehicle(runId, pair.tractor.vehicle)
run = ADRuns.getRun(ADRunsController.store, runId)
local wagons = ADRuns.copyWagons(run.wagons)
wagons[pair.tractor.id] = pair.implement.id
run.wagons = wagons
ADRunsController.resolveMarkers(adEnv, run)
local results = ADRunsController.startRun(adEnv, runId)
local started = 0
for _, result in ipairs(results or {}) do if result.ok then started = started + 1 end end
log("flow '%s' oprettet med '%s' + vogn '%s' (%d startet)", tostring(run.name), tostring(pair.tractor.name),
tostring(pair.implement.name), started)
end
function ADChainRun.createFlows(adEnv, data, edges)
local missing = ADChainActions.missingFlows(edges, ADChain.runsView(ADRunsController.store.runs))
local pool, used, count = {}, {}, 0
for _, edge in ipairs(missing) do
if count >= ADChain.MAX_NEW_FLOWS then break end
count = count + 1
local ok, err = pcall(createFlow, adEnv, data, edge, pool, used)
if not ok then warn("flow til %s (%s) kunne ikke oprettes: %s", tostring(edge.consumerName), tostring(edge.fillType), tostring(err)) end
end
return count
end
-- Flows for rødder der er slået fra: flyt til en anden rod der stadig bruger kanten, ellers stop og slip (chain
-- ryddes, så spilleren kan starte flowet igen uden at kæden stopper det næste minut; det slettes ikke).
function ADChainRun.stopFlows(adEnv, chain, edges)
local changed = 0
local active = ADChain.activeRoots(chain, ADChain.state.paused)
for _, run in ipairs(ADChainActions.flowsToStop(ADChain.runsView(ADRunsController.store.runs), active)) do
local rootKey = ADChain.stillNeeded(run, edges)
if rootKey ~= nil then
ADRunsController.updateRun(run.id, {chain = rootKey})
log("flow '%s' hører nu til kæden %s", tostring(run.name), tostring(rootKey))
changed = changed + 1
else
local ok, err = pcall(ADRunsController.stopRun, adEnv, run.id)
if ok then
ADRunsController.updateRun(run.id, {chain = ""})
log("flow '%s' stoppet og sluppet af kæden: kæden %s er slået fra", tostring(run.name), tostring(run.chain))
changed = changed + 1
else
warn("flow '%s' kunne ikke stoppes: %s", tostring(run.name), tostring(err))
end
end
end
return changed
end
-- Hele planen; hver del for sig i pcall.
function ADChainRun.apply(adEnv, data, result)
local okFields, errFields = pcall(ADChainRun.switchFields, data, result.actions)
if not okFields then warn("markskift fejlede: %s", tostring(errFields)) end
local okProd, errProd = pcall(ADChainRun.toggleProductions, data, result.prod)
if not okProd then warn("mellemled fejlede: %s", tostring(errProd)) end
local okNew, created = pcall(ADChainRun.createFlows, adEnv, data, result.edges)
if not okNew then warn("nye flows fejlede: %s", tostring(created)) end
-- delvise data: en rod der ikke kunne læses, må ikke få sine flows stoppet
local okStop, stopped = true, 0
if not data.partial then
okStop, stopped = pcall(ADChainRun.stopFlows, adEnv, result.chain, result.edges)
if not okStop then warn("stop af flows fejlede: %s", tostring(stopped)) end
end
if (okNew and created > 0) or (okStop and stopped > 0) then
local okSave, errSave = pcall(ADRunsController.save)
if not okSave then warn("flows kunne ikke gemmes: %s", tostring(errSave)) end
end
end
+392
View File
@@ -0,0 +1,392 @@
-- AD Chain View (ren logik)
-- Rækkerne til fanen Kæder ud fra ADChain.last. Hver rod er foldet sammen som standard (én linje med samlet
-- status + antal forslag); foldet ud vises varerne rekursivt (kilde, behov pr. måned, status, lager). Forslag
-- vises altid (under roden eller under afgrøden). Pausede rødder (som modellen ikke længere ser som rødder)
-- får en grå linje med [Genoptag]. Nederst frie marker + antal automatiske skift (+ "marker scannes endnu"). Ingen spiladgang:
-- titleOf(navn) -> titel, fmt(liter) -> kompakt tal, textOf(l10n-nøgle) -> tekst gives udefra.
-- Manglende led (Task 10): foldet ud står problemteksten (problemText) som en rød tekstrække ("problem", ingen celler)
-- lige under varen — teksten er for lang til en celle og ombrydes af fanen.
-- Række: {kind="header"|"root"|"node"|"suggest"|"auto"|"info"|"empty"|"problem", indent, level, cells={c1..c5}, text,
-- actions={[celle]={kind="approve"|"dismiss"|"pause"|"resume", fieldId|rootKey}}, toggle=rootKey, fillType}
ADChainView = {}
ADChainView.INDENT = " "
-- Tegnbudget pr. celle i flowListPrefab (gui/SmartPickupFrame.xml: textMaxWidth 192/162/62/244/92 px).
-- Antaget ~11,5 px pr. tegn (spuInfoText: 800 px ~ 70 tegn), fed ~12,5 px; c3 har kun tal (~10 px pr. tegn).
ADChainView.CELL_CHARS = {15, 14, 6, 21, 8}
ADChainView.MAX_SOURCE_NAMES = 2
-- Tegnbudget for en tekstlinje (addTextRow/spuInfoText: 800 px ~ 70 tegn), fx linjen med frie marker.
ADChainView.TEXT_ROW_CHARS = 70
-- "stramt": lager for en mark-afgrøde under så mange måneders behov -> gul
ADChainView.TIGHT_MONTHS = 3
-- nøgler rækkerne bruger (til l10n-testen)
ADChainView.TEXT_KEYS = {"spu_chain_empty", "spu_chain_approve", "spu_chain_dismiss", "spu_chain_auto",
"spu_chain_flowOk", "spu_chain_flowMissing", "spu_chain_flowPallet", "spu_chain_flowNone", "spu_chain_flowStopped", "spu_chain_bottleneck",
"spu_chain_buy", "spu_chain_cycle", "spu_chain_fieldShort", "spu_chain_tight", "spu_chain_noField", "spu_chain_buySource", "spu_chain_fromFields",
"spu_chain_pause", "spu_chain_resume", "spu_chain_scanning", "spu_chain_rootOk", "spu_chain_rootError", "spu_chain_rootWaiting",
"spu_chain_rootPaused", "spu_chain_rootSummary", "spu_chain_free", "spu_chain_switches", "spu_chain_gone", "spu_chain_col_item",
"spu_chain_col_source", "spu_chain_col_perMonth", "spu_chain_col_status", "spu_chain_col_stock",
"spu_chain_problem_buy", "spu_chain_problem_bottleneck", "spu_chain_problem_off", "spu_chain_problem_cycle", "spu_chain_problem_barn"}
local FLOW_KEYS = {ok = "spu_chain_flowOk", missing = "spu_chain_flowNone", stopped = "spu_chain_flowStopped", noVehicle = "spu_chain_flowMissing",
pallet = "spu_chain_flowPallet"}
local ROOT_STATUS = {running = "spu_chain_rootOk", error = "spu_chain_rootError", waiting = "spu_chain_rootWaiting", stopped = "spu_chain_rootPaused"}
local function identity(value) return tostring(value) end
-- samme som SmartPickupFrame.formatCompact (standard-fmt når ingen gives, fx i beskeden fra kædens tick)
function ADChainView.compact(liters)
liters = math.floor((liters or 0) + 0.5)
if liters >= 1000000 then return string.format("%.1fM", liters / 1000000)
elseif liters >= 1000 then return string.format("%.1fk", liters / 1000) end
return string.format("%d", liters)
end
-- ---- UTF-8-sikker længde/afkortning (æøå og → er flere bytes) ----
function ADChainView.textLength(text)
local count = 0
for _ in tostring(text):gmatch("[^\128-\191]") do count = count + 1 end
return count
end
local function utf8Prefix(text, chars)
local result, count = {}, 0
for char in text:gmatch("[^\128-\191][\128-\191]*") do
if count >= chars then break end
table.insert(result, char)
count = count + 1
end
return table.concat(result)
end
-- Afkort til celle-budgettet med ".." til sidst.
function ADChainView.fit(text, chars)
text = tostring(text or "")
if ADChainView.textLength(text) <= chars then return text end
return utf8Prefix(text, math.max(0, chars - 2)) .. ".."
end
local function fitCells(cells)
local result = {}
for index = 1, 5 do result[index] = ADChainView.fit(cells[index] or "", ADChainView.CELL_CHARS[index]) end
return result
end
local function haText(ha)
return string.format("%.0f", ha or 0)
end
local function plannedFruit(field)
if field.targetFruit ~= nil and field.targetFruit ~= "" then return field.targetFruit end
return field.fruit
end
local function edgeKey(node)
return tostring(node.consumer) .. "|" .. tostring(node.fillType)
end
local function shortageByFruit(last)
local result = {}
for _, shortage in ipairs(last.shortages or {}) do result[shortage.fruit] = shortage end
return result
end
local function stockOf(last, fruit)
local stock = last.data ~= nil and last.data.stock or nil
return stock ~= nil and fruit ~= nil and stock[fruit] or nil
end
-- Lager for en mark-node: afgrødens lager, eller varens eget lager for en markvare uden afgrøde (hø, hakkelse …).
local function fieldStockOf(last, node)
if node.fruit ~= nil then return stockOf(last, node.fruit) end
local byFill = last.data ~= nil and last.data.stockByFill or nil
return byFill ~= nil and byFill[node.fillType] or nil
end
-- Mark-afgrøde uden mangel men med lager under TIGHT_MONTHS måneders behov.
local function isTight(node, last, short)
if node.source ~= "field" or node.fruit == nil or short[node.fruit] ~= nil then return false end
return (stockOf(last, node.fruit) or 0) < ADChainView.TIGHT_MONTHS * (node.perMonth or 0)
end
local function eachNode(nodes, fn, depth)
for _, node in ipairs(nodes or {}) do
fn(node, depth or 1)
eachNode(node.children, fn, (depth or 1) + 1)
end
end
function ADChainView.rootLevel(root, last)
last = last or {}
if (last.paused or {})[root.key] then return "stopped" end
for _, problem in ipairs((last.chain or {}).problems or {}) do
if problem.rootKey == root.key then return "error" end
end
local short, flowStatus = shortageByFruit(last), last.flowStatus or {}
local hasShortage, hasWaiting = false, false
eachNode(root.children, function(node)
if node.fruit ~= nil and short[node.fruit] ~= nil then hasShortage = true end
if (node.shortPerMonth or 0) > 0 then hasShortage = true end
local status = node.consumer ~= nil and flowStatus[edgeKey(node)] or nil
if (status ~= nil and status ~= "ok") or isTight(node, last, short) then hasWaiting = true end
end)
if hasShortage then return "error" end
if hasWaiting then return "waiting" end
return "running"
end
-- Højst MAX_SOURCE_NAMES navne + " +k".
local function joinCapped(names)
local shown = {}
for index = 1, math.min(#names, ADChainView.MAX_SOURCE_NAMES) do shown[index] = names[index] end
local text = table.concat(shown, ", ")
if #names > #shown then text = text .. " +" .. tostring(#names - #shown) end
return text
end
-- Opskrifter (alle, også slukkede) der laver varen, i datarækkefølge.
local function producersOf(last, fillType)
local list = {}
for _, recipe in ipairs(last.data ~= nil and last.data.recipes or {}) do
if ((recipe.outputs or {})[fillType] or 0) > 0 then table.insert(list, recipe) end
end
return list
end
-- Årsag i klart sprog med råd for et problem fra kædemodellen ({kind="buy"|"bottleneck"|"cycle", fillType, rootKey,
-- detail = manglende l/md, need = behov l/md}). Køb af en rest efter gårdens stalde -> stald-tekst. En producent spilleren selv har slukket (last.userOff, stadig
-- slukket) slår igennem som "slukket af dig" — det er den reelle årsag, uanset om modellen så køb eller flaskehals.
function ADChainView.problemText(problem, last, textOf, titleOf, fmt)
last, textOf, fmt = last or {}, textOf or identity, fmt or ADChainView.compact
titleOf = titleOf or function(name) return (last.titles or {})[name] or tostring(name) end
local item = titleOf(problem.fillType)
if problem.kind == "cycle" then return string.format(textOf("spu_chain_problem_cycle"), item) end
local userOff, names, seen = last.userOff or {}, {}, {}
for _, recipe in ipairs(producersOf(last, problem.fillType)) do
if userOff[recipe.key] and not recipe.enabled then
return string.format(textOf("spu_chain_problem_off"), tostring(recipe.buildingName), titleOf(recipe.name))
end
local name = tostring(recipe.buildingName)
if (recipe.cyclesPerMonth or 0) > 0 and not seen[name] then
seen[name] = true
table.insert(names, name)
end
end
if problem.kind == "bottleneck" and #names > 0 then
local need = problem.need or problem.detail or 0
local made = math.max(0, need - (problem.detail or 0))
return string.format(textOf("spu_chain_problem_bottleneck"), joinCapped(names), fmt(math.floor(made + 0.5)), fmt(math.floor(need + 0.5)))
end
-- modellen melder også "køb" for resten, når gårdens stalde leverer for lidt: så er rådet ikke "byg en fabrik"
for _, herd in ipairs(last.data ~= nil and last.data.husbandry or {}) do
if ((herd.outputs or {})[problem.fillType] or 0) > 0 then
return string.format(textOf("spu_chain_problem_barn"), item)
end
end
return string.format(textOf("spu_chain_problem_buy"), item)
end
local function sourceText(node, last, textOf)
local names = {}
if node.source == "production" then
for _, producer in ipairs(node.producers or {}) do table.insert(names, tostring(producer.buildingName)) end
elseif node.source == "husbandry" then
for _, herd in ipairs(node.herds or {}) do table.insert(names, tostring(herd.buildingName)) end
elseif node.source == "field" then
-- markvare uden egen afgrøde (hø, hakkelse …): ingen afgrødeplan, bare "fra marker"
if node.fruit == nil then return textOf("spu_chain_fromFields") end
for _, field in ipairs(last.fields or {}) do
if plannedFruit(field) == node.fruit then table.insert(names, "F" .. tostring(field.id)) end
end
if #names == 0 then return textOf("spu_chain_noField") end
elseif node.source == "buy" then
return textOf("spu_chain_buySource")
else
return "-"
end
return joinCapped(names)
end
-- Nodens status (tekst, niveau): kredsløb/flaskehals/køb/mangel (rød) > flow ikke ok (gul) > stramt lager (gul) > flow ok.
local function nodeStatus(node, last, fmt, textOf, short)
if node.source == "cycle" then return textOf("spu_chain_cycle"), "error" end
if (node.shortPerMonth or 0) > 0 then
local key = node.source == "production" and "spu_chain_bottleneck" or "spu_chain_buy"
return string.format(textOf(key), fmt(node.shortPerMonth)), "error"
end
local shortage = node.fruit ~= nil and short[node.fruit] or nil
if shortage ~= nil then return string.format(textOf("spu_chain_fieldShort"), haText(shortage.missingHa)), "error" end
local status = node.consumer ~= nil and (last.flowStatus or {})[edgeKey(node)] or nil
if status ~= nil and status ~= "ok" then return textOf(FLOW_KEYS[status] or "spu_chain_flowNone"), "waiting" end
if isTight(node, last, short) then return textOf("spu_chain_tight"), "waiting" end
if status == "ok" then return textOf("spu_chain_flowOk"), "running" end
return "", "running"
end
local function joinText(cells)
local parts = {}
for _, cell in ipairs(cells) do
if cell ~= nil and cell ~= "" then table.insert(parts, cell) end
end
return table.concat(parts, " · ")
end
local function makeRow(kind, indent, level, cells, actions, toggle)
local fitted = fitCells(cells)
return {kind = kind, indent = indent, level = level, cells = fitted, actions = actions or {}, toggle = toggle, text = joinText(fitted)}
end
local function suggestRow(action, indent, titleOf, textOf)
local pad = string.rep(ADChainView.INDENT, indent)
local from = (action.from ~= nil and action.from ~= "") and titleOf(action.from) or "-"
local cells = {pad .. "F" .. tostring(action.fieldId) .. " " .. from, "→ " .. titleOf(action.to), haText(action.hectares) .. " ha"}
if action.mode == "suggest" then
cells[4], cells[5] = textOf("spu_chain_approve"), textOf("spu_chain_dismiss")
return makeRow("suggest", indent, "waiting", cells,
{[4] = {kind = "approve", fieldId = action.fieldId}, [5] = {kind = "dismiss", fieldId = action.fieldId}})
end
cells[4] = textOf("spu_chain_auto")
return makeRow("auto", indent, "running", cells)
end
-- Handlinger for de afgrøder (i rækkefølge) der hentes fra mark under roden.
local function rootFruits(root)
local fruits, seen = {}, {}
eachNode(root.children, function(node)
if node.source == "field" and node.fruit ~= nil and not seen[node.fruit] then
seen[node.fruit] = true
table.insert(fruits, node.fruit)
end
end)
return fruits
end
local function actionsFor(last, fruit, modes)
local list = {}
for _, action in ipairs(last.actions or {}) do
if action.to == fruit and modes[action.mode] then table.insert(list, action) end
end
return list
end
local function rootRows(rows, root, last, ctx, isExpanded)
local level = ADChainView.rootLevel(root, last)
local paused = (last.paused or {})[root.key] == true
local suggestions = {}
for _, fruit in ipairs(rootFruits(root)) do
for _, action in ipairs(actionsFor(last, fruit, {suggest = true})) do table.insert(suggestions, action) end
end
local status = ctx.textOf(ROOT_STATUS[level])
if #suggestions > 0 then status = string.format(ctx.textOf("spu_chain_rootSummary"), status, #suggestions) end
local marker = (#(root.children or {}) > 0) and (isExpanded and "– " or "+ ") or ""
table.insert(rows, makeRow("root", 0, level, {marker .. ctx.titleOf(root.name), tostring(root.buildingName or ""), "", status,
ctx.textOf(paused and "spu_chain_resume" or "spu_chain_pause")},
{[5] = {kind = paused and "resume" or "pause", rootKey = root.key}}, root.key))
if not isExpanded then
for _, action in ipairs(suggestions) do table.insert(rows, suggestRow(action, 1, ctx.titleOf, ctx.textOf)) end
return
end
local shown, problems, told = {}, {}, {}
for _, problem in ipairs((last.chain or {}).problems or {}) do
if problem.rootKey == root.key then table.insert(problems, problem) end
end
local function problemRows(fillType, indent)
for _, problem in ipairs(problems) do
local key = tostring(problem.fillType) .. "|" .. tostring(problem.kind)
if (fillType == nil or problem.fillType == fillType) and not told[key] then
told[key] = true
table.insert(rows, {kind = "problem", indent = indent, level = "error",
text = string.rep(ADChainView.INDENT, indent) .. ADChainView.problemText(problem, last, ctx.textOf, ctx.titleOf, ctx.fmt)})
end
end
end
eachNode(root.children, function(node, depth)
local text, nodeLevel = nodeStatus(node, last, ctx.fmt, ctx.textOf, ctx.short)
local stock = node.source == "field" and fieldStockOf(last, node) or nil
local row = makeRow("node", depth, nodeLevel, {string.rep(ADChainView.INDENT, depth) .. ctx.titleOf(node.fillType),
sourceText(node, last, ctx.textOf), ctx.fmt(node.perMonth or 0), text, stock ~= nil and ctx.fmt(stock) or ""})
row.fillType = node.fillType
table.insert(rows, row)
problemRows(node.fillType, depth + 1)
if node.source == "field" and node.fruit ~= nil and not shown[node.fruit] then
shown[node.fruit] = true
for _, action in ipairs(actionsFor(last, node.fruit, {suggest = true, auto = true})) do
table.insert(rows, suggestRow(action, depth + 1, ctx.titleOf, ctx.textOf))
end
end
end)
-- problemer uden en vist vare (bør ikke ske) står sidst under roden
problemRows(nil, 1)
end
-- Pausede rødder: modelRecipes markerer dem paused, så modellen ser dem ikke som rødder -> grå linje med [Genoptag].
local function pausedRows(rows, last, ctx)
local present, keys = {}, {}
for _, root in ipairs(last.chain ~= nil and last.chain.roots or {}) do present[root.key] = true end
for key in pairs(last.paused or {}) do
if not present[key] then table.insert(keys, key) end
end
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
for _, key in ipairs(keys) do
local recipe
for _, candidate in ipairs(last.data ~= nil and last.data.recipes or {}) do
if candidate.key == key then recipe = candidate end
end
local name = recipe ~= nil and recipe.name ~= nil and ctx.titleOf(recipe.name) or tostring(key)
table.insert(rows, makeRow("root", 0, "stopped", {name, recipe ~= nil and tostring(recipe.buildingName or "") or "", "",
ctx.textOf("spu_chain_rootPaused"), ctx.textOf("spu_chain_resume")}, {[5] = {kind = "resume", rootKey = key}}))
end
return #keys
end
-- "Marker fri til kæden: F1, F2 +k" — så mange navne som tekstlinjen kan rumme, resten talt.
function ADChainView.freeText(names, textOf)
local format = textOf("spu_chain_free")
if #names == 0 then return string.format(format, "-") end
for count = #names, 1, -1 do
local shown = {}
for index = 1, count do shown[index] = names[index] end
local list = table.concat(shown, ", ")
if count < #names then list = list .. " +" .. tostring(#names - count) end
local text = string.format(format, list)
if ADChainView.textLength(text) <= ADChainView.TEXT_ROW_CHARS then return text end
end
return ADChainView.fit(string.format(format, "+" .. tostring(#names)), ADChainView.TEXT_ROW_CHARS)
end
local function footerRows(rows, last, textOf)
local free = {}
for _, field in ipairs(last.fields or {}) do
if field.chainFree then table.insert(free, field.id) end
end
table.sort(free)
local names = {}
for _, id in ipairs(free) do table.insert(names, "F" .. tostring(id)) end
table.insert(rows, {kind = "info", indent = 0, text = ADChainView.freeText(names, textOf)})
table.insert(rows, {kind = "info", indent = 0, text = string.format(textOf("spu_chain_switches"), last.switchCount or 0)})
if last.fieldsIncomplete then table.insert(rows, {kind = "info", indent = 0, text = textOf("spu_chain_scanning")}) end
end
-- expanded = {[rodnøgle] = true} for udfoldede rødder (alle andre er foldet sammen).
function ADChainView.rows(last, titleOf, fmt, textOf, expanded)
textOf, fmt, expanded = textOf or identity, fmt or identity, expanded or {}
local titles = last ~= nil and last.titles or {}
titleOf = titleOf or function(name) return (titles or {})[name] or tostring(name) end
local rows = {}
local roots = last ~= nil and last.chain ~= nil and last.chain.roots or {}
local hasPaused = last ~= nil and next(last.paused or {}) ~= nil
if #roots == 0 and not hasPaused then
table.insert(rows, {kind = "empty", indent = 0, text = textOf("spu_chain_empty")})
end
if last == nil then return rows end
if #roots > 0 or hasPaused then
table.insert(rows, makeRow("header", 0, nil, {textOf("spu_chain_col_item"), textOf("spu_chain_col_source"),
textOf("spu_chain_col_perMonth"), textOf("spu_chain_col_status"), textOf("spu_chain_col_stock")}))
end
local ctx = {titleOf = titleOf, fmt = fmt, textOf = textOf, short = shortageByFruit(last)}
for _, root in ipairs(roots) do rootRows(rows, root, last, ctx, expanded[root.key] == true) end
pausedRows(rows, last, ctx)
footerRows(rows, last, textOf)
return rows
end
+238
View File
@@ -0,0 +1,238 @@
-- AD Course Cache
-- Genbrug af Courseplay-kurser. At lade CP beregne et kursus tager sekunder på store marker (Dakota 16x, 70+ ha)
-- og giver hak — men CP gemmer selv hver maskines seneste kursus i savegamet. Registret husker hvad kurset blev
-- lavet TIL: mark, bredde, markens form (ADFields.shapeSignature) og indstillingerne der former kurset
-- (værktøjsforskydning, multi-tool-sæt/bane, vin-offset/-skip). Passer alt, startes CP på maskinens eget kursus;
-- ellers kopieres en anden maskines matchende kursus; ellers beregnes et nyt, som så registreres.
-- Rene regler øverst (testet i tests/test_adCourseCache.lua); spil-nær lim nederst (alt pcall'et).
ADCourseCache = {}
-- ---- rene regler ----
local function round1(value)
local rounded = math.floor((tonumber(value) or 0) * 10 + 0.5) / 10
if rounded == 0 then rounded = 0 end -- ingen "-0.0"
return string.format("%.1f", rounded)
end
local function optional(value, format)
if value == nil then return "-" end
return format(value)
end
-- Indstillingerne der former kurset, som én streng (afrundet, så float-støj ikke tæller).
function ADCourseCache.settingsKey(s)
s = s or {}
return string.format("off=%s;lane=%s;multi=%d;vine=%s;skip=%s",
round1(s.toolOffset), optional(s.lane, tostring), tonumber(s.multiTools) or 1,
optional(s.vineOffset, round1), optional(s.vineSkip, tostring))
end
-- Ny post (kopi af felterne; nil uden køretøj).
function ADCourseCache.newRecord(values)
if values == nil or values.vehicle == nil then return nil end
return {vehicle = tostring(values.vehicle), field = tonumber(values.field), width = tonumber(values.width),
sig = values.sig, settings = values.settings, waypoints = tonumber(values.waypoints)}
end
-- Hvad kurset skal passe til: mark|bredde|form|indstillinger. nil hvis noget er ukendt (så genbruges intet).
function ADCourseCache.key(entry)
if entry == nil or entry.field == nil or entry.width == nil or entry.sig == nil or entry.settings == nil then return nil end
return string.format("%s|%s|%s|%s", tostring(entry.field), round1(entry.width), entry.sig, entry.settings)
end
function ADCourseCache.matches(record, request)
local key = ADCourseCache.key(record)
return key ~= nil and key == ADCourseCache.key(request)
end
-- Hvorfor maskinens gemte kursus ikke kan bruges (til loggen).
function ADCourseCache.mismatchReason(record, request)
if request == nil or request.sig == nil then return "markens form ukendt" end
if record == nil then return "intet kursus gemt" end
if record.field ~= request.field then return "kursus til en anden mark" end
if record.sig ~= request.sig then return "markens form ændret" end
if record.width == nil or round1(record.width) ~= round1(request.width) then return "ny bredde" end
if record.settings ~= request.settings then return "andre indstillinger" end
return nil
end
local function copyMap(records)
local result = {}
for id, record in pairs(records or {}) do result[id] = record end
return result
end
function ADCourseCache.withRecord(records, record)
local result = copyMap(records)
if record ~= nil then result[record.vehicle] = record end
return result
end
function ADCourseCache.without(records, vehicleId)
local result = copyMap(records)
if vehicleId ~= nil then result[tostring(vehicleId)] = nil end
return result
end
-- Kursus der kan genbruges: egen maskines først ("own"), ellers en anden maskines ("copy", laveste id først).
-- isValid(record) (valgfri) tjekker at kurset stadig sidder på maskinen. Intet: nil + grunden.
function ADCourseCache.findReusable(records, request, vehicleId, isValid)
records = records or {}
local ownId = vehicleId ~= nil and tostring(vehicleId) or nil
local own = ownId ~= nil and records[ownId] or nil
local anyStale = false
local function usable(record)
if not ADCourseCache.matches(record, request) then return false end
if isValid ~= nil and not isValid(record) then
anyStale = true
return false
end
return true
end
if own ~= nil and usable(own) then return own, "own" end
local ids = {}
for id in pairs(records) do
if id ~= ownId then table.insert(ids, id) end
end
table.sort(ids)
for _, id in ipairs(ids) do
if usable(records[id]) then return records[id], "copy" end
end
if anyStale then return nil, "kurset på maskinen er ændret" end
return nil, ADCourseCache.mismatchReason(own, request)
end
-- Marken har fået en anden form: dens poster (med anden signatur) slettes. Returnerer nyt register + antal.
-- Ukendt ny form (nil) sletter intet; intet slettet = samme tabel tilbage.
function ADCourseCache.invalidateField(records, fieldId, newSig)
if records == nil or newSig == nil then return records or {}, 0 end
local result, removed = {}, 0
for id, record in pairs(records) do
if record.field == fieldId and record.sig ~= newSig then
removed = removed + 1
else
result[id] = record
end
end
if removed == 0 then return records, 0 end
return result, removed
end
-- ---- spil-nær lim ----
local function log(...) ADFieldJobs.log(...) end
local function getRecords() return ADFieldJobs.state.courseRecords or {} end
local function setRecords(records)
ADFieldJobs.state.courseRecords = records
ADFieldJobs.save()
end
local function waypointCount(course)
if course == nil or course.getNumberOfWaypoints == nil then return nil end
local ok, n = pcall(course.getNumberOfWaypoints, course)
return ok and tonumber(n) or nil
end
-- Kurset sidder stadig på maskinen og har samme antal punkter som da det blev registreret (spilleren kan
-- have lavet et nyt kursus i CP's HUD).
local function courseStillThere(vehicle, record)
local course = ADCpBridge.getCourse(vehicle)
if course == nil then return nil end
local n = waypointCount(course)
if record.waypoints ~= nil and n ~= record.waypoints then return nil end
return course
end
local function vehicleById(adEnv, id)
local ok, found = pcall(function()
for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do
if ADRunsController.getVehicleId(vehicle) == id then return vehicle end
end
return nil
end)
return ok and found or nil
end
-- Hvad kurset til denne start skal passe til.
function ADCourseCache.requestFor(vehicle, fieldId, info, role, opts)
local vineOffset = ADCpBridge.vineOffsetFor(info.fruitName, role)
return {
field = fieldId, width = ADCpBridge.getWorkWidth(vehicle), sig = info.shapeSig,
settings = ADCourseCache.settingsKey({
toolOffset = ADCpBridge.getToolOffset(vehicle), lane = opts.lane, multiTools = opts.multiTools or 1,
vineOffset = vineOffset, vineSkip = vineOffset ~= nil and ADCpBridge.vineSkipFor(info.fruitName, role) or nil,
}),
}
end
-- Før ADCpBridge.start: genbrug/kopi/ny beregning. Returnerer (nye) opts + kontekst til afterStart.
-- Har opts allerede et kursus (slåmaskinens/rivens, lederens multi-tool-kursus), bruges registret ikke.
local function prepareUnsafe(adEnv, field, info, assignment, opts)
if opts.course ~= nil then return opts, nil end
local vehicle = assignment.vehicle
local ok, request = pcall(ADCourseCache.requestFor, vehicle, field.id, info, assignment.role, opts)
if not ok then return opts, nil end
local vehicles = {}
local function isValid(record)
local owner = record.vehicle == assignment.vehicleId and vehicle or vehicleById(adEnv, record.vehicle)
local course = owner ~= nil and courseStillThere(owner, record) or nil
vehicles[record.vehicle] = course ~= nil and {vehicle = owner, course = course} or nil
return course ~= nil
end
local record, how = ADCourseCache.findReusable(getRecords(), request, assignment.vehicleId, isValid)
local result = {}
for k, v in pairs(opts) do result[k] = v end
if how == "own" then
result.reuse = true
log(field.id, "kursus genbrugt ('%s', %.1f m) — ingen ny beregning", ADRuns.vehicleLabel(vehicle), request.width or 0)
elseif how == "copy" then
local source = vehicles[record.vehicle]
result.course = source.course
log(field.id, "kursus kopieret fra '%s' til '%s' — ingen ny beregning", ADRuns.vehicleLabel(source.vehicle), ADRuns.vehicleLabel(vehicle))
else
log(field.id, "nyt kursus beregnes til '%s' (%s)", ADRuns.vehicleLabel(vehicle), tostring(how))
end
return result, {request = request, how = how}
end
function ADCourseCache.prepare(adEnv, field, info, assignment, opts)
local ok, result, context = pcall(prepareUnsafe, adEnv, field, info, assignment, opts)
if ok then return result, context end
Logging.warning("ADSmartPickup: kursus-register (Courseplay laver nyt kursus): %s", tostring(result))
return opts, nil
end
-- Efter CP er startet: maskinens nuværende kursus registreres (nyt, genbrugt eller kopieret fra registret).
-- Uden kontekst (kursus kopieret fra slåmaskinen/lederen) passer det ikke til maskinens egen anmodning: glem.
function ADCourseCache.afterStart(vehicle, vehicleId, context)
if vehicleId == nil then return end
local ok, err = pcall(function()
local records = getRecords()
local course = context ~= nil and ADCpBridge.getCourse(vehicle) or nil
if course == nil or ADCourseCache.key(context.request) == nil then
if records[tostring(vehicleId)] ~= nil then setRecords(ADCourseCache.without(records, vehicleId)) end
return
end
local r = context.request
local record = ADCourseCache.newRecord({vehicle = vehicleId, field = r.field, width = r.width, sig = r.sig,
settings = r.settings, waypoints = waypointCount(course)})
local existing = records[record.vehicle]
if existing ~= nil and ADCourseCache.matches(existing, r) and existing.waypoints == record.waypoints then return end
setRecords(ADCourseCache.withRecord(records, record))
end)
if not ok then Logging.warning("ADSmartPickup: kursus-register: %s", tostring(err)) end
end
-- Markens form er (måske) ændret: poster med anden signatur glemmes (scanning og markguidens Gem).
function ADCourseCache.checkField(fieldId, sig)
local records = getRecords()
local kept, removed = ADCourseCache.invalidateField(records, fieldId, sig)
if removed > 0 then
setRecords(kept)
log(fieldId, "markens form er ændret — %d gemt(e) kursus(er) glemt, næste start beregner nyt", removed)
end
return removed
end
+237 -16
View File
@@ -62,16 +62,92 @@ local function collect(vehicle, list)
return list
end
-- Sprøjtens/sprederens rolle ud fra det fyld den har på (tom -> nil).
-- Hvilke varer der hører til en rolle (påfyldning af en tom spreder/sprøjte), i foretrukken rækkefølge.
ADCpBridge.ROLE_FILLTYPES = {
fertilizer = {"FERTILIZER", "LIQUIDFERTILIZER", "MANURE", "LIQUIDMANURE", "DIGESTATE"},
limer = {"LIME"},
weeder = {"HERBICIDE"},
}
ADCpBridge.ROLE_ORDER = {"fertilizer", "limer", "weeder"}
-- Sprøjtens/sprederens roller: alle roller dens fyldenhed kan tage (Bredal-spreder: gødning OG kalk).
-- Primær rolle = fyldets rolle (har den kalk på, er den kalkspreder), ellers den første den kan tage.
-- Returnerer primær, sæt {[rolle] = true}, fyldets rolle (nil = tom); nil hvis ingen roller.
function ADCpBridge.sprayRolesFor(filledName, supportedNames)
local roles, first = {}, nil
for _, role in ipairs(ADCpBridge.ROLE_ORDER) do
for _, name in ipairs(ADCpBridge.ROLE_FILLTYPES[role]) do
if supportedNames[name] then
roles[role] = true
first = first or role
break
end
end
end
local filledRole = filledName ~= nil and ADCpBridge.sprayRoleForFillType(filledName) or nil
if filledRole ~= nil then roles[filledRole] = true end
local primary = filledRole or first
return primary, primary ~= nil and roles or nil, filledRole
end
local function sprayerNames(object)
local unitIndex = object:getSprayerFillUnitIndex()
local filledName = nil
local level = object:getFillUnitFillLevel(unitIndex) or 0
local capacity = object.getFillUnitCapacity ~= nil and (object:getFillUnitCapacity(unitIndex) or 0) or 0
if level > 0 then
filledName = g_fillTypeManager:getFillTypeNameByIndex(object:getFillUnitFillType(unitIndex))
end
local supported = {}
local unit = object.spec_fillUnit ~= nil and object.spec_fillUnit.fillUnits ~= nil and object.spec_fillUnit.fillUnits[unitIndex] or nil
for fillType, isSupported in pairs(unit ~= nil and unit.supportedFillTypes or {}) do
if isSupported then
local name = g_fillTypeManager:getFillTypeNameByIndex(fillType)
if name ~= nil then supported[name] = true end
end
end
return filledName, supported, level, capacity
end
local function sprayRole(object)
if object.spec_sprayer == nil or object.getSprayerFillUnitIndex == nil then return nil end
local ok, role = pcall(function()
local unit = object:getSprayerFillUnitIndex()
if (object:getFillUnitFillLevel(unit) or 0) <= 0 then return nil end
local name = g_fillTypeManager:getFillTypeNameByIndex(object:getFillUnitFillType(unit))
return ADCpBridge.sprayRoleForFillType(name)
local ok, role, roles, filledRole, wrongFillDumpable = pcall(function()
local filledName, supported, level, capacity = sprayerNames(object)
local r, rs, fr = ADCpBridge.sprayRolesFor(filledName, supported)
return r, rs, fr, ADCpBridge.isDumpable(level, capacity)
end)
return ok and role or nil
if not ok then return nil end
return role, roles, filledRole, wrongFillDumpable
end
-- Må en forkert vare hældes ud? Kun en lille rest: højst 10 % af tanken og aldrig over 2.000 l
-- (in-game 26/9: 100.000 l flydende gødning og 4.000 l sprøjtemiddel blev hældt ud for at skifte opgave).
ADCpBridge.MAX_DUMP_LITERS = 2000
ADCpBridge.MAX_DUMP_SHARE = 0.10
function ADCpBridge.isDumpable(level, capacity)
level = level or 0
if level <= 0 then return true end
if level > ADCpBridge.MAX_DUMP_LITERS then return false end
return capacity == nil or capacity <= 0 or level <= capacity * ADCpBridge.MAX_DUMP_SHARE
end
-- Tøm sprøjten/sprederen for en vare der ikke hører til opgaven (kalk på, skal gøde). FS25 kan ikke
-- tippe gødning/kalk af, så resten kasseres. Returnerer liter og varenavn (0 hvis intet blev tømt).
function ADCpBridge.emptySprayerIfWrong(vehicle, role)
local liters, name = 0, nil
for _, object in ipairs(collect(vehicle)) do
if object.spec_sprayer ~= nil and object.getSprayerFillUnitIndex ~= nil then
pcall(function()
local filledName, _, level, capacity = sprayerNames(object)
if filledName == nil or ADCpBridge.sprayRoleForFillType(filledName) == role then return end
if not ADCpBridge.isDumpable(level, capacity) then return end
local unit = object:getSprayerFillUnitIndex()
object:addFillUnitFillLevel(object:getOwnerFarmId(), unit, -level, object:getFillUnitFillType(unit), ToolType.UNDEFINED, nil)
liters, name = liters + level, filledName
end)
end
end
return liters, name
end
function ADCpBridge.getSpecs(vehicle)
@@ -80,7 +156,7 @@ function ADCpBridge.getSpecs(vehicle)
for _, name in ipairs(ADCpBridge.SPECS) do
if object[name] ~= nil then specs[name] = true end
end
specs.sprayRole = specs.sprayRole or sprayRole(object)
if specs.sprayRole == nil then specs.sprayRole, specs.sprayRoles, specs.filledRole, specs.dumpable = sprayRole(object) end
end
if vehicle.getCanStartCpCombineUnloader ~= nil then
local ok, can = pcall(vehicle.getCanStartCpCombineUnloader, vehicle)
@@ -93,12 +169,43 @@ function ADCpBridge.getSpecs(vehicle)
return specs
end
-- Afgrøder høsteren kan tage (alle skærebord/optagere i kæden). nil = ikke en høster.
-- Vinhøster (fx Grégoire GL86): afgrøden vælges i konfigurationen og står i spec_vineCutter, ikke i spec_cutter
-- (in-game 29/9: F22 hvide druer "none can do it" — moddet så aldrig vinhøstere som høstere).
function ADCpBridge.vineFruitSet(spec)
local fruits = {}
if spec == nil then return fruits end
for _, key in ipairs({"inputFruitTypeIndex", "fruitTypeIndex", "fruitType"}) do
if type(spec[key]) == "number" then fruits[spec[key]] = true end
end
if type(spec.fruitTypes) == "table" then
for key, value in pairs(spec.fruitTypes) do
if type(value) == "number" then fruits[value] = true
elseif value == true and type(key) == "number" then fruits[key] = true end
end
end
return fruits
end
local vineLogged = setmetatable({}, {__mode = "k"})
-- Afgrøder høsteren kan tage (alle skærebord/optagere/vinhøstere i kæden). nil = ikke en høster.
function ADCpBridge.getHarvestFruits(vehicle)
if vehicle.spec_combine == nil then return nil end
local fruits = {}
for _, object in ipairs(collect(vehicle)) do
for fruit in pairs(ADCpBridge.harvestFruitSet(object.spec_cutter)) do fruits[fruit] = true end
if object.spec_vineCutter ~= nil then
local vine = ADCpBridge.vineFruitSet(object.spec_vineCutter)
for fruit in pairs(vine) do fruits[fruit] = true end
if next(vine) == nil and not vineLogged[object] then
-- ukendt feltnavn i denne spilversion: vis nøglerne, så reglen kan rettes
vineLogged[object] = true
local keys = {}
for key, value in pairs(object.spec_vineCutter) do table.insert(keys, tostring(key) .. "=" .. type(value)) end
table.sort(keys)
Logging.info("ADSmartPickup: vinhøster uden afgrøde fundet — spec_vineCutter: %s", table.concat(keys, ", "))
end
end
end
return fruits
end
@@ -164,8 +271,9 @@ function ADCpBridge.isPlantableNow(fruitIndex)
end
-- Hvilken vare sættet bruger og kan få fyldt på af AutoDrive: frø (såmaskine) eller sprøjtens/sprederens
-- nuværende fyld (gødning, kalk, sprøjtemiddel). nil = intet at fylde på (strigle, plov ...).
function ADCpBridge.getRefillFillType(vehicle)
-- fyld; en TOM spreder får rollens vare (gødning ved gødskning, kalk ved kalkning), hvis dens fyldenhed
-- kan tage den. nil = intet at fylde på (strigle, plov ...).
function ADCpBridge.getRefillFillType(vehicle, role)
for _, object in ipairs(collect(vehicle)) do
if object.spec_sowingMachine ~= nil and g_fillTypeManager ~= nil then
return g_fillTypeManager:getFillTypeIndexByName("SEEDS")
@@ -173,7 +281,16 @@ function ADCpBridge.getRefillFillType(vehicle)
end
for _, object in ipairs(collect(vehicle)) do
if object.spec_sprayer ~= nil and object.getSprayerFillUnitIndex ~= nil then
local ok, fillType = pcall(function() return object:getFillUnitFillType(object:getSprayerFillUnitIndex()) end)
local ok, fillType = pcall(function()
local filledName, supported = sprayerNames(object)
if filledName ~= nil and (role == nil or ADCpBridge.sprayRoleForFillType(filledName) == role) then
return g_fillTypeManager:getFillTypeIndexByName(filledName)
end
for _, name in ipairs(ADCpBridge.ROLE_FILLTYPES[role] or {}) do
if supported[name] then return g_fillTypeManager:getFillTypeIndexByName(name) end
end
return nil
end)
if ok and fillType ~= nil and fillType ~= 0 then return fillType end
end
end
@@ -274,9 +391,21 @@ local function courseplayWidth(vehicle)
return nil
end
-- Vinhøster uden AI-markører: bredden er vinrækken (3 m = Courseplays vin-gitter for druer).
ADCpBridge.VINE_ROW_WIDTH = 3
function ADCpBridge.widthOrVine(width, hasVineCutter)
-- under markarbejdets minimum (1,5 m) — også en lille bredde fra Courseplay (in-game 29/9: stadig widthUnknown)
if hasVineCutter and (width or 0) < 1.5 then return ADCpBridge.VINE_ROW_WIDTH end
return width
end
function ADCpBridge.getWorkWidth(vehicle)
local hasVine = false
for _, object in ipairs(collect(vehicle)) do
if object.spec_vineCutter ~= nil then hasVine = true end
end
local cpWidth = courseplayWidth(vehicle)
if cpWidth ~= nil then return cpWidth end
if cpWidth ~= nil then return ADCpBridge.widthOrVine(cpWidth, hasVine) end
local best = 0
for _, object in ipairs(collect(vehicle)) do
local variable = variableWidth(object)
@@ -288,11 +417,15 @@ function ADCpBridge.getWorkWidth(vehicle)
if ok and type(width) == "number" and width > best then best = width end
end
end
return best
return ADCpBridge.widthOrVine(best, hasVine)
end
-- AutoDrive kører også som AI-job i FS25: aktiv AD er ikke Courseplay (in-game 29/9: Milk Store ved Cow Wait
-- med AD aktiv blev meldt 'Courseplay kører' og ventede på sig selv).
function ADCpBridge.isWorking(vehicle)
if vehicle.getIsCpFieldWorkActive ~= nil and vehicle:getIsCpFieldWorkActive() then return true end
local module = vehicle.ad ~= nil and vehicle.ad.stateModule or nil
if module ~= nil and module.isActive ~= nil and module:isActive() then return false end
return vehicle.getIsAIActive ~= nil and vehicle:getIsAIActive() or false
end
@@ -365,6 +498,27 @@ function ADCpBridge.getCourse(vehicle)
return ok and course or nil
end
-- Forager: nye kurser får altid HEADLANDS runder og starter på forageren, så der ikke køres ind i træer, hegn
-- o.l. i hjørnerne (brugerkrav 29/9). Kan kurset ikke laves med forager (smal mark), prøves én gang uden.
ADCpBridge.HEADLANDS = 2
function ADCpBridge.setHeadlands(vehicle, n)
if vehicle.getCourseGeneratorSettings == nil then return false end
return (pcall(function()
local settings = vehicle:getCourseGeneratorSettings()
settings.numberOfHeadlands:setValue(n)
if settings.startOnHeadland ~= nil then settings.startOnHeadland:setValue(n > 0) end
end))
end
-- Har kurset forager? (ukendt API = antag ja, så intet genberegnes unødigt)
function ADCpBridge.courseHasHeadlands(course)
if course == nil then return false end
if course.getNumberOfHeadlands == nil then return true end
local ok, n = pcall(course.getNumberOfHeadlands, course)
return not ok or type(n) ~= "number" or n > 0
end
-- Multi-tool: sæt antal sæt på kursus-generatoren før kurset laves (1 = normalt kursus).
local function setMultiTools(vehicle, n)
pcall(function() vehicle:getCourseGeneratorSettings().multiTools:setValue(n or 1) end)
@@ -384,7 +538,36 @@ end
-- course = færdigt kursus der kopieres (slåmaskinens/rivens, eller trinnets fælles multi-tool-kursus)
-- multiTools = antal sæt når kurset laves her (multi-tool-leder)
-- lane = køretøjets bane på et multi-tool-kursus (laneOffset -2..2)
-- onDone(ok, reason) kaldes én gang; reason "copied" ved succes med kopieret kursus.
-- reuse = start på køretøjets eget nuværende kursus uden ny beregning (ADCourseCache fandt det passende)
-- onDone(ok, reason) kaldes én gang; reason "copied"/"reused" ved succes med kopieret/genbrugt kursus.
-- Vinmarker (druer/oliven): Courseplays kursus ligger som standard PÅ vinen — en sprøjte/spreder/harve kører
-- så ind i stolperne ("Blocked by an object", in-game 26/9 F22). Alt der arbejder i gangene køres til
-- venstre for vinen; høsteren kører hen over rækken. nil = ikke en vinmark (rør intet).
ADCpBridge.VINE_FRUITS = {GRAPE = true, GRAPEB = true, OLIVE = true, OLIVEB = true, HOPS = true}
function ADCpBridge.vineOffsetFor(fruitName, role)
if fruitName == nil or not ADCpBridge.VINE_FRUITS[string.upper(tostring(fruitName))] then return nil end
if role == "harvester" then return 0 end
return -1
end
-- Vinrækker der springes over: sprøjter/spredere arbejder på begge sider af gangen på én gang, så de kører kun
-- i hver anden gang (1). Andre redskaber (harve, mulcher) arbejder mod én side (0). nil = ikke en vinmark.
ADCpBridge.VINE_BOTH_SIDES = {fertilizer = true, weeder = true, limer = true}
function ADCpBridge.vineSkipFor(fruitName, role)
if ADCpBridge.vineOffsetFor(fruitName, role) == nil then return nil end
return ADCpBridge.VINE_BOTH_SIDES[role] and 1 or 0
end
function ADCpBridge.setVineSkip(vehicle, value)
if vehicle == nil or vehicle.getCpVineSettings == nil or value == nil then return false end
return (pcall(function() vehicle:getCpVineSettings().vineRowsToSkip:setValue(value) end))
end
function ADCpBridge.setVineOffset(vehicle, value)
if vehicle == nil or vehicle.getCpVineSettings == nil or value == nil then return false end
return (pcall(function() vehicle:getCpVineSettings().vineCenterOffset:setValue(value) end))
end
function ADCpBridge.start(vehicle, fieldX, fieldZ, onDone, opts)
opts = opts or {}
if not ADCpBridge.isAvailable() then
@@ -398,6 +581,12 @@ function ADCpBridge.start(vehicle, fieldX, fieldZ, onDone, opts)
local params = job.cpJobParameters
if params ~= nil and params.fieldPosition ~= nil then params.fieldPosition:setPosition(fieldX, fieldZ) end
if params ~= nil and params.startPosition ~= nil then params.startPosition:setPosition(vx, vz) end
-- startpunkt i kurset: CP's standard er "first" (forfra) — "nearest"/"last" på en påbegyndt mark
local startAtValues = CpFieldWorkJobParameters ~= nil and {first = CpFieldWorkJobParameters.START_AT_FIRST_POINT,
nearest = CpFieldWorkJobParameters.START_AT_NEAREST_POINT, last = CpFieldWorkJobParameters.START_AT_LAST_POINT} or {}
if opts.startAt ~= nil and startAtValues[opts.startAt] ~= nil and params ~= nil and params.startAt ~= nil then
pcall(function() params.startAt:setValue(startAtValues[opts.startAt]) end)
end
job:setValues()
local handle = {cancelled = false, fired = false, finished = false}
local function finish(ok, reason)
@@ -411,10 +600,16 @@ function ADCpBridge.start(vehicle, fieldX, fieldZ, onDone, opts)
local ok, err = job:validate(farmId)
if not ok then return finish(false, "Courseplay: " .. tostring(err)) end
g_currentMission.aiSystem:startJob(job, farmId)
finish(true, handle.copiedCourse and "copied" or nil)
finish(true, handle.copiedCourse and "copied" or (handle.reusedCourse and "reused" or nil))
end
local function generateAndStart()
if handle.cancelled then return end
-- genbrug: maskinens eget kursus (CP's validate kræver kun hasCpCourse) — ingen ny beregning
-- eget kursus genbruges kun med forager (ellers nyt kursus med 2 runder)
if opts.reuse and ADCpBridge.getCourse(vehicle) ~= nil and ADCpBridge.courseHasHeadlands(ADCpBridge.getCourse(vehicle)) then
handle.reusedCourse = true
return startWithCourse()
end
if opts.course ~= nil and vehicle.cpCopyCourse ~= nil then
local copied, copyError = pcall(vehicle.cpCopyCourse, vehicle, opts.course)
if copied then
@@ -424,12 +619,23 @@ function ADCpBridge.start(vehicle, fieldX, fieldZ, onDone, opts)
Logging.warning("ADSmartPickup: kunne ikke kopiere kursus: %s — laver nyt", tostring(copyError))
end
setMultiTools(vehicle, opts.multiTools or 1)
-- kursusberegningen kører over mange frames på store marker: moden venter længere imens
local function generate(headlands)
ADCpBridge.setHeadlands(vehicle, headlands)
handle.generating = true
job:onClickGenerateFieldWorkCourse(function(generated)
handle.generating = false
if handle.cancelled then return end
if not generated and headlands > 0 then
Logging.info("ADSmartPickup: kursus med %d forager-runder kunne ikke laves — prøver uden forager", headlands)
return generate(0)
end
if not generated then return finish(false, "Courseplay kunne ikke lave kursus") end
startWithCourse()
end)
end
generate(ADCpBridge.HEADLANDS)
end
handle.onBoundary = function(_, success, message)
if handle.fired then return end
handle.fired = true
@@ -462,6 +668,21 @@ function ADCpBridge.startUnloader(vehicle, fieldX, fieldZ, waitX, waitZ, station
if params ~= nil then
if params.fieldPosition ~= nil then pcall(function() params.fieldPosition:setPosition(fieldX, fieldZ) end) end
if params.startPosition ~= nil then pcall(function() params.startPosition:setPosition(waitX, waitZ) end) end
-- positioner med retning: CP's applyCurrentState sætter retningen, men fejler den (pcall ovenfor) bliver den
-- aldrig sat, og spillet afviser jobbet med "Angle missing" (in-game 27/9 F15, hvert 5. min i en time)
pcall(function()
local dirX, _, dirZ = localDirectionToWorld(vehicle.rootNode, 0, 0, 1)
local angle = MathUtil.getYRotationFromDirection(dirX, dirZ)
for _, position in ipairs({params.startPosition, params.fieldUnloadPosition}) do
if position ~= nil and position.getAngle ~= nil and position:getAngle() == nil then
if position.getPosition ~= nil and select(1, position:getPosition()) == nil then
local vx, _, vz = getWorldTranslation(vehicle.rootNode)
position:setPosition(vx, vz)
end
position:setAngle(angle)
end
end
end)
if params.useGiantsUnload ~= nil then pcall(function() params.useGiantsUnload:setValue(true) end) end
end
pcall(function() job:setValues() end)
+133
View File
@@ -0,0 +1,133 @@
-- AD CP Resume
-- Spillet stopper alle arbejdere ved gem, og Courseplay genstarter dem ikke (AutoDrive gør selv). Ved hvert gem
-- skrives de maskiner hvor CP kører (uden AD) til ADSmartPickup_cpresume.xml; efter indlæsning startes netop
-- dem igen fra nærmeste waypoint med CP's egen HUD-start (samme job som spilleren sidst valgte i HUD'en).
ADCpResume = {}
ADCpResume.FILE_NAME = "ADSmartPickup_cpresume.xml"
ADCpResume.ROOT = "ADSmartPickupCpResume"
ADCpResume.LOG_PREFIX = "ADSmartPickup: CP-genstart: "
ADCpResume.pending = nil -- {[vehicleId] = true} fra seneste gem; nil = ingen fil (gammel regel bruges)
-- ---- ren logik ----
-- vehicles = {{id, cpActive, adActive}} -> id'er der skal huskes
function ADCpResume.activeIds(vehicles)
local ids = {}
for _, vehicle in ipairs(vehicles or {}) do
if vehicle.cpActive and not vehicle.adActive then table.insert(ids, vehicle.id) end
end
return ids
end
function ADCpResume.fromRecords(ids)
local set = {}
for _, id in ipairs(ids or {}) do
if type(id) == "string" and id ~= "" then set[id] = true end
end
return set
end
-- vehicles = {{id, aiActive, controlled, fieldWork}} i fast rækkefølge -> id'er der skal startes (nil uden gemt liste).
-- fieldWork = false: CP's HUD-job er ikke markarbejde (fx bunkersilo) — HUD-start ville starte det forkerte job
-- (in-game 29/9: "CP: No silo found"); nil = ukendt (ældre CP) -> startes som før.
function ADCpResume.toStart(pending, vehicles)
if pending == nil then return nil end
local ids = {}
for _, vehicle in ipairs(vehicles or {}) do
if pending[vehicle.id] and not vehicle.aiActive and not vehicle.controlled and vehicle.fieldWork ~= false then
table.insert(ids, vehicle.id)
end
end
return ids
end
-- ---- spil ----
-- true/false fra CP's HUD; nil når CP-versionen ikke har funktionen
local function hudFieldWork(vehicle)
if vehicle.cpIsHudFieldWorkJobSelected == nil then return nil end
return vehicle:cpIsHudFieldWorkJobSelected() == true
end
local function vehicleState(adEnv, vehicle)
local ad = vehicle.ad
return {
id = ADRunsController.getVehicleId(vehicle),
cpActive = vehicle.getIsCpActive ~= nil and vehicle:getIsCpActive() == true,
adActive = ad ~= nil and ad.stateModule ~= nil and ad.stateModule:isActive(),
aiActive = vehicle.getIsAIActive ~= nil and vehicle:getIsAIActive() == true,
controlled = vehicle.getIsControlled ~= nil and vehicle:getIsControlled() == true,
fieldWork = hudFieldWork(vehicle),
}
end
local function farmVehicles(adEnv)
local list, byId = {}, {}
for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do
local ok, state = pcall(vehicleState, adEnv, vehicle)
if ok and state.id ~= nil then
table.insert(list, state)
byId[state.id] = vehicle
end
end
return list, byId
end
-- Starter CP fra nærmeste waypoint (HUD-startpunktet sættes som spillerens "start ved nærmeste"-tast).
function ADCpResume.startNearest(vehicle)
if vehicle.cpStartStopDriver == nil or (vehicle.getIsAIActive ~= nil and vehicle:getIsAIActive()) then return false end
if vehicle.getCpStartingPointSetting ~= nil and CpFieldWorkJobParameters ~= nil and vehicle.hasCpCourse ~= nil and vehicle:hasCpCourse() then
local setting = vehicle:getCpStartingPointSetting()
if setting ~= nil then setting:setValue(CpFieldWorkJobParameters.START_AT_NEAREST_POINT) end
end
vehicle:cpStartStopDriver(true)
return true
end
-- Id'er der skal startes nu (nil = ingen gemt liste). Listen bruges kun én gang.
function ADCpResume.takeToStart(adEnv)
local list, byId = farmVehicles(adEnv)
local ids = ADCpResume.toStart(ADCpResume.pending, list)
ADCpResume.pending = nil
return ids, byId
end
local function getPath()
local path = ADRunsStorage ~= nil and ADRunsStorage.getPath() or nil
if path == nil then return nil end
return (path:gsub(ADRunsStorage.FILE_NAME .. "$", ADCpResume.FILE_NAME))
end
function ADCpResume.save(adEnv)
local path = getPath()
if path == nil or adEnv == nil then return false end
local ids = ADCpResume.activeIds((farmVehicles(adEnv)))
local xmlFile = XMLFile.create("ADSmartPickupCpResume", path, ADCpResume.ROOT)
if xmlFile == nil then return false end
for index, id in ipairs(ids) do
xmlFile:setString(string.format("%s.vehicle(%d)#id", ADCpResume.ROOT, index - 1), id)
end
xmlFile:save()
xmlFile:delete()
-- gemmes ved hver runs-gem (flere gange i minuttet): log kun når listen ændrer sig
local signature = table.concat(ids, ",")
if signature ~= ADCpResume.lastLogged then
ADCpResume.lastLogged = signature
Logging.info(ADCpResume.LOG_PREFIX .. "gemt: %d maskine(r) kører Courseplay", #ids)
end
return true
end
function ADCpResume.load()
ADCpResume.pending = nil
local path = getPath()
if path == nil or not fileExists(path) then return end
local xmlFile = XMLFile.load("ADSmartPickupCpResume", path)
if xmlFile == nil then return end
local ids = {}
xmlFile:iterate(ADCpResume.ROOT .. ".vehicle", function(_, key) table.insert(ids, xmlFile:getString(key .. "#id")) end)
xmlFile:delete()
ADCpResume.pending = ADCpResume.fromRecords(ids)
Logging.info(ADCpResume.LOG_PREFIX .. "%d maskine(r) kørte Courseplay ved sidste gem", #ids)
end
+90
View File
@@ -0,0 +1,90 @@
-- AD Crop Plan (ren logik)
-- Mangel pr. afgrøde over 12 måneder (lager + forventet høst mod kædens behov) og valg af marker der skal skifte
-- målafgrøde: "fri til kæden" -> auto, ellers forslag. Brugerens egen målafgrøde ændres aldrig automatisk.
ADCropPlan = {}
ADCropPlan.HORIZON_MONTHS = 12
ADCropPlan.MIN_SHARE = 0.10
ADCropPlan.MIN_HA = 0.5
-- højst så mange forslag (ikke auto-skift) pr. afgrøde, så listen ikke vokser til snesevis af rækker
ADCropPlan.MAX_SUGGEST_PER_FRUIT = 3
function ADCropPlan.plannedFruit(field)
if field.targetFruit ~= nil and field.targetFruit ~= "" then return field.targetFruit end
return field.fruit
end
function ADCropPlan.shortages(demand, stock, fields, yields)
local list = {}
for fruit, perMonth in pairs(demand or {}) do
local perHa = (yields or {})[fruit] or 0
if perHa > 0 and (perMonth or 0) > 0 then
local need = perMonth * ADCropPlan.HORIZON_MONTHS
local supply = (stock or {})[fruit] or 0
for _, field in ipairs(fields or {}) do
if ADCropPlan.plannedFruit(field) == fruit then supply = supply + (field.areaHa or 0) * perHa end
end
local missing = need - supply
local missingHa = missing / perHa
if missing > need * ADCropPlan.MIN_SHARE and missingHa > ADCropPlan.MIN_HA then
table.insert(list, {fruit = fruit, needLiters = need, supplyLiters = supply, missingLiters = missing, missingHa = missingHa})
end
end
end
table.sort(list, function(a, b)
if a.missingHa ~= b.missingHa then return a.missingHa > b.missingHa end
return a.fruit < b.fruit
end)
return list
end
local function isCandidate(field, used, dismissedFruit, fruit)
if field.permanent or field.excluded then return false end
local planned = ADCropPlan.plannedFruit(field)
if planned ~= nil and planned ~= "" and used[planned] then return false end
return dismissedFruit ~= fruit
end
-- prices: {[afgrøde] = pris} eller en funktion afgrøde -> pris (slås kun op for kandidaternes afgrøder).
function ADCropPlan.choose(shortages, fields, usedFruits, yields, prices, dismissed)
local actions, taken = {}, {}
local used = usedFruits or {}
local priceOf = type(prices) == "function" and prices or function(fruit) return (prices or {})[fruit] or 0 end
local priceCache = {}
local function valueOf(fruit)
if fruit == "" then return 0 end
if priceCache[fruit] == nil then priceCache[fruit] = ((yields or {})[fruit] or 0) * (priceOf(fruit) or 0) end
return priceCache[fruit]
end
for _, shortage in ipairs(shortages or {}) do
local remaining = shortage.missingHa
local candidates = {}
for _, field in ipairs(fields or {}) do
if not taken[field.id] and isCandidate(field, used, (dismissed or {})[field.id], shortage.fruit) then
local planned = ADCropPlan.plannedFruit(field) or ""
local value = valueOf(planned)
table.insert(candidates, {field = field, value = value})
end
end
table.sort(candidates, function(a, b)
if a.value ~= b.value then return a.value < b.value end
local da, db = math.abs((a.field.areaHa or 0) - remaining), math.abs((b.field.areaHa or 0) - remaining)
if da ~= db then return da < db end
return a.field.id < b.field.id
end)
local suggested = 0
for _, candidate in ipairs(candidates) do
if remaining <= 0 then break end
local field = candidate.field
local auto = field.chainFree == true and field.targetSource ~= "user"
if auto or suggested < ADCropPlan.MAX_SUGGEST_PER_FRUIT then
table.insert(actions, {fieldId = field.id, from = ADCropPlan.plannedFruit(field) or "", to = shortage.fruit,
hectares = field.areaHa or 0, reason = shortage.fruit, mode = auto and "auto" or "suggest"})
taken[field.id] = true
remaining = remaining - (field.areaHa or 0)
if not auto then suggested = suggested + 1 end
end
end
end
return actions
end
+190
View File
@@ -0,0 +1,190 @@
-- AD Dependency (ren logik)
-- Advarsel når spilleren slukker en produktion gården er afhængig af (spec 2026-09-30-produktionskaeder afsnit 8).
-- En vare er "nødvendig" når et tændt forsyningsflow kører den (manuel vare, eller flowets mål tager den i
-- auto-tilstand), en stald æder den, eller en aktiv kæde har den som input. Varer er NAVNE; kapacitet og forbrug
-- i samme enhed (adapteren i adChain.lua vælger), lagerdage i spillets døgn. Ingen spiladgang.
ADDependency = {}
ADDependency.STOCK_DAYS_OK = 60 -- lager til mere end så mange døgn: ingen advarsel
ADDependency.MAX_USERS = 2 -- brugere nævnt i beskeden; resten "+k"
local function contains(list, value)
for _, item in ipairs(list or {}) do if item == value then return true end end
return false
end
local function sortedKeys(map)
local keys = {}
for key in pairs(map or {}) do table.insert(keys, key) end
table.sort(keys)
return keys
end
-- flows = {{name, kind, stopped, targetBuildingIds, ingredientMode, ingredients, chain}} (chain = kædens rodnøgle for kædens flows)
-- barnFood = {[vare] = {staldnavn...}}, chainInputs = {[vare] = {kædenavn...}}
-- accepts = {[bygnings-id] = {[vare] = true}}: hvad auto-flowenes mål tager (adapteren slår op)
-- -> {[vare] = {users = {navn...}, onlyChain, strong}} (brugere i rækkefølgen flows, stalde, kæder; hver kun én gang).
-- strong = varen er valgt som manuel vare i et flow eller er kæde-input: behovet gælder også uden kendt forbrug.
-- Stald-foder og auto-flowenes mål er "svage": uden kendt forbrug (lagerdage) er de ikke nødvendige.
function ADDependency.neededOutputs(flows, barnFood, chainInputs, accepts)
local result = {}
local function add(name, user, isChain, isStrong)
if name == nil or user == nil then return end
local entry = result[name]
if entry == nil then
entry = {users = {}, onlyChain = true, strong = false}
result[name] = entry
end
if not isChain then entry.onlyChain = false end
if isStrong then entry.strong = true end
if not contains(entry.users, user) then table.insert(entry.users, user) end
end
for _, flow in ipairs(flows or {}) do
if flow.kind ~= "outbound" and not flow.stopped then
local user = tostring(flow.name or "?")
-- kædens egne flows (flow.chain) er kæde-behov: Task 10 melder kæderne, så de gør ikke onlyChain falsk
local isChainFlow = flow.chain ~= nil and flow.chain ~= ""
if flow.ingredientMode == "manual" then
for _, name in ipairs(flow.ingredients or {}) do add(name, user, isChainFlow, true) end
else
for _, building in ipairs(flow.targetBuildingIds or {}) do
for _, name in ipairs(sortedKeys((accepts or {})[building])) do add(name, user, isChainFlow, isChainFlow) end
end
end
end
end
for _, name in ipairs(sortedKeys(barnFood)) do
for _, barn in ipairs(barnFood[name]) do add(name, tostring(barn), false) end
end
for _, name in ipairs(sortedKeys(chainInputs)) do
for _, root in ipairs(chainInputs[name]) do add(name, tostring(root), true, true) end
end
return result
end
-- Tændte produktioner der ikke er kædens (samme regel som ADChain.enabledKeys) -> {[nøgle] = true}
function ADDependency.enabledKeys(recipes)
local result = {}
for _, recipe in ipairs(recipes or {}) do
if recipe.enabled and not recipe.byChain then result[recipe.key] = true end
end
return result
end
-- Tændte produktioners kapacitet pr. vare (outputs × cyklusser pr. måned) -> {[vare] = mængde}
function ADDependency.capacity(recipes)
local result = {}
for _, recipe in ipairs(recipes or {}) do
if recipe.enabled then
for name, amount in pairs(recipe.outputs or {}) do
result[name] = (result[name] or 0) + (amount or 0) * (recipe.cyclesPerMonth or 0)
end
end
end
return result
end
-- Dækker andre tændte produktioner varen? Ukendt/intet forbrug: enhver kapacitet dækker.
local function isCovered(name, otherCapacity, demand)
local capacity = (otherCapacity or {})[name] or 0
return capacity > 0 and capacity >= ((demand or {})[name] or 0)
end
-- Rækker lageret længe nok? nil = ukendt (advar, uden dage).
local function stockLasts(name, stockDays)
local days = (stockDays or {})[name]
return days ~= nil and days > ADDependency.STOCK_DAYS_OK
end
-- {[bygning|vare] = true} for tændte opskrifter: spilleren har valgt hvilken opskrift i fabrikken der kører.
local function enabledInBuilding(recipes)
local result = {}
for _, recipe in ipairs(recipes or {}) do
if recipe.enabled then
for name, amount in pairs(recipe.outputs or {}) do
if (amount or 0) > 0 then result[tostring(recipe.building) .. "|" .. name] = true end
end
end
end
return result
end
-- Er behovet for varen reelt? Kun kæder: nej (Task 10 melder kæderne). Svagt behov (stald-foder, auto-flowets mål)
-- kræver kendt forbrug (lagerdage); manuel flow-vare og kæde-input gælder også uden.
local function isNeeded(need, name, stockDays)
if need == nil or need.onlyChain then return false end
return need.strong == true or (stockDays or {})[name] ~= nil
end
-- Første nødvendige, udækkede vare (efter navn) for en slukket produktion (ikke kædens) -> advarsel eller nil.
local function check(recipe, needed, stockDays, otherCapacity, demand, alternatives)
if recipe.enabled or recipe.byChain then return nil end
for _, name in ipairs(sortedKeys(recipe.outputs)) do
local need = (needed or {})[name]
if isNeeded(need, name, stockDays) and (recipe.outputs[name] or 0) > 0
and not alternatives[tostring(recipe.building) .. "|" .. name]
and not isCovered(name, otherCapacity, demand) and not stockLasts(name, stockDays) then
local days = (stockDays or {})[name]
return {recipeKey = recipe.key, building = recipe.building, buildingName = recipe.buildingName, fillType = name,
users = need.users, onlyChain = need.onlyChain == true, days = days}
end
end
return nil
end
-- Alle slukkede, nødvendige produktioner (én pr. fabrik) — "Slukket men nødvendig" i Kræver handling.
function ADDependency.offNeeded(recipes, needed, stockDays, otherCapacity, demand)
local result, seen = {}, {}
local alternatives = enabledInBuilding(recipes)
for _, recipe in ipairs(recipes or {}) do
local warning = check(recipe, needed, stockDays, otherCapacity, demand, alternatives)
if warning ~= nil and not seen[tostring(warning.building)] then
seen[tostring(warning.building)] = true
table.insert(result, warning)
end
end
return result
end
-- Fælles regler (warnings og offNeeded): ingen advarsel når en anden opskrift i SAMME fabrik laver varen og er
-- tændt, når andre tændte produktioner dækker behovet, når lageret rækker > 60 dage, eller når kun kæder bruger varen.
-- Nye slukninger siden sidste evaluering: tændt i prevEnabled, slukket nu, nødvendig og ikke dækket.
-- prevEnabled = nil (første evaluering efter indlæsning) -> ingen. Én pr. fabrik og vare.
function ADDependency.warnings(prevEnabled, recipes, needed, stockDays, otherCapacity, demand)
local result, seen = {}, {}
if prevEnabled == nil then return result end
local alternatives = enabledInBuilding(recipes)
for _, recipe in ipairs(recipes or {}) do
if prevEnabled[recipe.key] then
local warning = check(recipe, needed, stockDays, otherCapacity, demand, alternatives)
local id = warning ~= nil and (tostring(warning.building) .. "|" .. warning.fillType) or nil
if warning ~= nil and not seen[id] then
seen[id] = true
table.insert(result, warning)
end
end
end
return result
end
-- "A, B +2"
function ADDependency.usersText(users)
local shown = {}
for index = 1, math.min(#(users or {}), ADDependency.MAX_USERS) do shown[index] = tostring(users[index]) end
if #shown == 0 then return "?" end
local text = table.concat(shown, ", ")
if #users > #shown then text = text .. " +" .. tostring(#users - #shown) end
return text
end
-- Beskedtekst (spu_dep_warn med dage, spu_dep_warnNoDays uden).
function ADDependency.notifyText(warning, textOf, titleOf)
local title = tostring(titleOf ~= nil and titleOf(warning.fillType) or warning.fillType)
local users = ADDependency.usersText(warning.users)
local building = tostring(warning.buildingName or warning.building or "?")
local days = warning.days
if days ~= nil and days < math.huge then
return string.format(textOf("spu_dep_warn"), building, title, users, math.max(0, math.floor(days)))
end
return string.format(textOf("spu_dep_warnNoDays"), building, title, users)
end
+85 -4
View File
@@ -18,8 +18,9 @@ ADFieldFleet.ROLE_SPECS = {
{role = "weeder", spec = "spec_weeder"},
{role = "sprayRole"},
{role = "mulcher", spec = "spec_mulcher"},
{role = "cultivator", spec = "spec_cultivator"},
-- plov før kultivator: FS25-plove har også spec_cultivator (in-game 28/9: POV 5 XL blev "kultivator")
{role = "plow", spec = "spec_plow"},
{role = "cultivator", spec = "spec_cultivator"},
{role = "baleCollector", spec = "canBaleCollect"},
{role = "unloader", spec = "canUnload"},
}
@@ -35,12 +36,23 @@ function ADFieldFleet.roleFromSpecs(specs)
return nil
end
-- Ekstra opgaver en rolle kan tage: pløjning klargør jorden lige så godt som kultivering (ikke omvendt).
ADFieldFleet.EXTRA_ROLES = {plow = {plow = true, cultivator = true}}
function ADFieldFleet.rolesFor(role)
local roles = ADFieldFleet.EXTRA_ROLES[role]
if roles == nil then return nil end
local copy = {}
for key, value in pairs(roles) do copy[key] = value end
return copy
end
-- Kandidater med rollen, sorteret nærmest først, delt i brugbare og afviste (med første grund).
local function sortCandidates(candidates, role, x, z, requiredWidth, accept)
local usable, sawRole, sawAccepted = {}, false, false
local widthReason, widthArgs = nil, nil
for _, candidate in ipairs(candidates) do
if candidate.role == role and candidate.enabled then
if (candidate.role == role or (candidate.roles ~= nil and candidate.roles[role])) and candidate.enabled then
sawRole = true
if accept == nil or accept(candidate) then
sawAccepted = true
@@ -48,7 +60,12 @@ local function sortCandidates(candidates, role, x, z, requiredWidth, accept)
local ok, reason, args = ADFieldModel.checkWidth(role, candidate.width or 0, requiredWidth)
if ok then
local dx, dz = candidate.x - x, candidate.z - z
table.insert(usable, {candidate = candidate, distance = dx * dx + dz * dz})
-- spreder med en anden vare på skal tømmes først: kun hvis ingen anden kan
local wrongFill = candidate.filledRole ~= nil and candidate.filledRole ~= role
-- fuld af en anden vare: bruges slet ikke til denne opgave (resten hældes kun ud når den er lille)
if not (wrongFill and candidate.dumpable == false) then
table.insert(usable, {candidate = candidate, distance = dx * dx + dz * dz, wrongFill = wrongFill})
end
elseif widthReason == nil then
widthReason, widthArgs = reason, args
end
@@ -56,7 +73,10 @@ local function sortCandidates(candidates, role, x, z, requiredWidth, accept)
end
end
end
table.sort(usable, function(a, b) return a.distance < b.distance end)
table.sort(usable, function(a, b)
if a.wrongFill ~= b.wrongFill then return not a.wrongFill end
return a.distance < b.distance
end)
local reason, args = nil, nil
if not sawRole then
reason = "noRig"
@@ -73,6 +93,43 @@ end
-- Nærmeste brugbare sæt. Grund når intet passer: noRig (ingen med flueben og rollen), cannotDo (ingen kan
-- tage opgaven, fx afgrøden), allBusy, tooWide/tooNarrow (bredden, med tal fra den første).
-- accept (valgfri): candidate -> bool, fx "høsteren kan tage hvede".
-- Faste maskiner: rigIdsByField = {[fieldId] = {id, ...}}. En maskine der er fast på en ANDEN mark må
-- ikke bruges her. Har marken selv faste maskiner med rollen, bruges kun de (marken venter på sin egen);
-- ellers de frie. (in-game 26/9: sprøjten "til F22" blev sendt til F109.)
function ADFieldFleet.filterDedicated(candidates, fieldId, role, rigIdsByField)
local ownerOf, own = {}, {}
for otherId, ids in pairs(rigIdsByField or {}) do
for _, id in ipairs(ids or {}) do
ownerOf[id] = otherId
if otherId == fieldId then own[id] = true end
end
end
local ownWithRole = {}
for _, candidate in ipairs(candidates or {}) do
local hasRole = candidate.role == role or (candidate.roles ~= nil and candidate.roles[role])
if own[candidate.id] and hasRole then table.insert(ownWithRole, candidate) end
end
if #ownWithRole > 0 then return ownWithRole end
local free = {}
for _, candidate in ipairs(candidates or {}) do
if ownerOf[candidate.id] == nil then table.insert(free, candidate) end
end
return free
end
-- Gør en maskine fast på én mark (fjernes fra andre marker). Returnerer ny tabel {[fieldId] = ids}.
function ADFieldFleet.dedicate(rigIdsByField, fieldId, vehicleId, isOn)
local result = {}
for otherId, ids in pairs(rigIdsByField or {}) do
local copy = {}
for _, id in ipairs(ids or {}) do if id ~= vehicleId then table.insert(copy, id) end end
result[otherId] = copy
end
result[fieldId] = result[fieldId] or {}
if isOn then table.insert(result[fieldId], vehicleId) end
return result
end
function ADFieldFleet.pick(candidates, role, x, z, requiredWidth, accept)
local usable, reason, args = sortCandidates(candidates, role, x, z, requiredWidth, accept)
if usable[1] ~= nil then return usable[1].candidate end
@@ -103,3 +160,27 @@ function ADFieldFleet.toggle(enabledIds, id)
copy[id] = (not copy[id]) or nil
return copy
end
-- Til menuen: skjul maskiner der er faste på en ANDEN mark end den valgte. Returnerer synlige, antal
-- skjulte og de marker de er faste på (sorteret). selectedFieldId nil = vis alle.
function ADFieldFleet.visibleForField(rigs, selectedFieldId, rigIdsByField)
if selectedFieldId == nil then return rigs, 0, {} end
local ownerOf = {}
for fieldId, ids in pairs(rigIdsByField or {}) do
for _, id in ipairs(ids or {}) do ownerOf[id] = fieldId end
end
local visible, hidden, fieldSet = {}, 0, {}
for _, rig in ipairs(rigs or {}) do
local owner = ownerOf[rig.id]
if owner ~= nil and owner ~= selectedFieldId then
hidden = hidden + 1
fieldSet[owner] = true
else
table.insert(visible, rig)
end
end
local fields = {}
for fieldId in pairs(fieldSet) do table.insert(fields, fieldId) end
table.sort(fields, function(a, b) return tostring(a) < tostring(b) end)
return visible, hidden, fields
end
+491
View File
@@ -0,0 +1,491 @@
-- AD Field Guide
-- "Opsæt mark": markguidens trin oven på ADGuide (ren Lua, ingen spil-API). Kladden er en kopi af
-- markens plan (+ faste maskiner), så intet ændres før [Gem]. Alt spil-afhængigt kommer i `context`:
-- {info, sowable = {{name, title}}, mowable = {[name] = true}, straw = {[name] = true} | nil,
-- rigs = {{id, text, role}}, rigIdsByField = {[fieldId] = ids}, fieldSilos = {{id, text}},
-- stores = {{id, text}} | nil (nil = markens vare kendes ikke), storeHidden, fields = {{id, text}}}
-- Rækker er data: {type = "choice"|"toggle"|"check"|"text"|"link", id, title/label, ...}; GUI'et tegner dem.
ADFieldGuide = {}
ADFieldGuide.ORDER = {"field", "crop", "cropChoice", "prep", "care", "machines", "store", "summary"}
ADFieldGuide.SOIL_VALUES = {"auto", "cultivate", "plow", "none"}
ADFieldGuide.STRAW_VALUES = {"chop", "collect", "bale"}
local function L(key, args) return {key = key, args = args} end
local function T(text) return {text = text} end
local YES_NO = {off = L("ui_no"), on = L("ui_yes")}
-- ---- afledte egenskaber ----
local function target(draft)
return draft.plan ~= nil and draft.plan.targetFruit or ""
end
local function isMowable(context, name)
return name ~= nil and name ~= "" and (context.mowable or {})[name] == true
end
-- Ukendt halm-tabel (tom/nil): alle høstafgrøder regnes med halm (som før guiden).
local function hasStraw(context, name)
if context.straw == nil or next(context.straw) == nil then return true end
return name ~= nil and context.straw[name] == true
end
local function info(guide) return guide.context.info or {} end
local function mowOptions(guide)
local t = target(guide.draft)
return info(guide).kind == "mowable" or isMowable(guide.context, t)
end
local function strawOptions(guide)
local t, i = target(guide.draft), info(guide)
if t ~= "" then
if isMowable(guide.context, t) then return false end
return hasStraw(guide.context, t)
end
return i.kind == "harvest" and hasStraw(guide.context, i.fruitName)
end
-- Der høstes (mejetærsker + aflæsningsvogne) nu eller senere.
local function harvestInvolved(guide)
local t = target(guide.draft)
return info(guide).kind == "harvest" or (t ~= "" and not isMowable(guide.context, t))
end
local function prepVisible(guide)
local t, i = target(guide.draft), info(guide)
return i.kind ~= "mowable" or (t ~= "" and t ~= i.fruitName)
end
local function careVisible(guide)
return info(guide).kind ~= "bare" or target(guide.draft) ~= ""
end
-- Ukrudt giver kun mening på afgrøder der ikke slås (samme regel som før).
local function weedVisible(guide)
local t = target(guide.draft)
if t ~= "" then return not isMowable(guide.context, t) end
return info(guide).kind ~= "mowable"
end
local VISIBLE = {
field = function(guide) return guide.draft.pickField == true end,
crop = function(guide) return guide.draft.fieldId ~= nil end,
cropChoice = function(guide) return guide.draft.fieldId ~= nil and (mowOptions(guide) or strawOptions(guide)) end,
prep = function(guide) return guide.draft.fieldId ~= nil and prepVisible(guide) end,
care = function(guide) return guide.draft.fieldId ~= nil and careVisible(guide) end,
machines = function(guide) return guide.draft.fieldId ~= nil end,
store = function(guide) return guide.draft.fieldId ~= nil and guide.context.stores ~= nil and info(guide).kind ~= "bare" end,
summary = function(guide) return guide.draft.fieldId ~= nil end,
}
function ADFieldGuide.isVisible(step, guide)
local rule = VISIBLE[step]
return rule ~= nil and rule(guide) == true
end
ADFieldGuide.DEF = {order = ADFieldGuide.ORDER, isVisible = ADFieldGuide.isVisible}
function ADFieldGuide.stepKey(step) return "spu_guide_field_step_" .. tostring(step) end
function ADFieldGuide.hintKey(step) return "spu_guide_field_hint_" .. tostring(step) end
-- ---- kladde ----
local function isPrepAuto(plan)
return (plan.soil or "auto") == "auto" and plan.mulch ~= true and plan.lime ~= false and plan.roll ~= true
end
-- Pleje "Automatisk" = al pleje slået til (gødning, ukrudt, kalk). Kalk tæller kun her når den ikke hører
-- under klargøring — ellers afgør klargøringens valg den.
local function isCareAuto(plan)
return plan.fertilize ~= false and plan.weed ~= false and plan.lime ~= false
end
local function storeModeOf(ids, fieldSilos)
if #ids == 0 then return "auto" end
local silo = {}
for _, entry in ipairs(fieldSilos or {}) do silo[entry.id] = true end
for _, id in ipairs(ids) do
if not silo[id] then return "manual" end
end
return "fieldSilo"
end
local function draftFrom(field, context)
local plan = ADGuide.deepCopy(field.plan or {})
plan.storeBuildingIds = plan.storeBuildingIds or {}
plan.rigIds = plan.rigIds or {}
local mode = storeModeOf(plan.storeBuildingIds, context.fieldSilos)
return {
fieldId = field.id, plan = plan, originalTarget = plan.targetFruit or "",
prepAuto = isPrepAuto(plan), careAuto = isCareAuto(plan), useDedicated = #plan.rigIds > 0,
storeMode = mode, storeIds = mode == "manual" and ADGuide.deepCopy(plan.storeBuildingIds) or {},
}
end
function ADFieldGuide.open(field, context, options)
return ADGuide.new(ADFieldGuide.DEF, draftFrom(field, context), context, options)
end
-- Guiden åbnet uden valgt mark: trin 1 "Mark".
function ADFieldGuide.openPicker(context)
return ADGuide.new(ADFieldGuide.DEF, {pickField = true}, context, {step = "field"})
end
-- Mark valgt i trin 1: kladden bygges fra markens plan; trin 1 tæller stadig med.
function ADFieldGuide.pickField(guide, field, context)
local draft = draftFrom(field, context)
draft.pickField = true
local picked = ADGuide.withContext(ADGuide.withDraft(guide, draft), context)
return (ADGuide.next(picked))
end
-- ---- valg ----
local PLAN_VALUES = {
targetFruit = true, sow = true, rake = true, mulch = true, lime = true, roll = true,
fertilize = true, weed = true, straw = true, chainFree = true, soil = true, rigs = true, unloaders = true,
}
function ADFieldGuide.choose(guide, id, value)
local draft = ADGuide.deepCopy(guide.draft)
if PLAN_VALUES[id] then
draft.plan[id] = value
elseif id == "product" then
draft.plan.product = value and "hay" or "silage"
elseif id == "prepAuto" or id == "careAuto" or id == "useDedicated" then
draft[id] = value == true
elseif id == "rigIds" then
draft.plan.rigIds = ADGuide.toggleItem(draft.plan.rigIds, value.item, value.on)
elseif id == "useFieldSilo" then
draft.storeMode = value and "fieldSilo" or "auto"
elseif id == "storeAuto" then
draft.storeMode = value and "auto" or "manual"
elseif id == "storeBuildingIds" then
draft.storeIds = ADGuide.toggleItem(draft.storeIds, value.item, value.on)
else
return guide
end
return ADGuide.withDraft(guide, draft)
end
-- ---- rækker pr. trin ----
local function numberOptions(from, to)
local options = {}
for value = from, to do table.insert(options, {label = T(tostring(value)), value = value}) end
return options
end
local function choice(id, title, options, value)
return {type = "choice", id = id, title = title, options = options, value = value}
end
local function toggle(id, title, value, off, on)
return {type = "toggle", id = id, title = title, off = off or YES_NO.off, on = on or YES_NO.on, value = value == true}
end
local function rowsField(guide)
local rows = {}
for _, entry in ipairs(guide.context.fields or {}) do
table.insert(rows, {type = "link", id = "field", value = entry.id, label = T(entry.text)})
end
if #rows == 0 then table.insert(rows, {type = "text", label = L("spu_field_scanning")}) end
return rows
end
local function rowsCrop(guide)
local plan = guide.draft.plan
local options = {{label = L("spu_guide_crop_keep"), value = ""}}
for _, fruit in ipairs(guide.context.sowable or {}) do
table.insert(options, {label = T(fruit.title), value = fruit.name})
end
local rows = {choice("targetFruit", L("spu_guide_crop"), options, plan.targetFruit or "")}
if target(guide.draft) == "" and info(guide).kind ~= "mowable" then
table.insert(rows, toggle("sow", L("spu_guide_crop_sowAgain"), plan.sow))
end
table.insert(rows, toggle("chainFree", L("spu_field_chainFree"), plan.chainFree))
return rows
end
local function rowsCropChoice(guide)
local plan, rows = guide.draft.plan, {}
if mowOptions(guide) then
table.insert(rows, toggle("product", L("spu_field_plan"), ADFieldModel.product(plan, "mowable") == "hay",
L("spu_field_plan_silage"), L("spu_field_plan_hay")))
table.insert(rows, toggle("rake", L("spu_field_rake"), plan.rake))
end
if strawOptions(guide) then
local options = {}
for _, value in ipairs(ADFieldGuide.STRAW_VALUES) do
table.insert(options, {label = L("spu_field_straw_" .. value), value = value})
end
table.insert(rows, choice("straw", L("spu_field_straw"), options, plan.straw or "chop"))
end
return rows
end
local function rowsPrep(guide)
local draft, plan = guide.draft, guide.draft.plan
local rows = {choice("prepAuto", L("spu_guide_prep"), {
{label = L("spu_guide_auto"), value = true}, {label = L("spu_guide_manual"), value = false}}, draft.prepAuto)}
if draft.prepAuto then
table.insert(rows, {type = "text", label = L("spu_guide_prep_autoText")})
return rows
end
local soil = {}
for _, value in ipairs(ADFieldGuide.SOIL_VALUES) do
table.insert(soil, {label = L("spu_field_soil_" .. value), value = value})
end
table.insert(rows, choice("soil", L("spu_field_soil"), soil, plan.soil or "auto"))
table.insert(rows, toggle("lime", L("spu_field_lime"), plan.lime ~= false))
table.insert(rows, toggle("mulch", L("spu_field_mulch"), plan.mulch))
if target(draft) ~= "" or plan.sow then
table.insert(rows, toggle("roll", L("spu_field_roll"), plan.roll))
end
return rows
end
local function rowsCare(guide)
local draft, plan = guide.draft, guide.draft.plan
local rows = {choice("careAuto", L("spu_guide_care"), {
{label = L("spu_guide_auto"), value = true}, {label = L("spu_guide_manual"), value = false}}, draft.careAuto)}
if draft.careAuto then
table.insert(rows, {type = "text", label = L("spu_guide_care_autoText")})
return rows
end
table.insert(rows, toggle("fertilize", L("spu_field_fertilize"), plan.fertilize ~= false))
if weedVisible(guide) then table.insert(rows, toggle("weed", L("spu_field_weed"), plan.weed ~= false)) end
if not prepVisible(guide) then table.insert(rows, toggle("lime", L("spu_field_lime"), plan.lime ~= false)) end
return rows
end
-- Faste maskiner: kun ledige og dem der allerede er faste her; faste på andre marker skjules med en linje.
local function dedicatedRows(guide)
local draft, rows = guide.draft, {}
local byField = {}
for fieldId, ids in pairs(guide.context.rigIdsByField or {}) do byField[fieldId] = ids end
byField[draft.fieldId] = draft.plan.rigIds
local own = {}
for _, id in ipairs(draft.plan.rigIds) do own[id] = true end
local visible, hidden, fields = ADFieldFleet.visibleForField(guide.context.rigs or {}, draft.fieldId, byField)
local sorted = {}
for _, rig in ipairs(visible) do table.insert(sorted, rig) end
table.sort(sorted, function(a, b)
if (own[a.id] == true) ~= (own[b.id] == true) then return own[a.id] == true end
if (a.role or "") ~= (b.role or "") then return (a.role or "") < (b.role or "") end
return ADFlowView.naturalLess(tostring(a.text), tostring(b.text))
end)
for _, rig in ipairs(sorted) do
table.insert(rows, {type = "check", id = "rigIds", item = rig.id, title = T(rig.text), value = own[rig.id] == true})
end
if #sorted == 0 then table.insert(rows, {type = "text", label = L("spu_field_noRigs")}) end
if hidden > 0 then
local names = {}
for _, fieldId in ipairs(fields) do table.insert(names, "F" .. tostring(fieldId)) end
table.insert(rows, {type = "text", label = L("spu_field_hiddenDedicated", {hidden, table.concat(names, ", ")})})
end
return rows
end
local function rowsMachines(guide)
local draft, plan = guide.draft, guide.draft.plan
local rows = {choice("rigs", L("spu_guide_rigs"), numberOptions(1, ADFieldModel.MAX_RIGS), plan.rigs or 1)}
if harvestInvolved(guide) then
table.insert(rows, choice("unloaders", L("spu_field_unloaders"), numberOptions(1, ADFieldModel.MAX_UNLOADERS), plan.unloaders or 1))
end
table.insert(rows, toggle("useDedicated", L("spu_guide_dedicated"), draft.useDedicated))
if draft.useDedicated then
for _, row in ipairs(dedicatedRows(guide)) do table.insert(rows, row) end
end
return rows
end
local function rowsStore(guide)
local draft, context, rows = guide.draft, guide.context, {}
local silos = context.fieldSilos or {}
if #silos > 0 then
local names = {}
for _, silo in ipairs(silos) do table.insert(names, silo.text) end
table.insert(rows, toggle("useFieldSilo", L("spu_guide_useFieldSilo"), draft.storeMode == "fieldSilo"))
table.insert(rows, {type = "text", label = T(" " .. table.concat(names, ", "))})
if draft.storeMode == "fieldSilo" then return rows end
end
table.insert(rows, choice("storeAuto", L("spu_guide_store"), {
{label = L("spu_guide_auto"), value = true}, {label = L("spu_guide_manual"), value = false}}, draft.storeMode ~= "manual"))
if draft.storeMode ~= "manual" then
table.insert(rows, {type = "text", label = L("spu_guide_store_autoText")})
return rows
end
local chosen = {}
for _, id in ipairs(draft.storeIds) do chosen[id] = true end
for _, store in ipairs(context.stores or {}) do
table.insert(rows, {type = "check", id = "storeBuildingIds", item = store.id, title = T(store.text), value = chosen[store.id] == true})
end
if #(context.stores or {}) == 0 then table.insert(rows, {type = "text", label = L("spu_field_noStore")}) end
if (context.storeHidden or 0) > 0 then
table.insert(rows, {type = "text", label = L("spu_field_storeHidden", {context.storeHidden})})
end
return rows
end
local function rowsSummary(guide)
local rows = {}
for _, line in ipairs(ADFieldGuide.summary(guide)) do
table.insert(rows, {type = "link", id = "jump", value = line.step, line = line})
end
return rows
end
local ROWS = {
field = rowsField, crop = rowsCrop, cropChoice = rowsCropChoice, prep = rowsPrep, care = rowsCare,
machines = rowsMachines, store = rowsStore, summary = rowsSummary,
}
function ADFieldGuide.rows(guide)
local builder = ROWS[guide.step]
return builder ~= nil and builder(guide) or {}
end
-- ---- resumé ----
local function fruitTitle(context, name)
for _, fruit in ipairs(context.sowable or {}) do
if fruit.name == name then return fruit.title end
end
return tostring(name)
end
local function storeText(guide)
local draft, context = guide.draft, guide.context
if draft.storeMode == "fieldSilo" then return {L("spu_guide_sum_fieldSilo")} end
if draft.storeMode ~= "manual" or #draft.storeIds == 0 then return {L("spu_guide_sum_auto")} end
local names = {}
for _, id in ipairs(draft.storeIds) do
for _, store in ipairs(context.stores or {}) do
if store.id == id then table.insert(names, store.text) end
end
end
if #names == 0 or #names > 2 or #names ~= #draft.storeIds then
return {L("spu_guide_sum_stores", {#draft.storeIds})}
end
return {T(table.concat(names, " + "))}
end
local SUMMARY = {
crop = function(guide)
local plan, t = guide.draft.plan, target(guide.draft)
if t ~= "" then return {L("spu_guide_sum_target", {fruitTitle(guide.context, t)})} end
local parts = {L("spu_guide_crop_keep")}
if plan.sow and info(guide).kind ~= "mowable" then table.insert(parts, L("spu_guide_sum_sowAgain")) end
return parts
end,
cropChoice = function(guide)
local plan, parts = guide.draft.plan, {}
if mowOptions(guide) then
table.insert(parts, L(ADFieldModel.product(plan, "mowable") == "hay" and "spu_field_plan_hay" or "spu_field_plan_silage"))
table.insert(parts, L(plan.rake and "spu_guide_sum_rake" or "spu_guide_sum_noRake"))
end
if strawOptions(guide) then table.insert(parts, L("spu_guide_sum_straw_" .. (plan.straw or "chop"))) end
return parts
end,
prep = function(guide)
local plan = guide.draft.plan
if guide.draft.prepAuto then return {L("spu_guide_sum_auto")} end
local parts = {L("spu_field_soil_" .. (plan.soil or "auto"))}
if plan.lime ~= false then table.insert(parts, L("spu_guide_sum_lime")) end
if plan.mulch then table.insert(parts, L("spu_guide_sum_mulch")) end
if plan.roll and (plan.sow or target(guide.draft) ~= "") then table.insert(parts, L("spu_guide_sum_roll")) end
return parts
end,
care = function(guide)
if guide.draft.careAuto then return {L("spu_guide_sum_auto")} end
local plan, parts = guide.draft.plan, {}
if plan.fertilize ~= false then table.insert(parts, L("spu_guide_sum_fert")) end
if weedVisible(guide) and plan.weed ~= false then table.insert(parts, L("spu_guide_sum_weed")) end
if not prepVisible(guide) and plan.lime ~= false then table.insert(parts, L("spu_guide_sum_lime")) end
if #parts == 0 then parts = {L("spu_guide_sum_noCare")} end
return parts
end,
machines = function(guide)
local draft, plan = guide.draft, guide.draft.plan
local parts = {}
if draft.useDedicated and #plan.rigIds > 0 then
table.insert(parts, L("spu_guide_sum_dedicated", {#plan.rigIds}))
else
table.insert(parts, L("spu_guide_sum_freeRigs"))
end
table.insert(parts, L("spu_guide_sum_rigs", {plan.rigs or 1}))
if harvestInvolved(guide) then table.insert(parts, L("spu_guide_sum_unloaders", {plan.unloaders or 1})) end
return parts
end,
store = storeText,
}
-- Resumé-linjer {step, title, parts} for de synlige trin (ikke "Mark" og "Oversigt").
function ADFieldGuide.summary(guide)
local lines = {}
for _, step in ipairs(ADGuide.visibleSteps(guide)) do
local build = SUMMARY[step]
if build ~= nil then
table.insert(lines, {step = step, title = L(ADFieldGuide.stepKey(step)), parts = build(guide)})
end
end
return lines
end
-- ---- gem ----
-- start: true = [Kør automatisk], false = [Gem uden at starte], nil = [Ret] (tilstanden bevares).
-- Mål-afgrøde kører altid automatisk (ADFieldModel.setTarget).
function ADFieldGuide.result(guide, start)
local draft = guide.draft
local plan = ADGuide.deepCopy(draft.plan)
-- kilden ejes af setTarget; kladdens gamle værdi må ikke overskrive den
plan.targetSource = nil
local t = plan.targetFruit or ""
if t ~= "" then
plan.mode = "auto"
elseif start ~= nil then
plan.mode = start and "auto" or "manual"
end
if draft.prepAuto and ADFieldGuide.isVisible("prep", guide) then
plan.soil, plan.mulch, plan.lime, plan.roll = "auto", false, true, false
end
if draft.careAuto and ADFieldGuide.isVisible("care", guide) then
plan.fertilize, plan.weed = true, true
if not prepVisible(guide) then plan.lime = true end
end
if ADFieldGuide.isVisible("store", guide) then
if draft.storeMode == "fieldSilo" then
local ids = {}
for _, silo in ipairs(guide.context.fieldSilos or {}) do table.insert(ids, silo.id) end
plan.storeBuildingIds = ids
elseif draft.storeMode == "manual" then
plan.storeBuildingIds = ADGuide.deepCopy(draft.storeIds)
else
plan.storeBuildingIds = {}
end
end
local rigIds = draft.useDedicated and ADGuide.deepCopy(plan.rigIds) or {}
plan.rigIds = nil
return {fieldId = draft.fieldId, targetFruit = t, targetChanged = t ~= (draft.originalTarget or ""), plan = plan, rigIds = rigIds}
end
-- Faste maskiner for alle marker efter gem: marken får rigIds, og de fjernes fra andre marker.
function ADFieldGuide.applyRigs(rigIdsByField, fieldId, rigIds)
local mine = {}
for _, id in ipairs(rigIds or {}) do mine[id] = true end
local result = {}
for otherId, ids in pairs(rigIdsByField or {}) do
if otherId ~= fieldId then
local copy = {}
for _, id in ipairs(ids or {}) do if not mine[id] then table.insert(copy, id) end end
result[otherId] = copy
end
end
result[fieldId] = ADGuide.deepCopy(rigIds or {})
return result
end
+269 -22
View File
@@ -7,29 +7,139 @@ ADFieldJobs = {}
ADFieldJobs.LOG_PREFIX = "ADSmartPickup: "
ADFieldJobs.TICK_MS = 1000
-- én mark scannes hvert andet sekund; passive marker (manuelle, uden mål) vurderes i ti portioner
ADFieldJobs.PASSIVE_SLICES = 10
-- tidsmåling: tager et tick mere end dette, skrives det i loggen (højst én gang pr. minut)
ADFieldJobs.SLOW_TICK_MS = 3
ADFieldJobs.tickCount = 0
-- Courseplay kan efterlade hjørner på skæve marker: efter så mange færdige ture pr. sæt går marken videre.
ADFieldJobs.MAX_PASSES = 2
-- fleetMode: "all" = alle ledige maskiner (standard, "vælg og glem"), "ticked" = kun med flueben
ADFieldJobs.state = {fields = {}, fleet = {}, assignments = {}, fleetMode = "all"}
-- courseRecords: vehicleId -> post i kursus-registret (ADCourseCache)
-- manual: {[vehicleId] = true} = høstere spilleren selv kører til marken (for brede til vejene)
ADFieldJobs.state = {fields = {}, fleet = {}, assignments = {}, fleetMode = "all", courseRecords = {}, manual = {}}
-- mark-id -> {mower = kursus, rake = kursus, <trin> = fælles multi-tool-kursus}: kopier (kun i hukommelsen;
-- efter genindlæsning hentes slå-/rivekurset fra maskinen, som CP selv gemmer kurset på)
ADFieldJobs.courses = {}
ADFieldJobs.finished = {}
ADFieldJobs.timer = 0
-- starter halvt forskudt, så markarbejdet og menuens opdatering (begge hvert sekund) ikke rammer samme frame
ADFieldJobs.timer = 500
ADFieldJobs.now = 0
ADFieldJobs.loaded = false
ADFieldJobs.ACTIVE_PHASES = {driving = true, starting = true, waitingCourse = true, working = true}
-- awaitingPlayer: høster med manuel transport, spilleren kører den til marken (tæller som i gang)
ADFieldJobs.ACTIVE_PHASES = {fetching = true, driving = true, starting = true, waitingCourse = true, working = true, awaitingPlayer = true}
-- Andel græs i mellemhøjde (høstbart, men halv ydelse) der får en afbrudt slåning til at vente.
ADFieldJobs.REGROW_RATIO = 0.1
-- Trin efter høsten der ødelægger/ignorerer en stående afgrøde.
-- Klargøring der kan måles på jorden: trin -> info-felt (andel af marken). Kultivering er gjort når jorden er
-- klargjort (kultiveret/pløjet/såbed/stubbearbejdet); pløjning først når den er pløjet.
ADFieldJobs.GROUND_PREP_STAGES = {cultivating = "prepared", plowing = "plowed"}
ADFieldJobs.PREPARED_DONE_RATIO = 0.95
ADFieldJobs.PREP_STAGES = {strawCollecting = true, baling = true, baleCollecting = true, mulching = true, liming = true,
plowing = true, cultivating = true, sowing = true, rolling = true}
-- ---- ren beslutning ----
-- Er afgrøden taget (trin med afgrøde)? Slåning: under 50 % står. Høst: marken er bar eller under 50 %.
-- Er afgrøden taget (trin med afgrøde)? Slåning: under 50 % står. Høst: marken er bar, eller under 5 % af HELE
-- marken står (rester i hjørnerne). readyRatio regner kun med den afgrøde der er tilbage, så en halvt høstet mark
-- lignede "væk" (in-game 27/9 F111 gik til kultivering med halvdelen stående).
ADFieldJobs.HARVEST_DONE_RATIO = 0.05
local function cropGone(stage, info)
if info == nil then return false end
if stage == "harvesting" and info.kind == "bare" then return true end
if stage == "harvesting" and type(info.standingRatio) == "number" then
return info.standingRatio < ADFieldJobs.HARVEST_DONE_RATIO
end
return (info.readyRatio or 0) < ADFieldModel.CUT_RATIO
end
-- Ligger (x, z) inden for omridset? (lige-ulige-reglen; kanten tæller som inde)
function ADFieldJobs.insidePolygon(x, z, verts)
local inside, j = false, #verts
for i = 1, #verts do
local a, b = verts[i], verts[j]
if (a.z > z) ~= (b.z > z) and x <= (b.x - a.x) * (z - a.z) / (b.z - a.z) + a.x then inside = not inside end
j = i
end
return inside
end
-- Efter genindlæsning: marken en CP-maskine står på, hvis markens aktuelle trin er maskinens rolle (ellers nil).
-- fields = {{id, stage, verts = {{x, z}}}}
function ADFieldJobs.resumeField(x, z, role, fields)
for _, field in ipairs(fields or {}) do
if #(field.verts or {}) >= 3 and ADFieldModel.roleFor(field.stage) == role and ADFieldJobs.insidePolygon(x, z, field.verts) then
return field.id
end
end
return nil
end
-- Hvor Courseplay skal starte i kurset: "last" (eget kursus + husket waypoint = fortsæt præcis), "nearest"
-- (marken er allerede delvist høstet/slået), ellers "first". CP's eget standardvalg er "first" — også på en
-- halvt høstet mark (in-game 27/9: mejetærskeren startede F111 forfra).
-- s = {reuse, remembered, stage, standingRatio}
ADFieldJobs.PARTLY_DONE_RATIO = 0.95
function ADFieldJobs.startAtFor(s)
if s.reuse and s.remembered then return "last" end
local cropStage = s.stage == "harvesting" or s.stage == "mowing"
if cropStage and type(s.standingRatio) == "number" and s.standingRatio < ADFieldJobs.PARTLY_DONE_RATIO then return "nearest" end
return "first"
end
-- Skal en maskine med husket CP-waypoint genstartes efter indlæsning? Kun hvis den stadig er midt i jobbet: den
-- står på en mark der er i gang med dens trin (fieldId), eller den står lige ved sit huskede waypoint. Et husket
-- waypoint fra et gammelt job sendte ellers maskinen ud ad vejene (in-game 27/9: to ROPA'er). s = {fieldId, distance}
ADFieldJobs.RESUME_NEAR_M = 30
function ADFieldJobs.shouldResume(s)
if s.fieldId ~= nil then return true end
return type(s.distance) == "number" and s.distance <= ADFieldJobs.RESUME_NEAR_M
end
-- Højst én maskine sendt ud pr. DISPATCH_GAP_MS på tværs af alle marker (in-game 27/9: 6 maskiner kørte fra
-- parkeringen i samme sekund efter Start alle).
ADFieldJobs.DISPATCH_GAP_MS = 10000
function ADFieldJobs.mayDispatch(now, lastAt)
return lastAt == nil or now - lastAt >= ADFieldJobs.DISPATCH_GAP_MS
end
function ADFieldJobs.assignmentCount()
local count = 0
for _ in pairs(ADFieldJobs.state.assignments) do count = count + 1 end
return count
end
-- Har Courseplay-starten taget for lang tid? Mens CP beregner kursus (store marker), må det tage op til
-- COURSE_GENERATE_MS; ellers START_MS (in-game 27/9 F90: 140 ha tog over 60 s, og moden opgav midt i beregningen).
ADFieldJobs.START_MS = 60000
ADFieldJobs.COURSE_GENERATE_MS = 300000
function ADFieldJobs.startTimedOut(elapsed, generating)
return elapsed > (generating and ADFieldJobs.COURSE_GENERATE_MS or ADFieldJobs.START_MS)
end
-- Høsten er færdig, men høsteren har korn i tanken: tøm den på marken før marken går videre (én gang pr. mark).
-- s = {fill, near, busy, controlled, tried}
ADFieldJobs.emptyAttempts = {}
function ADFieldJobs.shouldEmptyHarvester(s)
return (s.fill or 0) > 0 and s.near == true and not s.busy and not s.controlled and not s.tried
end
-- Markens punkt nærmest (x, z) — CP bruger det som kursets startsted. Uden position: første punkt.
function ADFieldJobs.nearestPoint(points, x, z)
if points == nil or points[1] == nil then return nil end
if x == nil or z == nil then return points[1] end
local best, bestD = nil, nil
for _, point in ipairs(points) do
local d = (point.x - x) ^ 2 + (point.z - z) ^ 2
if bestD == nil or d < bestD then best, bestD = point, d end
end
return best
end
-- Beslutning for én mark. assignments = sæt på marken ({phase, stage, helper}); kun sæt på det aktuelle
-- trin tæller (en aflæsningsvogn på vej hjem fra høsten forstyrrer ikke kultiveringen).
-- env = {raining = bool}: vejrfølsomme trin (høst/slå/vend/riv/hø) sendes ikke ud i regn.
@@ -47,14 +157,20 @@ function ADFieldJobs.decide(field, info, assignments, now, env)
end
return {action = "start"}
end
-- mål-afgrøde på en bar mark: klargør og så så snart målet kan sås
-- mål-afgrøde på en bar mark: klargør straks; såningen venter selv på såtiden
if info ~= nil and info.kind == "bare" and field.plan.targetFruit ~= nil and field.plan.targetFruit ~= "" then
local plantable = env ~= nil and env.plantable ~= nil and env.plantable(field.plan.targetFruit) or false
if ADFieldModel.shouldStartBare(field, "bare", plantable) then
if ADFieldModel.shouldStartBare(field, "bare") then
if not hasMarker then return {action = "wait"} end
return {action = "start"}
end
end
-- klargøring på en mark hvor afgrøden står uhøstet: tilbage til klar (in-game 27/9 F16: kartofler
-- blev regnet som høstet, og marken stod til kultivering med afgrøden i jorden)
local standing = info ~= nil and type(info.standingRatio) == "number" and info.standingRatio >= ADFieldJobs.HARVEST_DONE_RATIO
if ADFieldJobs.PREP_STAGES[field.stage] and info ~= nil and info.kind == "harvest" and info.phase == "ready"
and (standing or (info.readyRatio or 0) >= ADFieldModel.READY_RATIO) then
return {action = "rewind"}
end
if not ADFieldModel.isWorking(field) then
local care = ADFieldModel.careNeed(field, info)
if care == nil then return {action = "none"} end
@@ -62,7 +178,7 @@ function ADFieldJobs.decide(field, info, assignments, now, env)
if not hasMarker then return {action = "wait", care = care} end
return {action = "care", stage = care}
end
local active, finished, helpers = 0, 0, 0
local active, finished, helpers, awaiting = 0, 0, 0, 0
for _, assignment in ipairs(assignments) do
if assignment.stage == nil or assignment.stage == field.stage then
local isActive = ADFieldJobs.ACTIVE_PHASES[assignment.phase]
@@ -70,6 +186,7 @@ function ADFieldJobs.decide(field, info, assignments, now, env)
if isActive then helpers = helpers + 1 end
else
if isActive then active = active + 1 end
if assignment.phase == "awaitingPlayer" then awaiting = awaiting + 1 end
if assignment.phase == "finished" then finished = finished + 1 end
end
end
@@ -78,10 +195,21 @@ function ADFieldJobs.decide(field, info, assignments, now, env)
if active == 0 then
-- afgrøden er væk (sluppet undervejs, genindlæst, eller færdig): videre
if isCropStage and cropGone(field.stage, info) then return {action = "advance"} end
-- klargøringen er allerede gjort (målt på jorden): videre — også efter genindlæsning, hvor
-- opgaven ikke er husket (in-game 28/9: F17 blev kultiveret 3 gange)
local groundKey = ADFieldJobs.GROUND_PREP_STAGES[field.stage]
if groundKey ~= nil and info ~= nil and type(info[groundKey]) == "number" and info[groundKey] >= ADFieldJobs.PREPARED_DONE_RATIO then
return {action = "advance"}
end
if finished > 0 then
local maxPasses = ADFieldJobs.MAX_PASSES * ADFieldModel.maxRigs(field.stage, field.plan)
if not isCropStage or finished >= maxPasses then return {action = "advance"} end
end
-- slåning uden maskine på en halvt slået mark: vent til genvæksten er fuldt udvokset igen
-- (et hjørne Courseplay efterlod i samme omgang, finished > 0, slås straks)
if field.stage == "mowing" and finished == 0 and info ~= nil and (info.immatureRatio or 0) > ADFieldJobs.REGROW_RATIO then
return {action = "wait", reason = "regrowing"}
end
if not hasMarker then return {action = "wait"} end
if (field.retryAt or 0) > now then return {action = "none"} end
if raining and ADFieldModel.isWeatherSensitive(field.stage, field.plan, kind) then
@@ -91,7 +219,8 @@ function ADFieldJobs.decide(field, info, assignments, now, env)
end
-- hovedsættene arbejder: fyld op med hjælpere (aflæsningsvogne) en ad gangen
local helperRole = ADFieldModel.helperFor(field.stage)
if helperRole ~= nil and helpers < ADFieldModel.maxHelpers(field.stage, field.plan) and hasMarker and (field.retryAt or 0) <= now then
-- tømmevogne først når en høster faktisk er i gang (ikke mens spilleren kører den derud)
if helperRole ~= nil and active > awaiting and helpers < ADFieldModel.maxHelpers(field.stage, field.plan) and hasMarker and (field.retryAt or 0) <= now then
return {action = "dispatch", role = helperRole, count = 1, helper = true}
end
return {action = "none"}
@@ -134,6 +263,9 @@ function ADFieldJobs.load()
if ok then
ADFieldJobs.state.fields, ADFieldJobs.state.fleet = loaded.fields, loaded.fleet
ADFieldJobs.state.fleetMode = loaded.fleetMode or "all"
ADFieldJobs.state.courseRecords = loaded.courseRecords or {}
ADFieldJobs.state.manual = loaded.manual or {}
ADFieldJobs.state.paused = loaded.paused == true
else
Logging.warning(ADFieldJobs.LOG_PREFIX .. "marker kunne ikke indlæses: %s", tostring(loaded))
end
@@ -145,6 +277,30 @@ function ADFieldJobs.setPlan(id, changes)
ADFieldJobs.save()
end
-- "Start nu" på en mark hvor moden venter på spilleren (manuel transport): overtag uden at vente
ADFieldJobs.takeoverRequested = {}
function ADFieldJobs.requestTakeover(id)
ADFieldJobs.takeoverRequested[id] = true
end
-- Venter moden på at spilleren kører en høster til marken? (assignment eller nil)
function ADFieldJobs.awaitingPlayer(fieldId)
for _, assignment in pairs(ADFieldJobs.state.assignments) do
if assignment.fieldId == fieldId and assignment.phase == "awaitingPlayer" then return assignment end
end
return nil
end
-- Under global pause: kun denne mark genoptages (de andre venter på Start alle). nil = pause igen.
function ADFieldJobs.setFieldResumed(id, isOn)
ADFieldJobs.state.resumed = ADFieldJobs.state.resumed or {}
ADFieldJobs.state.resumed[id] = isOn ~= false or nil
end
function ADFieldJobs.isFieldResumed(id)
return (ADFieldJobs.state.resumed or {})[id] == true
end
function ADFieldJobs.requestStart(id)
ADFieldJobs.note(id, {startRequested = true})
end
@@ -153,8 +309,8 @@ function ADFieldJobs.forceReady(id)
ADFieldJobs.setField(ADFieldModel.forceReady(ADFieldJobs.getField(id)))
end
function ADFieldJobs.setTarget(id, fruitName)
ADFieldJobs.setField(ADFieldModel.setTarget(ADFieldJobs.getField(id), fruitName))
function ADFieldJobs.setTarget(id, fruitName, source)
ADFieldJobs.setField(ADFieldModel.setTarget(ADFieldJobs.getField(id), fruitName, source))
ADFieldJobs.log(id, "mål-afgrøde: %s", fruitName ~= "" and fruitName or "ingen")
ADFieldJobs.save()
end
@@ -164,6 +320,19 @@ function ADFieldJobs.setFleetMode(mode)
ADFieldJobs.save()
end
-- Pause (Stop alt og parkér): intet nyt sendes ud, ingen genstart efter indlæsning. Gemmes.
function ADFieldJobs.setPaused(value)
ADFieldJobs.state.paused = value == true
ADFieldJobs.state.resumed = {}
ADFieldJobs.save()
Logging.info(ADFieldJobs.LOG_PREFIX .. "markarbejde %s", value and "på pause" or "genoptaget")
end
function ADFieldJobs.toggleManual(vehicleId)
ADFieldJobs.state.manual = ADFieldFleet.toggle(ADFieldJobs.state.manual or {}, vehicleId)
ADFieldJobs.save()
end
function ADFieldJobs.toggleFleet(vehicleId)
ADFieldJobs.state.fleet = ADFieldFleet.toggle(ADFieldJobs.state.fleet, vehicleId)
ADFieldJobs.save()
@@ -226,12 +395,16 @@ end
-- Seneste beslutning pr. mark (GUI viser pleje-behov og grunde).
ADFieldJobs.lastDecision = {}
local function stepField(adEnv, info)
local field = ADFieldModel.onReadiness(ADFieldJobs.getField(info.id), info.readyRatio or 0)
ADFieldJobs.setField(field)
-- Hvornår en mark begyndte at vente på en maskine (kø: længst ventende får næste ledige traktor).
ADFieldJobs.waitingSince = {}
local function stepField(adEnv, info, env)
local current = ADFieldJobs.getField(info.id)
local field = ADFieldModel.onReadiness(current, info.readyRatio or 0)
if field ~= current then ADFieldJobs.setField(field) end
local assignments = ADFieldJobs.assignmentsFor(info.id)
for _, done in ipairs(finishedFor(info.id)) do table.insert(assignments, done) end
local decision = ADFieldJobs.decide(field, info, assignments, ADFieldJobs.now, ADFieldJobs.environment())
local decision = ADFieldJobs.decide(field, info, assignments, ADFieldJobs.now, env)
ADFieldJobs.lastDecision[info.id] = decision
if decision.action == "start" then
local started = ADFieldModel.start(field, info.kind, info.needs, info.fruitName)
@@ -239,13 +412,26 @@ local function stepField(adEnv, info)
ADFieldJobs.setField(started)
ADFieldJobs.log(info.id, "starter (%s: %s)", tostring(info.kind), started.stage)
ADFieldJobs.save()
elseif decision.action == "rewind" then
ADFieldJobs.setField(ADFieldModel.with(field, {stage = "ready", retryAt = 0, reason = false}))
ADFieldJobs.waitingSince[info.id] = nil
ADFieldWork.windDownHelpers(adEnv, info.id, field.stage)
ADFieldJobs.log(info.id, "%s -> ready: afgrøden står stadig uhøstet (%.0f %% klar)", field.stage, (info.readyRatio or 0) * 100)
ADFieldJobs.save()
elseif decision.action == "care" then
ADFieldJobs.setField(ADFieldModel.startCare(field, decision.stage))
ADFieldJobs.log(info.id, "pleje: %s", decision.stage)
ADFieldJobs.save()
elseif decision.action == "advance" then
-- høsteren har stadig korn i tanken: CP genstartes og venter ved kursets ende, tømmevognene kommer
-- (marken bliver på høst til tanken er tom) — in-game 27/9 F15
if field.stage == "harvesting" and ADFieldWork.tryEmptyHarvester ~= nil then
local okEmpty, emptying = pcall(ADFieldWork.tryEmptyHarvester, adEnv, field, info)
if okEmpty and emptying then return end
end
local nextField = ADFieldModel.advance(field, info.needs)
ADFieldJobs.setField(nextField)
ADFieldJobs.waitingSince[info.id] = nil
ADFieldJobs.forgetFinished(info.id)
ADFieldWork.windDownHelpers(adEnv, info.id, field.stage)
ADFieldJobs.log(info.id, "%s -> %s", field.stage, nextField.stage)
@@ -253,23 +439,58 @@ local function stepField(adEnv, info)
ADFieldJobs.notify(string.format("F%d %s", info.id, g_i18n ~= nil and g_i18n:getText("spu_field_notice_done") or "done"), false)
end
ADFieldJobs.save()
elseif decision.action == "wait" and decision.reason == "rain" then
if field.reason == nil or field.reason.key ~= "spu_field_reason_rain" then
ADFieldJobs.note(info.id, {reason = {key = "spu_field_reason_rain"}})
elseif decision.action == "wait" and decision.reason ~= nil then
local key = "spu_field_reason_" .. decision.reason
if field.reason == nil or field.reason.key ~= key then
ADFieldJobs.note(info.id, {reason = {key = key}})
end
elseif decision.action == "dispatch" then
-- én udsendelse ad gangen på tværs af markerne: marken prøver igen ved næste tick
if not ADFieldJobs.mayDispatch(ADFieldJobs.now, ADFieldJobs.lastDispatchAt) then return end
local before = ADFieldJobs.assignmentCount()
ADFieldWork.dispatch(adEnv, field, info, decision)
if ADFieldJobs.assignmentCount() > before then
ADFieldJobs.lastDispatchAt = ADFieldJobs.now
ADFieldJobs.waitingSince[info.id] = nil
elseif ADFieldJobs.waitingSince[info.id] == nil then
ADFieldJobs.waitingSince[info.id] = ADFieldJobs.now
end
end
end
-- Realtid i ms: netGetTime (bruges overalt i FS), ellers getTimeSec.
local function clockMs()
local ok, ms = pcall(function() return netGetTime() end)
if ok and type(ms) == "number" then return ms end
local okSec, seconds = pcall(function() return getTimeSec() end)
return okSec and type(seconds) == "number" and seconds * 1000 or nil
end
-- Et langsomt tick (> SLOW_TICK_MS) skrives i loggen med fordeling, højst én gang pr. minut.
local function reportSlow(total, scan, follow, steps, stepped)
if total == nil or total <= ADFieldJobs.SLOW_TICK_MS then return end
if ADFieldJobs.lastSlowReport ~= nil and ADFieldJobs.now - ADFieldJobs.lastSlowReport < 60000 then return end
ADFieldJobs.lastSlowReport = ADFieldJobs.now
Logging.info(ADFieldJobs.LOG_PREFIX .. "markarbejde-tick %.1f ms (scan %.1f, sæt %.1f, marker %.1f for %d marker)",
total, scan or 0, follow or 0, steps or 0, stepped)
end
ADFieldJobs.STARTUP_GRACE_MS = 120000
function ADFieldJobs.tick(adEnv, dt)
ADFieldJobs.now = ADFieldJobs.now + dt
ADFieldJobs.timer = ADFieldJobs.timer + dt
if ADFieldJobs.loaded then
-- markscanningen kører et lille stykke HVERT frame (arbejdskø), aldrig en hel mark i ét frame
local okScan, errScan = pcall(ADFields.scanStep, adEnv, g_currentMission:getFarmId(), ADFieldJobs.now)
if not okScan then Logging.warning(ADFieldJobs.LOG_PREFIX .. "markscanning: %s", tostring(errScan)) end
end
if ADFieldJobs.timer < ADFieldJobs.TICK_MS then return end
ADFieldJobs.timer = 0
ADFieldJobs.tickCount = ADFieldJobs.tickCount + 1
if not ADFieldJobs.loaded then ADFieldJobs.load() end
local farmId = g_currentMission:getFarmId()
ADFields.scanNext(adEnv, farmId)
local t0 = clockMs()
local t1 = t0
for _, assignment in pairs(ADFieldJobs.state.assignments) do
local ok, err = pcall(ADFieldWork.follow, adEnv, assignment)
if not ok then
@@ -277,8 +498,34 @@ function ADFieldJobs.tick(adEnv, dt)
ADFieldWork.release(assignment)
end
end
for _, info in ipairs(ADFields.list()) do
local ok, err = pcall(stepField, adEnv, info)
local t2 = clockMs()
-- de første minutter efter indlæsning sendes intet ud: Courseplay/AutoDrive genoptager deres egne jobs
-- lidt efter start, og indtil da ligner de maskiner ledige (in-game 26/9: kultivatoren blev taget midt
-- i spillerens eget CP-job 45 s efter indlæsning)
if ADFieldJobs.now < ADFieldJobs.STARTUP_GRACE_MS then return end
-- Stop alt og parkér: intet sendes ud og intet genstartes, før spilleren trykker Start alle — undtagen
-- marker spilleren har genoptaget enkeltvis ([Start] i Fields-listen, ADFieldJobs.setFieldResumed)
local paused, resumed = ADFieldJobs.state.paused, ADFieldJobs.state.resumed or {}
if paused and next(resumed) == nil then return end
if not paused and not ADFieldJobs.resumedAfterLoad then
-- én gang, før noget nyt sendes ud: CP-maskiner der var midt i et job, fortsætter (og tæller som aktive)
ADFieldJobs.resumedAfterLoad = true
local okResume, errResume = pcall(ADFieldWork.resumeAfterLoad, adEnv)
if not okResume then Logging.warning(ADFieldJobs.LOG_PREFIX .. "genstart efter indlæsning fejlede: %s", tostring(errResume)) end
end
local env = ADFieldJobs.environment()
local slice = ADFieldJobs.tickCount % ADFieldJobs.PASSIVE_SLICES
local stepped = 0
local infos = ADFields.list()
if ADPool ~= nil then infos = ADPool.queueOrder(infos, ADFieldJobs.waitingSince) end
for index, info in ipairs(infos) do
-- passive marker (der sker intet uden brugeren) vurderes kun i deres tiendedel af sekunderne
if (not paused or resumed[info.id]) and (not ADFieldModel.isPassive(ADFieldJobs.getField(info.id)) or index % ADFieldJobs.PASSIVE_SLICES == slice) then
stepped = stepped + 1
local ok, err = pcall(stepField, adEnv, info, env)
if not ok then Logging.warning(ADFieldJobs.LOG_PREFIX .. "F%s fejl: %s", tostring(info.id), tostring(err)) end
end
end
local t3 = clockMs()
if t0 ~= nil and t3 ~= nil then reportSlow(t3 - t0, t1 - t0, t2 - t1, t3 - t2, stepped) end
end
+47 -7
View File
@@ -43,6 +43,8 @@ local DEFAULT_PLAN = {
mode = "manual", product = "auto", rake = false, rigs = 1, unloaders = 1,
mulch = false, lime = true, soil = "auto", sow = false, sowFruit = "", roll = false,
fertilize = true, weed = true, targetFruit = "", prepAfterMow = false, straw = "chop",
-- kæde: marken er frigivet til produktionskæden; hvem der valgte målafgrøden ("user"|"chain"|"")
chainFree = false, targetSource = "",
}
local STRAW_VALUES = {chop = true, collect = true, bale = true}
-- halm-trin efter høst (mejetærskeren lægger skår når halmen skal bruges)
@@ -61,6 +63,8 @@ local function copyPlan(plan)
for key, value in pairs(DEFAULT_PLAN) do copy[key] = value end
for key, value in pairs(plan or {}) do copy[key] = value end
copy.storeBuildingIds = copyList(plan ~= nil and plan.storeBuildingIds or nil)
-- faste maskiner (køretøjs-id'er) til marken; en fast maskine bruges kun her
copy.rigIds = copyList(plan ~= nil and plan.rigIds or nil)
return copy
end
@@ -87,6 +91,8 @@ end
-- Plan "auto": slåafgrøder -> ensilage, alt andet -> høst.
function ADFieldModel.product(plan, kind)
-- silage/hø gælder kun slåafgrøder: et gemt produkt fra en fejlklassificeret mark (vinmark F22) ignoreres
if kind ~= nil and kind ~= "mowable" then return "harvest" end
if plan.product ~= nil and plan.product ~= "auto" then return plan.product end
return kind == "mowable" and "silage" or "harvest"
end
@@ -154,9 +160,12 @@ end
-- Vælg mål-afgrøde fra menuen ("" = ingen). Med mål kører marken automatisk; en færdig/klar mark sættes
-- til venter, så den kan starte forfra. Uden mål: tilbage til manuel.
function ADFieldModel.setTarget(field, fruitName)
function ADFieldModel.setTarget(field, fruitName, source)
local copy = copyField(field)
copy.plan.targetFruit = fruitName or ""
-- kilden huskes kun mens der er et mål; standard er at spilleren valgte det
copy.plan.targetSource = copy.plan.targetFruit ~= "" and (source or "user") or ""
copy.held = nil
copy.plan.mode = copy.plan.targetFruit ~= "" and "auto" or "manual"
if not ADFieldModel.isWorking(field) and field.stage == "done" then
copy.stage = "idle"
@@ -164,12 +173,14 @@ function ADFieldModel.setTarget(field, fruitName)
return copy
end
-- Bar mark med mål-afgrøde starter selv klargøring + såning, når målet kan sås nu.
function ADFieldModel.shouldStartBare(field, kind, plantableNow)
-- Bar mark med mål-afgrøde starter selv klargøring + såning med det samme: klargøringen (kalk, plov,
-- kultivator) kører uanset årstid, og kun såningen venter på såtiden (preflight: notPlantable). Med mange
-- marker nås det ellers ikke inden såvinduet lukker (brugerønske 28/9).
function ADFieldModel.shouldStartBare(field, kind)
if field.plan.targetFruit == nil or field.plan.targetFruit == "" then return false end
-- kun fra idle: efter egen såning står marken "done" (nysået kan læses som bar) — ingen dobbelt-såning
if kind ~= "bare" or field.stage ~= "idle" then return false end
return plantableNow == true
if field.held and not field.startRequested then return false end
return kind == "bare" and field.stage == "idle"
end
-- Krævet bredde for et sæt: kun læssevognen uden riv skal dække slåmaskinens baner (halmskår ligger i
@@ -223,17 +234,28 @@ function ADFieldModel.lanePositions(n)
end
-- Venter/færdig -> klar ved fuld vækst; klar -> venter hvis afgrøden blev taget uden os.
-- Uændret -> samme tabel (kaldes for hver mark hvert sekund; nye kopier giver affald og GC-hak).
function ADFieldModel.onReadiness(field, ratio)
local copy = copyField(field)
if (field.stage == "idle" or field.stage == "done") and ratio >= ADFieldModel.READY_RATIO then
local copy = copyField(field)
copy.stage = "ready"
return copy
elseif field.stage == "ready" and ratio < ADFieldModel.CUT_RATIO and not field.startRequested then
-- (en Start nu-forespørgsel holder marken klar: bare marker har klar-andel 0)
local copy = copyField(field)
copy.stage = "idle"
copy.startRequested = false
end
return copy
end
return field
end
-- Passiv mark: manuel, uden mål, venter/færdig, ingen Start nu — der sker intet uden brugeren.
function ADFieldModel.isPassive(field)
local plan = field.plan
return plan.mode ~= "auto" and (plan.targetFruit == nil or plan.targetFruit == "") and not field.startRequested
and (field.stage == "idle" or field.stage == "done")
end
function ADFieldModel.shouldStart(field)
return field.stage == "ready" and (field.plan.mode == "auto" or field.startRequested == true)
@@ -263,6 +285,7 @@ end
-- currentFruit (valgfri): afgrøden på marken nu; med mål-afgrøde udfyldes planen ud fra den.
function ADFieldModel.start(field, kind, needs, currentFruit)
local copy = copyField(field)
copy.held = nil
copy.plan = ADFieldModel.applyTarget(copy.plan, kind, currentFruit)
local stages = ADFieldModel.workStages(copy.plan, kind, needs)
copy.stage = stages[1] or "done"
@@ -306,6 +329,8 @@ function ADFieldModel.stop(field)
end
copy.startRequested = false
copy.reason = nil
-- stoppet af spilleren: en bar mark med mål-afgrøde starter ikke selv igen (Start eller nyt mål slipper)
copy.held = true
return copy
end
@@ -349,7 +374,16 @@ function ADFieldModel.startCare(field, stage)
end
-- Et sæt må ikke over Courseplays grænse; efter-trin skal dække mindst den krævede bredde.
-- Under dette er bredden ikke læst rigtigt (spreder uden AI-markører gav 0,5 m): Courseplay laver så et
-- kursus med rækker hver halve meter — på en 72 ha mark fryser spillet (in-game 26/9, F108).
ADFieldModel.MIN_WORK_WIDTH = 1.5
-- roller uden eget kursus (følger høsteren / samler baller): ingen arbejdsbredde
ADFieldModel.NO_WIDTH_ROLES = {unloader = true, baleCollector = true}
function ADFieldModel.checkWidth(role, width, requiredWidth)
if width < ADFieldModel.MIN_WORK_WIDTH and not ADFieldModel.NO_WIDTH_ROLES[role] then
return false, "widthUnknown", {width}
end
if width > ADFieldModel.CP_MAX_WIDTH + ADFieldModel.WIDTH_TOLERANCE then
return false, "tooWide", {width, ADFieldModel.CP_MAX_WIDTH}
end
@@ -428,10 +462,12 @@ function ADFieldModel.toRecord(field)
return {
id = field.id, stage = field.stage, mode = plan.mode, product = plan.product,
rake = flag(plan.rake), store = table.concat(plan.storeBuildingIds, " "),
dedicated = table.concat(plan.rigIds or {}, " "),
rigs = plan.rigs, unloaders = plan.unloaders, mulch = flag(plan.mulch), lime = flag(plan.lime),
soil = plan.soil, sow = flag(plan.sow), sowFruit = plan.sowFruit or "", roll = flag(plan.roll),
fertilize = flag(plan.fertilize), weed = flag(plan.weed),
targetFruit = plan.targetFruit or "", prepAfterMow = flag(plan.prepAfterMow), straw = plan.straw or "chop",
chainFree = flag(plan.chainFree), targetSource = plan.targetSource or "",
kind = field.kind or "", lastFruit = field.lastFruit or "", care = table.concat(care, " "),
mowWidth = field.mowWidth or 0,
mowVehicle = field.mowVehicleId or "",
@@ -459,6 +495,7 @@ function ADFieldModel.fromRecord(record)
plan.product = PRODUCTS[record.product] and record.product or "auto"
plan.rake = readFlag(record.rake, false)
for id in string.gmatch(record.store or "", "%S+") do table.insert(plan.storeBuildingIds, id) end
for id in string.gmatch(record.dedicated or "", "%S+") do table.insert(plan.rigIds, id) end
plan.rigs = clamp(record.rigs or 1, 1, ADFieldModel.MAX_RIGS)
plan.unloaders = clamp(record.unloaders or 1, 1, ADFieldModel.MAX_UNLOADERS)
plan.mulch = readFlag(record.mulch, false)
@@ -471,6 +508,9 @@ function ADFieldModel.fromRecord(record)
plan.weed = readFlag(record.weed, true)
plan.targetFruit = record.targetFruit or ""
plan.prepAfterMow = readFlag(record.prepAfterMow, false)
plan.chainFree = readFlag(record.chainFree, false)
-- gamle saves: et eksisterende mål er valgt af spilleren
plan.targetSource = record.targetSource or (plan.targetFruit ~= "" and "user" or "")
plan.straw = STRAW_VALUES[record.straw] and record.straw or "chop"
field.kind = readText(record.kind)
field.lastFruit = readText(record.lastFruit)
+94
View File
@@ -0,0 +1,94 @@
-- AD Field Silo Config
-- Marksilo-flowets opsætning som ren model (ny tabel ud, input muteres ikke) + flade records til XML-laget.
ADFieldSiloConfig = {}
ADFieldSiloConfig.MAX_VEHICLES = 10
ADFieldSiloConfig.DEFAULT_MAX_VEHICLES = 2
ADFieldSiloConfig.MIN_LITERS_CHOICES = {5000, 10000, 25000, 50000}
ADFieldSiloConfig.DEFAULT_MIN_LITERS = 10000
-- foderreserve: staldenes forbrug i så mange døgn bliver på gården, før overskud køres til fabrik
ADFieldSiloConfig.RESERVE_DAYS_CHOICES = {3, 7, 14, 30}
ADFieldSiloConfig.DEFAULT_RESERVE_DAYS = 7
local function copy(cfg)
local ids = {}
for index, id in ipairs(cfg.storeIds or {}) do ids[index] = id end
return {enabled = cfg.enabled == true, storeIds = ids, maxVehicles = cfg.maxVehicles, minLiters = cfg.minLiters,
toProduction = cfg.toProduction == true, reserveDays = cfg.reserveDays}
end
local function clampVehicles(value)
value = tonumber(value) or ADFieldSiloConfig.DEFAULT_MAX_VEHICLES
return math.max(1, math.min(ADFieldSiloConfig.MAX_VEHICLES, math.floor(value)))
end
local function snap(value, choices, default)
value = tonumber(value)
if value == nil then return default end
local best, bestDiff = nil, nil
for _, choice in ipairs(choices) do
local diff = math.abs(choice - value)
if bestDiff == nil or diff < bestDiff then best, bestDiff = choice, diff end
end
return best
end
local function snapLiters(value)
return snap(value, ADFieldSiloConfig.MIN_LITERS_CHOICES, ADFieldSiloConfig.DEFAULT_MIN_LITERS)
end
local function snapDays(value)
return snap(value, ADFieldSiloConfig.RESERVE_DAYS_CHOICES, ADFieldSiloConfig.DEFAULT_RESERVE_DAYS)
end
function ADFieldSiloConfig.new()
return {enabled = false, storeIds = {}, maxVehicles = ADFieldSiloConfig.DEFAULT_MAX_VEHICLES, minLiters = ADFieldSiloConfig.DEFAULT_MIN_LITERS,
toProduction = false, reserveDays = ADFieldSiloConfig.DEFAULT_RESERVE_DAYS}
end
function ADFieldSiloConfig.with(cfg, changes)
local new = copy(cfg)
if changes.enabled ~= nil then new.enabled = changes.enabled == true end
if changes.maxVehicles ~= nil then new.maxVehicles = clampVehicles(changes.maxVehicles) end
if changes.minLiters ~= nil then new.minLiters = snapLiters(changes.minLiters) end
if changes.toProduction ~= nil then new.toProduction = changes.toProduction == true end
if changes.reserveDays ~= nil then new.reserveDays = snapDays(changes.reserveDays) end
return new
end
function ADFieldSiloConfig.hasStore(cfg, id)
for _, storeId in ipairs(cfg.storeIds or {}) do
if storeId == id then return true end
end
return false
end
function ADFieldSiloConfig.toggleStore(cfg, id)
local new = copy(cfg)
local kept, found = {}, false
for _, storeId in ipairs(new.storeIds) do
if storeId == id then found = true else table.insert(kept, storeId) end
end
if not found then table.insert(kept, id) end
new.storeIds = kept
return new
end
function ADFieldSiloConfig.toRecord(cfg)
return {enabled = cfg.enabled and 1 or 0, stores = table.concat(cfg.storeIds or {}, " "),
maxVehicles = cfg.maxVehicles, minLiters = cfg.minLiters, toProduction = cfg.toProduction and 1 or 0,
reserveDays = cfg.reserveDays}
end
function ADFieldSiloConfig.fromRecord(record)
local cfg = ADFieldSiloConfig.new()
if record == nil then return cfg end
for token in string.gmatch(tostring(record.stores or ""), "%S+") do table.insert(cfg.storeIds, token) end
cfg.enabled = tonumber(record.enabled) == 1
cfg.maxVehicles = clampVehicles(record.maxVehicles)
cfg.minLiters = snapLiters(record.minLiters)
cfg.toProduction = tonumber(record.toProduction) == 1
cfg.reserveDays = snapDays(record.reserveDays)
return cfg
end
+195
View File
@@ -0,0 +1,195 @@
-- AD Field Silo Planner
-- Ren planlægning for marksilo-flowet: hvilke (silo, vare) skal hjem, hvor mange lånte vogne sendes, og til
-- hvilket gårdlager eller (med "kør til produktion") hvilken fabrik. Ingen spil-kald; input muteres ikke.
ADFieldSiloPlanner = {}
function ADFieldSiloPlanner.key(siloId, fillType)
return tostring(siloId) .. ":" .. tostring(fillType)
end
local key = ADFieldSiloPlanner.key
local function dist2(a, b)
return ((a.x or 0) - (b.x or 0)) ^ 2 + ((a.z or 0) - (b.z or 0)) ^ 2
end
local function freeIn(store, fillType, reserved)
return ((store.free or {})[fillType] or 0) - (reserved[store.id .. ":" .. tostring(fillType)] or 0)
end
local function anyStoreTakes(stores, fillType, minLiters, reserved)
for _, store in ipairs(stores or {}) do
if store.markerId ~= nil and freeIn(store, fillType, reserved) >= minLiters then return true end
end
return false
end
local function isProduction(store)
return store ~= nil and store.kind == "production"
end
-- Nærmeste lager af den ønskede slags (fabrik/gård) med plads til hele turen; ellers det med mest plads.
local function chooseStore(stores, silo, fillType, liters, minLiters, reserved, production)
local best, bestD, most, mostFree = nil, nil, nil, nil
for _, store in ipairs(stores or {}) do
local free = freeIn(store, fillType, reserved)
if store.markerId ~= nil and free >= minLiters and isProduction(store) == production then
local d = dist2(store, silo)
if free >= liters and (bestD == nil or d < bestD) then best, bestD = store, d end
if mostFree == nil or free > mostFree then most, mostFree = store, free end
end
end
-- fabrikker kun med plads til hele læsset: en fabrik tager kun det den forbruger, så en vogn med resten
-- holder ved den i timevis (in-game 28/9: 252.000 l kartofler ved en fuld snackfabrik)
if production then return best end
return best or most
end
-- Tom silo: en lånt vogn med last, der har stået stille ved en tom marksilo i REST_IDLE_MS, kører resten videre
-- (AutoDrive venter ellers til vognen er fuld). s = {atSilo, stillMs, siloStock, cargo}
ADFieldSiloPlanner.REST_IDLE_MS = 30000
ADFieldSiloPlanner.REST_MIN_LITERS = 100
function ADFieldSiloPlanner.restDue(s)
return s.atSilo == true and (s.stillMs or 0) >= ADFieldSiloPlanner.REST_IDLE_MS
and (s.cargo or 0) >= ADFieldSiloPlanner.REST_MIN_LITERS and (s.siloStock or 0) < ADFieldSiloPlanner.REST_MIN_LITERS
end
-- Fuld fabrik/lager: vognen med last har holdt ved sit lager i STORE_FULL_MS, og lageret har ikke plads.
-- s = {atStore, stillMs, cargo, storeFree}
ADFieldSiloPlanner.STORE_FULL_MS = 60000
function ADFieldSiloPlanner.storeFullDue(s)
return s.atStore == true and (s.stillMs or 0) >= ADFieldSiloPlanner.STORE_FULL_MS
and (s.cargo or 0) >= ADFieldSiloPlanner.REST_MIN_LITERS and (s.storeFree or 0) < ADFieldSiloPlanner.REST_MIN_LITERS
end
-- Lager til en rest: fabrik kun med plads til hele resten, ellers gårdlager (nærmeste med plads, ellers mest plads).
-- excludeId: lageret vognen holder fast ved (fuldt).
function ADFieldSiloPlanner.restStore(stores, silo, fillType, liters, excludeId)
local candidates = {}
for _, store in ipairs(stores or {}) do
if store.id ~= excludeId then table.insert(candidates, store) end
end
return chooseStore(candidates, silo, fillType, liters, 1, {}, true) or chooseStore(candidates, silo, fillType, liters, 1, {}, false)
end
local function canCarry(vehicle, fillType)
if (vehicle.capacity or 0) <= 0 then return false end
if vehicle.cargo ~= nil and vehicle.cargo ~= fillType then return false end
return (vehicle.carries or {})[fillType] == true
end
local function nearestVehicle(vehicles, used, silo, fillType)
local best, bestD = nil, nil
for _, vehicle in ipairs(vehicles or {}) do
if not used[vehicle.id] and canCarry(vehicle, fillType) then
local d = dist2(vehicle, silo)
if bestD == nil or d < bestD then best, bestD = vehicle, d end
end
end
return best
end
-- Foder først: mangler gården stadig foderreserve for varen (reserve − beholdning − læs på vej hjem), køres til
-- gårdlager; ellers til fabrik. Findes kun den ene slags, bruges den.
local function pickStore(input, farmIncoming, task, liters, minLiters, reserved)
local fillType = task.fillType
local short = ((input.feedReserve or {})[fillType] or 0) - ((input.farmStock or {})[fillType] or 0) - (farmIncoming[fillType] or 0)
local wantProduction = short <= 0
return chooseStore(input.stores, task.silo, fillType, liters, minLiters, reserved, wantProduction)
or chooseStore(input.stores, task.silo, fillType, liters, minLiters, reserved, not wantProduction)
end
-- Høsten først: for hvert behov {fillType, count} (høstere i arbejde, der mangler tømmevogne) holdes så mange
-- vogne der kan bære afgrøden tilbage; resten må marksilo-flowet låne. Ny liste; input muteres ikke.
function ADFieldSiloPlanner.withoutHarvestReserve(vehicles, needs)
local held = {}
for _, need in ipairs(needs or {}) do
local left = need.count or 0
for _, vehicle in ipairs(vehicles or {}) do
if left <= 0 then break end
if not held[vehicle.id] and canCarry(vehicle, need.fillType) then
held[vehicle.id] = true
left = left - 1
end
end
end
local free = {}
for _, vehicle in ipairs(vehicles or {}) do
if not held[vehicle.id] then table.insert(free, vehicle) end
end
return free
end
function ADFieldSiloPlanner.plan(input)
local minLiters = input.minLiters or 10000
local storesById = {}
for _, store in ipairs(input.stores or {}) do storesById[store.id] = store end
local reserved, onWay, farmIncoming = {}, {}, {}
for _, loan in ipairs(input.loans or {}) do
local k = key(loan.siloId, loan.fillType)
onWay[k] = (onWay[k] or 0) + (loan.liters or 0)
if loan.storeId ~= nil and not isProduction(storesById[loan.storeId]) then
farmIncoming[loan.fillType] = (farmIncoming[loan.fillType] or 0) + (loan.liters or 0)
end
-- læs på vej til et lager optager pladsen, til de er aflæsset (også i senere runder)
if loan.storeId ~= nil then
local reserveKey = loan.storeId .. ":" .. tostring(loan.fillType)
reserved[reserveKey] = (reserved[reserveKey] or 0) + (loan.liters or 0)
end
end
local status, tasks, statusByKey = {}, {}, {}
for _, silo in ipairs(input.silos or {}) do
for fillType, liters in pairs(silo.stock or {}) do
if (liters or 0) >= minLiters then
local k = key(silo.id, fillType)
local entry = {siloId = silo.id, siloName = silo.name, fillType = fillType, liters = liters, onWay = onWay[k] or 0}
if silo.markerId == nil then
entry.state = "noMarker"
elseif not anyStoreTakes(input.stores, fillType, minLiters, reserved) then
entry.state = "noFarmStore"
elseif (input.paused or {})[k] then
entry.state = "paused"
else
entry.state = "ok"
local rest = liters - entry.onWay
if rest >= minLiters then table.insert(tasks, {silo = silo, fillType = fillType, rest = rest}) end
end
table.insert(status, entry)
statusByKey[k] = entry
end
end
end
table.sort(tasks, function(a, b)
if a.rest ~= b.rest then return a.rest > b.rest end
if tostring(a.silo.id) ~= tostring(b.silo.id) then return tostring(a.silo.id) < tostring(b.silo.id) end
return a.fillType < b.fillType
end)
table.sort(status, function(a, b)
if tostring(a.siloId) ~= tostring(b.siloId) then return tostring(a.siloId) < tostring(b.siloId) end
return a.fillType < b.fillType
end)
local freeSlots = math.max(0, (input.maxVehicles or 2) - #(input.loans or {}))
local used, dispatch = {}, {}
for _, task in ipairs(tasks) do
while freeSlots > 0 and task.rest >= minLiters do
local vehicle = nearestVehicle(input.vehicles, used, task.silo, task.fillType)
if vehicle == nil then break end
local liters = math.min(vehicle.capacity, task.rest)
local store = pickStore(input, farmIncoming, task, liters, minLiters, reserved)
if store == nil then break end
if isProduction(store) then
statusByKey[key(task.silo.id, task.fillType)].toProduction = true
else
farmIncoming[task.fillType] = (farmIncoming[task.fillType] or 0) + liters
end
used[vehicle.id] = true
local reserveKey = store.id .. ":" .. tostring(task.fillType)
reserved[reserveKey] = (reserved[reserveKey] or 0) + liters
table.insert(dispatch, {vehicleId = vehicle.id, siloId = task.silo.id, siloMarkerId = task.silo.markerId,
fillType = task.fillType, storeId = store.id, storeMarkerId = store.markerId, liters = liters})
task.rest = task.rest - liters
freeSlots = freeSlots - 1
end
end
return {dispatch = dispatch, status = status, freeSlots = freeSlots}
end
+720
View File
@@ -0,0 +1,720 @@
-- AD Field Silos
-- Marksilo-flowet i spillet: samler marksiloer, gårdlagre og ledige vogne, lader ADFieldSiloPlanner vælge,
-- med "kør til produktion" går overskud ud over gårdens foderreserve til gårdens fabrikker;
-- låner vogne (AutoDrive "Hent og aflever", én tur, parkering bagefter) og frigiver dem igen.
-- Lån ligger kun i hukommelsen; opsætningen gemmes i ADSmartPickup_fieldsilos.xml.
ADFieldSilos = {}
ADFieldSilos.PLAN_INTERVAL_MS = 60000
ADFieldSilos.CHECK_INTERVAL_MS = 2000
ADFieldSilos.STUCK_MS = 120000
ADFieldSilos.PAUSE_MS = 600000
ADFieldSilos.FAIL_MS = 30000 -- AD slukket inden for 30 s uden fremdrift = turen kunne ikke køres
ADFieldSilos.CARGO_MIN_LITERS = 100 -- rest under dette tæller ikke som last
-- Silo så tæt på et gårdlager er en del af gården (Farma-siloer deler lager inden for 50 m): ikke marksilo.
-- (in-game 27/9: sukkerroer blev kørt fra én Farma-silo til nabosiloen i samme klynge)
ADFieldSilos.FARM_RADIUS = 100
-- Siloer højst så langt fra en nabo hører til samme klynge (Farma-rækker: 15 m mellem siloer, 18 m mellem rækker)
ADFieldSilos.CLUSTER_LINK = 60
ADFieldSilos.REST_SILO_DISTANCE = 40 -- m fra marksiloens markør: vognen holder ved siloen
ADFieldSilos.FETCH_TIMEOUT_MS = 300000 -- så længe må en traktor være om at hente en puljevogn
ADFieldSilos.MOVING_SPEED = 0.00028 -- m/ms, samme grænse som AutoDrive
ADFieldSilos.FILE_NAME = "ADSmartPickup_fieldsilos.xml"
ADFieldSilos.ROOT = "ADSmartPickupFieldSilos"
ADFieldSilos.LOG_PREFIX = "ADSmartPickup: marksilo: "
ADFieldSilos.config = ADFieldSiloConfig.new()
ADFieldSilos.lastPlan = nil
local loans = setmetatable({}, {__mode = "k"}) -- vehicle -> {siloId, siloName, fillType, liters, startedMs, lastProgressMs, lastFill}
local paused = {} -- key -> untilMs
local planTimerMs, checkTimerMs, nowMs = ADFieldSilos.PLAN_INTERVAL_MS, 0, 0
-- ---- ren frigivelsesregel ----
-- vehicleState = {adActive, controlled, fill, moving}. Fremdrift = kører, eller fyldningen ændrer sig (læsser
-- eller aflæsser). Vagten gælder kun før turen er kommet i gang — kø ved siloen/lageret er normalt.
function ADFieldSilos.releaseReason(loan, vehicleState, now)
if vehicleState.controlled then return "player" end
local progressing = vehicleState.moving or (vehicleState.fill or 0) ~= (loan.lastFill or 0)
local hasProgressed = loan.hasProgressed or progressing
local age = now - (loan.startedMs or 0)
if not vehicleState.adActive then
return (not hasProgressed and age < ADFieldSilos.FAIL_MS) and "failed" or "done"
end
if hasProgressed then return nil end
if age > ADFieldSilos.STUCK_MS then return "stuck" end
return nil
end
-- ---- opsætning ----
function ADFieldSilos.setConfig(cfg)
local wasEnabled = ADFieldSilos.config.enabled
ADFieldSilos.config = cfg
if wasEnabled and not cfg.enabled then ADFieldSilos.stopAll(ADFieldSilos.adEnv) end
planTimerMs = ADFieldSilos.PLAN_INTERVAL_MS -- planlæg straks ved næste tick
end
function ADFieldSilos.isLoaned(vehicle)
return loans[vehicle] ~= nil
end
function ADFieldSilos.loanCount()
local n = 0
for _ in pairs(loans) do n = n + 1 end
return n
end
-- ---- spil-input ----
local function farmId()
return g_currentMission ~= nil and g_currentMission.getFarmId ~= nil and g_currentMission:getFarmId() or 1
end
local function position(node)
if node == nil then return 0, 0 end
local x, _, z = getWorldTranslation(node)
return x, z
end
local function siloStock(placeable)
local stock = {}
local spec = placeable ~= nil and placeable.spec_silo or nil
for _, storage in pairs(spec ~= nil and spec.storages or {}) do
for fillType, level in pairs(storage.fillLevels or {}) do
if (level or 0) > 0 then stock[fillType] = (stock[fillType] or 0) + level end
end
end
return stock
end
local function siloFree(placeable)
local free = {}
local spec = placeable ~= nil and placeable.spec_silo or nil
for _, storage in pairs(spec ~= nil and spec.storages or {}) do
for fillType in pairs(storage.fillTypes or {}) do
local amount = storage.getFreeCapacity ~= nil and (storage:getFreeCapacity(fillType) or 0) or 0
free[fillType] = (free[fillType] or 0) + amount
end
end
return free
end
local function firstMarker(building, role)
local markers = ADBuildings.getMarkers(building, role)
return markers[1] ~= nil and markers[1].id or nil
end
-- Bygninger der er kilde, mål eller lager i et flow: dem tømmer marksilo-flowet ikke (de hører til den drift).
local function runBuildingIds()
local ids = {}
local store = ADRunsController ~= nil and ADRunsController.store or nil
for _, run in ipairs(store ~= nil and store.runs or {}) do
for _, list in ipairs({run.sourceBuildingIds or {}, run.targetBuildingIds or {}, run.storeBuildingIds or {}}) do
for _, id in ipairs(list) do ids[id] = true end
end
end
return ids
end
-- Alle siloer i samme klynge som et gårdlager (kæde af naboer ≤ CLUSTER_LINK): gårdens, aldrig marksiloer.
-- (in-game 27/9: gårdens Farma-klynge er ~300 m lang; siloer over 100 m fra et valgt lager blev marksiloer)
local function farmCluster(allSilos, storeIds)
local farm, queue = {}, {}
for _, silo in ipairs(allSilos) do
if storeIds[silo.id] then farm[silo.id] = true; table.insert(queue, silo) end
end
local link2 = ADFieldSilos.CLUSTER_LINK ^ 2
while #queue > 0 do
local current = table.remove(queue)
for _, silo in ipairs(allSilos) do
if not farm[silo.id] and (silo.x - current.x) ^ 2 + (silo.z - current.z) ^ 2 <= link2 then
farm[silo.id] = true
table.insert(queue, silo)
end
end
end
return farm
end
local function nearStore(stores, x, z)
for _, store in ipairs(stores) do
if (store.x - x) ^ 2 + (store.z - z) ^ 2 <= ADFieldSilos.FARM_RADIUS ^ 2 then return true end
end
return false
end
-- Fabrikkens input-varer (dem aflæsningen tager imod) med fri plads i dens lager.
local function factoryFree(placeable)
local spec = placeable ~= nil and placeable.spec_productionPoint or nil
local point = spec ~= nil and spec.productionPoint or nil
local storage = point ~= nil and point.storage or nil
if storage == nil or storage.getFreeCapacity == nil then return {} end
local inputs = {}
for fillType in pairs(point.inputFillTypeIds or {}) do inputs[fillType] = true end
for _, production in ipairs(point.productions or {}) do
for _, input in ipairs(production.inputs or {}) do inputs[input.type] = true end
end
local free = {}
for fillType in pairs(inputs) do free[fillType] = storage:getFreeCapacity(fillType) or 0 end
return free
end
-- "Kør til produktion": gårdens fabrikker med aflæsningsmarkør bliver mål ved siden af gårdlagrene.
local function addFactories(stores, buildings)
for _, building in ipairs(buildings) do
local markerId = building.kind == "factory" and firstMarker(building, "unload") or nil
if markerId ~= nil then
local x, z = position(building.placeable ~= nil and building.placeable.rootNode or nil)
table.insert(stores, {id = building.id, name = building.name, kind = "production", markerId = markerId,
x = x, z = z, free = factoryFree(building.placeable)})
end
end
end
-- Foderreserve (staldenes døgnforbrug × døgn) og gårdlagrenes beholdning pr. vare.
function ADFieldSilos.collectFeed(stores, byId, fid, reserveDays)
local reserve, stock = {}, {}
local rates = ADSources ~= nil and ADSources.getFarmRates ~= nil and ADSources.getFarmRates(ADFieldSilos.adEnv, fid, true) or {}
for fillType, rate in pairs(rates) do reserve[fillType] = (rate.consumption or 0) * reserveDays end
local counted = {} -- Farma-siloer i samme klynge deler lager: tæl hvert lager én gang
for _, store in ipairs(stores) do
local building = byId[store.id]
local spec = store.kind ~= "production" and building ~= nil and building.placeable ~= nil and building.placeable.spec_silo or nil
for _, storage in pairs(spec ~= nil and spec.storages or {}) do
if not counted[storage] then
counted[storage] = true
for fillType, level in pairs(storage.fillLevels or {}) do stock[fillType] = (stock[fillType] or 0) + (level or 0) end
end
end
end
return reserve, stock
end
-- Gårdlagre fra opsætningen + marksiloer (alle markers marksiloer, uden dubletter og uden gårdlagre).
function ADFieldSilos.collectBuildings(adEnv)
local fid = farmId()
local stores, byId, allSilos, storeIds = {}, {}, {}, {}
for _, building in ipairs(ADBuildings.list(adEnv, fid)) do
byId[building.id] = building
if building.kind == "silo" then
local sx, sz = position(building.placeable ~= nil and building.placeable.rootNode or nil)
table.insert(allSilos, {id = building.id, x = sx, z = sz})
if ADFieldSiloConfig.hasStore(ADFieldSilos.config, building.id) then storeIds[building.id] = true end
end
if building.kind == "silo" and ADFieldSiloConfig.hasStore(ADFieldSilos.config, building.id) then
local x, z = position(building.placeable ~= nil and building.placeable.rootNode or nil)
table.insert(stores, {id = building.id, name = building.name, markerId = firstMarker(building, "unload"),
x = x, z = z, free = siloFree(building.placeable)})
end
end
if ADFieldSilos.config.toProduction then addFactories(stores, ADBuildings.list(adEnv, fid)) end
local silos, seen = {}, runBuildingIds()
for id in pairs(farmCluster(allSilos, storeIds)) do seen[id] = true end
for _, info in ipairs(ADFields.list()) do
for _, building in ipairs(ADFields.fieldSilos(adEnv, fid, info)) do
local x, z = position(building.placeable ~= nil and building.placeable.rootNode or nil)
if not seen[building.id] and not ADFieldSiloConfig.hasStore(ADFieldSilos.config, building.id) and not nearStore(stores, x, z) then
seen[building.id] = true
-- marknummeret først: navnet skæres af i fanen, og mange siloer hedder ens ("Farma 500 …")
-- marknummeret først (medmindre navnet allerede starter med det, fx "F22 marksilo")
local siloName = tostring(building.name)
if not siloName:match("^F" .. tostring(info.id) .. "%f[%D]") then siloName = string.format("F%s %s", tostring(info.id), siloName) end
table.insert(silos, {id = building.id, name = siloName, markerId = firstMarker(building, "load"),
x = x, z = z, stock = siloStock(building.placeable)})
end
end
end
return silos, stores, byId
end
-- Marksiloernes placeables som sæt (til at udelukke dem som tømmested — de er til markens høst).
function ADFieldSilos.fieldSiloPlaceableSet(adEnv)
local set = {}
local silos, _, byId = ADFieldSilos.collectBuildings(adEnv)
for _, silo in ipairs(silos or {}) do
local building = byId[silo.id]
if building ~= nil and building.placeable ~= nil then set[building.placeable] = true end
end
return set
end
local function trailerInfo(adEnv, vehicle)
local capacity, carries, cargo = 0, {}, nil
for _, unit in ipairs(adEnv.AutoDrive.getAllDischargeableUnits(vehicle) or {}) do
local object, index = unit.object, unit.fillUnitIndex
if object ~= nil and object.getFillUnitCapacity ~= nil then
capacity = capacity + (object:getFillUnitCapacity(index) or 0)
for fillType in pairs(object.getFillUnitSupportedFillTypes ~= nil and object:getFillUnitSupportedFillTypes(index) or {}) do
carries[fillType] = true
end
if (object:getFillUnitFillLevel(index) or 0) >= ADFieldSilos.CARGO_MIN_LITERS then cargo = object:getFillUnitFillType(index) end
end
end
return capacity, carries, cargo
end
local function fillLevel(adEnv, vehicle)
local ok, level = pcall(function() return (adEnv.AutoDrive.getAllFillLevels(adEnv.AutoDrive.getAllUnits(vehicle))) end)
return ok and tonumber(level) or 0
end
function ADFieldSilos.collectVehicles(adEnv)
local vehicles, byId = {}, {}
-- hvorfor tømmevogne ikke kan lånes (til fanens statuslinje)
local why = {notEnabled = 0, busy = 0, controlled = 0, noPark = 0}
local rigs = ADFieldWork.getRigs(adEnv)
for _, rig in ipairs(rigs) do
-- uden AD-parkeringspunkt bliver vognen holdende på lagerets markør og spærrer for de næste
local canPark = ADFieldWork.parkMarkerId == nil or ADFieldWork.parkMarkerId(adEnv, rig.vehicle) ~= nil
if rig.role == "unloader" then
if not rig.enabled then why.notEnabled = why.notEnabled + 1
elseif rig.controlled then why.controlled = why.controlled + 1
elseif rig.busy then why.busy = why.busy + 1
elseif not canPark then why.noPark = why.noPark + 1 end
end
if rig.role == "unloader" and rig.enabled and not rig.busy and not rig.controlled and canPark then
local capacity, carries, cargo = trailerInfo(adEnv, rig.vehicle)
table.insert(vehicles, {id = rig.id, x = rig.x, z = rig.z, capacity = capacity, carries = carries, cargo = cargo})
byId[rig.id] = rig.vehicle
end
end
-- puljevogne: en ledig solo-traktor henter vognen først (maskinpulje etape 3)
local poolById = {}
if ADPool ~= nil then
local okPool, implements, tractors = pcall(ADPool.collect, adEnv, function(v)
return ADFieldWork.isBusyVehicle(v, ADRunsController.getVehicleId(v))
end)
if okPool then
for _, pair in ipairs(ADPool.pairAll("unloader", implements, tractors, ADPool.blocked(ADFieldJobs.now or 0))) do
local entryId = "pool:" .. tostring(pair.implement.id)
table.insert(vehicles, {id = entryId, x = pair.implement.x, z = pair.implement.z,
capacity = pair.implement.capacity or 0, carries = pair.implement.carries or {}})
byId[entryId] = pair.tractor.vehicle
poolById[entryId] = pair
end
else
Logging.warning(ADFieldSilos.LOG_PREFIX .. "maskinpuljen kunne ikke læses: %s", tostring(implements))
end
end
local ok, needs = pcall(ADFieldSilos.harvestNeeds, adEnv, rigs)
if ok and #needs > 0 then
local before = #vehicles
vehicles = ADFieldSiloPlanner.withoutHarvestReserve(vehicles, needs)
why.harvest = before - #vehicles
elseif not ok then
Logging.warning(ADFieldSilos.LOG_PREFIX .. "høstreserve kunne ikke beregnes: %s", tostring(needs))
end
why.eligible = #vehicles
ADFieldSilos.lastVehicleReasons = why
return vehicles, byId, poolById
end
-- Høsten først: pr. høster der arbejder (CP kører) holdes dens planlagte antal tømmevogne tilbage, minus dem
-- markarbejdet allerede har sendt til marken. (in-game 28/9: marksilo-flowet havde lånt begge kartoffelvogne,
-- og ingen kom til roeoptagerne.)
function ADFieldSilos.harvestNeeds(adEnv, rigs)
local assignments = ADFieldJobs ~= nil and ADFieldJobs.state ~= nil and ADFieldJobs.state.assignments or {}
local sent = {}
for _, assignment in pairs(assignments) do
if assignment.role == "unloader" and assignment.fieldId ~= nil then sent[assignment.fieldId] = (sent[assignment.fieldId] or 0) + 1 end
end
local needs = {}
for _, rig in ipairs(rigs) do
if rig.role == "harvester" and ADCpBridge.isWorking(rig.vehicle) then
local assignment = assignments[rig.id]
local fieldId = assignment ~= nil and assignment.fieldId or nil
local field = fieldId ~= nil and ADFieldJobs.getField(fieldId) or nil
local info = fieldId ~= nil and ADFields.getInfo(fieldId) or nil
local count = field ~= nil and ADFieldModel.maxHelpers("harvesting", field.plan) or 1
count = count - (fieldId ~= nil and (sent[fieldId] or 0) or 0)
local fillTypes = {}
if info ~= nil and info.harvestFillType ~= nil then
fillTypes[info.harvestFillType] = true
else
for fruitIndex in pairs(rig.fruits or {}) do
local fruit = g_fruitTypeManager ~= nil and g_fruitTypeManager:getFruitTypeByIndex(fruitIndex) or nil
if fruit ~= nil and fruit.fillType ~= nil then fillTypes[fruit.fillType.index] = true end
end
end
for fillType in pairs(fillTypes) do
if count > 0 then table.insert(needs, {fillType = fillType, count = count}) end
end
end
end
return needs
end
-- ---- lån ----
local function fillTypeName(fillType)
local desc = g_fillTypeManager ~= nil and g_fillTypeManager:getFillTypeByIndex(fillType) or nil
return desc ~= nil and (desc.title or desc.name) or tostring(fillType)
end
local function dispatchOne(adEnv, order, vehicle, silosById, storesById, isPool)
local module = vehicle.ad.stateModule
module:setMode(adEnv.AutoDrive.MODE_PICKUPANDDELIVER)
module:setFirstMarkerByWayPointId(order.siloMarkerId)
module:setSecondMarkerByWayPointId(order.storeMarkerId)
module.selectedFillTypes = {order.fillType}
module:setFillType(order.fillType)
module.loopCounter = 1
if module.setLoopsDone ~= nil then module:setLoopsDone(0) end
-- puljevogn: ingen AD-parkering — ved turens slut sætter returnHome vognen af og kører traktoren hjem
-- (med AD-parkering kørte den først hjem med vognen og så tilbage efter den)
if not isPool then ADFieldWork.enableParkAtJobFinished(vehicle) end
-- rotation fra: ellers kan modens egne pickup-hooks sende vognen til en anden silo i mappen
if ADRunsController ~= nil and ADRunsController.setSetting ~= nil then
ADRunsController.setSetting(adEnv, vehicle, "rotateTargets", adEnv.AutoDrive.RT_NONE)
end
module:raiseDirtyFlag()
ADFieldWork.startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_PICKUPANDDELIVER, order.siloMarkerId, order.storeMarkerId, order.fillType)
local silo, store = silosById[order.siloId], storesById[order.storeId]
loans[vehicle] = {siloId = order.siloId, storeId = order.storeId, siloName = silo ~= nil and silo.name or order.siloId, fillType = order.fillType,
liters = order.liters, startedMs = nowMs, lastProgressMs = nowMs, lastFill = fillLevel(adEnv, vehicle)}
Logging.info(ADFieldSilos.LOG_PREFIX .. "'%s' henter %d l %s i %s -> %s", tostring(ADRuns.vehicleLabel(vehicle)),
math.floor(order.liters), fillTypeName(order.fillType), tostring(silo ~= nil and silo.name or order.siloId),
tostring(store ~= nil and store.name or order.storeId))
end
-- Puljevogn: traktoren henter vognen ved "Connect X" og kører derefter turen som en almindelig tømmevogn.
local function dispatchPool(adEnv, order, pair, silosById, storesById)
local tractor = pair.tractor.vehicle
local silo = silosById[order.siloId]
local owner = {siloId = order.siloId, storeId = order.storeId, siloName = silo ~= nil and silo.name or order.siloId,
fillType = order.fillType, liters = order.liters, startedMs = nowMs, lastProgressMs = nowMs, lastFill = 0, fetching = true}
loans[tractor] = owner
ADHitch.fetch(adEnv, tractor, pair.implement.connectId, function(_, ok, detail)
ADPool.release(owner)
if loans[tractor] ~= owner then return end -- lånet blev afbrudt imens (Stop alt / flow slået fra)
if not ok then
loans[tractor] = nil
local blamed = ADPool.failureBlames(detail) == "tractor" and pair.tractor.id or pair.implement.id
ADPool.quarantine(blamed, ADFieldJobs.now or 0)
Logging.warning(ADFieldSilos.LOG_PREFIX .. "'%s' kunne ikke hente puljevognen '%s' (%s) — prøver en anden om lidt",
tostring(ADRuns.vehicleLabel(tractor)), tostring(pair.implement.name), tostring(detail))
local park = ADFieldWork.parkMarkerId(adEnv, tractor)
if park ~= nil then ADFieldWork.startAd(adEnv, tractor, adEnv.AutoDrive.MODE_DRIVETO, park, nil) end
return
end
pcall(ADHitch.foldForTransport, tractor)
local okDispatch, err = pcall(dispatchOne, adEnv, order, tractor, silosById, storesById, true)
if not okDispatch then
Logging.warning(ADFieldSilos.LOG_PREFIX .. "'%s' kunne ikke starte turen med puljevognen: %s", tostring(ADRuns.vehicleLabel(tractor)), tostring(err))
ADFieldSilos.releaseVehicle(adEnv, tractor, "failed")
end
end)
-- reservationen først når hentningen er sat i gang (fejler fetch, er der intet at frigive)
ADPool.reserve(owner, {pair.implement.id, pair.tractor.id})
Logging.info(ADFieldSilos.LOG_PREFIX .. "'%s' henter puljevognen '%s' til %d l %s i %s", tostring(ADRuns.vehicleLabel(tractor)),
tostring(pair.implement.name), math.floor(order.liters), fillTypeName(order.fillType), tostring(silo ~= nil and silo.name or order.siloId))
end
local function release(adEnv, vehicle, reason)
local loan = loans[vehicle]
loans[vehicle] = nil
if loan == nil then return end
if loan.fetching then
-- henter stadig puljevognen: afbryd koblingen og frigiv reservationen
if ADHitch ~= nil then ADHitch.cancel(vehicle) end
if ADPool ~= nil then ADPool.release(loan) end
end
if reason == "stuck" or reason == "failed" then
local k = ADFieldSiloPlanner.key(loan.siloId, loan.fillType)
paused[k] = nowMs + ADFieldSilos.PAUSE_MS
pcall(vehicle.stopAutoDrive, vehicle)
local why = reason == "stuck" and "kom ikke i gang på 2 min — stoppet" or "AutoDrive stoppede turen med det samme (rute/læssested?)"
Logging.warning(ADFieldSilos.LOG_PREFIX .. "'%s' ved %s (%s): %s; siloen springes over i 10 min",
tostring(ADRuns.vehicleLabel(vehicle)), tostring(loan.siloName), fillTypeName(loan.fillType), why)
elseif reason == "stopped" then
pcall(vehicle.stopAutoDrive, vehicle)
end
Logging.info(ADFieldSilos.LOG_PREFIX .. "'%s' frigivet (%s)", tostring(ADRuns.vehicleLabel(vehicle)), tostring(reason))
-- puljevogn bag traktoren: sæt den af på sin plads og kør hjem (ikke når spilleren har overtaget)
if reason ~= "player" and ADHitch ~= nil then
local okHome, pooled = pcall(ADHitch.returnHome, adEnv, vehicle)
if not (okHome and pooled) and loan.fetching then
local park = ADFieldWork.parkMarkerId(adEnv, vehicle)
if park ~= nil then ADFieldWork.startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, park, nil) end
end
end
end
function ADFieldSilos.releaseVehicle(adEnv, vehicle, reason)
release(adEnv, vehicle, reason)
end
function ADFieldSilos.stopAll(adEnv)
local list = {}
for vehicle in pairs(loans) do table.insert(list, vehicle) end
for _, vehicle in ipairs(list) do release(adEnv, vehicle, "stopped") end
end
-- Efter genindlæsning genoptager AutoDrive selv "Hent og aflever" fra marksiloerne, men lånene er glemt: sådanne
-- vogne overtages igen som lån (tæller i "højst x ude", og tom silo-vagten gælder).
function ADFieldSilos.adoptOrphans(adEnv, silos)
local byMarker = {}
for _, silo in ipairs(silos or {}) do
if silo.markerId ~= nil then byMarker[silo.markerId] = silo end
end
for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do
local module = vehicle.ad ~= nil and vehicle.ad.stateModule or nil
if loans[vehicle] == nil and module ~= nil and module:isActive() and module:getMode() == adEnv.AutoDrive.MODE_PICKUPANDDELIVER then
local first = module:getFirstMarker()
local silo = first ~= nil and byMarker[first.id] or nil
if silo ~= nil then
local fillType = module.getFillType ~= nil and module:getFillType() or nil
local second = module.getSecondMarker ~= nil and module:getSecondMarker() or nil
local storeId = nil
for _, store in ipairs(ADFieldSilos.lastStores or {}) do
if second ~= nil and store.markerId == second.id then storeId = store.id end
end
loans[vehicle] = {siloId = silo.id, storeId = storeId, siloName = silo.name, fillType = fillType, liters = 0, startedMs = nowMs,
lastProgressMs = nowMs, lastFill = fillLevel(adEnv, vehicle), hasProgressed = true, adopted = true}
Logging.info(ADFieldSilos.LOG_PREFIX .. "'%s' overtaget igen (AutoDrive kører fra %s efter genindlæsning)",
tostring(ADRuns.vehicleLabel(vehicle)), tostring(silo.name))
end
end
end
end
-- Tom silo-vagt: en lånt vogn med last, der står stille ved en tom marksilo, kører resten videre — til en fabrik
-- med plads til hele resten, ellers til et gårdlager (AutoDrive venter ellers til vognen er fuld).
local warnedNoRestStore = {}
local function isNear(adEnv, vehicle, markerId)
local wayPoint = markerId ~= nil and adEnv.ADGraphManager:getWayPointById(markerId) or nil
if wayPoint == nil or vehicle.rootNode == nil then return false end
local x, _, z = getWorldTranslation(vehicle.rootNode)
return (x - wayPoint.x) ^ 2 + (z - wayPoint.z) ^ 2 <= ADFieldSilos.REST_SILO_DISTANCE ^ 2
end
local function findById(list, id)
for _, item in ipairs(list or {}) do
if item.id == id then return item end
end
return nil
end
local function checkRest(adEnv, vehicle, loan, moving, fill)
if loan.fetching then return end
local silo = findById(ADFieldSilos.lastSilos, loan.siloId)
if silo == nil then return end
if moving then loan.stillSince = nil return end
loan.stillSince = loan.stillSince or nowMs
local stillMs = nowMs - loan.stillSince
local fullStore = nil
if not loan.delivering and isNear(adEnv, vehicle, silo.markerId) then
-- tom marksilo med last på
local state = {atSilo = true, stillMs = stillMs, siloStock = (silo.stock or {})[loan.fillType] or 0, cargo = fill}
if not ADFieldSiloPlanner.restDue(state) then return end
else
-- fuldt lager (fabrik der kun tager sit forbrug) med last på
local current = findById(ADFieldSilos.lastStores, loan.storeId)
if current == nil or not isNear(adEnv, vehicle, current.markerId) then return end
local state = {atStore = true, stillMs = stillMs, cargo = fill, storeFree = (current.free or {})[loan.fillType] or 0}
if not ADFieldSiloPlanner.storeFullDue(state) then return end
fullStore = current
end
local store = ADFieldSiloPlanner.restStore(ADFieldSilos.lastStores, silo, loan.fillType, fill, fullStore ~= nil and fullStore.id or nil)
if store == nil then
if not warnedNoRestStore[vehicle] then
warnedNoRestStore[vehicle] = true
Logging.warning(ADFieldSilos.LOG_PREFIX .. "'%s' har %d l %s fra tom %s, men intet lager har plads",
tostring(ADRuns.vehicleLabel(vehicle)), math.floor(fill), fillTypeName(loan.fillType), tostring(silo.name))
end
return
end
warnedNoRestStore[vehicle] = nil
loan.delivering, loan.storeId, loan.stillSince = true, store.id, nil
loan.startedMs, loan.hasProgressed = nowMs, true
local isPool = ADHitch ~= nil and #ADHitch.poolParkIds(adEnv, vehicle) > 0
if not isPool then ADFieldWork.enableParkAtJobFinished(vehicle) end
ADFieldWork.startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DELIVERTO, store.markerId, nil, loan.fillType)
Logging.info(ADFieldSilos.LOG_PREFIX .. "'%s': %s — kører resten (%d l %s) til %s",
tostring(ADRuns.vehicleLabel(vehicle)), fullStore ~= nil and (tostring(fullStore.name) .. " er fuld") or (tostring(silo.name) .. " er tom"),
math.floor(fill), fillTypeName(loan.fillType), tostring(store.name))
end
local function checkLoans(adEnv)
local list = {}
for vehicle in pairs(loans) do table.insert(list, vehicle) end
for _, vehicle in ipairs(list) do
if vehicle.isDeleted then
loans[vehicle] = nil -- solgt/slettet mens den var lånt
else
local ok, err = pcall(function()
local loan = loans[vehicle]
-- henter puljevognen: AD holder stille under koblingen — ikke "færdig"; men spilleren kan overtage,
-- og en hentning der ikke bliver færdig (trafik, rute), opgives
if loan.fetching then
local controlled = vehicle.getIsControlled ~= nil and vehicle:getIsControlled() or false
if controlled then
release(adEnv, vehicle, "player")
elseif nowMs - (loan.startedMs or nowMs) > ADFieldSilos.FETCH_TIMEOUT_MS then
release(adEnv, vehicle, "stuck")
end
return
end
local stateModule = vehicle.ad ~= nil and vehicle.ad.stateModule or nil
local fill = fillLevel(adEnv, vehicle)
local vehicleState = {
adActive = stateModule ~= nil and stateModule:isActive(),
controlled = vehicle.getIsControlled ~= nil and vehicle:getIsControlled() or false,
fill = fill, moving = (vehicle.lastSpeedReal or 0) >= ADFieldSilos.MOVING_SPEED,
}
checkRest(adEnv, vehicle, loan, vehicleState.moving, fill)
if loans[vehicle] ~= loan then return end
vehicleState.adActive = stateModule ~= nil and stateModule:isActive()
local reason = ADFieldSilos.releaseReason(loan, vehicleState, nowMs)
if vehicleState.moving or fill ~= (loan.lastFill or 0) then loan.hasProgressed = true end
loan.lastFill = fill
if reason ~= nil then release(adEnv, vehicle, reason) end
end)
if not ok then
loans[vehicle] = nil
Logging.warning(ADFieldSilos.LOG_PREFIX .. "lån opgivet (fejl): %s", tostring(err))
end
end
end
end
local function currentPaused()
local result = {}
for k, untilMs in pairs(paused) do
if untilMs > nowMs then result[k] = true else paused[k] = nil end
end
return result
end
local function loanList()
local list = {}
for _, loan in pairs(loans) do table.insert(list, {siloId = loan.siloId, storeId = loan.storeId, fillType = loan.fillType, liters = loan.liters}) end
return list
end
-- Rute fra vognen til siloen og videre til lageret (samme opslag som Wait-punkterne); én advarsel pr. kombination.
local warnedRoute = {}
local function hasRoutes(adEnv, vehicle, order)
if ADUnloadWait == nil or ADUnloadWait.hasRouteTo == nil then return true end
local ok = ADUnloadWait.hasRouteTo(adEnv, vehicle, order.siloMarkerId) and ADUnloadWait.hasRouteTo(adEnv, vehicle, order.storeMarkerId)
local warnKey = tostring(order.vehicleId) .. ">" .. tostring(order.siloMarkerId)
if not ok and not warnedRoute[warnKey] then
warnedRoute[warnKey] = true
Logging.warning(ADFieldSilos.LOG_PREFIX .. "ingen AutoDrive-rute for '%s' til marksilo-markør %s eller gårdlager-markør %s",
tostring(ADRuns.vehicleLabel(vehicle)), tostring(order.siloMarkerId), tostring(order.storeMarkerId))
end
return ok
end
function ADFieldSilos.planNow(adEnv)
local cfg = ADFieldSilos.config
local silos, stores, byId = ADFieldSilos.collectBuildings(adEnv)
ADFieldSilos.lastSilos, ADFieldSilos.lastStores = silos, stores
if cfg.enabled then
local okAdopt, errAdopt = pcall(ADFieldSilos.adoptOrphans, adEnv, silos)
if not okAdopt then Logging.warning(ADFieldSilos.LOG_PREFIX .. "overtagelse efter genindlæsning fejlede: %s", tostring(errAdopt)) end
end
local vehicles, vehiclesById, poolById = ADFieldSilos.collectVehicles(adEnv)
local feedReserve, farmStock = nil, nil
if cfg.toProduction then
local ok, reserve, stock = pcall(ADFieldSilos.collectFeed, stores, byId, farmId(), cfg.reserveDays)
if ok then feedReserve, farmStock = reserve, stock
else Logging.warning(ADFieldSilos.LOG_PREFIX .. "foderreserve kunne ikke beregnes: %s", tostring(reserve)) end
end
local plan = ADFieldSiloPlanner.plan({silos = silos, stores = stores, loans = loanList(), vehicles = vehicles,
paused = currentPaused(), maxVehicles = cfg.maxVehicles, minLiters = cfg.minLiters,
feedReserve = feedReserve, farmStock = farmStock})
ADFieldSilos.lastFeed = {reserve = feedReserve, stock = farmStock}
ADFieldSilos.lastPlan = plan
local paused = ADFieldJobs ~= nil and ADFieldJobs.state ~= nil and ADFieldJobs.state.paused
if not cfg.enabled or paused then return plan end
local silosById, storesById = {}, {}
for _, silo in ipairs(silos) do silosById[silo.id] = silo end
for _, store in ipairs(stores) do storesById[store.id] = store end
for _, order in ipairs(plan.dispatch) do
local vehicle = vehiclesById[order.vehicleId]
if vehicle ~= nil and not hasRoutes(adEnv, vehicle, order) then
vehicle = nil
end
local pair = poolById ~= nil and poolById[order.vehicleId] or nil
if vehicle ~= nil and pair ~= nil then
local ok, err = pcall(dispatchPool, adEnv, order, pair, silosById, storesById)
if not ok then
loans[vehicle] = nil
Logging.warning(ADFieldSilos.LOG_PREFIX .. "kunne ikke sende '%s' efter puljevogn: %s", tostring(ADRuns.vehicleLabel(vehicle)), tostring(err))
end
elseif vehicle ~= nil then
local ok, err = pcall(dispatchOne, adEnv, order, vehicle, silosById, storesById)
if not ok then
loans[vehicle] = nil
Logging.warning(ADFieldSilos.LOG_PREFIX .. "kunne ikke sende '%s': %s", tostring(ADRuns.vehicleLabel(vehicle)), tostring(err))
end
end
end
return plan
end
function ADFieldSilos.tick(adEnv, dt)
if adEnv == nil then return end
ADFieldSilos.adEnv = adEnv
nowMs = nowMs + (dt or 0)
checkTimerMs = checkTimerMs + (dt or 0)
if checkTimerMs >= ADFieldSilos.CHECK_INTERVAL_MS then
checkTimerMs = 0
checkLoans(adEnv)
end
planTimerMs = planTimerMs + (dt or 0)
if planTimerMs >= ADFieldSilos.PLAN_INTERVAL_MS then
planTimerMs = 0
ADFieldSilos.planNow(adEnv)
end
end
-- ---- gem / indlæs (samme sti-opslag som runs) ----
local function getPath()
local path = ADRunsStorage ~= nil and ADRunsStorage.getPath() or nil
if path == nil then return nil end
return (path:gsub(ADRunsStorage.FILE_NAME .. "$", ADFieldSilos.FILE_NAME))
end
function ADFieldSilos.save()
local path = getPath()
if path == nil then return false end
local xmlFile = XMLFile.create("ADSmartPickupFieldSilos", path, ADFieldSilos.ROOT)
if xmlFile == nil then return false end
local record = ADFieldSiloConfig.toRecord(ADFieldSilos.config)
xmlFile:setInt(ADFieldSilos.ROOT .. "#enabled", record.enabled)
xmlFile:setString(ADFieldSilos.ROOT .. "#stores", record.stores)
xmlFile:setInt(ADFieldSilos.ROOT .. "#maxVehicles", record.maxVehicles)
xmlFile:setInt(ADFieldSilos.ROOT .. "#minLiters", record.minLiters)
xmlFile:setInt(ADFieldSilos.ROOT .. "#toProduction", record.toProduction)
xmlFile:setInt(ADFieldSilos.ROOT .. "#reserveDays", record.reserveDays)
xmlFile:save()
xmlFile:delete()
return true
end
function ADFieldSilos.load()
local path = getPath()
if path == nil or not fileExists(path) then
ADFieldSilos.config = ADFieldSiloConfig.new()
return
end
local xmlFile = XMLFile.load("ADSmartPickupFieldSilos", path)
if xmlFile == nil then
ADFieldSilos.config = ADFieldSiloConfig.new()
return
end
ADFieldSilos.config = ADFieldSiloConfig.fromRecord({
enabled = xmlFile:getInt(ADFieldSilos.ROOT .. "#enabled"),
stores = xmlFile:getString(ADFieldSilos.ROOT .. "#stores"),
maxVehicles = xmlFile:getInt(ADFieldSilos.ROOT .. "#maxVehicles"),
minLiters = xmlFile:getInt(ADFieldSilos.ROOT .. "#minLiters"),
toProduction = xmlFile:getInt(ADFieldSilos.ROOT .. "#toProduction"),
reserveDays = xmlFile:getInt(ADFieldSilos.ROOT .. "#reserveDays"),
})
xmlFile:delete()
end
+43 -1
View File
@@ -12,7 +12,14 @@ ADFieldStorage.FIELD_TYPES = {
rigs = "Int", unloaders = "Int", mulch = "Int", lime = "Int", soil = "String", sow = "Int",
sowFruit = "String", roll = "Int", fertilize = "Int", weed = "Int",
kind = "String", lastFruit = "String", care = "String", targetFruit = "String", prepAfterMow = "Int", straw = "String",
chainFree = "Int", targetSource = "String",
mowWidth = "Float", mowVehicle = "String", rakeVehicle = "String", harvestVehicle = "String", windrowOffset = "String",
dedicated = "String",
}
-- Kursus-registret (ADCourseCache): hvilken mark/bredde/form/indstillinger hver maskines CP-kursus er lavet til.
ADFieldStorage.COURSE_TYPES = {
vehicle = "String", field = "Int", width = "Float", sig = "String", settings = "String", waypoints = "Int",
}
function ADFieldStorage.getPath()
@@ -36,6 +43,21 @@ function ADFieldStorage.readRecord(xmlFile, key)
return record
end
function ADFieldStorage.writeCourseRecord(xmlFile, key, record)
for name, kind in pairs(ADFieldStorage.COURSE_TYPES) do
local value = record[name]
if value ~= nil then xmlFile["set" .. kind](xmlFile, key .. "#" .. name, value) end
end
end
function ADFieldStorage.readCourseRecord(xmlFile, key)
local record = {}
for name, kind in pairs(ADFieldStorage.COURSE_TYPES) do
record[name] = xmlFile["get" .. kind](xmlFile, key .. "#" .. name)
end
return record
end
function ADFieldStorage.save(state)
local path = ADFieldStorage.getPath()
if path == nil then return false end
@@ -47,23 +69,35 @@ function ADFieldStorage.save(state)
index = index + 1
end
xmlFile:setString(ADFieldStorage.ROOT .. "#fleetMode", state.fleetMode or "all")
xmlFile:setInt(ADFieldStorage.ROOT .. "#paused", state.paused == true and 1 or 0)
local vehicleIndex = 0
for vehicleId in pairs(state.fleet) do
xmlFile:setString(string.format("%s.fleet.vehicle(%d)#id", ADFieldStorage.ROOT, vehicleIndex), vehicleId)
vehicleIndex = vehicleIndex + 1
end
local manualIndex = 0
for vehicleId in pairs(state.manual or {}) do
xmlFile:setString(string.format("%s.manual.vehicle(%d)#id", ADFieldStorage.ROOT, manualIndex), vehicleId)
manualIndex = manualIndex + 1
end
local courseIndex = 0
for _, record in pairs(state.courseRecords or {}) do
ADFieldStorage.writeCourseRecord(xmlFile, string.format("%s.courses.course(%d)", ADFieldStorage.ROOT, courseIndex), record)
courseIndex = courseIndex + 1
end
xmlFile:save()
xmlFile:delete()
return true
end
function ADFieldStorage.load()
local result = {fields = {}, fleet = {}, fleetMode = "all"}
local result = {fields = {}, fleet = {}, fleetMode = "all", courseRecords = {}, manual = {}, paused = false}
local path = ADFieldStorage.getPath()
if path == nil or not fileExists(path) then return result end
local xmlFile = XMLFile.load("ADSmartPickupFields", path)
if xmlFile == nil then return result end
result.fleetMode = xmlFile:getString(ADFieldStorage.ROOT .. "#fleetMode") == "ticked" and "ticked" or "all"
result.paused = xmlFile:getInt(ADFieldStorage.ROOT .. "#paused") == 1
xmlFile:iterate(ADFieldStorage.ROOT .. ".field", function(_, key)
local field = ADFieldModel.fromRecord(ADFieldStorage.readRecord(xmlFile, key))
if field.id ~= nil then result.fields[field.id] = field end
@@ -72,6 +106,14 @@ function ADFieldStorage.load()
local id = xmlFile:getString(key .. "#id")
if id ~= nil and id ~= "" then result.fleet[id] = true end
end)
xmlFile:iterate(ADFieldStorage.ROOT .. ".manual.vehicle", function(_, key)
local id = xmlFile:getString(key .. "#id")
if id ~= nil and id ~= "" then result.manual[id] = true end
end)
xmlFile:iterate(ADFieldStorage.ROOT .. ".courses.course", function(_, key)
local record = ADCourseCache ~= nil and ADCourseCache.newRecord(ADFieldStorage.readCourseRecord(xmlFile, key)) or nil
if record ~= nil then result.courseRecords[record.vehicle] = record end
end)
xmlFile:delete()
return result
end
+72
View File
@@ -37,3 +37,75 @@ function ADFieldView.soilParts(info)
if (info.weed or 0) >= ADFieldModel.WEED_MIN then table.insert(parts, "weed") end
return parts
end
-- Afgrødekolonnen: høstet mark = "stub (Hvede)", bar mark = "bar jord", ellers afgrøden.
function ADFieldView.cropText(info, getText)
if info.kind == "bare" and info.stubbleOf ~= nil then
return string.format(getText("spu_field_stubble"), tostring(info.stubbleOf))
end
if info.kind == "bare" then return getText("spu_field_bare") end
return tostring(info.cropTitle or "-")
end
-- Lagervalget for en mark: marksilo først, så gæringssiloer, så efter afstand. Valgte, marksiloer og
-- gæringssiloer vises altid; af resten kun de `limit` nærmeste (34 ens "Farma 500"-rækker var ulæselige).
-- entries = {{id, distance, fieldSilo, fermenter, chosen}}.
function ADFieldView.storeRows(entries, limit)
local sorted = {}
for _, entry in ipairs(entries or {}) do table.insert(sorted, entry) end
local function rank(e) return e.fieldSilo and 0 or (e.fermenter and 1 or 2) end
table.sort(sorted, function(a, b)
if rank(a) ~= rank(b) then return rank(a) < rank(b) end
if (a.distance or math.huge) ~= (b.distance or math.huge) then return (a.distance or math.huge) < (b.distance or math.huge) end
return tostring(a.id) < tostring(b.id)
end)
local rows, others, hidden = {}, 0, 0
for _, entry in ipairs(sorted) do
if entry.chosen or entry.fieldSilo or entry.fermenter then
table.insert(rows, entry)
elseif others < limit then
others = others + 1
table.insert(rows, entry)
else
hidden = hidden + 1
end
end
return rows, hidden
end
-- "90 m" / "1,4 km"
function ADFieldView.distanceText(meters)
if meters == nil then return "?" end
if meters < 1000 then return string.format("%d m", math.floor(meters + 0.5)) end
return (string.gsub(string.format("%.1f km", meters / 1000), "%.", ","))
end
-- ---- status pr. mark (Fields-listen) ----
-- Årsager der betyder "kan ikke" (rød) — resten er "venter" (gul).
ADFieldView.ERROR_REASONS = {
spu_field_reason_noRig = true, spu_field_reason_cannotDo = true, spu_field_reason_tooWide = true,
spu_field_reason_tooNarrow = true, spu_field_reason_widthUnknown = true, spu_field_reason_noSowFruit = true,
}
-- Arbejder marken reelt? Under global pause (Stop alt) kun hvis spilleren har genoptaget netop den.
-- s = {working, paused, resumed}
function ADFieldView.isActiveWork(s)
return s.working == true and (not s.paused or s.resumed == true)
end
-- s = {hasMarker, working, paused, resumed, awaiting, startRequested, reasonKey} -> "running"|"waiting"|"error"|"stopped"
function ADFieldView.fieldStatus(s)
if not s.hasMarker then return "error" end
if s.reasonKey ~= nil and ADFieldView.ERROR_REASONS[s.reasonKey] then return "error" end
if s.paused and not s.resumed then return "stopped" end
if s.awaiting or s.startRequested then return "waiting" end
if ADFieldView.isActiveWork(s) then return s.reasonKey ~= nil and "waiting" or "running" end
if s.reasonKey ~= nil then return "waiting" end
return "stopped"
end
-- Må [Start]/[Stop] trykkes? Stop altid når marken arbejder; Start kræver en markør.
function ADFieldView.canStart(s)
return s.working == true or s.hasMarker == true
end
+682 -29
View File
@@ -15,11 +15,19 @@ ADFieldWork.PLAYER_DRIVING_KMH = 3
ADFieldWork.WINDROW_MEASURE_MS = 10000
-- mangler der sæt, prøves igen efter så lang tid (ikke hvert sekund over hele flåden)
ADFieldWork.NO_RIG_RETRY_MS = 30000
-- AutoDrive der stopper før markøren startes igen så mange gange, før turen opgives
ADFieldWork.DRIVE_RETRIES = 2
-- længere fra AD-vejnettet end dette kan AutoDrive ikke finde en rute ("Could not calculate path", in-game 26/9:
-- kultivatoren stod 666 m fra nærmeste waypoint); sådan en maskine sendes ikke ud
ADFieldWork.MAX_OFF_NETWORK = 60
-- flyttede køretøjet sig mindre end dette før AD stoppede, fandt AD ingen rute — ingen genforsøg
ADFieldWork.NO_MOVE_DISTANCE = 5
ADFieldWork.REFILL_ROLES = {seeder = true, fertilizer = true, limer = true, weeder = true}
local function log(...) ADFieldJobs.log(...) end
local function note(...) ADFieldJobs.note(...) end
local function now() return ADFieldJobs.now end
local lastNoRig = {}
-- ---- AutoDrive ----
@@ -57,12 +65,18 @@ local function helperCp(adEnv)
return adEnv.ADStateModule ~= nil and adEnv.ADStateModule.HELPER_CP or 1
end
local function startAd(adEnv, vehicle, mode, firstId, secondId)
-- fillType (valgfri, LOAD): varen AutoDrive skal læsse — uden den kører AD til påfyldningen og hjem tom
-- (in-game 26/9: sprøjten kom tom tilbage fra Buying Station Multifruit)
local function startAd(adEnv, vehicle, mode, firstId, secondId, fillType)
local module = stateModule(vehicle)
stopAd(vehicle)
module:setMode(mode)
module:setFirstMarkerByWayPointId(firstId)
if secondId ~= nil then module:setSecondMarkerByWayPointId(secondId) end
if fillType ~= nil then
module:setFillType(fillType)
module.selectedFillTypes = {fillType}
end
module:setStartHelper(false)
if module.setActualFarmId ~= nil then module:setActualFarmId(vehicle:getOwnerFarmId()) end
module:raiseDirtyFlag()
@@ -75,14 +89,27 @@ local function startAd(adEnv, vehicle, mode, firstId, secondId)
wake(vehicle, true)
end
ADFieldWork.startAd = startAd
ADFieldWork.enableParkAtJobFinished = enableParkAtJobFinished
-- AD-parkering: sidder på redskabet og kopieres af AD til traktorens stateModule (markør-INDEKS).
local function parkMarkerId(adEnv, vehicle)
local module = stateModule(vehicle)
local index = module ~= nil and module.getParkDestinationAtJobFinished ~= nil and module:getParkDestinationAtJobFinished() or nil
-- parkeringen kan ligge på vognen/redskabet (advd.parkDestination); AD kopierer den først til traktoren når
-- redskabet kobles på/vælges — efter indlæsning er kopien ofte tom (in-game 27/9: 20 af 25 "uden parkering")
if index == nil or index < 1 then
local ok, units = pcall(adEnv.AutoDrive.getAllUnits, vehicle)
for _, unit in ipairs(ok and units or {}) do
local own = unit.advd ~= nil and unit.advd.parkDestination or nil
if type(own) == "number" and own >= 1 then index = own break end
end
end
if index == nil or index < 1 then return nil end
local marker = adEnv.ADGraphManager:getMapMarkerById(index)
return marker ~= nil and marker.id or nil
end
ADFieldWork.parkMarkerId = parkMarkerId
local function restLoad(adEnv, vehicle)
local ok, level = pcall(function()
@@ -102,6 +129,16 @@ end
-- ---- flåde ----
-- Optaget for markarbejdet: vores egen opgave, AutoDrive/Courseplay kører den, lånt af marksilo-flowet,
-- eller på hjemtur fra puljen (sætter redskab af).
local function isBusyVehicle(vehicle, id)
return ADFieldJobs.state.assignments[id] ~= nil or isAdActive(vehicle) or ADCpBridge.isWorking(vehicle)
or (ADFieldSilos ~= nil and ADFieldSilos.isLoaned(vehicle)) or (ADPool ~= nil and ADPool.isReturning(vehicle))
or (ADFlowWagons ~= nil and ADFlowWagons.isEmptying(vehicle))
end
ADFieldWork.isBusyVehicle = isBusyVehicle
function ADFieldWork.getRigs(adEnv)
local rigs = {}
local state = ADFieldJobs.state
@@ -109,17 +146,27 @@ function ADFieldWork.getRigs(adEnv)
local specs = ADCpBridge.getSpecs(vehicle)
local role = ADFieldFleet.roleFromSpecs(specs)
local id = ADRunsController.getVehicleId(vehicle)
if role ~= nil and id ~= nil then
-- traktorer i et almindeligt flow (æg, gylle, foder ...) hører til den drift og er ikke markflåde
-- (et stoppet flows traktorer er ledige — maskinpulje/flow-vogne)
if role ~= nil and id ~= nil and (ADPool ~= nil and ADPool.isFreeFromFlows(vehicle) or ADRunsController.getRunForVehicle(vehicle) == nil) then
local x, z = vehiclePosition(vehicle)
local rig = {
id = id, vehicle = vehicle, role = role, label = ADRuns.vehicleLabel(vehicle),
enabled = ADFieldFleet.isEnabled(state.fleetMode, state.fleet, id),
-- optaget: vores egen opgave, et Smart Pickup-flow, eller AutoDrive/Courseplay kører den allerede
-- (fx spillerens egen parkeringstur) — den må ikke kapres midt i en anden kørsel
busy = state.assignments[id] ~= nil or ADRunsController.getRunForVehicle(vehicle) ~= nil
or isAdActive(vehicle) or ADCpBridge.isWorking(vehicle),
-- optaget: vores egen opgave, eller AutoDrive/Courseplay kører den allerede (fx spillerens
-- egen parkeringstur) — den må ikke kapres midt i en anden kørsel
busy = isBusyVehicle(vehicle, id),
-- hvorfor optaget (til loggen, når ingen maskine kan sendes ud)
busyWhy = (state.assignments[id] ~= nil and ("opgave F" .. tostring(state.assignments[id].fieldId)))
or (isAdActive(vehicle) and "AutoDrive kører") or (ADCpBridge.isWorking(vehicle) and "Courseplay kører")
or (ADFieldSilos ~= nil and ADFieldSilos.isLoaned(vehicle) and "lånt af marksilo-flow")
or (ADPool ~= nil and ADPool.isReturning(vehicle) and "sætter redskab af") or nil,
controlled = isControlledByPlayer(vehicle),
-- manuel transport: spilleren kører den til marken (for bred til vejene med skærebord)
manual = (state.manual or {})[id] == true,
width = ADCpBridge.getWorkWidth(vehicle), x = x, z = z,
-- tom spreder kan flere ting (gødning og kalk): alle dens roller
roles = specs.sprayRoles or ADFieldFleet.rolesFor(role), filledRole = specs.filledRole, dumpable = specs.dumpable,
}
if role == "harvester" then rig.fruits = ADCpBridge.getHarvestFruits(vehicle) or {} end
if role == "seeder" then
@@ -242,7 +289,12 @@ end
function ADFieldWork.release(assignment, phase)
restoreToolOffset(assignment)
if assignment.handle ~= nil then assignment.handle.cancelled = true end
-- henter stadig puljeredskabet: afbryd koblingen, så det ikke kobles på uden en opgave
if assignment.phase == "fetching" and assignment.hitch ~= nil and not assignment.hitch.done and ADHitch ~= nil then
ADHitch.cancel(assignment.vehicle)
end
assignment.phase = phase or "released"
if ADPool ~= nil and assignment.pool ~= nil then ADPool.release(assignment) end
ADFieldJobs.state.assignments[assignment.vehicleId] = nil
end
@@ -253,10 +305,33 @@ local function fail(fieldId, text)
end
-- Hvad et sæt skal kunne ud over rollen (afgrøde, frø, ukrudtets størrelse).
-- Afstand fra (x, z) til nærmeste AD-waypoint (nil hvis vejnettet ikke kan læses).
function ADFieldWork.distanceToNetwork(adEnv, x, z)
-- gemmes 30 s pr. 10 m-celle (15.000 waypoints pr. opslag)
if ADCacheGet ~= nil then
local key = string.format("%d:%d", math.floor(x / 10), math.floor(z / 10))
return ADCacheGet("networkDistance", key, 30000, function() return ADFieldWork.distanceToNetworkUncached(adEnv, x, z) end)
end
return ADFieldWork.distanceToNetworkUncached(adEnv, x, z)
end
function ADFieldWork.distanceToNetworkUncached(adEnv, x, z)
local ok, best = pcall(function()
local wayPoints = adEnv.ADGraphManager:getWayPoints()
local bestD = math.huge
for _, wp in pairs(wayPoints or {}) do
local d = (wp.x - x) ^ 2 + (wp.z - z) ^ 2
if d < bestD then bestD = d end
end
return math.sqrt(bestD)
end)
return ok and best ~= math.huge and best or nil
end
local function acceptFor(field, info, role)
if role == "harvester" then
local fruit = info.fruitIndex
return function(candidate) return fruit ~= nil and candidate.fruits ~= nil and candidate.fruits[fruit] == true end
return function(candidate) return ADManualTransport.acceptsHarvester(candidate, fruit) end
elseif role == "seeder" then
local fruitIndex = ADFieldWork.sowFruitIndex(field)
return function(candidate) return candidate.hasSeeds and fruitIndex ~= nil and candidate.seedFruits[fruitIndex] == true end
@@ -286,6 +361,81 @@ local function preflight(field, role)
return true
end
ADFieldWork.REFILL_ROLES = {seeder = true, fertilizer = true, limer = true, weeder = true}
local function fillTypeName(fillType)
return g_fillTypeManager ~= nil and g_fillTypeManager:getFillTypeNameByIndex(fillType) or tostring(fillType)
end
-- Før udsendelse: forkert vare af (kalk på, skal gøde), og er sættet tomt, findes påfyldningen.
-- Returnerer {fillType, empty, refillId} eller nil (sættet bruger intet fyld: strigle, plov ...).
local function refillPlan(adEnv, fieldId, vehicle, role, x, z)
if not ADFieldWork.REFILL_ROLES[role] then return nil end
local dumped, dumpedName = ADCpBridge.emptySprayerIfWrong(vehicle, role)
if dumped > 0 then
log(fieldId, "'%s' tømt for %.0f l %s (skal bruges til %s)", ADRuns.vehicleLabel(vehicle), dumped, tostring(dumpedName), tostring(role))
end
local fillType = ADCpBridge.getRefillFillType(vehicle, role)
if fillType == nil then return nil end
local empty = ADCpBridge.getRefillLevel(vehicle, fillType) <= 0
local refillId = empty and ADFieldWork.findRefillMarker(adEnv, vehicle, fillType, x, z) or nil
return {fillType = fillType, empty = empty, refillId = refillId}
end
-- Manuel transport: bed spilleren køre høsteren til marken (dialog første gang, ellers besked i hjørnet).
local function askPlayer(field, info, assignment, withDialog)
local text = string.format("F%s skal høstes: kør '%s' til %s, montér skærebordet og stig ud — så overtager Smart Pickup",
tostring(field.id), ADRuns.vehicleLabel(assignment.vehicle), tostring(info.markerName or ("F" .. tostring(field.id))))
assignment.remindedAt = now()
log(field.id, "venter på spilleren: %s", text)
if withDialog and InfoDialog ~= nil and InfoDialog.show ~= nil then
pcall(InfoDialog.show, "Smart Pickup: " .. text)
else
ADFieldJobs.notify(text, true)
end
end
-- Puljekombination: nærmeste puljeredskab til marken + nærmeste ledige traktor med nok hk. Traktoren kører til
-- "Connect X", kobler på og fortsætter så som en almindelig udsendelse (fase fetching -> driving).
function ADFieldWork.dispatchPool(adEnv, field, info, role, roleAccept, decision)
local implements, tractors = ADPool.collect(adEnv, function(vehicle)
return isBusyVehicle(vehicle, ADRunsController.getVehicleId(vehicle))
end)
local accept = function(implement)
-- samme felter som getRigs giver sæt, så markens krav (frø, afgrøde, ukrudt) tjekkes på redskabet selv
local candidate = {role = implement.role, roles = implement.roles, vehicle = implement.vehicle}
if role == "seeder" then
candidate.seedFruits = ADCpBridge.getSeedFruits(implement.vehicle)
candidate.hasSeeds = ADCpBridge.hasSeeds(implement.vehicle)
end
if role == "weeder" then candidate.mechanical = ADCpBridge.isMechanicalWeeder(implement.vehicle) end
if roleAccept ~= nil and not roleAccept(candidate) then return false end
-- tømmevogn: skal kunne bære høstens vare
if role == "unloader" and not ADPool.canCarry(implement, info.harvestFillType) then return false end
local required = ADFieldModel.requiredWidth(field, role)
return (implement.width or 0) <= 0 or ADFieldModel.checkWidth(role, implement.width, required)
end
local pair, why, args = ADPool.pairFor(role, info.x, info.z, implements, tractors, ADPool.blocked(now()), accept)
if pair == nil then return false, why, args end
local tractor, implement = pair.tractor, pair.implement
local assignment = {
vehicleId = tractor.id, vehicle = tractor.vehicle, fieldId = field.id, role = role, stage = field.stage,
helper = decision ~= nil and decision.helper == true, phase = "fetching", since = now(), width = implement.width,
groupSize = 1, leader = true, fillType = decision ~= nil and decision.helper and info.harvestFillType or nil,
startX = tractor.x, startZ = tractor.z,
pool = {implementId = implement.id, implementName = implement.name, connectId = implement.connectId},
hitch = {done = false},
}
ADFieldJobs.state.assignments[tractor.id] = assignment
ADPool.reserve(assignment, {implement.id, tractor.id})
ADHitch.fetch(adEnv, tractor.vehicle, implement.connectId, function(_, ok, detail)
assignment.hitch = {done = true, ok = ok, detail = detail}
end)
note(field.id, {reason = {key = "spu_field_reason_fetching", name = implement.name}})
log(field.id, "%s: '%s' henter '%s' (%s)", role, tractor.name, implement.name, "pulje")
return true
end
function ADFieldWork.dispatch(adEnv, field, info, decision)
local role = decision.role
local ok, reason = preflight(field, role)
@@ -293,9 +443,25 @@ function ADFieldWork.dispatch(adEnv, field, info, decision)
note(field.id, {retryAt = now() + ADFieldModel.RETRY_MS, reason = reason})
return
end
local candidates = ADFieldWork.getRigs(adEnv)
-- faste maskiner: andre markers faste bruges ikke; har marken selv faste med rollen, kun dem
local rigIdsByField = {}
for fieldId, other in pairs(ADFieldJobs.state.fields) do rigIdsByField[fieldId] = other.plan ~= nil and other.plan.rigIds or nil end
local candidates = ADFieldFleet.filterDedicated(ADFieldWork.getRigs(adEnv), field.id, role, rigIdsByField)
local required = ADFieldModel.requiredWidth(field, role)
local accept = acceptFor(field, info, role)
local roleAccept = acceptFor(field, info, role)
-- kun maskiner AutoDrive kan køre fra: tæt på vejnettet
local offNetwork = {}
local accept = function(candidate)
if roleAccept ~= nil and not roleAccept(candidate) then return false end
-- manuel transport: spilleren kører den — afstand til AutoDrive-vejnettet er ligegyldig
if role == "harvester" and candidate.manual then return true end
local d = candidate.x ~= nil and ADFieldWork.distanceToNetwork(adEnv, candidate.x, candidate.z) or nil
if d ~= nil and d > ADFieldWork.MAX_OFF_NETWORK then
table.insert(offNetwork, string.format("'%s' %.0f m", tostring(candidate.label), d))
return false
end
return true
end
local rigs, why, args
if (decision.count or 1) > 1 then
rigs, why, args = ADFieldFleet.pickMany(candidates, role, decision.count, info.x, info.z, required, accept)
@@ -305,9 +471,51 @@ function ADFieldWork.dispatch(adEnv, field, info, decision)
rigs = rig ~= nil and {rig} or nil
end
if rigs == nil then
note(field.id, {reason = {key = "spu_field_reason_" .. tostring(why), role = role, args = args}, retryAt = now() + ADFieldWork.NO_RIG_RETRY_MS})
-- ingen færdig kombination: en ledig solo-traktor henter et puljeredskab (kun hovedopgaver, ikke tømmevogne)
-- marken har egne faste maskiner til rollen: den venter på dem (som uden puljen)
local ownIds, hasOwnForRole = {}, false
for _, id in ipairs(field.plan ~= nil and field.plan.rigIds or {}) do ownIds[id] = true end
for _, candidate in ipairs(candidates) do
if ownIds[candidate.id] and (candidate.role == role or (candidate.roles ~= nil and candidate.roles[role])) then hasOwnForRole = true end
end
if ADPool ~= nil and ADPool.mayUsePool(decision, hasOwnForRole)
and (why == "noRig" or why == "allBusy" or why == "cannotDo") then
local pooled, poolWhy, poolArgs = ADFieldWork.dispatchPool(adEnv, field, info, role, roleAccept, decision)
if pooled then return end
if poolWhy == "tooWeak" then
note(field.id, {reason = {text = string.format("ingen ledig traktor med ≥%.0f hk til '%s'",
(poolArgs or {})[1] or 0, tostring((poolArgs or {})[2] or role))}, retryAt = ADPool.alignedRetry(now(), ADFieldWork.NO_RIG_RETRY_MS)})
return
end
if poolWhy == "noPoolTractor" and why ~= "allBusy" then
why, args = poolWhy, poolArgs
end
end
if #offNetwork > 0 and (why == "cannotDo" or why == "noRig") then
-- de eneste med rollen står uden for AD-vejnettet
note(field.id, {reason = {text = string.format("ingen %s kan køre til %s — står for langt fra AutoDrive-vejnettet (%s)",
tostring(role), tostring(info.markerName), table.concat(offNetwork, ", "))}, retryAt = ADPool.alignedRetry(now(), ADFieldWork.NO_RIG_RETRY_MS)})
return
end
note(field.id, {reason = {key = "spu_field_reason_" .. tostring(why), role = role, args = args}, retryAt = ADPool.alignedRetry(now(), ADFieldWork.NO_RIG_RETRY_MS)})
-- også i loggen (én gang pr. ændring): før stod grunden kun i fanen, og loggen var tavs (in-game 27/9)
local noRigKey = tostring(role) .. ":" .. tostring(why)
if lastNoRig[field.id] ~= noRigKey then
lastNoRig[field.id] = noRigKey
-- hvem har rollen, og hvorfor kan de ikke bruges (in-game 27/9: "allBusy" uden at sige hvem)
local who = {}
for _, candidate in ipairs(candidates) do
if candidate.role == role or (candidate.roles ~= nil and candidate.roles[role]) then
local state = candidate.controlled and "spiller i førersædet" or candidate.busyWhy
or (not candidate.enabled and "ikke i flåden") or (roleAccept ~= nil and not roleAccept(candidate) and "kan ikke (afgrøde/frø)") or "ledig"
table.insert(who, string.format("'%s': %s", tostring(candidate.label), state))
end
end
log(field.id, "ingen maskine sendt til %s: %s%s", tostring(role), tostring(why), #who > 0 and (" — " .. table.concat(who, "; ")) or "")
end
return
end
lastNoRig[field.id] = nil
local count = #rigs
local lanes = ADFieldModel.lanePositions(count)
for index, rig in ipairs(rigs) do
@@ -317,11 +525,36 @@ function ADFieldWork.dispatch(adEnv, field, info, decision)
groupSize = decision.helper and 1 or count, lane = count > 1 and lanes[index] or nil, leader = index == 1,
fillType = decision.helper and info.harvestFillType or nil,
}
if role == "harvester" and rig.manual and not assignment.helper then
-- for bred til vejene: spilleren kører den selv til marken og monterer skærebordet
assignment.phase, assignment.manual = "awaitingPlayer", true
assignment.startX, assignment.startZ = rig.x, rig.z
ADFieldJobs.state.assignments[rig.id] = assignment
askPlayer(field, info, assignment, true)
else
local refill = refillPlan(adEnv, field.id, rig.vehicle, role, info.x, info.z)
if refill ~= nil and refill.empty and refill.refillId == nil then
-- tom og intet sted at fylde op: ud på marken ville den bare køre kurset uden at sprede
fail(field.id, string.format("'%s' er tom for %s og der er ingen påfyldning med AutoDrive-markør — fyld den op", rig.label, fillTypeName(refill.fillType)))
return
end
assignment.startX, assignment.startZ = rig.x, rig.z
ADFieldJobs.state.assignments[rig.id] = assignment
if refill ~= nil and refill.empty then
-- AutoDrive LOAD: påfyldningen (2. markør), fyld op, videre til F-markøren (1.) — så Courseplay
startAd(adEnv, rig.vehicle, adEnv.AutoDrive.MODE_LOAD, info.markerId, refill.refillId, refill.fillType)
assignment.drive = {mode = adEnv.AutoDrive.MODE_LOAD, first = info.markerId, second = refill.refillId, fillType = refill.fillType}
local marker = adEnv.ADGraphManager:getMapMarkerByWayPointId(refill.refillId)
log(field.id, "%s '%s' er tom — fyldes med %s ved %s og kører så til %s", role, rig.label, fillTypeName(refill.fillType),
tostring(marker ~= nil and marker.name or refill.refillId), tostring(info.markerName))
else
startAd(adEnv, rig.vehicle, adEnv.AutoDrive.MODE_DRIVETO, info.markerId, nil)
assignment.drive = {mode = adEnv.AutoDrive.MODE_DRIVETO, first = info.markerId}
log(field.id, "%s '%s' sendt til %s (%.1f m%s)", role, rig.label, tostring(info.markerName), rig.width or 0,
assignment.lane ~= nil and string.format(", bane %d", assignment.lane) or "")
end
end
end
note(field.id, {reason = false})
end
@@ -350,26 +583,83 @@ end
-- Nærmeste påfyldning af varen: en læssestation gården har adgang til, med varen på lager, en AutoDrive-
-- markør ved triggeren og vej fra traktoren. (AutoDrives eget "automatiske påfyldningsmål" er slået fra i
-- AutoDrive — AutoDrive.automaticPickupTarget = false — så vi finder stedet selv, som silovalget.)
-- Sælger købestationen varen? (placeable.spec_buyingStation.buyingStation; FS25-felter varierer, så flere
-- opslag: udbudte varer, triggernes varer, AI-varer.)
function ADFieldWork.buyingSupports(station, fillType)
if station == nil or fillType == nil then return false end
local ok, result = pcall(function()
for _, key in ipairs({"providedFillTypes", "supportedFillTypes", "fillTypes"}) do
local set = station[key]
if type(set) == "table" and set[fillType] then return true end
end
for _, trigger in pairs(station.loadTriggers or {}) do
if type(trigger.fillTypes) == "table" and trigger.fillTypes[fillType] then return true end
end
if station.getAISupportedFillTypes ~= nil then
local set = station:getAISupportedFillTypes()
if type(set) == "table" and set[fillType] then return true end
end
return false
end)
return ok and result == true
end
-- AD-markøren ved stationen: AutoDrives eget opslag (30 m fra triggeren), ellers nærmeste ikke-Wait
-- markør inden for 40 m af bygningen ("Liquid Buy Station" står ved tanken, ikke ved triggeren).
local function markerAtStation(adEnv, vehicle, station, placeable)
local okAd, wayPointId = pcall(function() return adEnv.ADTriggerManager:getMarkerAtStation(station, vehicle, 30) end)
if okAd and wayPointId ~= nil and wayPointId > 0 then return wayPointId end
local node = placeable ~= nil and placeable.rootNode or nil
if node == nil then return nil end
local px, _, pz = getWorldTranslation(node)
local best, bestDistance = nil, 40 * 40
for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do
local isWait = ADSupplyPlanner ~= nil and ADSupplyPlanner.isWaitMarker ~= nil and ADSupplyPlanner.isWaitMarker(marker)
local wayPoint = not isWait and adEnv.ADGraphManager:getWayPointById(marker.id) or nil
if wayPoint ~= nil then
local d = (wayPoint.x - px) ^ 2 + (wayPoint.z - pz) ^ 2
if d < bestDistance then best, bestDistance = marker.id, d end
end
end
return best
end
-- Nærmeste påfyldning af varen med AD-markør og vej: lagre/siloer med varen, og købestationer (købes
-- efter behov — ingen lagerbeholdning, så de fandtes ikke før; in-game 26/9 "Liquid Buy Station").
function ADFieldWork.findRefillMarker(adEnv, vehicle, fillType, x, z)
local farmId = vehicle:getOwnerFarmId()
local best, bestDistance = nil, math.huge
local ok = pcall(function()
for _, station in pairs(g_currentMission.storageSystem:getLoadingStations()) do
local supported = station.getAISupportedFillTypes ~= nil and station:getAISupportedFillTypes() or {}
if g_currentMission.accessHandler:canFarmAccess(farmId, station) and supported[fillType]
and (station:getFillLevel(fillType, farmId) or 0) > 0 then
local wayPointId = adEnv.ADTriggerManager:getMarkerAtStation(station, vehicle, 10)
local function consider(wayPointId)
local wayPoint = wayPointId ~= nil and wayPointId > 0 and adEnv.ADGraphManager:getWayPointById(wayPointId) or nil
if wayPoint ~= nil then
if wayPoint == nil then return end
local distance = (wayPoint.x - x) ^ 2 + (wayPoint.z - z) ^ 2
if distance < bestDistance and ADUnloadWait.hasRouteTo(adEnv, vehicle, wayPointId) then
best, bestDistance = wayPointId, distance
end
end
-- gårdens egne lagre først (stalde/tanke med gylle, lager med gødning): købes kun når intet eget findes
-- (in-game 26/9: 7 mio. l gylle ved BGA'en, men påfyldningen ville købe ved købestationen)
pcall(function()
for _, station in pairs(g_currentMission.storageSystem:getLoadingStations()) do
local supported = station.getAISupportedFillTypes ~= nil and station:getAISupportedFillTypes() or {}
local isShop = station.owningPlaceable ~= nil and station.owningPlaceable.spec_buyingStation ~= nil
if not isShop and g_currentMission.accessHandler:canFarmAccess(farmId, station) and supported[fillType]
and (station:getFillLevel(fillType, farmId) or 0) > 0 then
consider(markerAtStation(adEnv, vehicle, station, station.owningPlaceable))
end
end
end)
return ok and best or nil
if best ~= nil then return best end
pcall(function()
for _, placeable in pairs(g_currentMission.placeableSystem.placeables) do
local spec = placeable.spec_buyingStation
local station = spec ~= nil and spec.buyingStation or nil
if station ~= nil and ADFieldWork.buyingSupports(station, fillType) then
consider(markerAtStation(adEnv, vehicle, station, placeable))
end
end
end)
return best
end
-- Påfyldning undervejs (såmaskine/sprøjte/spreder): AutoDrive LOAD med hjælper = Courseplay. Når CP melder
@@ -377,7 +667,7 @@ end
-- og genstarter CP. Findes ingen påfyldning, stopper sættet når det er tomt ("tom for …", se finish).
local function prepareRefill(adEnv, field, info, assignment)
local vehicle = assignment.vehicle
local fillType = ADCpBridge.getRefillFillType(vehicle)
local fillType = ADCpBridge.getRefillFillType(vehicle, assignment.role)
if fillType == nil then return end
assignment.refillFillType = fillType
local name = g_fillTypeManager ~= nil and g_fillTypeManager:getFillTypeNameByIndex(fillType) or tostring(fillType)
@@ -411,13 +701,16 @@ local function beginUnloader(adEnv, field, info, assignment)
local store, why = ADFieldWork.chooseStore(adEnv, field, info, vehicle, fillType)
if store == nil then
ADFieldWork.release(assignment)
ADHitch.returnHome(adEnv, vehicle)
fail(field.id, "intet lager til høsten: " .. tostring(why))
return
end
assignment.storeId = store.id
local station = ADSmartPickup.getUnloadStationAtWayPointId(adEnv, store.id, vehicle:getOwnerFarmId())
local markerPoint = adEnv.ADGraphManager:getWayPointById(info.markerId)
local point = info.points[1] or {x = info.x, z = info.z}
-- punktet nærmest traktoren: CP lægger kursets start dér (ikke i den anden ende af marken)
local vx, vz = vehiclePosition(assignment.vehicle)
local point = ADFieldJobs.nearestPoint(info.points, vx, vz) or {x = info.x, z = info.z}
assignment.phase = "starting"
assignment.since = now()
local handle, err = ADCpBridge.startUnloader(vehicle, point.x, point.z, markerPoint ~= nil and markerPoint.x or point.x,
@@ -428,11 +721,13 @@ local function beginUnloader(adEnv, field, info, assignment)
log(field.id, "aflæsningsvogn '%s' følger høsteren -> %s", ADRuns.vehicleLabel(vehicle), tostring(store.name))
else
ADFieldWork.release(assignment)
ADHitch.returnHome(adEnv, vehicle)
fail(field.id, "aflæsningsvogn: " .. tostring(reason))
end
end)
if handle == nil then
ADFieldWork.release(assignment)
ADHitch.returnHome(adEnv, vehicle)
fail(field.id, "aflæsningsvogn: " .. tostring(err))
return
end
@@ -463,7 +758,9 @@ local function beginBaleCollector(adEnv, field, info, assignment)
else
log(field.id, "intet ballelager med plads og vej fundet — ballesamleren stopper når den er fuld")
end
local point = info.points[1] or {x = info.x, z = info.z}
-- punktet nærmest traktoren: CP lægger kursets start dér (ikke i den anden ende af marken)
local vx, vz = vehiclePosition(assignment.vehicle)
local point = ADFieldJobs.nearestPoint(info.points, vx, vz) or {x = info.x, z = info.z}
assignment.phase = "starting"
assignment.since = now()
local handle, err = ADCpBridge.startBaleFinder(vehicle, point.x, point.z, function(ok, reason)
@@ -507,12 +804,31 @@ end
local function beginWork(adEnv, field, info, assignment)
local vehicle = assignment.vehicle
-- aldrig Courseplay med tom tank (den kører kurset uden at sprede/så)
if ADFieldWork.REFILL_ROLES[assignment.role] then
local fillType = ADCpBridge.getRefillFillType(vehicle, assignment.role)
if fillType ~= nil and ADCpBridge.getRefillLevel(vehicle, fillType) <= 0 then
ADFieldWork.release(assignment)
fail(field.id, string.format("'%s' er stadig tom for %s ved marken — fyld den op", ADRuns.vehicleLabel(vehicle), fillTypeName(fillType)))
return
end
end
if assignment.role == "unloader" then return beginUnloader(adEnv, field, info, assignment) end
if assignment.role == "baleCollector" then return beginBaleCollector(adEnv, field, info, assignment) end
-- høsteren: halmskår efter planen (hakkes = fra; samles op/presses = skår)
if assignment.role == "harvester" then
local keepStraw = field.plan.straw == "collect" or field.plan.straw == "bale"
ADCpBridge.setStrawSwath(vehicle, keepStraw)
-- høsteren sendes IKKE selv til lager med resten (in-game 27/9: den er for stor til at komme til lageret).
-- Courseplay venter selv ved kursets ende til tømmevognene har tømt tanken (WAITING_FOR_UNLOAD_AFTER_
-- FIELDWORK_ENDED), og tømmevognene bliver på marken, så længe høsterens CP-job kører.
end
-- vinmark: kurset i gangen (ikke på vinen), ellers kører maskinen ind i stolperne
local vineOffset = ADCpBridge.vineOffsetFor(info.fruitName, assignment.role)
if vineOffset ~= nil and ADCpBridge.setVineOffset(vehicle, vineOffset) then
local skip = ADCpBridge.vineSkipFor(info.fruitName, assignment.role) or 0
ADCpBridge.setVineSkip(vehicle, skip)
log(field.id, "vinmark: Courseplay-kurset lægges %s, springer %d række(r) over", vineOffset == 0 and "på vinen (høster)" or "til venstre for vinen (i gangen)", skip)
end
local opts, source = courseOptions(adEnv, field, assignment)
if opts == nil then
@@ -546,13 +862,25 @@ local function beginWork(adEnv, field, info, assignment)
math.abs(field.windrowOffset), field.windrowOffset > 0 and "til venstre" or "til højre", target, own)
end
end
local point = info.points[1] or {x = info.x, z = info.z}
-- punktet nærmest traktoren: CP lægger kursets start dér (ikke i den anden ende af marken)
local vx, vz = vehiclePosition(assignment.vehicle)
local point = ADFieldJobs.nearestPoint(info.points, vx, vz) or {x = info.x, z = info.z}
-- genbrug af Courseplay-kurser: maskinens eget / en andens matchende kursus frem for ny beregning
local cacheContext
opts, cacheContext = ADCourseCache.prepare(adEnv, field, info, assignment, opts)
-- påbegyndt mark: fortsæt/start nærmest i stedet for forfra (CP's standard)
opts = opts or {}
local remembered = vehicle.getCpLastRememberedWaypointIx ~= nil and vehicle:getCpLastRememberedWaypointIx() ~= nil
opts.startAt = ADFieldJobs.startAtFor({reuse = opts.reuse == true, remembered = remembered, stage = field.stage,
standingRatio = info.standingRatio})
if opts.startAt ~= "first" then log(field.id, "'%s' starter kurset ved %s punkt (marken er påbegyndt)", ADRuns.vehicleLabel(vehicle), opts.startAt == "last" and "sidste" or "nærmeste") end
assignment.phase = "starting"
assignment.since = now()
local handle, err = ADCpBridge.start(vehicle, point.x, point.z, function(ok, reason)
if ok then
assignment.phase = "working"
assignment.since = now()
ADCourseCache.afterStart(vehicle, assignment.vehicleId, cacheContext)
if assignment.role == "mower" then
note(field.id, {mowWidth = assignment.width, mowVehicleId = assignment.vehicleId})
rememberCourse(field, "mower", vehicle)
@@ -567,7 +895,7 @@ local function beginWork(adEnv, field, info, assignment)
rememberCourse(field, field.stage, vehicle)
end
log(field.id, "Courseplay startet (%s%s%s)", assignment.role,
reason == "copied" and (", " .. tostring(source) .. "-kursus kopieret") or "",
reason == "copied" and source ~= nil and (", " .. tostring(source) .. "-kursus kopieret") or "",
assignment.lane ~= nil and string.format(", bane %d", assignment.lane) or "")
else
ADFieldWork.release(assignment)
@@ -585,23 +913,77 @@ end
-- Sættet er færdigt: rest til lager (læsse-/aflæsningsvogn), ellers parkering.
local function finishAssignment(adEnv, assignment)
local vehicle = assignment.vehicle
-- høster der stadig har korn efter rest-turen: AutoDrive kunne ikke tømme den — sig det tydeligt
if assignment.role == "harvester" and restLoad(adEnv, vehicle) > 0 then
local text = string.format("'%s' har stadig korn i tanken (%.0f l) — tøm den (AutoDrive kunne ikke aflæsse den)",
ADRuns.vehicleLabel(vehicle), restLoad(adEnv, vehicle))
log(assignment.fieldId, "%s", text)
ADFieldJobs.notify(string.format("F%s: %s", tostring(assignment.fieldId), text), true)
end
-- tom såmaskine/sprøjte der ikke blev fyldt op: arbejdet er IKKE færdigt — marken må ikke gå videre
if assignment.refillFillType ~= nil and ADCpBridge.getRefillLevel(vehicle, assignment.refillFillType) <= 0 then
local name = g_fillTypeManager ~= nil and g_fillTypeManager:getFillTypeNameByIndex(assignment.refillFillType) or "?"
ADFieldWork.release(assignment)
if ADHitch ~= nil then ADHitch.returnHome(adEnv, vehicle) end
fail(assignment.fieldId, string.format("'%s' er tom for %s — fyld op, så fortsætter marken", ADRuns.vehicleLabel(vehicle), tostring(name)))
return
end
log(assignment.fieldId, "%s '%s' færdig", assignment.role, ADRuns.vehicleLabel(vehicle))
-- læssevogn/påfyldte sæt parkeres af AutoDrive selv (onCpFinished med parkering sat)
local park = not assignment.adHandlesEnd and parkMarkerId(adEnv, vehicle) or nil
-- manuel transport: ALDRIG ud på vejen med AutoDrive (skærebordet sidder på) — spilleren henter den
-- puljeredskab bag traktoren: sæt det af på sin parkering, traktoren hjem (afhænger kun af hvad der hænger
-- bag, så det virker også efter genindlæsning); ellers som før
local pooled = ADPool ~= nil and ADPool.shouldReturnHome(assignment) and ADHitch ~= nil
and ADHitch.returnHome(adEnv, vehicle, function(_, ok, detail)
if not ok then log(assignment.fieldId, "'%s' kom ikke hjem fra puljen: %s", ADRuns.vehicleLabel(vehicle), tostring(detail)) end
end)
local park = not pooled and not assignment.adHandlesEnd and not assignment.manual and parkMarkerId(adEnv, vehicle) or nil
if park ~= nil then startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, park, nil) end
if pooled then log(assignment.fieldId, "'%s' sætter redskabet af og kører hjem", ADRuns.vehicleLabel(vehicle)) end
if assignment.manual then
ADFieldJobs.notify(string.format("F%s høstet — afmontér skærebordet og kør '%s' hjem", tostring(assignment.fieldId), ADRuns.vehicleLabel(vehicle)), true)
end
restoreToolOffset(assignment)
assignment.phase = "finished"
table.insert(ADFieldJobs.finished, assignment)
ADFieldJobs.state.assignments[assignment.vehicleId] = nil
end
local MANUAL_REASON_TEXT = {
notThere = "kør den til marken",
noHeader = "montér skærebordet",
wrongHeader = "skærebordet kan ikke høste afgrøden — montér det rigtige",
playerInside = "stig ud, så overtager Smart Pickup",
}
-- Manuel transport: overtag når høsteren står på marken med passende skærebord og spilleren er steget ud
-- (eller når spilleren trykker "Start nu"); påmind hvert 5. minut indtil da.
local function followManual(adEnv, field, info, assignment)
if field == nil or info == nil then return ADFieldWork.release(assignment) end
local vehicle = assignment.vehicle
local x, z = vehiclePosition(vehicle)
local atField = #(info.verts or {}) >= 3 and ADFieldJobs.insidePolygon(x, z, info.verts)
if not atField and info.markerId ~= nil then
local wayPoint = adEnv.ADGraphManager:getWayPointById(info.markerId)
atField = wayPoint ~= nil and math.sqrt((wayPoint.x - x) ^ 2 + (wayPoint.z - z) ^ 2) <= ADFieldWork.ARRIVE_DISTANCE
end
local forced = ADFieldJobs.takeoverRequested[field.id] == true
ADFieldJobs.takeoverRequested[field.id] = nil
local ok, why = ADManualTransport.readiness({atField = atField, fruits = ADCpBridge.getHarvestFruits(vehicle) or {},
fruit = info.fruitIndex, controlled = isControlledByPlayer(vehicle), forced = forced})
if ok then
log(field.id, "overtager '%s'%s", ADRuns.vehicleLabel(vehicle), forced and " (Start nu)" or "")
return beginWork(adEnv, field, info, assignment)
end
local reason = MANUAL_REASON_TEXT[why] or tostring(why)
note(field.id, {reason = {text = string.format("venter på dig: '%s' — %s", ADRuns.vehicleLabel(vehicle), reason)}})
if forced then
ADFieldJobs.notify(string.format("F%s: kan ikke starte '%s' endnu — %s", tostring(field.id), ADRuns.vehicleLabel(vehicle), reason), true)
elseif ADManualTransport.shouldRemind(assignment.remindedAt, now()) then
askPlayer(field, info, assignment, false)
end
end
local function followWorking(adEnv, field, assignment)
local vehicle = assignment.vehicle
if assignment.role == "rake" and field.windrowOffset == nil and now() - assignment.since >= ADFieldWork.WINDROW_MEASURE_MS
@@ -612,6 +994,15 @@ local function followWorking(adEnv, field, assignment)
end
if ADCpBridge.isWorking(vehicle) or isAdActive(vehicle) then
assignment.idleSince = nil
assignment.seenWorking = true
return
end
-- genoptaget efter indlæsning: CP starter asynkront — vent til den kører, før "står stille" betyder færdig
if assignment.resumed and not assignment.seenWorking then
if now() - assignment.since > ADFieldWork.START_TIMEOUT_MS then
ADFieldWork.release(assignment)
fail(assignment.fieldId, string.format("'%s': Courseplay fortsatte ikke efter genindlæsning", ADRuns.vehicleLabel(vehicle)))
end
return
end
assignment.idleSince = assignment.idleSince or now()
@@ -628,52 +1019,289 @@ local function followWorking(adEnv, field, assignment)
finishAssignment(adEnv, assignment)
end
-- Efter genindlæsning: spillet stopper alle arbejdere ved gem, og Courseplay genstarter dem ikke (in-game 27/9).
-- En maskine med CP-kursus og et husket waypoint (CP nulstiller det når kurset er kørt færdigt) var midt i et job:
-- start den fra sidste waypoint med CP's eget "fortsæt"-job (med gemt liste fra ADCpResume: netop de maskiner
-- der kørte CP ved gem, fra nærmeste waypoint) (HUD'ens eget startpunkt røres ikke). Står den på en
-- mark hvis aktuelle trin er maskinens rolle, meldes den ind på marken igen, så den følges og trinnet afsluttes.
function ADFieldWork.resumeAfterLoad(adEnv)
-- gemt liste over maskiner hvor CP kørte (ADCpResume): den afgør alene hvem der startes, fra nærmeste waypoint
local toStart, cpVehicles = nil, {}
if ADCpResume ~= nil then toStart, cpVehicles = ADCpResume.takeToStart(adEnv) end
local startSet, handled = {}, {}
for _, id in ipairs(toStart or {}) do startSet[id] = true end
local fields, stageById = {}, {}
for _, info in ipairs(ADFields.list()) do
local field = ADFieldJobs.getField(info.id)
if field ~= nil and ADFieldModel.isWorking(field) then
table.insert(fields, {id = info.id, stage = field.stage, verts = info.verts})
stageById[info.id] = field.stage
end
end
for _, rig in ipairs(ADFieldWork.getRigs(adEnv)) do
local vehicle = rig.vehicle
local wasWorking = vehicle.startCpAtLastWp ~= nil and vehicle.hasCpCourse ~= nil and vehicle:hasCpCourse()
and vehicle.getCpLastRememberedWaypointIx ~= nil and vehicle:getCpLastRememberedWaypointIx() ~= nil
if toStart ~= nil then wasWorking = startSet[rig.id] == true end
handled[rig.id] = true
local fieldId, distance = nil, nil
if wasWorking and toStart ~= nil then
fieldId = ADFieldJobs.resumeField(rig.x, rig.z, rig.role, fields)
elseif wasWorking then
fieldId = ADFieldJobs.resumeField(rig.x, rig.z, rig.role, fields)
local course = ADCpBridge.getCourse(vehicle)
local okWp, wp = pcall(function() return course ~= nil and course:getWaypoint(vehicle:getCpLastRememberedWaypointIx()) or nil end)
if okWp and wp ~= nil and wp.x ~= nil then distance = math.sqrt((wp.x - rig.x) ^ 2 + (wp.z - rig.z) ^ 2) end
end
if wasWorking and toStart == nil and not rig.busy and not rig.controlled and not ADFieldJobs.shouldResume({fieldId = fieldId, distance = distance}) then
Logging.info(ADFieldJobs.LOG_PREFIX .. "'%s' genstartes ikke: husket waypoint fra et gammelt job (%s væk, ikke på en mark i gang)",
tostring(rig.label), distance ~= nil and string.format("%.0f m", distance) or "ukendt afstand")
elseif wasWorking and not rig.busy and not rig.controlled then
local ok, started
if toStart ~= nil then ok, started = pcall(ADCpResume.startNearest, vehicle)
else ok, started = pcall(vehicle.startCpAtLastWp, vehicle) end
if not ok then
Logging.warning(ADFieldJobs.LOG_PREFIX .. "'%s' kunne ikke genstartes: %s", tostring(rig.label), tostring(started))
elseif started then
if fieldId ~= nil then
ADFieldJobs.state.assignments[rig.id] = {vehicleId = rig.id, vehicle = vehicle, fieldId = fieldId, role = rig.role,
stage = stageById[fieldId], helper = false, phase = "working", since = now(), width = rig.width,
groupSize = 1, leader = true, resumed = true, manual = rig.manual == true}
-- lager til læssevognens rest, som ved en normal start
local assignment = ADFieldJobs.state.assignments[rig.id]
local field, info = ADFieldJobs.getField(fieldId), ADFields.getInfo(fieldId)
local fillType = nil
-- kun læssevognen kører selv hjem med læsset; høsteren tømmes af tømmevognene på marken
if rig.role == "collector" then fillType = ADFieldWork.storeFillType(field, info) end
if fillType ~= nil then
local okStore, store = pcall(ADFieldWork.chooseStore, adEnv, field, info, vehicle, fillType)
if okStore and store ~= nil then assignment.storeId = store.id end
end
log(fieldId, "'%s' fortsætter efter genindlæsning (Courseplay fra sidste waypoint)%s", tostring(rig.label),
assignment.storeId ~= nil and "" or (fillType ~= nil and " — intet lager til resten fundet" or ""))
else
Logging.info(ADFieldJobs.LOG_PREFIX .. "'%s' genstartet fra %s waypoint efter genindlæsning", tostring(rig.label),
toStart ~= nil and "nærmeste" or "sidste")
end
end
end
end
-- CP-maskiner uden markrolle (fx balleopsamler, tømmevogn i CP-job): start dem også
for _, id in ipairs(toStart or {}) do
local vehicle = cpVehicles[id]
if not handled[id] and vehicle ~= nil then
local ok, started = pcall(ADCpResume.startNearest, vehicle)
if ok and started then
Logging.info(ADFieldJobs.LOG_PREFIX .. "'%s' genstartet fra nærmeste waypoint efter genindlæsning", tostring(ADRuns.vehicleLabel(vehicle)))
elseif not ok then
Logging.warning(ADFieldJobs.LOG_PREFIX .. "'%s' kunne ikke genstartes: %s", tostring(ADRuns.vehicleLabel(vehicle)), tostring(started))
end
end
end
end
-- Markmaskiner der stadig hedder modelnavnet ("Unrealistic Engine 3") får et navn efter rollen ("Roeoptager 2").
-- Navne spilleren/moden har givet røres aldrig; flow-traktorer navngives af deres flow (ikke i markflåden).
local BEET_FRUITS = {SUGARBEET = true, POTATO = true, CARROT = true, PARSNIP = true, BEETROOT = true}
local function harvesterSubtype(rig)
for fruit in pairs(rig.fruits or {}) do
local desc = g_fruitTypeManager ~= nil and g_fruitTypeManager:getFruitTypeByIndex(fruit) or nil
if desc ~= nil and BEET_FRUITS[desc.name] then return "beet" end
end
local chaff = g_fillTypeManager ~= nil and g_fillTypeManager:getFillTypeIndexByName("CHAFF") or nil
local ok, isForage = pcall(function()
local vehicle = rig.vehicle
for index in ipairs(vehicle.getFillUnits ~= nil and vehicle:getFillUnits() or {}) do
if chaff ~= nil and vehicle:getFillUnitSupportsFillType(index, chaff) then return true end
end
return false
end)
return ok and isForage and "forage" or nil
end
function ADFieldWork.autoNameFleet(adEnv)
local names = {}
for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do table.insert(names, ADRuns.vehicleLabel(vehicle)) end
for _, rig in ipairs(ADFieldWork.getRigs(adEnv)) do
local vehicle = rig.vehicle
local stateModule = vehicle.ad ~= nil and vehicle.ad.stateModule or nil
local model = vehicle.getName ~= nil and tostring(vehicle:getName()) or nil
-- puljetraktor med et lånt redskab: ikke opkaldt efter redskabet (in-game 28/9: Challenger blev "Kultivator 1")
local pooled = ADHitch ~= nil and ADPool ~= nil and #ADHitch.poolParkIds(adEnv, vehicle) > 0
if stateModule ~= nil and stateModule.setName ~= nil and model ~= nil and ADRuns.vehicleLabel(vehicle) == model and not rig.controlled and not pooled then
local name = ADNaming.fleetName(rig.role, rig.role == "harvester" and harvesterSubtype(rig) or nil, names)
if name ~= nil then
stateModule:setName(name)
if stateModule.raiseDirtyFlag ~= nil then stateModule:raiseDirtyFlag() end
table.insert(names, name)
Logging.info(ADFieldJobs.LOG_PREFIX .. "'%s' navngivet '%s' (markflåde, %s)", model, name, tostring(rig.role))
end
end
end
end
-- Høsten er færdig, men den høster der høstede marken har korn i tanken og står ved marken: start dens CP-job igen
-- (sidste/nærmeste punkt), så CP venter ved kursets ende til tømmevognene har tømt den. Én gang pr. mark.
function ADFieldWork.tryEmptyHarvester(adEnv, field, info)
local vehicleId = field.harvestVehicleId
if vehicleId == nil or info == nil then return false end
local rig = nil
for _, candidate in ipairs(ADFieldWork.getRigs(adEnv)) do
if candidate.id == vehicleId then rig = candidate break end
end
if rig == nil then return false end
local fill = restLoad(adEnv, rig.vehicle)
local near = #(info.verts or {}) >= 3 and ADFieldJobs.insidePolygon(rig.x, rig.z, info.verts)
if not near and info.markerId ~= nil then
local wayPoint = adEnv.ADGraphManager:getWayPointById(info.markerId)
near = wayPoint ~= nil and math.sqrt((wayPoint.x - rig.x) ^ 2 + (wayPoint.z - rig.z) ^ 2) <= 100
end
if not ADFieldJobs.shouldEmptyHarvester({fill = fill, near = near, busy = rig.busy, controlled = rig.controlled,
tried = ADFieldJobs.emptyAttempts[field.id] == true}) then
return false
end
ADFieldJobs.emptyAttempts[field.id] = true
local assignment = {vehicleId = rig.id, vehicle = rig.vehicle, fieldId = field.id, role = "harvester", stage = field.stage,
helper = false, phase = "driving", since = now(), width = rig.width, groupSize = 1, leader = true,
manual = rig.manual == true, emptying = true}
ADFieldJobs.state.assignments[rig.id] = assignment
log(field.id, "høsten er færdig, men '%s' har %.0f l i tanken — tømmes på marken, før marken går videre", tostring(rig.label), fill)
beginWork(adEnv, field, info, assignment)
return true
end
function ADFieldWork.follow(adEnv, assignment)
local vehicle = assignment.vehicle
-- Kun når spilleren selv KØRER (bevæger sig uden Courseplay/AutoDrive) slippes sættet — at sidde i
-- førersædet og kigge må ikke slippe det (in-game 25/9).
local steeredByAi = ADCpBridge.isWorking(vehicle) or isAdActive(vehicle) or assignment.phase == "starting"
local speed = vehicle.getLastSpeed ~= nil and vehicle:getLastSpeed() or 0
if isControlledByPlayer(vehicle) and not steeredByAi and speed > ADFieldWork.PLAYER_DRIVING_KMH then
if assignment.phase ~= "awaitingPlayer" and isControlledByPlayer(vehicle) and not steeredByAi and speed > ADFieldWork.PLAYER_DRIVING_KMH then
log(assignment.fieldId, "'%s' frigivet — spilleren kører det", ADRuns.vehicleLabel(vehicle))
return ADFieldWork.release(assignment)
end
local field = ADFieldJobs.getField(assignment.fieldId)
local info = ADFields.getInfo(assignment.fieldId)
wake(vehicle)
if assignment.phase == "driving" then
if assignment.phase == "fetching" then
local hitch = assignment.hitch
if not hitch.done then return end
ADPool.release(assignment)
if info == nil or field == nil then
-- marken findes ikke længere (fx solgt): redskabet tilbage, traktoren hjem
ADFieldWork.release(assignment)
if hitch.ok then ADHitch.returnHome(adEnv, vehicle) end
return
end
if not hitch.ok then
-- koblingen fejlede: den skyldige i karantæne (kom traktoren ikke frem, er det traktoren), hjem, prøv igen
local blamed = ADPool.failureBlames(hitch.detail) == "tractor" and assignment.vehicleId or assignment.pool.implementId
ADPool.quarantine(blamed, now())
ADFieldWork.release(assignment)
note(assignment.fieldId, {retryAt = ADPool.alignedRetry(now(), ADFieldWork.NO_RIG_RETRY_MS),
reason = {text = string.format("kunne ikke koble '%s' på (%s) — prøver et andet om lidt",
tostring(assignment.pool.implementName), tostring(hitch.detail))}})
local park = parkMarkerId(adEnv, vehicle)
if park ~= nil then startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, park, nil) end
return
end
-- koblet på: sammenfoldet til vejen, bredden med redskabet på (for bred -> hjem igen)
pcall(ADHitch.foldForTransport, vehicle)
local width = ADCpBridge.getWorkWidth(vehicle)
local okWidth, widthReason, widthArgs = ADFieldModel.checkWidth(assignment.role, width, ADFieldModel.requiredWidth(field, assignment.role))
if not okWidth then
ADFieldWork.release(assignment)
note(assignment.fieldId, {retryAt = now() + ADFieldModel.RETRY_MS, reason = {key = "spu_field_reason_" .. tostring(widthReason), role = assignment.role, args = widthArgs}})
ADHitch.returnHome(adEnv, vehicle)
return
end
-- som almindelig udsendelse: tom såmaskine/sprøjte fyldes op på vejen, uden påfyldning køres den hjem igen
local refill = refillPlan(adEnv, assignment.fieldId, vehicle, assignment.role, info.x, info.z)
local nextDrive = ADPool.nextDrive(refill)
if nextDrive == "empty" then
ADFieldWork.release(assignment)
ADHitch.returnHome(adEnv, vehicle)
fail(assignment.fieldId, string.format("'%s' er tom for %s og der er ingen påfyldning med AutoDrive-markør — fyld den op",
tostring(assignment.pool.implementName), fillTypeName(refill.fillType)))
return
end
assignment.width, assignment.phase, assignment.since = width, "driving", now()
assignment.startX, assignment.startZ = vehiclePosition(vehicle)
if nextDrive == "load" then
startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_LOAD, info.markerId, refill.refillId, refill.fillType)
assignment.drive = {mode = adEnv.AutoDrive.MODE_LOAD, first = info.markerId, second = refill.refillId, fillType = refill.fillType}
else
startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, info.markerId, nil)
assignment.drive = {mode = adEnv.AutoDrive.MODE_DRIVETO, first = info.markerId}
end
note(assignment.fieldId, {reason = false})
log(assignment.fieldId, "%s '%s' har '%s' på — kører til %s (%.1f m)", assignment.role, ADRuns.vehicleLabel(vehicle),
tostring(assignment.pool.implementName), tostring(info.markerName), width or 0)
elseif assignment.phase == "driving" then
if not isAdActive(vehicle) and now() - assignment.since > 2000 then
local x, z = vehiclePosition(vehicle)
local wayPoint = info ~= nil and adEnv.ADGraphManager:getWayPointById(info.markerId) or nil
local distance = wayPoint ~= nil and math.sqrt((wayPoint.x - x) ^ 2 + (wayPoint.z - z) ^ 2) or math.huge
if distance <= ADFieldWork.ARRIVE_DISTANCE then
beginWork(adEnv, field, info, assignment)
elseif assignment.startX ~= nil and math.sqrt((x - assignment.startX) ^ 2 + (z - assignment.startZ) ^ 2) < ADFieldWork.NO_MOVE_DISTANCE
and (assignment.driveRetries or 0) >= 1 then
-- AD startede og stoppede uden at flytte sig (to gange): ingen rute — genforsøg hjælper ikke
ADFieldWork.release(assignment)
fail(assignment.fieldId, string.format("AutoDrive fandt ingen rute fra '%s' til %s — står den uden for vejnettet?", ADRuns.vehicleLabel(vehicle), tostring(info ~= nil and info.markerName or "?")))
elseif assignment.drive ~= nil and (assignment.driveRetries or 0) < ADFieldWork.DRIVE_RETRIES then
-- AutoDrive stoppede undervejs (spilleren byggede, trafik, ...): kør videre i stedet for at opgive
assignment.driveRetries = (assignment.driveRetries or 0) + 1
assignment.since = now()
local d = assignment.drive
startAd(adEnv, vehicle, d.mode, d.first, d.second, d.fillType)
log(assignment.fieldId, "'%s' stoppede %.0f m fra %s — AutoDrive startes igen (%d/%d)", ADRuns.vehicleLabel(vehicle), distance,
tostring(info ~= nil and info.markerName or "?"), assignment.driveRetries, ADFieldWork.DRIVE_RETRIES)
else
ADFieldWork.release(assignment)
fail(assignment.fieldId, string.format("%s nåede ikke %s (%.0f m væk)", ADRuns.vehicleLabel(vehicle), tostring(info ~= nil and info.markerName or "?"), distance))
-- ikke efterladt midt på vejen/ved en mark: hjem til parkeringen (puljeredskab sættes af først)
if not (ADHitch ~= nil and ADHitch.returnHome(adEnv, vehicle)) then
local park = parkMarkerId(adEnv, vehicle)
if park ~= nil then startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, park, nil) end
end
end
end
elseif assignment.phase == "waitingCourse" then
if now() - assignment.since > ADFieldWork.COURSE_WAIT_MS then
ADFieldWork.release(assignment)
ADHitch.returnHome(adEnv, vehicle)
fail(assignment.fieldId, "multi-tool: lederens kursus kom ikke (3 min)")
elseif coursesOf(field.id)[field.stage] ~= nil then
beginWork(adEnv, field, info, assignment)
end
elseif assignment.phase == "starting" then
if now() - assignment.since > ADFieldWork.START_TIMEOUT_MS then
local generating = assignment.handle ~= nil and assignment.handle.generating == true
if ADFieldJobs.startTimedOut(now() - assignment.since, generating) then
ADFieldWork.release(assignment)
fail(assignment.fieldId, "Courseplay svarede ikke (60 s)")
ADHitch.returnHome(adEnv, vehicle)
fail(assignment.fieldId, generating and "Courseplay blev ikke færdig med kurset (5 min)" or "Courseplay svarede ikke (60 s)")
end
elseif assignment.phase == "working" then
followWorking(adEnv, field, assignment)
elseif assignment.phase == "awaitingPlayer" then
followManual(adEnv, field, info, assignment)
end
end
-- Høsten er færdig: aflæsningsvognene stoppes; har de rest, kører den til lager, ellers parkering.
function ADFieldWork.windDownHelpers(adEnv, fieldId, stage)
for _, assignment in ipairs(ADFieldJobs.assignmentsFor(fieldId)) do
if assignment.helper and assignment.stage == stage then
if assignment.helper and assignment.stage == stage and assignment.phase == "fetching" then
-- høsten sluttede mens puljevognen blev hentet: afbryd, vognen tilbage hvis den nåede at komme på
local vehicle = assignment.vehicle
ADFieldWork.release(assignment)
if not ADHitch.returnHome(adEnv, vehicle) then
local park = parkMarkerId(adEnv, vehicle)
if park ~= nil then startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, park, nil) end
end
log(fieldId, "tømmevogn '%s' afbrudt under hentning — høsten er færdig", ADRuns.vehicleLabel(vehicle))
elseif assignment.helper and assignment.stage == stage then
if assignment.handle ~= nil then assignment.handle.cancelled = true end
ADCpBridge.stop(assignment.vehicle)
assignment.phase = "working"
@@ -683,10 +1311,35 @@ function ADFieldWork.windDownHelpers(adEnv, fieldId, stage)
end
end
-- Stop alle markarbejdets opgaver (Stop alt og parkér). Returnerer {{vehicle, manual}}; markernes trin bevares,
-- så arbejdet fortsætter efter pausen.
function ADFieldWork.stopAllAssignments(adEnv)
local stopped = {}
local list = {}
for _, assignment in pairs(ADFieldJobs.state.assignments) do table.insert(list, assignment) end
for _, assignment in ipairs(list) do
pcall(stopAd, assignment.vehicle)
pcall(ADCpBridge.stop, assignment.vehicle)
ADHitch.stow(assignment.vehicle, "stop alt")
ADFieldWork.release(assignment)
-- puljeredskab: sættes af på sin plads og traktoren kører hjem (i stedet for almindelig parkering)
local okPool, pooled = pcall(ADHitch.returnHome, adEnv, assignment.vehicle)
table.insert(stopped, {vehicle = assignment.vehicle, manual = assignment.manual == true, pooled = okPool and pooled == true})
end
return stopped
end
function ADFieldWork.stopAll(adEnv, fieldId)
for _, assignment in ipairs(ADFieldJobs.assignmentsFor(fieldId)) do
pcall(stopAd, assignment.vehicle)
ADCpBridge.stop(assignment.vehicle)
ADHitch.stow(assignment.vehicle, "marken stoppet")
local wasFetching = assignment.phase == "fetching"
ADFieldWork.release(assignment)
local okHome, pooled = pcall(ADHitch.returnHome, adEnv, assignment.vehicle)
if wasFetching and not (okHome and pooled) then
local park = parkMarkerId(adEnv, assignment.vehicle)
if park ~= nil then pcall(startAd, adEnv, assignment.vehicle, adEnv.AutoDrive.MODE_DRIVETO, park, nil) end
end
end
end
+443 -119
View File
@@ -10,7 +10,15 @@ ADFields.MARKER_MAX_DISTANCE = 400
ADFields.cache = {}
ADFields.order = {}
ADFields.scanIndex = 0
-- {[markId] = true}: marker hvis scanning er afsluttet (med resultat) eller fejlede — til isScanComplete
ADFields.scanFailed = {}
ADFields.ownedDone = false
ADFields.mowable = nil
-- markliste og F-markører ændrer sig sjældent: slås op igen efter så lang tid (ikke hvert scan)
ADFields.OWNED_REFRESH_MS = 30000
ADFields.MARKER_REFRESH_MS = 30000
ADFields.ownedAt = nil
ADFields.now = 0
function ADFields.parseMarkerName(name)
if type(name) ~= "string" then return nil end
@@ -45,6 +53,7 @@ end
function ADFields.growthPhase(state, fruit)
if state == nil or fruit == nil then return nil end
if state == 0 then return "none" end
if ADFields.isPreparable(state, fruit) then return "ready" end
if fruit.cutState ~= nil and fruit.cutState ~= 0 and state == fruit.cutState then return "cut" end
if fruit.witheredState ~= nil and state == fruit.witheredState then return "withered" end
local minReady = fruit.minHarvestingGrowthState or fruit.minHarvestGrowthState
@@ -54,6 +63,33 @@ function ADFields.growthPhase(state, fruit)
return "cut"
end
-- Fuldt udvokset stadie for en slåafgrøde: græs er "høstbart" allerede i mellemhøjde (halv ydelse,
-- Dakota-græs: greenMiddle 3 < harvestReady 4 < harvestReady2 5), så slåning venter til stadiet efter
-- det første høstbare, når afgrøden har flere. nil = ukendte grænser (ingen ekstra krav).
function ADFields.primeState(fruit)
if fruit == nil then return nil end
local minReady = fruit.minHarvestingGrowthState or fruit.minHarvestGrowthState
if minReady == nil then return nil end
local maxReady = fruit.maxHarvestingGrowthState or fruit.maxHarvestGrowthState or minReady
if maxReady > minReady then return minReady + 1 end
return minReady
end
-- Kan stadiet "forberedes" (toppes)? Dakota-kartofler: "harvestReady" er kun forberedelig — først det
-- toppede stadie er høstklart, men skærebord med topper (Ropa rrXL9) tager begge i ét drag.
function ADFields.isPreparable(state, fruit)
if fruit == nil or type(state) ~= "number" or state <= 0 then return false end
local minPrep, maxPrep = fruit.minPreparingGrowthState, fruit.maxPreparingGrowthState
if type(minPrep) ~= "number" or minPrep <= 0 then return false end
return state >= minPrep and state <= (type(maxPrep) == "number" and maxPrep >= minPrep and maxPrep or minPrep)
end
-- Tæller punktets klar-pixels? Kun når stadiet (4. værdi fra getFruitArea) er fuldt udvokset.
function ADFields.isPrime(growthState, prime)
if prime == nil or type(growthState) ~= "number" or growthState <= 0 then return true end
return growthState >= prime
end
-- Vokser marken? Fasen vinder; ukendt fase -> der er voksende afgrøde (getFruitArea) og den er ikke klar.
function ADFields.isGrowing(phase, anyPixels, readyRatio)
if phase ~= nil then return phase == "growing" end
@@ -69,6 +105,16 @@ function ADFields.soilNeeds(levels, settings)
}
end
-- Vin og oliven høstes med egen høster — aldrig slåafgrøder, selv om et mod har sat dem på MOWER-listen
-- (in-game 29/9: F22 hvide druer fik "Silage + så" og en skårlægger). Samme liste som ADCpBridge.VINE_FRUITS.
ADFields.VINE_FRUITS = {GRAPE = true, GRAPEB = true, OLIVE = true, OLIVEB = true, HOPS = true}
-- fruitName + indgangen fra getMowable() (nil = ikke på listen) -> slåbar?
function ADFields.isMowableFruit(fruitName, mowableEntry)
if mowableEntry == nil then return false end
return not ADFields.VINE_FRUITS[string.upper(tostring(fruitName or ""))]
end
function ADFields.getMowable()
if ADFields.mowable ~= nil then return ADFields.mowable end
local ok, result = pcall(function()
@@ -122,15 +168,6 @@ local function readWeed(x, z)
return value
end
local function average(points, reader, channel)
local sum, count = 0, 0
for _, p in ipairs(points) do
local value = reader(channel, p.x, p.z)
if value ~= nil then sum, count = sum + value, count + 1 end
end
return count > 0 and sum / count or nil
end
local function missionSettings()
local info = g_currentMission ~= nil and g_currentMission.missionInfo or {}
local function flag(value) return value == nil or value == true end
@@ -141,22 +178,6 @@ local function missionSettings()
}
end
-- Jord og pleje: gødning (og max), kalk, pløjning, ukrudt — gennemsnit over prøvepunkterne.
local function readSoil(info)
if type(FieldDensityMap) ~= "table" or #info.points == 0 then return end
local settings = missionSettings()
info.fert = average(info.points, readDensity, FieldDensityMap.SPRAY_LEVEL)
local okMax, maxValue = pcall(function() return g_currentMission.fieldGroundSystem:getMaxValue(FieldDensityMap.SPRAY_LEVEL) end)
info.fertMax = okMax and type(maxValue) == "number" and maxValue or 2
info.lime = average(info.points, readDensity, FieldDensityMap.LIME_LEVEL)
info.plowLevel = average(info.points, readDensity, FieldDensityMap.PLOW_LEVEL)
if settings.weedsEnabled then
info.weed = average(info.points, function(_, x, z) return readWeed(x, z) end, nil)
end
info.needs = ADFields.soilNeeds(info, settings)
if not settings.limeRequired then info.lime = nil end
end
local function fieldOwner(field)
local farmland = field.farmland or (field.getFarmland ~= nil and field:getFarmland()) or nil
if farmland == nil then return nil end
@@ -171,82 +192,152 @@ local function isOnField(x, z)
return FSDensityMapUtil.getFieldDataAtWorldPosition(x, y, z) and true or false
end
-- Centroid + halvvejs mod op til 8 hjørner; kun punkter der stadig er mark (bygninger fjerner areal).
local function samplePoints(field)
ADFields.SAMPLE_DIRECTIONS = 16
-- field.fieldState (FS25, bekræftet i AI-Bonde via aifProbe): {fruitIndex|nil, growthState} eller nil
-- når spillet ikke har tilstanden (så bruges punkt-afstemningen).
function ADFields.readFieldState(field)
local state = field ~= nil and field.fieldState or nil
if type(state) ~= "table" or type(state.fruitTypeIndex) ~= "number" then return nil end
local fruitIndex = state.fruitTypeIndex > 0 and state.fruitTypeIndex or nil
local groundNone = type(FieldGroundType) == "table" and FieldGroundType.NONE or 0
return {fruitIndex = fruitIndex, growthState = fruitIndex ~= nil and state.growthState or nil,
noFieldGround = type(state.groundType) == "number" and state.groundType == groundNone}
end
-- Jordtyper der tæller som klargjort til såning (stubbearbejdet, kultiveret, såbed, pløjet).
ADFields.PREPARED_GROUNDS = {"STUBBLE_TILLAGE", "CULTIVATED", "SEEDBED", "PLOWED", "ROLLED_SEEDBED"}
-- value = jordtypens tæthedsværdi i et punkt; values = {[typenavn] = værdi}. -> klargjort, pløjet (nil: ukendt)
function ADFields.classifyGround(value, values)
if value == nil or values == nil then return nil, nil end
-- ingen markjord (bygget over / uden for marken): tæller ikke med i andelen
if values.NONE ~= nil and values.NONE == value then return nil, nil end
local prepared = false
for _, name in ipairs(ADFields.PREPARED_GROUNDS) do
if values[name] ~= nil and values[name] == value then prepared = true end
end
return prepared, values.PLOWED ~= nil and values.PLOWED == value
end
-- Spillets tæthedsværdier for jordtyperne (FieldGroundType -> værdi i GROUND_TYPE-kanalen).
function ADFields.groundValues()
if ADFields.groundValueCache ~= nil then return ADFields.groundValueCache or nil end
local system = g_currentMission ~= nil and g_currentMission.fieldGroundSystem or nil
if type(FieldGroundType) ~= "table" or system == nil then return nil end
local values = {}
for _, name in ipairs({"NONE", "STUBBLE_TILLAGE", "CULTIVATED", "SEEDBED", "PLOWED", "ROLLED_SEEDBED"}) do
local groundType = FieldGroundType[name]
if groundType ~= nil then
local ok, value = pcall(function() return system:getFieldGroundValue(groundType) end)
values[name] = ok and type(value) == "number" and value or groundType
end
end
ADFields.groundValueCache = values
return values
end
-- Kan marken dyrkes? Nej hvis spillet siger "ingen markjord" (groundType NONE: bygget over — gårdsplads,
-- stald, biogasanlæg), eller hvis intet prøvepunkt ligger på markjord. En markensilo i et hjørne ændrer
-- intet: resten af marken er stadig markjord.
function ADFields.isCultivable(state, onFieldPoints)
if state ~= nil and state.noFieldGround then return false end
return (onFieldPoints or 0) > 0
end
-- Markens omrids som {{x, z}, ...}. FS25: field:getDensityMapPolygon():getVerticesList() (flad liste
-- x1, z1, x2, z2 ... — som Courseplay); ældre: polygonPoints (noder). Tom liste hvis intet findes.
function ADFields.readPolygon(field)
local verts = {}
local okList, list = pcall(function()
return field.getDensityMapPolygon ~= nil and field:getDensityMapPolygon():getVerticesList() or nil
end)
if okList and type(list) == "table" and #list >= 6 then
for index = 1, #list - 1, 2 do
table.insert(verts, {x = list[index], z = list[index + 1]})
end
return verts
end
for _, node in ipairs(field.polygonPoints or {}) do
if type(node) == "number" then
local x, _, z = getWorldTranslation(node)
table.insert(verts, {x = x, z = z})
end
end
local cx, cz = field.posX, field.posZ
if (cx == nil or cz == nil) and #verts > 0 then
cx, cz = 0, 0
return verts
end
-- Markens form-fingeraftryk: antal hjørner, areal (shoelace, nærmeste 10 m²) og tyngdepunkt (nærmeste meter).
-- Samme mark giver samme streng trods små float-forskelle; en ændret mark (hjørne/kant) giver en ny.
-- Bruges til at genbruge Courseplay-kurser (ADCourseCache). nil uden en flade (under 3 hjørner).
ADFields.SHAPE_AREA_STEP = 10
local function roundTo(value, step)
return math.floor(value / step + 0.5) * step
end
function ADFields.shapeSignature(verts)
if type(verts) ~= "table" or #verts < 3 then return nil end
local n = #verts
local twiceArea, sx, sz = 0, 0, 0
for index = 1, n do
local a, b = verts[index], verts[index % n + 1]
local cross = a.x * b.z - b.x * a.z
twiceArea = twiceArea + cross
sx, sz = sx + (a.x + b.x) * cross, sz + (a.z + b.z) * cross
end
local cx, cz
if math.abs(twiceArea) > 1e-6 then
cx, cz = sx / (3 * twiceArea), sz / (3 * twiceArea)
else
cx, cz = ADFields.polygonCenter(verts)
end
return string.format("n=%d;a=%d;c=%d,%d", n, roundTo(math.abs(twiceArea) / 2, ADFields.SHAPE_AREA_STEP),
roundTo(cx, 1), roundTo(cz, 1))
end
-- Markens areal i hektar (shoelace på {x,z}-polygonen, /10000); 0 uden en flade (under 3 hjørner).
function ADFields.polygonAreaHa(verts)
if type(verts) ~= "table" or #verts < 3 then return 0 end
local n, twiceArea = #verts, 0
for index = 1, n do
local a, b = verts[index], verts[index % n + 1]
twiceArea = twiceArea + a.x * b.z - b.x * a.z
end
return math.abs(twiceArea) / 2 / 10000
end
-- Hjørnernes gennemsnit (nil uden hjørner).
function ADFields.polygonCenter(verts)
if verts == nil or #verts == 0 then return nil end
local cx, cz = 0, 0
for _, v in ipairs(verts) do cx, cz = cx + v.x, cz + v.z end
cx, cz = cx / #verts, cz / #verts
end
if cx == nil then return {}, verts end
local candidates = {{x = cx, z = cz}}
local step = math.max(1, math.floor(#verts / 8))
for index = 1, #verts, step do
table.insert(candidates, {x = (cx + verts[index].x) / 2, z = (cz + verts[index].z) / 2})
end
local points = {}
for _, p in ipairs(candidates) do
local ok, onField = pcall(isOnField, p.x, p.z)
if ok and onField then table.insert(points, p) end
end
return points, verts
return cx / #verts, cz / #verts
end
local function fruitAt(points)
local votes, best, bestVotes = {}, nil, 0
for _, p in ipairs(points) do
local ok, index = pcall(FSDensityMapUtil.getFruitTypeIndexAtWorldPos, p.x, p.z)
if ok and type(index) == "number" and index > 0 then
votes[index] = (votes[index] or 0) + 1
if votes[index] > bestVotes then best, bestVotes = index, votes[index] end
end
-- Afstand fra (x, z) til omridsets nærmeste kant; nil uden omrids.
function ADFields.distanceToPolygon(verts, x, z)
if verts == nil or #verts == 0 then return nil end
local best = math.huge
for index = 1, #verts do
local a, b = verts[index], verts[index % #verts + 1]
local dx, dz = b.x - a.x, b.z - a.z
local len2 = dx * dx + dz * dz
local t = len2 > 0 and math.max(0, math.min(1, ((x - a.x) * dx + (z - a.z) * dz) / len2)) or 0
local d = math.sqrt((a.x + dx * t - x) ^ 2 + (a.z + dz * t - z) ^ 2)
if d < best then best = d end
end
return best
end
-- Andel af prøvefladen i høstvinduet (samme kald som Arbejdsseddel: any = true,true; klar = false,false).
local function readyRatio(fruitIndex, points)
local anySum, readySum = 0, 0
for _, p in ipairs(points) do
local okA, _, anyPixels = pcall(FSDensityMapUtil.getFruitArea, fruitIndex, p.x - 2, p.z - 2, p.x + 2, p.z, p.x, p.z + 2, true, true)
if okA and anyPixels then anySum = anySum + anyPixels end
local okR, _, readyPixels = pcall(FSDensityMapUtil.getFruitArea, fruitIndex, p.x - 2, p.z - 2, p.x + 2, p.z, p.x, p.z + 2, false, false)
if okR and readyPixels then readySum = readySum + readyPixels end
end
return anySum > 0 and readySum / anySum or 0, anySum
end
-- Vækststadiet på prøvepunkterne (flertal): getFieldDataAtWorldPosition's anden returværdi.
local function growthStateAt(points)
local votes, best, bestVotes = {}, nil, 0
for _, p in ipairs(points) do
local ok, _, state = pcall(function()
local y = getTerrainHeightAtWorldPos(g_currentMission.terrainRootNode, p.x, 1, p.z)
return FSDensityMapUtil.getFieldDataAtWorldPosition(p.x, y, p.z)
end)
if ok and type(state) == "number" then
votes[state] = (votes[state] or 0) + 1
if votes[state] > bestVotes then best, bestVotes = state, votes[state] end
end
end
return best
end
local function findMarker(adEnv, fieldId, x, z)
-- Markøren "F<nr>" nærmest marken: afstand til omridset (markører står ved kanten; midten kan
-- ligge 900 m væk på store kort), ellers til midten.
local function findMarker(adEnv, fieldId, x, z, verts)
local best, bestDistance = nil, math.huge
for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do
if ADFields.parseMarkerName(marker.name) == fieldId then
local wayPoint = adEnv.ADGraphManager:getWayPointById(marker.id)
if wayPoint ~= nil then
local distance = math.sqrt((wayPoint.x - x) ^ 2 + (wayPoint.z - z) ^ 2)
local distance = ADFields.distanceToPolygon(verts, wayPoint.x, wayPoint.z)
or math.sqrt((wayPoint.x - x) ^ 2 + (wayPoint.z - z) ^ 2)
if distance < bestDistance then best, bestDistance = marker, distance end
end
end
@@ -257,84 +348,317 @@ local function findMarker(adEnv, fieldId, x, z)
return nil, best ~= nil and bestDistance or nil
end
local function scanField(adEnv, field, fieldId)
local points, verts = samplePoints(field)
local info = {id = fieldId, field = field, points = points, verts = verts, mowable = false, readyRatio = 0}
info.x, info.z = field.posX, field.posZ
if (info.x == nil or info.z == nil) and points[1] ~= nil then info.x, info.z = points[1].x, points[1].z end
info.fruitIndex = #points > 0 and fruitAt(points) or nil
local mowable = info.fruitIndex ~= nil and ADFields.getMowable()[info.fruitIndex] or nil
if info.fruitIndex ~= nil then
local fruit = g_fruitTypeManager:getFruitTypeByIndex(info.fruitIndex)
local fillType = fruit ~= nil and fruit.fillType or nil
info.cropTitle = fillType ~= nil and fillType.title or (fruit ~= nil and fruit.name) or "?"
-- ---- scanning som arbejdskø ----
-- En marks scanning er en kø af små opgaver (ét prøvepunkt pr. opgave), og scanStep kører én opgave pr.
-- frame. In-game 26/9 gav en hel mark i ét frame et hak (flere opslag gennemgår alle kortets ~40
-- afgrødetyper pr. punkt). Coroutines findes ikke i FS25 (Giants fjernede dem), derfor en kø.
-- (eksplicit hale: # er udefineret på en tabel med huller, og udførte opgaver nulstilles)
local function push(scan, task)
scan.tail = (scan.tail or 0) + 1
scan.queue[scan.tail] = task
end
local function vote(votes, key)
votes[key] = (votes[key] or 0) + 1
end
local function winner(votes)
local best, bestVotes = nil, 0
for key, count in pairs(votes) do
if count > bestVotes then best, bestVotes = key, count end
end
return best
end
local function newScan(adEnv, entry)
local field, fieldId = entry.field, entry.id
local scan = {id = fieldId, queue = {}, head = 1, tail = 0, points = {}, fruitVotes = {},
anySum = 0, readySum = 0, immatureSum = 0, totalSum = 0, soil = {fert = {0, 0}, lime = {0, 0}, plowLevel = {0, 0}, weed = {0, 0}, prepared = {0, 0}, plowed = {0, 0}}}
local info = {id = fieldId, field = field, points = scan.points, verts = {}, mowable = false, readyRatio = 0}
scan.info = info
-- 1) polygon og kandidatpunkter (centroid + halvvejs mod op til 8 hjørner)
push(scan, function()
local verts = ADFields.readPolygon(field)
info.verts = verts
local cx, cz = ADFields.polygonCenter(verts)
if cx == nil then cx, cz = field.posX, field.posZ end
info.x, info.z = cx, cz
if cx == nil then return end
-- midten, markskiltet og halvvejs mod op til SAMPLE_DIRECTIONS hjørner: store marker (Dakota
-- 16x: 1,7 km) har striber uden afgrøde, så ét punkt kan ramme forkert
local candidates = {{x = cx, z = cz}}
if field.posX ~= nil and field.posZ ~= nil and (field.posX - cx) ^ 2 + (field.posZ - cz) ^ 2 > 1 then
table.insert(candidates, {x = field.posX, z = field.posZ})
end
local step = math.max(1, math.floor(#verts / ADFields.SAMPLE_DIRECTIONS))
for index = 1, #verts, step do
table.insert(candidates, {x = (cx + verts[index].x) / 2, z = (cz + verts[index].z) / 2})
end
-- 2) én opgave pr. kandidat: er punktet stadig mark? (bygninger fjerner areal)
for _, candidate in ipairs(candidates) do
push(scan, function()
local ok, onField = pcall(isOnField, candidate.x, candidate.z)
if ok and onField then table.insert(scan.points, candidate) end
end)
end
-- 3) afgrøde pr. punkt (efter alle kandidater)
push(scan, function()
for _, point in ipairs(scan.points) do
push(scan, function()
local ok, index = pcall(FSDensityMapUtil.getFruitTypeIndexAtWorldPos, point.x, point.z)
if ok and type(index) == "number" and index > 0 then vote(scan.fruitVotes, index) end
end)
end
push(scan, function() ADFields.scanPerFruit(scan) end)
end)
end)
return scan
end
-- Når afgrøden kendes: klar-andel, vækststadie og jord pr. punkt (én opgave pr. punkt).
function ADFields.scanPerFruit(scan)
local info = scan.info
-- spillets egen marktilstand (som fields.xml) er sandheden; punkt-afstemningen er reserve.
-- (getFieldDataAtWorldPosition giver isOnField, densityBits, groundType — IKKE vækststadie)
local state = ADFields.readFieldState(info.field)
info.growthState = state ~= nil and state.growthState or nil
info.cultivable = ADFields.isCultivable(state, #scan.points)
if state ~= nil then
info.fruitIndex = state.fruitIndex
else
info.fruitIndex = winner(scan.fruitVotes)
end
local fruitIndex = info.fruitIndex
local settings = missionSettings()
scan.settings = settings
-- slåafgrøder: genvækst i mellemhøjde tæller ikke som klar (in-game 27/9: halvt slået F90 blev
-- klippet igen i mellemhøjde)
local prime = nil
local fruitDesc = fruitIndex ~= nil and g_fruitTypeManager:getFruitTypeByIndex(fruitIndex) or nil
if fruitIndex ~= nil and ADFields.getMowable()[fruitIndex] ~= nil then
prime = ADFields.primeState(fruitDesc)
end
for _, point in ipairs(scan.points) do
push(scan, function()
local x, z = point.x, point.z
if fruitIndex ~= nil then
-- returnerer fruitValue, numPixels (afgrøde i høsthøjde), totalNumPixels (hele arealet), growthState
local okA, _, anyPixels, totalPixels, pointState = pcall(FSDensityMapUtil.getFruitArea, fruitIndex, x - 2, z - 2, x + 2, z, x, z + 2, true, true)
if okA and type(totalPixels) == "number" then scan.totalSum = scan.totalSum + totalPixels end
if okA and anyPixels then
scan.anySum = scan.anySum + anyPixels
if not ADFields.isPrime(pointState, prime) then scan.immatureSum = scan.immatureSum + anyPixels end
end
local okR, _, readyPixels = pcall(FSDensityMapUtil.getFruitArea, fruitIndex, x - 2, z - 2, x + 2, z, x, z + 2, false, false)
-- utoppede kartofler tæller som klar (in-game 27/9 F16: klar-andel 0 -> sprang høsten over)
if okA and anyPixels and ADFields.isPreparable(pointState, fruitDesc) then
okR, readyPixels = true, math.max(readyPixels or 0, anyPixels)
end
if okR and readyPixels and ADFields.isPrime(okA and pointState or nil, prime) then
scan.readySum = scan.readySum + readyPixels
end
end
end)
push(scan, function()
if type(FieldDensityMap) ~= "table" then return end
local x, z = point.x, point.z
local readings = {
fert = readDensity(FieldDensityMap.SPRAY_LEVEL, x, z),
lime = readDensity(FieldDensityMap.LIME_LEVEL, x, z),
plowLevel = readDensity(FieldDensityMap.PLOW_LEVEL, x, z),
weed = settings.weedsEnabled and readWeed(x, z) or nil,
}
-- klargjort/pløjet jord i punktet (kun på markjord): klargøringstrin der allerede er gjort går videre
local prepared, plowed = ADFields.classifyGround(readDensity(FieldDensityMap.GROUND_TYPE, x, z), ADFields.groundValues())
if prepared ~= nil then
readings.prepared = prepared and 1 or 0
readings.plowed = plowed and 1 or 0
end
for key, value in pairs(readings) do
if value ~= nil then
scan.soil[key][1] = scan.soil[key][1] + value
scan.soil[key][2] = scan.soil[key][2] + 1
end
end
end)
end
end
-- Samler resultatet når køen er tom.
local function finishScan(adEnv, scan)
local info = scan.info
local mowable = info.fruitIndex ~= nil and ADFields.getMowable()[info.fruitIndex] or nil
local fruitDesc = info.fruitIndex ~= nil and g_fruitTypeManager ~= nil and g_fruitTypeManager:getFruitTypeByIndex(info.fruitIndex) or nil
if mowable ~= nil and not ADFields.isMowableFruit(fruitDesc ~= nil and fruitDesc.name or nil, mowable) then mowable = nil end
if mowable ~= nil then
info.mowable, info.windrow, info.hay = true, mowable.windrow, mowable.hay
end
local fruit = nil
if info.fruitIndex ~= nil then
info.readyRatio, info.anyPixels = readyRatio(info.fruitIndex, points)
info.readyRatio = scan.anySum > 0 and scan.readySum / scan.anySum or 0
info.immatureRatio = scan.anySum > 0 and scan.immatureSum / scan.anySum or 0
info.anyPixels = scan.anySum
-- andel af hele arealet der stadig står i høsthøjde (fremdrift på fanen Måned)
info.standingRatio = scan.totalSum > 0 and scan.readySum / scan.totalSum or nil
fruit = g_fruitTypeManager:getFruitTypeByIndex(info.fruitIndex)
info.fruitName = fruit ~= nil and fruit.name or nil
info.harvestFillType = fruit ~= nil and fruit.fillType ~= nil and fruit.fillType.index or nil
local fillType = fruit ~= nil and fruit.fillType or nil
info.cropTitle = fillType ~= nil and fillType.title or (fruit ~= nil and fruit.name) or "?"
end
info.kind = ADFields.classify(info.fruitIndex, info.mowable, info.readyRatio)
info.phase = fruit ~= nil and ADFields.growthPhase(growthStateAt(points), fruit) or nil
info.phase = fruit ~= nil and ADFields.growthPhase(info.growthState, fruit) or nil
info.growing = info.fruitIndex ~= nil and ADFields.isGrowing(info.phase, info.anyPixels, info.readyRatio)
-- høstet stub eller visnet afgrøde er i praksis en bar mark (klargøring/såning)
if info.kind == "harvest" and (info.phase == "cut" or info.phase == "withered" or info.phase == "none") then
info.kind = "bare"
info.stubbleOf = info.phase == "cut" and info.cropTitle or nil
end
readSoil(info)
if info.x ~= nil then
local marker, distance = findMarker(adEnv, fieldId, info.x, info.z)
local settings = scan.settings or missionSettings()
for key, sum in pairs(scan.soil) do
info[key] = sum[2] > 0 and sum[1] / sum[2] or nil
end
local okMax, maxValue = pcall(function() return g_currentMission.fieldGroundSystem:getMaxValue(FieldDensityMap.SPRAY_LEVEL) end)
info.fertMax = okMax and type(maxValue) == "number" and maxValue or 2
info.needs = ADFields.soilNeeds(info, settings)
if not settings.limeRequired then info.lime = nil end
local previous = ADFields.cache[info.id]
if previous ~= nil and previous.markerCheckedAt ~= nil and ADFields.now - previous.markerCheckedAt < ADFields.MARKER_REFRESH_MS then
info.markerId, info.markerName, info.markerDistance, info.markerCheckedAt = previous.markerId, previous.markerName, previous.markerDistance, previous.markerCheckedAt
elseif info.x ~= nil then
local marker, distance = findMarker(adEnv, info.id, info.x, info.z, info.verts)
info.markerId = marker ~= nil and marker.id or nil
info.markerName = marker ~= nil and marker.name or nil
info.markerDistance = distance
info.markerCheckedAt = ADFields.now
end
return info
-- markens form: ændret mark (anden signatur end kursus-registret) -> gemte kurser til marken glemmes
info.shapeSig = ADFields.shapeSignature(info.verts)
if ADCourseCache ~= nil and ADCourseCache.checkField ~= nil then pcall(ADCourseCache.checkField, info.id, info.shapeSig) end
ADFields.cache[info.id] = info
ADFields.listCache = nil
end
local function ownedFields(farmId)
local result = {}
local all = g_fieldManager ~= nil and (g_fieldManager:getFields() or g_fieldManager.fields) or {}
for _, field in pairs(all) do
-- Marklisten (ejede marker) i portioner af 25 marker pr. frame.
local function newOwnedScan(farmId)
local all = {}
for _, field in pairs(g_fieldManager ~= nil and (g_fieldManager:getFields() or g_fieldManager.fields) or {}) do
table.insert(all, field)
end
local scan = {owned = true, queue = {}, head = 1, tail = 0, result = {}}
for start = 1, #all, 25 do
push(scan, function()
for index = start, math.min(start + 24, #all) do
local field = all[index]
local id = field.getId ~= nil and field:getId() or field.fieldId
if id ~= nil and fieldOwner(field) == farmId then
table.insert(result, {id = id, field = field})
table.insert(scan.result, {id = id, field = field})
end
end
table.sort(result, function(a, b) return a.id < b.id end)
return result
end)
end
return scan
end
-- Scanner næste mark (round-robin) og returnerer alle kendte. Kaldes hvert sekund af job-løkken.
function ADFields.scanNext(adEnv, farmId)
local owned = ownedFields(farmId)
local function finishOwnedScan(scan)
table.sort(scan.result, function(a, b) return a.id < b.id end)
local ids = {}
for _, entry in ipairs(owned) do ids[entry.id] = true end
for _, entry in ipairs(scan.result) do ids[entry.id] = true end
for id in pairs(ADFields.cache) do
if not ids[id] then ADFields.cache[id] = nil end
end
ADFields.order = owned
if #owned == 0 then return end
ADFields.scanIndex = ADFields.scanIndex % #owned + 1
local entry = owned[ADFields.scanIndex]
local ok, info = pcall(scanField, adEnv, entry.field, entry.id)
if ok then
ADFields.cache[entry.id] = info
ADFields.order = scan.result
ADFields.ownedDone = true
ADFields.listCache = nil
end
-- Har scanningen nået alle ejede marker mindst én gang (resultat i cachen, eller scanningen fejlede)?
-- Indtil da er marklisten ufuldstændig (efter indlæsning fyldes den ~én mark pr. SCAN_INTERVAL_MS).
function ADFields.isScanComplete()
if not ADFields.ownedDone then return false end
for _, entry in ipairs(ADFields.order) do
if ADFields.cache[entry.id] == nil and not ADFields.scanFailed[entry.id] then return false end
end
return true
end
-- Kaldes HVERT frame: kører én opgave af den aktuelle scanning. En ny mark påbegyndes højst hvert
-- SCAN_INTERVAL_MS; marklisten opdateres hvert OWNED_REFRESH_MS.
ADFields.SCAN_INTERVAL_MS = 2000
ADFields.MAX_TASKS_PER_FRAME = 8
ADFields.FRAME_BUDGET_MS = 0.5
function ADFields.clock()
if netGetTime == nil then return nil end
local ok, ms = pcall(netGetTime)
return ok and type(ms) == "number" and ms or nil
end
ADFields.lastScanStart = nil
function ADFields.scanStep(adEnv, farmId, now)
ADFields.now = now or ADFields.now
local scan = ADFields.activeScan
if scan == nil then
if ADFields.ownedAt == nil or ADFields.now - ADFields.ownedAt >= ADFields.OWNED_REFRESH_MS then
ADFields.ownedAt = ADFields.now
scan = newOwnedScan(farmId)
else
Logging.warning("ADSmartPickup: F%s kunne ikke scannes: %s", tostring(entry.id), tostring(info))
if ADFields.lastScanStart ~= nil and ADFields.now - ADFields.lastScanStart < ADFields.SCAN_INTERVAL_MS then return end
local owned = ADFields.order
if #owned == 0 then return end
ADFields.lastScanStart = ADFields.now
ADFields.scanIndex = ADFields.scanIndex % #owned + 1
scan = newScan(adEnv, owned[ADFields.scanIndex])
end
ADFields.activeScan = scan
end
local task = scan.queue[scan.head]
if task ~= nil then
-- flere små opgaver pr. frame inden for et tidsbudget (efter genstart skal alle marker scannes;
-- én pr. frame gjorde listen fuld først efter flere minutter). Uden ur (tests): én pr. frame.
local started = ADFields.clock()
for _ = 1, ADFields.MAX_TASKS_PER_FRAME do
task = scan.queue[scan.head]
if task == nil then return end
scan.queue[scan.head] = nil
scan.head = scan.head + 1
local ok, err = pcall(task)
if not ok then
Logging.warning("ADSmartPickup: F%s kunne ikke scannes: %s", tostring(scan.id or "-"), tostring(err))
if scan.id ~= nil then ADFields.scanFailed[scan.id] = true end
ADFields.activeScan = nil
return
end
local current = started ~= nil and ADFields.clock() or nil
if current == nil or current - started >= ADFields.FRAME_BUDGET_MS then return end
end
return
end
-- køen er tom: saml resultatet
ADFields.activeScan = nil
local ok, err
if scan.owned then
ok, err = pcall(finishOwnedScan, scan)
else
ok, err = pcall(finishScan, adEnv, scan)
end
if not ok then
Logging.warning("ADSmartPickup: F%s scanning kunne ikke afsluttes: %s", tostring(scan.id or "-"), tostring(err))
if scan.id ~= nil then ADFields.scanFailed[scan.id] = true end
end
end
-- Samme liste indtil næste scanning (list() kaldes hvert sekund; ingen ny tabel hver gang).
function ADFields.list()
if ADFields.listCache ~= nil then return ADFields.listCache end
local infos = {}
for _, entry in ipairs(ADFields.order) do
local info = ADFields.cache[entry.id]
if info ~= nil then table.insert(infos, info) end
-- bygget-over marker (stald, biogas, gårdsplads) kan ikke dyrkes: ikke med i oversigt/arbejde
if info ~= nil and info.cultivable ~= false then table.insert(infos, info) end
end
ADFields.listCache = infos
return infos
end
+91
View File
@@ -0,0 +1,91 @@
-- AD Fleet Names
-- Konsol "spuNameFleet": traktorer/lastbiler uden for flows får spillets butiksnavn (nummereret når der er flere
-- af samme model), og deres egen AD-parkering hedder "P <navn>". Flow-traktorer beholder flowets navn (mod'en
-- sætter det selv); selvkørende maskiner (mejetærsker, roeoptager …) beholder deres navn.
ADFleetNames = {}
ADFleetNames.LOG_PREFIX = "ADSmartPickup: navne: "
-- ---- ren logik ----
-- vehicles = {{id, model, name, inRun, selfPropelled}} -> {{id, name, parkName, rename}}
-- parkUse = {[parkIndex] = antal køretøjer (også redskaber) med den parkering}: delte parkeringer røres ikke.
function ADFleetNames.plan(vehicles, parkUse)
local byModel = {}
for _, vehicle in ipairs(vehicles or {}) do
if not vehicle.inRun and not vehicle.selfPropelled then
byModel[vehicle.model] = byModel[vehicle.model] or {}
table.insert(byModel[vehicle.model], vehicle)
end
end
local newNames = {}
for model, list in pairs(byModel) do
table.sort(list, function(a, b) return tostring(a.id) < tostring(b.id) end)
for index, vehicle in ipairs(list) do
newNames[vehicle.id] = #list > 1 and string.format("%s %d", model, index) or model
end
end
local result = {}
for _, vehicle in ipairs(vehicles or {}) do
local name = newNames[vehicle.id] or vehicle.name or vehicle.model
local ownPark = vehicle.park == nil or ((parkUse or {})[vehicle.park] or 1) <= 1
table.insert(result, {id = vehicle.id, name = name, parkName = "P " .. name,
rename = newNames[vehicle.id] ~= nil and name ~= vehicle.name,
renamePark = newNames[vehicle.id] ~= nil and ownPark})
end
return result
end
-- ---- spil ----
function ADFleetNames.apply(adEnv)
local vehicles, byId, parkUse = {}, {}, {}
local farmId = g_currentMission:getFarmId()
for _, any in pairs(g_currentMission.vehicleSystem.vehicles) do
local index = any.advd ~= nil and any.advd.parkDestination or nil
if type(index) == "number" and index >= 1 and any.getOwnerFarmId ~= nil and any:getOwnerFarmId() == farmId then
parkUse[index] = (parkUse[index] or 0) + 1
end
end
for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do
local id = ADRunsController.getVehicleId(vehicle)
if id ~= nil and vehicle.getName ~= nil then
local ownIndex = vehicle.advd ~= nil and vehicle.advd.parkDestination or nil
table.insert(vehicles, {id = id, model = tostring(vehicle:getName()), name = ADRuns.vehicleLabel(vehicle),
park = type(ownIndex) == "number" and ownIndex >= 1 and ownIndex or nil,
inRun = ADRunsController.getRunForVehicle(vehicle) ~= nil,
selfPropelled = not ADPool.canPull(ADPool.ownRole(vehicle))})
byId[id] = vehicle
end
end
local renamed, parks = 0, 0
for _, entry in ipairs(ADFleetNames.plan(vehicles, parkUse)) do
local vehicle = byId[entry.id]
local module = vehicle.ad ~= nil and vehicle.ad.stateModule or nil
if entry.rename and module ~= nil and module.setName ~= nil then
module:setName(entry.name)
if module.raiseDirtyFlag ~= nil then module:raiseDirtyFlag() end
renamed = renamed + 1
end
local index = vehicle.advd ~= nil and vehicle.advd.parkDestination or nil
local marker = type(index) == "number" and index >= 1 and adEnv.ADGraphManager:getMapMarkerById(index) or nil
if entry.renamePark and marker ~= nil and marker.name ~= entry.parkName then
adEnv.ADGraphManager:renameMapMarker(entry.parkName, index)
parks = parks + 1
end
end
Logging.info(ADFleetNames.LOG_PREFIX .. "%d maskiner omdøbt, %d parkeringer omdøbt", renamed, parks)
return renamed, parks
end
function ADFleetNames:consoleNameFleet()
local adEnv = ADFieldSilos ~= nil and ADFieldSilos.adEnv or nil
if adEnv == nil then return "spuNameFleet: AutoDrive er ikke klar endnu" end
local ok, renamed, parks = pcall(ADFleetNames.apply, adEnv)
if not ok then return "spuNameFleet: fejl: " .. tostring(renamed) end
return string.format("spuNameFleet: %d maskiner og %d parkeringer omdøbt — gem spillet", renamed, parks)
end
if addConsoleCommand ~= nil then
addConsoleCommand("spuNameFleet", "Giver traktorer uden for flows butiksnavne og deres parkeringer 'P <navn>'", "consoleNameFleet", ADFleetNames)
end
+567
View File
@@ -0,0 +1,567 @@
-- AD Flow Guide
-- "Opsæt flow": flowguidens trin oven på ADGuide (ren Lua, ingen spil-API) — samme trin-skærm og knapper
-- som markguiden. Kladden er en kopi af flowets felter, så intet ændres før [Gem]/[Start flow]. Det
-- spil-afhængige kommer i `context` (GUI'et henter det ud fra kladden, se previewRun):
-- {buildings = {{id, name, label, hasMarker, text}} -- kandidater til Fra (udkørsel) / Til (forsyning)
-- fillTypes = {{name, title}} -- varer målet tager (forsyning, Vælg selv)
-- products = {{name, title}} -- varer kilderne har (udkørsel)
-- storeBuildings = {{id, name, text}} -- lagre der kan tage flowets lagervarer
-- waitPools = {{group, count}}
-- vehicles = {{id, label, text, otherRunId, otherRunName, blocked, solo}} -- label = kort navn (resumé)
-- wagons = {{id, label}}} -- ledige vogne til solo-traktorer (bedste først)
-- Solo-traktor (ingen vogn bagpå) får en ledig vogn fra context.wagons (draft.wagons = {[traktorId] = vognId}),
-- som den henter ved "Connect <vognens parkering>" — uden at vognens parkering skal omdøbes.
-- Gem: ADRunsController.createRun/updateRun + assignVehicle/unassignVehicle (GUI'et).
ADFlowGuide = {}
ADFlowGuide.ORDER = {"name", "direction", "buildings", "goods", "store", "rules", "wait", "tractors", "summary"}
ADFlowGuide.POLICIES = {"off", "store", "sell", "sellElseStore"}
ADFlowGuide.MAX_NAMED_TRACTORS = 3
ADFlowGuide.MAX_NAMED_GOODS = 3
local function L(key, args) return {key = key, args = args} end
local function T(text) return {text = text} end
local function isOut(guide) return guide.draft.kind == "outbound" end
local function hasStorePolicy(draft)
for _, product in ipairs(draft.products or {}) do
if product.policy == "store" or product.policy == "sellElseStore" then return true end
end
return false
end
local VISIBLE = {
name = function(guide) return guide.draft.isNew == true end,
store = function(guide) return isOut(guide) and hasStorePolicy(guide.draft) end,
}
function ADFlowGuide.isVisible(step, guide)
local rule = VISIBLE[step]
if rule ~= nil then return rule(guide) == true end
for _, id in ipairs(ADFlowGuide.ORDER) do
if id == step then return true end
end
return false
end
ADFlowGuide.DEF = {order = ADFlowGuide.ORDER, isVisible = ADFlowGuide.isVisible}
-- Overskrift pr. trin; "Fra"/"Til" følger retningen.
function ADFlowGuide.stepKey(step, guide)
if step == "buildings" then
return (guide ~= nil and isOut(guide)) and "spu_guide_flow_step_from" or "spu_guide_flow_step_to"
end
return "spu_guide_flow_step_" .. tostring(step)
end
local KIND_HINTS = {buildings = true, goods = true, rules = true}
function ADFlowGuide.hintKey(step, guide)
if KIND_HINTS[step] then
return "spu_guide_flow_hint_" .. step .. ((guide ~= nil and isOut(guide)) and "_out" or "_in")
end
return "spu_guide_flow_hint_" .. tostring(step)
end
-- ---- kladde ----
local RUN_FIELDS = {
"id", "name", "kind", "sourceBuildingIds", "targetBuildingIds", "storeBuildingIds", "products",
"ingredientMode", "ingredients", "sellAtPercent", "storeAbovePercent", "sellAbovePercent", "collectRound", "loops",
"returnBelowPercent", "waitPoolGroup", "vehicleIds", "wagons", "chain",
}
local function draftFrom(run, isNew)
local draft = {}
for _, key in ipairs(RUN_FIELDS) do draft[key] = ADGuide.deepCopy(run[key]) end
for _, key in ipairs({"sourceBuildingIds", "targetBuildingIds", "storeBuildingIds", "products", "ingredients", "vehicleIds"}) do
draft[key] = draft[key] or {}
end
draft.kind = draft.kind == "outbound" and "outbound" or "supply"
draft.ingredientMode = draft.ingredientMode == "manual" and "manual" or "auto"
draft.sellAtPercent = draft.sellAtPercent or ADRuns.DEFAULT_SELL_AT_PERCENT
draft.storeAbovePercent = draft.storeAbovePercent or ADRuns.DEFAULT_STORE_ABOVE_PERCENT
draft.sellAbovePercent = draft.sellAbovePercent or ADRuns.DEFAULT_SELL_ABOVE_PERCENT
draft.returnBelowPercent = draft.returnBelowPercent or ADRuns.DEFAULT_RETURN_BELOW_PERCENT
draft.loops = draft.loops or 0
draft.waitPoolGroup = draft.waitPoolGroup or ""
draft.wagons = draft.wagons or {}
draft.collectRound = draft.collectRound == true
draft.isNew = isNew == true
draft.showAllBuildings = false
draft.storeAuto = #draft.storeBuildingIds == 0
return draft
end
function ADFlowGuide.open(run, context, options)
return ADGuide.new(ADFlowGuide.DEF, draftFrom(run, false), context, options)
end
-- Nyt flow: intet oprettes før [Gem]; kladden har standardværdierne fra ADRuns.createRun.
function ADFlowGuide.openNew(name, context)
local _, run = ADRuns.createRun(ADRuns.newStore(), name, "supply")
local draft = draftFrom(run, true)
draft.id = nil
return ADGuide.new(ADFlowGuide.DEF, draft, context)
end
-- Run-formet tabel til controllerens opslag (varer, vogne) før noget er gemt: markører fra de valgte
-- bygninger. markerIds(buildingId, role) -> {wayPointId, ...}.
function ADFlowGuide.previewRun(draft, markerIds)
local run = ADGuide.deepCopy(draft)
local function resolve(ids, role)
local result, seen = {}, {}
for _, buildingId in ipairs(ids or {}) do
for _, wayPointId in ipairs(markerIds(buildingId, role) or {}) do
if not seen[wayPointId] then
seen[wayPointId] = true
table.insert(result, wayPointId)
end
end
end
return result
end
run.sourceWayPointIds = resolve(draft.sourceBuildingIds, "load")
run.targetWayPointIds = resolve(draft.targetBuildingIds, "unload")
run.targetWayPointId = run.targetWayPointIds[1]
return run
end
-- ---- vogne til solo-traktorer ----
local function soloIds(context)
local set = {}
for _, vehicle in ipairs((context or {}).vehicles or {}) do
if vehicle.solo then set[vehicle.id] = true end
end
return set
end
-- Ny vogn-tabel: fravalgte traktorer slipper deres vogn; valgte solo-traktorer uden (gyldig) vogn får den første
-- ledige i context.wagons, som ingen anden traktor i flowet har. Vogne til ikke-solo-traktorer bevares (husket).
function ADFlowGuide.assignWagons(draft, context)
local chosen, solo, offered = {}, soloIds(context), {}
for _, id in ipairs(draft.vehicleIds or {}) do chosen[id] = true end
for _, wagon in ipairs((context or {}).wagons or {}) do offered[wagon.id] = true end
local wagons, used = {}, {}
for tractorId, wagonId in pairs(draft.wagons or {}) do
local valid = chosen[tractorId] and (not solo[tractorId] or offered[wagonId])
if valid and not used[wagonId] then
wagons[tractorId] = wagonId
used[wagonId] = true
end
end
for _, tractorId in ipairs(draft.vehicleIds or {}) do
if solo[tractorId] and wagons[tractorId] == nil then
for _, wagon in ipairs((context or {}).wagons or {}) do
if not used[wagon.id] then
wagons[tractorId] = wagon.id
used[wagon.id] = true
break
end
end
end
end
return wagons
end
-- ---- valg ----
local SET_VALUES = {
kind = true, ingredientMode = true, sellAtPercent = true, storeAbovePercent = true, sellAbovePercent = true, loops = true,
returnBelowPercent = true, waitPoolGroup = true,
}
local BOOL_VALUES = {showAllBuildings = true, storeAuto = true, collectRound = true}
local LIST_VALUES = {ingredients = true, storeBuildingIds = true, vehicleIds = true}
local function setPolicy(products, name, policy)
local result = {}
for _, product in ipairs(products or {}) do
if product.fillType ~= name then table.insert(result, {fillType = product.fillType, policy = product.policy}) end
end
if policy ~= "off" then table.insert(result, {fillType = name, policy = policy}) end
return result
end
function ADFlowGuide.choose(guide, id, value)
if id == "showBlocked" then
-- visning, ikke en del af flowet: fold listen over traktorer der ikke kan bruges ud/ind
local copy = {}
for key, field in pairs(guide) do copy[key] = field end
copy.showBlocked = not guide.showBlocked
return copy
end
local draft = ADGuide.deepCopy(guide.draft)
if SET_VALUES[id] then
draft[id] = value
elseif BOOL_VALUES[id] then
draft[id] = value == true
elseif LIST_VALUES[id] then
draft[id] = ADGuide.toggleItem(draft[id], value.item, value.on)
if id == "vehicleIds" then draft.wagons = ADFlowGuide.assignWagons(draft, guide.context) end
elseif id == "wagon" then
draft.wagons = ADGuide.deepCopy(draft.wagons or {})
draft.wagons[value.item] = value.value
elseif id == "buildings" then
local key = draft.kind == "outbound" and "sourceBuildingIds" or "targetBuildingIds"
draft[key] = ADGuide.toggleItem(draft[key], value.item, value.on)
elseif id == "policy" then
draft.products = setPolicy(draft.products, value.item, value.value)
elseif id == "name" then
if type(value) ~= "string" or not value:match("%S") then return guide end
draft.name = value
else
return guide
end
return ADGuide.withDraft(guide, draft)
end
-- ---- rækker ----
local function choice(id, title, options, value, item)
return {type = "choice", id = id, title = title, options = options, value = value, item = item}
end
local function toggle(id, title, value)
return {type = "toggle", id = id, title = title, off = L("ui_no"), on = L("ui_yes"), value = value == true}
end
local function check(id, item, text, value)
return {type = "check", id = id, item = item, title = T(text), value = value == true}
end
local function text(key, args) return {type = "text", label = L(key, args)} end
local function percentOptions(from, to, step)
local options = {}
for percent = from, to, step do table.insert(options, {label = T(string.format("%d %%", percent)), value = percent}) end
return options
end
-- "Sælg altid over": Fra, 50-100 %
local function sellAboveOptions()
local options = {{label = L("spu_sellAbove_off"), value = 0}}
for _, option in ipairs(percentOptions(50, ADRuns.MAX_SELL_ABOVE_PERCENT, 5)) do table.insert(options, option) end
return options
end
local function chosenSet(list)
local set = {}
for _, value in ipairs(list or {}) do set[value] = true end
return set
end
local function buildingIds(draft)
return draft.kind == "outbound" and draft.sourceBuildingIds or draft.targetBuildingIds
end
local function rowsName(guide)
return {
{type = "text", label = T(guide.draft.name or "")},
{type = "link", id = "rename", value = true, label = L("spu_guide_flow_rename")},
}
end
local function rowsDirection(guide)
return {choice("kind", L("spu_direction"), {
{label = L("spu_direction_in"), value = "supply"}, {label = L("spu_direction_out"), value = "outbound"}}, guide.draft.kind)}
end
-- Bygninger i fast rækkefølge (navn, markør); uden markør kun med "Vis alle" (eller når valgt).
local function rowsBuildings(guide)
local draft, rows = guide.draft, {}
local chosen = chosenSet(buildingIds(draft))
local candidates = guide.context.buildings or {}
local shown = ADFlowView.buildingRows(candidates, chosen, draft.showAllBuildings)
local hidden = ADFlowView.hiddenCount(candidates, shown)
if hidden > 0 or draft.showAllBuildings then
table.insert(rows, toggle("showAllBuildings", L("spu_showAllBuildings", {hidden}), draft.showAllBuildings))
end
for _, building in ipairs(shown) do
table.insert(rows, check("buildings", building.id, building.text or building.name, chosen[building.id]))
end
if #shown == 0 then table.insert(rows, text("spu_noBuildings")) end
return rows
end
local function rowsGoods(guide)
local draft, context, rows = guide.draft, guide.context, {}
if isOut(guide) then
local products = context.products or {}
if #products == 0 then return {text("spu_guide_flow_noProducts")} end
local options = {}
for _, policy in ipairs(ADFlowGuide.POLICIES) do table.insert(options, {label = L("spu_policy_" .. policy), value = policy}) end
for _, product in ipairs(products) do
table.insert(rows, choice("policy", T(product.text or product.title), options, ADRuns.getProductPolicy(draft, product.name), product.name))
end
return rows
end
table.insert(rows, choice("ingredientMode", L("spu_guide_flow_goods"), {
{label = L("spu_guide_flow_goodsAutoChoice"), value = "auto"}, {label = L("spu_guide_manual"), value = "manual"}}, draft.ingredientMode))
if draft.ingredientMode ~= "manual" then
table.insert(rows, text("spu_guide_flow_goodsAutoText"))
return rows
end
local chosen = chosenSet(draft.ingredients)
for _, fillType in ipairs(context.fillTypes or {}) do
table.insert(rows, check("ingredients", fillType.name, " " .. tostring(fillType.title), chosen[fillType.name]))
end
if #(context.fillTypes or {}) == 0 then table.insert(rows, text("spu_guide_flow_noGoodsTo")) end
return rows
end
local function rowsStore(guide)
local draft, rows = guide.draft, {}
table.insert(rows, choice("storeAuto", L("spu_guide_store"), {
{label = L("spu_guide_flow_storeAutoChoice"), value = true}, {label = L("spu_guide_manual"), value = false}}, draft.storeAuto))
if draft.storeAuto then
table.insert(rows, text("spu_guide_flow_storeAutoText"))
return rows
end
local chosen = chosenSet(draft.storeBuildingIds)
for _, building in ipairs(guide.context.storeBuildings or {}) do
table.insert(rows, check("storeBuildingIds", building.id, building.text, chosen[building.id]))
end
if #(guide.context.storeBuildings or {}) == 0 then table.insert(rows, text("spu_noBuildings")) end
return rows
end
local function productPolicies(draft)
local policies = {}
for _, product in ipairs(draft.products or {}) do table.insert(policies, product.policy) end
return policies
end
-- Kun regler varernes politik bruger: rent lager-flow viser ikke salgsgrænser og omvendt (in-game 29/9).
local function rowsRules(guide)
local draft = guide.draft
if isOut(guide) then
local sells, stores = ADFlowView.policyUse(productPolicies(draft))
local rows = {}
if sells then table.insert(rows, choice("sellAtPercent", L("spu_sellAt"), percentOptions(ADRuns.MIN_SELL_AT_PERCENT, ADRuns.MAX_SELL_AT_PERCENT, 5), draft.sellAtPercent)) end
if stores then table.insert(rows, choice("storeAbovePercent", L("spu_storeAbove"), percentOptions(ADRuns.MIN_STORE_ABOVE_PERCENT, ADRuns.MAX_STORE_ABOVE_PERCENT, 5), draft.storeAbovePercent)) end
if sells then table.insert(rows, choice("sellAbovePercent", L("spu_sellAbove"), sellAboveOptions(), draft.sellAbovePercent)) end
table.insert(rows, toggle("collectRound", L("spu_collectRound"), draft.collectRound))
return rows
end
local loops = {{label = L("spu_loops_infinite"), value = 0}}
for count = 1, ADRuns.MAX_LOOPS do table.insert(loops, {label = T(tostring(count)), value = count}) end
return {
choice("loops", L("spu_loops"), loops, draft.loops),
choice("returnBelowPercent", L("spu_returnBelow"), percentOptions(ADRuns.MIN_RETURN_BELOW_PERCENT, ADRuns.MAX_RETURN_BELOW_PERCENT, 10), draft.returnBelowPercent),
}
end
local function rowsWait(guide)
local options = {{label = L("spu_waitPool_all"), value = ""}}
for _, pool in ipairs(guide.context.waitPools or {}) do
table.insert(options, {label = T(string.format("%s (%d)", tostring(pool.group), pool.count or 0)), value = pool.group})
end
return {choice("waitPoolGroup", L("spu_waitPool"), options, guide.draft.waitPoolGroup or "")}
end
-- Vognvalg under en valgt solo-traktor: dens egen vogn + ledige vogne ingen anden traktor i flowet har.
local function rowWagon(guide, tractorId)
local wagons = guide.draft.wagons or {}
local usedByOthers = {}
for otherId, wagonId in pairs(wagons) do
if otherId ~= tractorId then usedByOthers[wagonId] = true end
end
local options = {}
for _, wagon in ipairs(guide.context.wagons or {}) do
if not usedByOthers[wagon.id] then table.insert(options, {label = T(tostring(wagon.label)), value = wagon.id}) end
end
if #options == 0 or wagons[tractorId] == nil then return text("spu_guide_flow_noFreeWagon") end
return choice("wagon", L("spu_guide_flow_wagon"), options, wagons[tractorId], tractorId)
end
-- Traktorer: flowets egne først, så de ledige. Traktorer i andre flows og blokerede (vognen passer ikke)
-- skjules med en linje hver.
local function rowsTractors(guide)
local draft, rows = guide.draft, {}
local inRun = chosenSet(draft.vehicleIds)
local entries = {}
local other, blocked = 0, 0
local blockedTexts = {}
for _, vehicle in ipairs(guide.context.vehicles or {}) do
local state = "free"
if inRun[vehicle.id] then
state = "in"
elseif vehicle.otherRunId ~= nil and vehicle.otherRunId ~= draft.id then
state = "other"
other = other + 1
elseif vehicle.blocked then
state = "blocked"
blocked = blocked + 1
table.insert(blockedTexts, tostring(vehicle.text))
end
table.insert(entries, {id = vehicle.id, label = tostring(vehicle.text), state = state})
end
local solo = soloIds(guide.context)
for _, entry in ipairs(ADFlowView.vehicleRows(entries, false)) do
table.insert(rows, check("vehicleIds", entry.id, entry.label, entry.state == "in"))
if entry.state == "in" and solo[entry.id] then
local wagonRow = rowWagon(guide, entry.id)
if wagonRow ~= nil then table.insert(rows, wagonRow) end
end
end
if #rows == 0 then table.insert(rows, text("spu_guide_flow_noTractors")) end
if other > 0 then table.insert(rows, text("spu_guide_flow_otherFlows", {other})) end
if blocked > 0 then
-- foldet sammen som standard; linjen med antallet folder navn + grund ud/ind
local key = guide.showBlocked and "spu_guide_flow_blockedHide" or "spu_guide_flow_blockedShow"
table.insert(rows, {type = "link", id = "showBlocked", value = true, label = L(key, {blocked})})
if guide.showBlocked then
table.sort(blockedTexts)
for _, line in ipairs(blockedTexts) do table.insert(rows, {type = "text", label = T(" " .. line)}) end
end
end
return rows
end
local function rowsSummary(guide)
local rows = {}
for _, line in ipairs(ADFlowGuide.summary(guide)) do
table.insert(rows, {type = "link", id = "jump", value = line.step, line = line})
end
return rows
end
local ROWS = {
name = rowsName, direction = rowsDirection, buildings = rowsBuildings, goods = rowsGoods, store = rowsStore,
rules = rowsRules, wait = rowsWait, tractors = rowsTractors, summary = rowsSummary,
}
function ADFlowGuide.rows(guide)
local builder = ROWS[guide.step]
return builder ~= nil and builder(guide) or {}
end
-- ---- resumé ----
-- Korte navne til resuméet (field, ellers navn, ellers rækkens tekst).
local function namesFor(ids, entries, field)
local byId = {}
for _, entry in ipairs(entries or {}) do byId[entry.id or entry.name] = entry[field] or entry.name or entry.text end
local names = {}
for _, id in ipairs(ids or {}) do table.insert(names, tostring(byId[id] or id)) end
return names
end
local SUMMARY = {
name = function(guide) return {T(guide.draft.name or "")} end,
direction = function(guide) return {L(isOut(guide) and "spu_direction_out" or "spu_direction_in")} end,
buildings = function(guide)
local names = namesFor(buildingIds(guide.draft), guide.context.buildings, "name")
if #names == 0 then return {L("spu_guide_flow_none")} end
return {T(ADFlowView.joinCounted(names, " + ", ""))}
end,
goods = function(guide)
local draft = guide.draft
if isOut(guide) then
local parts = {}
local titles = {}
for _, product in ipairs(guide.context.products or {}) do titles[product.name] = product.title end
for _, product in ipairs(draft.products or {}) do
table.insert(parts, {key = "spu_policy_" .. product.policy, prefix = tostring(titles[product.fillType] or product.fillType) .. ": "})
end
if #parts == 0 then return {L("spu_guide_flow_none")} end
return parts
end
if draft.ingredientMode ~= "manual" then return {L("spu_guide_flow_goodsAuto")} end
if #draft.ingredients == 0 then return {L("spu_guide_flow_none")} end
if #draft.ingredients > ADFlowGuide.MAX_NAMED_GOODS then return {L("spu_guide_flow_sumGoods", {#draft.ingredients})} end
local byName = {}
for _, fillType in ipairs(guide.context.fillTypes or {}) do byName[fillType.name] = fillType.title end
local names = {}
for _, name in ipairs(draft.ingredients) do table.insert(names, tostring(byName[name] or name)) end
return {T(table.concat(names, " + "))}
end,
store = function(guide)
local draft = guide.draft
if draft.storeAuto or #draft.storeBuildingIds == 0 then return {L("spu_guide_sum_auto")} end
if #draft.storeBuildingIds > 2 then return {L("spu_guide_sum_stores", {#draft.storeBuildingIds})} end
return {T(table.concat(namesFor(draft.storeBuildingIds, guide.context.storeBuildings, "name"), " + "))}
end,
rules = function(guide)
local draft = guide.draft
if isOut(guide) then
local sells, stores = ADFlowView.policyUse(productPolicies(draft))
local parts = {}
if sells then table.insert(parts, L("spu_guide_flow_sumSellAt", {draft.sellAtPercent})) end
if stores then table.insert(parts, L("spu_guide_flow_sumStoreAbove", {draft.storeAbovePercent})) end
if sells and (draft.sellAbovePercent or 0) > 0 then table.insert(parts, L("spu_guide_flow_sumSellAbove", {draft.sellAbovePercent})) end
if draft.collectRound then table.insert(parts, L("spu_guide_flow_sumRound")) end
return parts
end
local loops = (draft.loops or 0) > 0 and L("spu_guide_flow_sumLoops", {draft.loops}) or L("spu_guide_flow_sumLoopsInf")
return {loops, L("spu_guide_flow_sumReturn", {draft.returnBelowPercent})}
end,
wait = function(guide)
local group = guide.draft.waitPoolGroup or ""
return {group == "" and L("spu_waitPool_all") or T(group)}
end,
tractors = function(guide)
local ids = guide.draft.vehicleIds
if #ids == 0 then return {L("spu_guide_flow_sumNoTractors")} end
if #ids > ADFlowGuide.MAX_NAMED_TRACTORS then return {L("spu_guide_flow_sumTractors", {#ids})} end
-- traktorer der ikke findes (solgt, eller ikke indlæst endnu) vises ikke som rå id
local known = {}
for _, vehicle in ipairs(guide.context.vehicles or {}) do known[vehicle.id] = true end
local present = {}
for _, id in ipairs(ids) do if known[id] then table.insert(present, id) end end
if #present == 0 then return {L("spu_guide_flow_sumNoTractors")} end
return {T(table.concat(namesFor(present, guide.context.vehicles, "label"), ", "))}
end,
}
-- Resumé-linjer {step, title, parts}: Navn (altid) + de synlige trin.
function ADFlowGuide.summary(guide)
local lines = {}
for _, step in ipairs(ADFlowGuide.ORDER) do
local build = SUMMARY[step]
if build ~= nil and (step == "name" or ADFlowGuide.isVisible(step, guide)) then
table.insert(lines, {step = step, title = L(ADFlowGuide.stepKey(step, guide)), parts = build(guide)})
end
end
return lines
end
-- ---- gem ----
function ADFlowGuide.result(guide)
local draft = guide.draft
local storeIds = ADGuide.deepCopy(draft.storeBuildingIds)
if ADFlowGuide.isVisible("store", guide) and draft.storeAuto then storeIds = {} end
local changes = {
kind = draft.kind, sourceBuildingIds = ADGuide.deepCopy(draft.sourceBuildingIds),
targetBuildingIds = ADGuide.deepCopy(draft.targetBuildingIds), storeBuildingIds = storeIds,
products = ADGuide.deepCopy(draft.products), ingredientMode = draft.ingredientMode,
ingredients = ADGuide.deepCopy(draft.ingredients), sellAtPercent = draft.sellAtPercent,
storeAbovePercent = draft.storeAbovePercent, sellAbovePercent = draft.sellAbovePercent or 0,
collectRound = draft.collectRound, loops = draft.loops,
returnBelowPercent = draft.returnBelowPercent, waitPoolGroup = draft.waitPoolGroup or "",
}
if draft.name ~= nil and draft.name:match("%S") then changes.name = draft.name end
return {isNew = draft.isNew == true, runId = draft.id, name = draft.name, kind = draft.kind, changes = changes,
vehicleIds = ADGuide.deepCopy(draft.vehicleIds), wagons = ADFlowGuide.resultWagons(draft)}
end
-- Vogne for de valgte traktorer (til run.wagons ved gem).
function ADFlowGuide.resultWagons(draft)
local chosen, wagons = chosenSet(draft.vehicleIds), {}
for tractorId, wagonId in pairs(draft.wagons or {}) do
if chosen[tractorId] then wagons[tractorId] = wagonId end
end
return wagons
end
-- Traktorer der skal tildeles/fjernes efter gem.
function ADFlowGuide.vehicleChanges(oldIds, newIds)
local old, new = chosenSet(oldIds), chosenSet(newIds)
local added, removed = {}, {}
for _, id in ipairs(newIds or {}) do if not old[id] then table.insert(added, id) end end
for _, id in ipairs(oldIds or {}) do if not new[id] then table.insert(removed, id) end end
return added, removed
end
+65 -6
View File
@@ -101,18 +101,27 @@ end
-- Bygninger: buildings = {{id, name, hasMarker}}, chosen = {[id] = true}. Valgte først; bygninger uden
-- markør kun hvis valgte eller showAll (de kan alligevel ikke bruges før en markør er sat).
function ADFlowView.buildingRows(buildings, chosen, showAll)
local picked, withMarker, withoutMarker = {}, {}, {}
local shown, withoutMarker = {}, {}
for _, building in ipairs(buildings or {}) do
if chosen ~= nil and chosen[building.id] then
table.insert(picked, building)
elseif building.hasMarker then
table.insert(withMarker, building)
if (chosen ~= nil and chosen[building.id]) or building.hasMarker then
table.insert(shown, building)
elseif showAll then
table.insert(withoutMarker, building)
end
end
-- fast rækkefølge: bygningsnavn, så markørnavn (label) — et tryk må ikke flytte rækken under markøren
local function byNameThenLabel(list)
local copy = {}
for _, item in ipairs(list) do table.insert(copy, item) end
table.sort(copy, function(a, b)
if a.name ~= b.name then return ADFlowView.naturalLess(a.name, b.name) end
if (a.label or "") ~= (b.label or "") then return ADFlowView.naturalLess(a.label or "", b.label or "") end
return tostring(a.id) < tostring(b.id)
end)
return copy
end
local rows = {}
for _, list in ipairs({sortedBy(picked, "name"), sortedBy(withMarker, "name"), sortedBy(withoutMarker, "name")}) do
for _, list in ipairs({byNameThenLabel(shown), byNameThenLabel(withoutMarker)}) do
for _, building in ipairs(list) do table.insert(rows, building) end
end
return rows
@@ -138,3 +147,53 @@ function ADFlowView.joinCounted(names, separator, empty)
end
return table.concat(parts, separator)
end
-- Lang tekst -> linjer på højst maxChars (ombrydes ved mellemrum; indrykning gentages på hver linje,
-- så en indrykket forklaring forbliver indrykket). Ét ord længere end linjen deles ikke.
function ADFlowView.wrapText(text, maxChars)
text = tostring(text or "")
if #text <= maxChars then return {text} end
local indent = string.match(text, "^%s*") or ""
local lines, line = {}, indent
for word in string.gmatch(string.sub(text, #indent + 1), "%S+") do
if line ~= indent and #line + 1 + #word > maxChars then
table.insert(lines, line)
line = indent .. word
else
line = line == indent and (indent .. word) or (line .. " " .. word)
end
end
if line ~= indent then table.insert(lines, line) end
return lines
end
-- ---- status pr. flow (Flows-listen) ----
ADFlowView.LEVEL_COLORS = {
running = {0.30, 0.75, 0.35, 1},
waiting = {0.95, 0.80, 0.25, 1},
error = {0.90, 0.35, 0.30, 1},
stopped = {0.55, 0.55, 0.55, 1},
}
-- s = {running, total, stopped, canStop, warning, reason, lastStartFailed} -> "running"|"waiting"|"error"|"stopped"
-- Hvilke udkørselsregler betyder noget for varernes politik? policies = {"store"|"sell"|"sellElseStore"|"off"}
-- -> sells, stores. Ingen varer med salg/lager endnu = begge (reglerne kan sættes før varerne vælges).
function ADFlowView.policyUse(policies)
local sells, stores = false, false
for _, policy in ipairs(policies or {}) do
if policy == "sell" or policy == "sellElseStore" then sells = true end
if policy == "store" or policy == "sellElseStore" then stores = true end
end
if not sells and not stores then return true, true end
return sells, stores
end
function ADFlowView.flowStatus(s)
if s.stopped then return "stopped" end
if s.warning ~= nil then return "error" end
if (s.running or 0) == 0 and s.lastStartFailed then return "error" end
if (s.running or 0) > 0 and s.reason == nil then return "running" end
if (s.running or 0) > 0 or s.canStop then return "waiting" end
return "stopped"
end
+761
View File
@@ -0,0 +1,761 @@
-- AD Flow Wagons
-- Flows ejer deres vogne, men traktor og vogn er ikke låst sammen: den vogn der hænger på en flow-traktor huskes
-- (run.wagons = {[traktorId] = vognId}). Starter flowet med en solo-traktor, henter den sin vogn ved "Connect <vognens
-- parkering>". Stoppes/slettes flowet, eller er dets bygning væk, sættes vognen på plads og traktoren kører hjem —
-- så er begge ledige i maskinpuljen (en stoppet flows vogn må lånes; en kørende flows aldrig).
ADFlowWagons = {}
ADFlowWagons.LOG_PREFIX = "ADSmartPickup: flow-vogne: "
ADFlowWagons.REMEMBER_MS = 10000 -- husk vogne / prøv ventende starter igen
ADFlowWagons.BUILDING_CHECK_MS = 60000
ADFlowWagons.MISSING_CHECKS = 2 -- så mange tjek i træk før en bygning regnes for væk
-- ---- ren logik ----
ADFlowWagons.serialize = function(wagons) return ADRuns.serializeWagons(wagons) end
ADFlowWagons.parse = function(text) return ADRuns.parseWagons(text) end
-- Husk traktorens vogn: ny tabel ved ændring, samme tabel når intet ændres.
function ADFlowWagons.remember(wagons, tractorId, wagonId)
if (wagons or {})[tractorId] == wagonId then return wagons end
local copy = ADRuns.copyWagons(wagons)
copy[tractorId] = wagonId
return copy
end
-- Må redskabet bag traktoren huskes som flowets vogn? Kun noget der kan rumme last — ikke plov/kultivator (ingen
-- plads) og ikke såmaskine (frøtank). s = {capacity, isSowingMachine}. in-game 30/9: 'Sell Slurry 2' huskede ploven.
function ADFlowWagons.isRememberableWagon(s)
return (s.capacity or 0) > 0 and not s.isSowingMachine
end
-- s = {hasRear, wagonId, wagonExists, hasConnect, wagonAttached, wagonBusy} -> "start" | "fetch" | "wait"
function ADFlowWagons.startAction(s)
-- lånt ud af puljen (markarbejde, marksilo, på hjemtur): vent til den er fri
if s.tractorBusy then return "wait" end
if s.hasRear or s.wagonId == nil or not s.wagonExists or not s.hasConnect then return "start" end
if s.wagonAttached or s.wagonBusy then return "wait" end
return "fetch"
end
-- Hent vogn fejlede: ét nyt forsøg når AD standsede undervejs (forbigående, fx spærret af en anden traktor);
-- ikke ved kobling der fejler ved markøren (den skal rettes).
ADFlowWagons.FETCH_RETRIES = 1
function ADFlowWagons.retryFetch(reason, retries)
return tostring(reason):find("^stoppedAway") ~= nil and (retries or 0) < ADFlowWagons.FETCH_RETRIES
end
-- s = {flowRunning, ownWagon, adActive} -> start traktoren efter spillerens egen påkobling?
function ADFlowWagons.startAfterManualAttach(s)
return s.flowRunning == true and s.ownWagon == true and not s.adActive
end
-- Husk vognen bag traktoren ved hjemtur? Kun når et KØRENDE flow stoppes (reason "stop") — ved oprydning efter
-- indlæsning ("afterLoad") kan traktoren have et puljeredskab på fra et lån (in-game 29/9).
function ADFlowWagons.shouldRememberOnHome(reason)
return reason == "stop"
end
-- Påkobling uden ejer (hentet før genindlæsning, markopgaven tabt): s = {owned, idleMs} -> "home"|"wait"|"none"
ADFlowWagons.ORPHAN_MS = 120000
-- s.atOwnPark: traktoren holder på sin egen parkering = parkeret sæt, ikke forældreløs (vinmarks-REX, 29/9)
ADFlowWagons.OWN_PARK_M = 30
function ADFlowWagons.orphanAction(s)
if s.owned or s.atOwnPark then return "none" end
return (s.idleMs or 0) >= ADFlowWagons.ORPHAN_MS and "home" or "wait"
end
-- Hvorfor "wait" (til loggen). s som startAction.
function ADFlowWagons.waitReason(s)
if s.tractorBusy then return "tractorBusy" end
if s.wagonAttached then return "wagonAttached" end
return "wagonReserved"
end
-- Ingen husket vogn: gæt ud fra parkeringsnavnet — en ledig vogn hvis parkering indeholder flowets navn, helst med
-- traktorens nummer til sidst ("Cow Food 3" -> "P Cow Food vogn 3"). candidates = {{id, parkName}}, taken = {[id]=true}
function ADFlowWagons.guessWagon(runName, tractorNumber, candidates, taken)
local wanted = tostring(runName or ""):lower()
if wanted == "" then return nil end
local matching = {}
for _, candidate in ipairs(candidates or {}) do
local park = tostring(candidate.parkName or ""):lower()
if not (taken or {})[candidate.id] and park:find(wanted, 1, true) ~= nil then table.insert(matching, candidate) end
end
table.sort(matching, function(a, b) return tostring(a.parkName) < tostring(b.parkName) end)
for _, candidate in ipairs(matching) do
if tractorNumber ~= nil and tostring(candidate.parkName):match("%s(%d+)$") == tostring(tractorNumber) then return candidate.id end
end
return matching[1] ~= nil and matching[1].id or nil
end
-- Ledige vogne til flow-guiden, bedste først: parkering med flowets navn, så "Disponibel", så resten (alfabetisk).
-- candidates = {{id, label}} -> ny sorteret liste.
function ADFlowWagons.rankWagons(runName, candidates)
local wanted = tostring(runName or ""):lower()
local function rank(wagon)
local label = tostring(wagon.label or ""):lower()
if wanted ~= "" and label:find(wanted, 1, true) ~= nil then return 1 end
if label:find("disponibel", 1, true) ~= nil then return 2 end
return 3
end
local ranked = {}
for _, wagon in ipairs(candidates or {}) do table.insert(ranked, wagon) end
table.sort(ranked, function(a, b)
local ra, rb = rank(a), rank(b)
if ra ~= rb then return ra < rb end
return tostring(a.label) < tostring(b.label)
end)
return ranked
end
function ADFlowWagons.ownerOf(runs, wagonId)
for _, run in ipairs(runs or {}) do
for _, id in pairs(run.wagons or {}) do
if id == wagonId then return run end
end
end
return nil
end
-- Holdes vognen af et flow? Flowets vogn er flowets, også når flowet er stoppet — puljen, markarbejde og
-- marksilo må ikke låne den (in-game 29/9: marksilo lånte Cow Food-traktoren med vognen på efter stop).
function ADFlowWagons.isHeld(runs, wagonId)
return ADFlowWagons.ownerOf(runs, wagonId) ~= nil
end
-- Et tjek af om bygningen mangler: -> ny tæller, væk?
function ADFlowWagons.missingStep(count, missingNow)
if not missingNow then return 0, false end
local next = (count or 0) + 1
return next, next >= ADFlowWagons.MISSING_CHECKS
end
-- Flowets bygnings-id'er der ikke findes i exists = {[id] = true}. Kun bygninger der er set i denne session
-- (seen) tæller: et forældet id i et gammelt flow må ikke stoppe et flow der virker.
function ADFlowWagons.missingBuildings(run, exists, seen)
local missing = {}
for _, key in ipairs({"sourceBuildingIds", "targetBuildingIds", "storeBuildingIds"}) do
for _, id in ipairs(run[key] or {}) do
if not exists[id] and (seen or {})[id] then table.insert(missing, id) end
end
end
return missing
end
-- Hvor læsset afleveres ved stop: AD's 2. markør kun hvis den er et af flowets mål — den kan være forældet
-- (gammel AD-tilstand) eller et Wait-punkt, Wait-logikken har skiftet til. Ellers flowets første mål; nil = silo.
function ADFlowWagons.emptyTarget(secondMarkerId, runTargetIds)
local targets = runTargetIds or {}
for _, id in ipairs(targets) do
if id == secondMarkerId then return id end
end
return targets[1]
end
-- Tøm vognen før den parkeres (flowet stoppet): flowets mål -> silo/egen fabrik -> hjem.
-- stage: "start" | "target" | "dump"; s = {cargo, hasTarget, adActive, elapsedMs}
-- -> "home" | "target" | "dump" | "wait" | "giveUp"
ADFlowWagons.EMPTY_MIN_LITERS = 100
ADFlowWagons.EMPTY_TIMEOUT_MS = 600000
ADFlowWagons.EMPTY_STALL_MS = 45000 -- ved målet uden at læsset falder så længe = målet er fuldt
ADFlowWagons.EMPTY_NEAR_M = 40 -- m fra aflæsningsmarkøren tæller som "ved målet"
ADFlowWagons.MAX_DUMP_ATTEMPTS = 3 -- siloer der prøves før vognen parkeres med last
-- s = {nearTarget, sinceChangeMs}
function ADFlowWagons.isStalled(s)
return s.nearTarget == true and (s.sinceChangeMs or 0) >= ADFlowWagons.EMPTY_STALL_MS
end
function ADFlowWagons.emptyStep(stage, s)
local cargo = s.cargo or 0
if stage == "start" then
if cargo < ADFlowWagons.EMPTY_MIN_LITERS then return "home" end
return s.hasTarget and "target" or "dump"
end
if (s.elapsedMs or 0) >= ADFlowWagons.EMPTY_TIMEOUT_MS then return "giveUp" end
if s.stalled and cargo >= ADFlowWagons.EMPTY_MIN_LITERS then
if stage == "target" then return "dump" end
-- silo der ikke kunne tage imod: prøv den næste (op til MAX_DUMP_ATTEMPTS)
return (s.dumpAttempts or 0) < ADFlowWagons.MAX_DUMP_ATTEMPTS and "dump" or "giveUp"
end
if s.adActive then return "wait" end
if cargo < ADFlowWagons.EMPTY_MIN_LITERS then return "home" end
if stage == "target" then return "dump" end
return "giveUp"
end
-- ---- spil ----
local waiting = setmetatable({}, {__mode = "k"}) -- traktor -> runId (venter på sin vogn)
local fetching = setmetatable({}, {__mode = "k"}) -- traktor -> runId (henter sin vogn)
local emptying = setmetatable({}, {__mode = "k"}) -- traktor -> {stage, since, fillType, runName}
local homing = setmetatable({}, {__mode = "k"}) -- traktor -> true (flowets hjemtur: vogn af, traktor hjem)
local fetchRetries = setmetatable({}, {__mode = "k"}) -- traktor -> antal nye hente-forsøg
local lastWaitText = setmetatable({}, {__mode = "k"}) -- traktor -> sidst loggede venteårsag (kun ændringer logges)
local orphans = setmetatable({}, {__mode = "k"}) -- traktor -> ms siden påkobling uden ejer
local missingCount = {} -- runId -> tjek i træk
local seenBuildings = {} -- bygnings-id'er set i denne session
local rememberTimer, buildingTimer = 0, 0
local function runs()
return ADRunsController.store ~= nil and ADRunsController.store.runs or {}
end
local function findAnyVehicle(id)
for _, vehicle in pairs(g_currentMission.vehicleSystem.vehicles) do
if ADRunsController.getVehicleId(vehicle) == id then return vehicle end
end
return nil
end
local function connectOf(adEnv, wagon)
local index = wagon.advd ~= nil and wagon.advd.parkDestination or nil
local park = type(index) == "number" and index >= 1 and adEnv.ADGraphManager:getMapMarkerById(index) or nil
if park == nil then return nil end
local marker = ADPool.connectFor(park.name, adEnv.ADGraphManager:getMapMarkers())
return marker ~= nil and marker.id or nil
end
-- Traktorer i stoppede flows er ledige i puljen; traktorer i kørende flows aldrig.
-- s = {hasRun, stopped, carriesFlowWagon, homeTrip} -> må puljen/markarbejde/marksilo låne traktoren?
function ADFlowWagons.tractorFree(s)
if not s.hasRun then return true end
return s.stopped == true and not s.carriesFlowWagon and not s.homeTrip
end
function ADFlowWagons.isFreeTractor(vehicle)
local run = ADRunsController.getRunForVehicle(vehicle)
local carries = false
if run ~= nil then
for _, implement in ipairs(ADHitch.rearImplementsOf(vehicle)) do
local wagonId = ADRunsController.getVehicleId(implement.object)
if wagonId ~= nil and ADFlowWagons.ownerOf(runs(), wagonId) ~= nil then carries = true end
end
end
return ADFlowWagons.tractorFree({hasRun = run ~= nil, stopped = run ~= nil and run.stopped == true,
carriesFlowWagon = carries, homeTrip = emptying[vehicle] ~= nil or homing[vehicle] ~= nil})
end
-- Holder flowet noget: en traktor henter/venter på sin vogn eller har en vogn bagpå (til Stop-knappen).
function ADFlowWagons.hasActivity(run, vehiclesById)
for _, vehicleId in ipairs(run.vehicleIds or {}) do
local vehicle = vehiclesById ~= nil and vehiclesById[vehicleId] or nil
-- vogn bagpå tæller kun i et flow der er startet: et flow der aldrig kom i gang (fx 'ingen vogn', og spilleren
-- har selv koblet på) skal vise Start, ikke Stop (in-game 29/9: Milk Store)
local holdsWagon = vehicle ~= nil and ADRunsController.runningRuns[run.id] == true and #ADHitch.rearImplementsOf(vehicle) > 0
if vehicle ~= nil and (waiting[vehicle] ~= nil or fetching[vehicle] ~= nil or holdsWagon) then
return true
end
end
return false
end
function ADFlowWagons.hasWagonFor(run, tractorId)
return (run.wagons or {})[tractorId] ~= nil
end
function ADFlowWagons.isHeldWagon(wagonId)
return ADFlowWagons.isHeld(runs(), wagonId)
end
-- Frakoblede vogne med parkering, som intet kørende flow holder, og som flowet ikke allerede har givet en traktor.
local function guessFor(adEnv, run, vehicle, tractorId)
local taken = {}
for _, id in pairs(run.wagons or {}) do taken[id] = true end
local candidates = {}
local farmId = g_currentMission:getFarmId()
for _, any in pairs(g_currentMission.vehicleSystem.vehicles) do
local index = any.advd ~= nil and any.advd.parkDestination or nil
local attached = any.getAttacherVehicle ~= nil and any:getAttacherVehicle() ~= nil
if any.spec_motorized == nil and any.spec_attachable ~= nil and not attached and type(index) == "number" and index >= 1
and any.getOwnerFarmId ~= nil and any:getOwnerFarmId() == farmId then
local id = ADRunsController.getVehicleId(any)
local park = adEnv.ADGraphManager:getMapMarkerById(index)
if id ~= nil and park ~= nil and not ADFlowWagons.isHeld(runs(), id) then
table.insert(candidates, {id = id, parkName = park.name})
end
end
end
local number = nil
for index, id in ipairs(run.vehicleIds or {}) do
if id == tractorId then number = index end
end
local label = tostring(ADRuns.vehicleLabel(vehicle))
number = tonumber(label:match("%s(%d+)$")) or number
local wagonId = ADFlowWagons.guessWagon(run.name, number, candidates, taken)
if wagonId ~= nil then
Logging.info(ADFlowWagons.LOG_PREFIX .. "flow '%s': '%s' får vognen ved parkeringen der matcher flowets navn", tostring(run.name), label)
end
return wagonId
end
-- Flowet startes igen mens traktoren tømmer/kører hjem efter et stop: afbryd, så den kører videre med sin vogn.
-- Puljens egen hjemtur (fra markarbejde) røres ikke — der sidder et andet redskab bagpå.
-- s = {emptying, flowHoming, poolReturning} -> true = afbryd
function ADFlowWagons.abortHomeTripOnStart(s)
return s.emptying == true or s.flowHoming == true
end
-- Hvilken af isPoolBusy's grunde (til loggen).
function ADFlowWagons.busyWhy(vehicle, tractorId)
if ADFieldJobs ~= nil and ADFieldJobs.state.assignments[tractorId] ~= nil then return "markopgave" end
if ADFieldSilos ~= nil and ADFieldSilos.isLoaned(vehicle) then return "lånt af marksilo" end
if ADPool ~= nil and ADPool.isReturning(vehicle) then return "sætter redskab af" end
if ADCpBridge ~= nil and ADCpBridge.isWorking(vehicle) then return "Courseplay kører" end
if emptying[vehicle] ~= nil then return "tømmer vognen" end
return "?"
end
-- Lånt ud af puljen: markarbejde, marksilo-lån, på hjemtur eller Courseplay kører den.
function ADFlowWagons.isPoolBusy(vehicle, tractorId)
return (ADFieldJobs ~= nil and ADFieldJobs.state.assignments[tractorId] ~= nil)
or (ADFieldSilos ~= nil and ADFieldSilos.isLoaned(vehicle))
or (ADPool ~= nil and ADPool.isReturning(vehicle))
or (ADCpBridge ~= nil and ADCpBridge.isWorking(vehicle))
or (ADFlowWagons.isEmptying ~= nil and ADFlowWagons.isEmptying(vehicle))
end
-- Før en flow-traktor startes: hent sin vogn / vent på den. -> håndteret, resultat (som startVehicle)
function ADFlowWagons.beforeStart(adEnv, run, vehicle)
local tractorId = ADRunsController.getVehicleId(vehicle)
if fetching[vehicle] ~= nil then return true, {vehicle = vehicle, ok = true, reason = "fetchingWagon"} end
if ADFlowWagons.abortHomeTripOnStart({emptying = emptying[vehicle] ~= nil, flowHoming = homing[vehicle] ~= nil}) then
Logging.info(ADFlowWagons.LOG_PREFIX .. "flow '%s': '%s' startet igen — tømning/hjemtur afbrudt, kører videre med vognen",
tostring(run.name), tostring(ADRuns.vehicleLabel(vehicle)))
emptying[vehicle] = nil
if homing[vehicle] ~= nil then
homing[vehicle] = nil
ADHitch.cancel(vehicle)
end
end
local wagonId = (run.wagons or {})[tractorId]
if wagonId == nil and #ADHitch.rearImplementsOf(vehicle) == 0 then
wagonId = guessFor(adEnv, run, vehicle, tractorId)
if wagonId ~= nil then run.wagons = ADFlowWagons.remember(run.wagons, tractorId, wagonId) end
end
local wagon = wagonId ~= nil and findAnyVehicle(wagonId) or nil
local connectId = wagon ~= nil and connectOf(adEnv, wagon) or nil
local startState = {
tractorBusy = ADFlowWagons.isPoolBusy(vehicle, tractorId),
hasRear = #ADHitch.rearImplementsOf(vehicle) > 0, wagonId = wagonId, wagonExists = wagon ~= nil,
hasConnect = connectId ~= nil,
wagonAttached = wagon ~= nil and wagon.getAttacherVehicle ~= nil and wagon:getAttacherVehicle() ~= nil,
wagonBusy = wagonId ~= nil and ADPool.isReserved(wagonId),
}
local action = ADFlowWagons.startAction(startState)
if action == "start" then lastWaitText[vehicle] = nil return false end
ADRunsController.runningRuns[run.id] = true
if action == "wait" then
local holder = wagon ~= nil and wagon.getAttacherVehicle ~= nil and wagon:getAttacherVehicle() or nil
local waitText = ADFlowWagons.waitReason(startState) .. ": " .. (startState.tractorBusy and ADFlowWagons.busyWhy(vehicle, tractorId)
or (holder ~= nil and ("sidder på " .. tostring(ADRuns.vehicleLabel(holder)))) or "reserveret af puljen")
if lastWaitText[vehicle] ~= waitText then
lastWaitText[vehicle] = waitText
Logging.info(ADFlowWagons.LOG_PREFIX .. "flow '%s': '%s' venter på sin vogn (%s)", tostring(run.name),
tostring(ADRuns.vehicleLabel(vehicle)), waitText)
end
waiting[vehicle] = run.id
return true, {vehicle = vehicle, ok = true, reason = "waitingWagon"}
end
waiting[vehicle] = nil
lastWaitText[vehicle] = nil
fetching[vehicle] = run.id
local runId = run.id
local okFetch, errFetch = pcall(ADHitch.fetch, adEnv, vehicle, connectId, function(tractor, ok, detail)
fetching[tractor] = nil
local current = ADRuns.getRun(ADRunsController.store, runId)
if current == nil or current.stopped then
ADHitch.returnHome(adEnv, tractor)
return
end
if ok then
fetchRetries[tractor] = nil
local result = ADRunsController.startVehicle(adEnv, current, tractor)
if result ~= nil and not result.ok then
-- flowet kan ikke startes (markør, bygning …): ikke efterladt ved Connect med vognen
Logging.warning(ADFlowWagons.LOG_PREFIX .. "flow '%s': '%s' kunne ikke starte (%s) — vognen sættes tilbage",
tostring(current.name), tostring(ADRuns.vehicleLabel(tractor)), tostring(result.reason))
ADHitch.returnHome(adEnv, tractor)
end
elseif ADFlowWagons.retryFetch(detail, fetchRetries[tractor]) then
fetchRetries[tractor] = (fetchRetries[tractor] or 0) + 1
waiting[tractor] = runId -- retryWaiting starter den igen om lidt
Logging.warning(ADFlowWagons.LOG_PREFIX .. "flow '%s': '%s' kunne ikke hente sin vogn (%s) — prøver igen", tostring(current.name),
tostring(ADRuns.vehicleLabel(tractor)), tostring(detail))
else
fetchRetries[tractor] = nil
Logging.warning(ADFlowWagons.LOG_PREFIX .. "flow '%s': '%s' kunne ikke hente sin vogn (%s)", tostring(current.name),
tostring(ADRuns.vehicleLabel(tractor)), tostring(detail))
end
end)
if not okFetch then
fetching[vehicle] = nil
return true, {vehicle = vehicle, ok = false, reason = "error"}
end
Logging.info(ADFlowWagons.LOG_PREFIX .. "flow '%s': '%s' henter sin vogn", tostring(run.name), tostring(ADRuns.vehicleLabel(vehicle)))
return true, {vehicle = vehicle, ok = true, reason = "fetchingWagon"}
end
-- Stop/slet/bygning væk: vognen tømmes (flowets mål, ellers silo/egen fabrik), sættes på plads, traktoren hjem.
local nowMs = 0
local function cargoOf(adEnv, vehicle)
local level, fillType = 0, nil
local ok, units = pcall(adEnv.AutoDrive.getAllDischargeableUnits, vehicle)
for _, unit in ipairs(ok and units or {}) do
local object, index = unit.object, unit.fillUnitIndex
if object ~= nil and object.getFillUnitFillLevel ~= nil then
local unitLevel = object:getFillUnitFillLevel(index) or 0
if unitLevel > 0 then
level = level + unitLevel
fillType = fillType or object:getFillUnitFillType(index)
end
end
end
return level, fillType
end
local function goHome(adEnv, vehicle)
emptying[vehicle] = nil
homing[vehicle] = true
local okHome, pooled = pcall(ADHitch.returnHome, adEnv, vehicle, function(tractor) homing[tractor] = nil end)
if not (okHome and pooled) then
homing[vehicle] = nil
local park = ADFieldWork.parkMarkerId(adEnv, vehicle)
if park ~= nil then pcall(ADFieldWork.startAd, adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, park, nil) end
end
end
local function fillName(fillType)
local desc = fillType ~= nil and g_fillTypeManager ~= nil and g_fillTypeManager:getFillTypeByIndex(fillType) or nil
return desc ~= nil and (desc.title or desc.name) or tostring(fillType)
end
local function startDump(adEnv, vehicle, state)
local cargo, fillType = cargoOf(adEnv, vehicle)
local isReachable = function(markerId)
return ADUnloadWait == nil or ADUnloadWait.hasRouteTo == nil or ADUnloadWait.hasRouteTo(adEnv, vehicle, markerId)
end
-- marksiloer er ikke tømmesteder (de er til markens høst); siloer der allerede fejlede springes over
local okSet, fieldSilos = pcall(function() return ADFieldSilos ~= nil and ADFieldSilos.fieldSiloPlaceableSet(adEnv) or nil end)
state.triedMarkers = state.triedMarkers or {}
local ok, marker, why = pcall(ADLoadSwap.findDumpMarker, adEnv, vehicle, fillType, cargo, nil, isReachable,
okSet and fieldSilos or nil, state.triedMarkers, true)
if not ok or marker == nil then
Logging.warning(ADFlowWagons.LOG_PREFIX .. "'%s' har stadig %d l %s — intet sted at aflæsse (%s); parkeres med last",
tostring(ADRuns.vehicleLabel(vehicle)), math.floor(cargo), fillName(fillType), tostring(ok and why or marker))
goHome(adEnv, vehicle)
return
end
state.stage, state.since = "dump", nowMs
state.markerId, state.markerName, state.lastCargo = marker.id, marker.name, nil
state.dumpAttempts = (state.dumpAttempts or 0) + 1
state.triedMarkers[marker.id] = true
ADFieldWork.startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DELIVERTO, marker.id, nil, fillType)
Logging.info(ADFlowWagons.LOG_PREFIX .. "'%s' aflæsser resten (%d l %s) ved %s før parkering",
tostring(ADRuns.vehicleLabel(vehicle)), math.floor(cargo), fillName(fillType), tostring(marker.name))
end
-- Husk vognen bag traktoren som flowets (også når flowet lige er stoppet — rememberAll ser kun kørende flows).
-- in-game 29/9: Milk Store's tanke blev sat af ved Stop alt uden at være husket -> 'noTrailer' ved næste start.
local function isCargoWagon(object)
local _, capacity = ADPool.cargoOf(object)
return ADFlowWagons.isRememberableWagon({capacity = capacity, isSowingMachine = object.spec_sowingMachine ~= nil})
end
local function rememberWagonOf(vehicle)
local run = ADRunsController.getRunForVehicle(vehicle)
local rear = run ~= nil and ADHitch.rearImplementsOf(vehicle) or {}
local tractorId = ADRunsController.getVehicleId(vehicle)
local wagonId = rear[1] ~= nil and isCargoWagon(rear[1].object) and ADRunsController.getVehicleId(rear[1].object) or nil
if wagonId == nil or tractorId == nil then return end
local updated = ADFlowWagons.remember(run.wagons, tractorId, wagonId)
if updated ~= run.wagons then
run.wagons = updated
Logging.info(ADFlowWagons.LOG_PREFIX .. "flow '%s': '%s' har vognen '%s'", tostring(run.name),
tostring(ADRuns.vehicleLabel(vehicle)), tostring(ADRuns.vehicleLabel(rear[1].object)))
end
end
-- Én traktor hjem (fra et stoppet/slettet flow): tøm først, så parkering.
-- reason: "stop" (et kørende flow stoppes: vognen huskes) | "afterLoad" (oprydning: intet huskes)
function ADFlowWagons.homeVehicle(adEnv, vehicle, runName, reason)
waiting[vehicle] = nil
if ADFlowWagons.shouldRememberOnHome(reason or "stop") then
rememberWagonOf(vehicle) -- vognen sættes af nu: flowet skal kunne finde den igen ved næste start
end
ADHitch.stow(vehicle, "flowet stoppet")
if fetching[vehicle] ~= nil then
fetching[vehicle] = nil
ADHitch.cancel(vehicle)
end
local module = vehicle.ad ~= nil and vehicle.ad.stateModule or nil
local second = module ~= nil and module.getSecondMarker ~= nil and module:getSecondMarker() or nil
local run = ADRunsController.getRunForVehicle(vehicle)
local targetId = ADFlowWagons.emptyTarget(second ~= nil and second.id or nil, run ~= nil and run.targetWayPointIds or nil)
local target = targetId ~= nil and adEnv.ADGraphManager:getMapMarkerByWayPointId(targetId) or nil
local cargo, fillType = cargoOf(adEnv, vehicle)
local action = ADFlowWagons.emptyStep("start", {cargo = cargo, hasTarget = target ~= nil})
if action == "home" then return goHome(adEnv, vehicle) end
local state = {stage = action, since = nowMs, runName = runName,
markerId = target ~= nil and target.id or nil, markerName = target ~= nil and target.name or nil}
emptying[vehicle] = state
if action == "target" then
ADFieldWork.startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DELIVERTO, target.id, nil, fillType)
Logging.info(ADFlowWagons.LOG_PREFIX .. "'%s' afleverer sit læs (%d l %s) ved %s før parkering",
tostring(ADRuns.vehicleLabel(vehicle)), math.floor(cargo), fillName(fillType), tostring(target.name))
else
startDump(adEnv, vehicle, state)
end
end
-- ADHitch: spilleren har selv kørt traktoren til Connect og koblet på.
function ADFlowWagons.onManualAttach(adEnv, vehicle, wagon)
local run = ADRunsController.getRunForVehicle(vehicle)
orphans[vehicle] = 0 -- ryddes af tickOrphans når nogen ejer opgaven (flow, markopgave, AD, spilleren)
if run == nil then return end
local tractorId = ADRunsController.getVehicleId(vehicle)
local wagonId = wagon ~= nil and ADRunsController.getVehicleId(wagon) or nil
local remembered = (run.wagons or {})[tractorId]
local module = vehicle.ad ~= nil and vehicle.ad.stateModule or nil
-- ikke stoppet af spilleren = gælder også et flow hvis Start blev afvist ('ingen vogn') — det er derfor han henter vognen
if not ADFlowWagons.startAfterManualAttach({flowRunning = not run.stopped,
ownWagon = wagonId ~= nil and (remembered == nil or remembered == wagonId),
adActive = module ~= nil and module:isActive()}) then return end
fetchRetries[vehicle], waiting[vehicle], orphans[vehicle] = nil, nil, nil
Logging.info(ADFlowWagons.LOG_PREFIX .. "flow '%s': '%s' har selv hentet sin vogn — startes", tostring(run.name), tostring(ADRuns.vehicleLabel(vehicle)))
ADRunsController.startVehicle(adEnv, run, vehicle)
end
-- Traktorer der har koblet et redskab på uden ejer: efter ORPHAN_MS sættes redskabet tilbage og traktoren kører hjem.
local function tickOrphans(adEnv, elapsed)
for vehicle, idle in pairs(orphans) do
local id = ADRunsController.getVehicleId(vehicle)
local run = ADRunsController.getRunForVehicle(vehicle)
local module = vehicle.ad ~= nil and vehicle.ad.stateModule or nil
local owned = (module ~= nil and module:isActive()) or (vehicle.getIsControlled ~= nil and vehicle:getIsControlled())
or (run ~= nil and not run.stopped) or (ADFieldJobs ~= nil and id ~= nil and ADFieldJobs.state.assignments[id] ~= nil)
or (ADCpBridge ~= nil and ADCpBridge.isWorking(vehicle)) or #ADHitch.rearImplementsOf(vehicle) == 0
local atOwnPark = false
local parkId = ADFieldWork ~= nil and ADFieldWork.parkMarkerId ~= nil and ADFieldWork.parkMarkerId(adEnv, vehicle) or nil
local parkWp = parkId ~= nil and adEnv.ADGraphManager:getWayPointById(parkId) or nil
if parkWp ~= nil and vehicle.rootNode ~= nil then
local x, _, z = getWorldTranslation(vehicle.rootNode)
atOwnPark = (x - parkWp.x) ^ 2 + (z - parkWp.z) ^ 2 <= ADFlowWagons.OWN_PARK_M ^ 2
end
local action = ADFlowWagons.orphanAction({owned = owned, atOwnPark = atOwnPark, idleMs = idle + elapsed})
if action == "none" then
orphans[vehicle] = nil
elseif action == "home" then
orphans[vehicle] = nil
Logging.info(ADFlowWagons.LOG_PREFIX .. "'%s' har et redskab på uden opgave (hentet før genindlæsning?) — sættes tilbage og kører hjem",
tostring(ADRuns.vehicleLabel(vehicle)))
goHome(adEnv, vehicle)
else
orphans[vehicle] = idle + elapsed
end
end
end
function ADFlowWagons.isEmptying(vehicle)
return emptying[vehicle] ~= nil
end
-- Kun læsning (statusvinduet): hvad flowets vogn-logik har traktoren i gang med -> "fetching"|"waiting"|"emptying"|"homing"|nil
function ADFlowWagons.statusOf(vehicle)
if fetching[vehicle] ~= nil then return "fetching" end
if waiting[vehicle] ~= nil then return "waiting" end
if emptying[vehicle] ~= nil then return "emptying" end
if homing[vehicle] ~= nil then return "homing" end
return nil
end
function ADFlowWagons.sendHome(adEnv, run)
for _, vehicleId in ipairs(run.vehicleIds or {}) do
local vehicle = ADRunsController.findVehicleById(adEnv, vehicleId)
if vehicle ~= nil then
local ok, err = pcall(ADFlowWagons.homeVehicle, adEnv, vehicle, run.name)
if not ok then
Logging.warning(ADFlowWagons.LOG_PREFIX .. "hjem fejlede: %s", tostring(err))
goHome(adEnv, vehicle)
end
end
end
end
-- Står traktoren ved tømningens markør, uden at læsset er faldet i EMPTY_STALL_MS?
local function stalledAt(adEnv, vehicle, state, cargo)
if state.lastCargo == nil or cargo < state.lastCargo - 1 then
state.lastCargo, state.changedAt = cargo, nowMs
end
local wayPoint = state.markerId ~= nil and adEnv.ADGraphManager:getWayPointById(state.markerId) or nil
local near = false
if wayPoint ~= nil and vehicle.rootNode ~= nil then
local x, _, z = getWorldTranslation(vehicle.rootNode)
near = math.sqrt((x - wayPoint.x) ^ 2 + (z - wayPoint.z) ^ 2) <= ADFlowWagons.EMPTY_NEAR_M
end
return ADFlowWagons.isStalled({nearTarget = near, sinceChangeMs = nowMs - (state.changedAt or nowMs)})
end
local function tickEmptying(adEnv)
local list = {}
for vehicle, state in pairs(emptying) do table.insert(list, {vehicle = vehicle, state = state}) end
for _, item in ipairs(list) do
local vehicle, state = item.vehicle, item.state
local module = vehicle.ad ~= nil and vehicle.ad.stateModule or nil
local cargo, fillType = cargoOf(adEnv, vehicle)
local controlled = vehicle.getIsControlled ~= nil and vehicle:getIsControlled()
if controlled then
emptying[vehicle] = nil -- spilleren har overtaget
else
local stalled = stalledAt(adEnv, vehicle, state, cargo)
local action = ADFlowWagons.emptyStep(state.stage, {cargo = cargo, adActive = module ~= nil and module:isActive(),
elapsedMs = nowMs - state.since, stalled = stalled, dumpAttempts = state.dumpAttempts})
if stalled and action ~= "wait" then
Logging.info(ADFlowWagons.LOG_PREFIX .. "'%s' kan ikke aflæsse ved %s (fuldt?) — videre", tostring(ADRuns.vehicleLabel(vehicle)),
tostring(state.markerName))
end
if action == "home" then
goHome(adEnv, vehicle)
elseif action == "dump" then
startDump(adEnv, vehicle, state)
elseif action == "giveUp" then
Logging.warning(ADFlowWagons.LOG_PREFIX .. "'%s' kunne ikke tømme vognen (%d l %s tilbage) — parkeres med last",
tostring(ADRuns.vehicleLabel(vehicle)), math.floor(cargo), fillName(fillType))
goHome(adEnv, vehicle)
end
end
end
end
local function rememberAll(adEnv)
for _, run in ipairs(runs()) do
if not run.stopped then
for _, vehicleId in ipairs(run.vehicleIds or {}) do
local vehicle = ADRunsController.findVehicleById(adEnv, vehicleId)
local rear = vehicle ~= nil and ADHitch.rearImplementsOf(vehicle) or {}
local wagonId = rear[1] ~= nil and isCargoWagon(rear[1].object) and ADRunsController.getVehicleId(rear[1].object) or nil
if wagonId ~= nil then
local updated = ADFlowWagons.remember(run.wagons, vehicleId, wagonId)
if updated ~= run.wagons then
run.wagons = updated
Logging.info(ADFlowWagons.LOG_PREFIX .. "flow '%s': '%s' har vognen '%s'", tostring(run.name),
tostring(ADRuns.vehicleLabel(vehicle)), tostring(ADRuns.vehicleLabel(rear[1].object)))
end
end
end
end
end
end
local function retryWaiting(adEnv)
local list = {}
for vehicle, runId in pairs(waiting) do table.insert(list, {vehicle = vehicle, runId = runId}) end
for _, item in ipairs(list) do
waiting[item.vehicle] = nil
local run = ADRuns.getRun(ADRunsController.store, item.runId)
if run ~= nil and not run.stopped then pcall(ADRunsController.startVehicle, adEnv, run, item.vehicle) end
end
end
local function checkBuildings(adEnv)
local exists = {}
local placeables = g_currentMission.placeableSystem ~= nil and g_currentMission.placeableSystem.placeables or {}
for _, placeable in pairs(placeables) do
local id = ADBuildings.getId(placeable)
if id ~= nil then exists[id] = true end
end
if next(exists) == nil then return end -- listen er ikke klar: sig intet
for id in pairs(exists) do seenBuildings[id] = true end
for _, run in ipairs(runs()) do
if not run.stopped then
local missing = ADFlowWagons.missingBuildings(run, exists, seenBuildings)
local count, gone = ADFlowWagons.missingStep(missingCount[run.id], #missing > 0)
missingCount[run.id] = count
if gone then
missingCount[run.id] = 0
Logging.warning(ADFlowWagons.LOG_PREFIX .. "flow '%s': bygning findes ikke længere (%s) — stoppet, traktorer kørt hjem",
tostring(run.name), table.concat(missing, ", "))
ADRunsController.stopRun(adEnv, run.id)
if ADFieldJobs ~= nil and ADFieldJobs.notify ~= nil then
pcall(ADFieldJobs.notify, string.format("Flow '%s': bygning findes ikke længere — stoppet, traktorer kørt hjem", tostring(run.name)), true)
end
end
end
end
end
-- Én gang efter indlæsning: et stoppet flows traktor der holder stille med sin vogn (gemt midt i hjemturen — AD
-- kørte den færdig, men afkoblingen er glemt) køres hjem. Ikke løbende: spilleren kan selv have koblet på.
ADFlowWagons.AFTER_LOAD_MS = 120000
local sinceLoad, cleanedUp = 0, false
-- Efter indlæsning: traktorer uden for flows med et puljeredskab på (hentet før gemning, opgaven tabt) følges som
-- forældreløse — får de ingen opgave inden ORPHAN_MS, sættes redskabet tilbage (in-game 29/9: 3 læssevogne).
local function adoptOrphansAfterLoad(adEnv)
for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do
local run = ADRunsController.getRunForVehicle(vehicle)
if (run == nil or run.stopped) and vehicle.spec_attacherJoints ~= nil and #ADHitch.poolParkIds(adEnv, vehicle) > 0
and not ADFlowWagons.isEmptying(vehicle) and not homing[vehicle] then
orphans[vehicle] = orphans[vehicle] or 0
end
end
end
local function cleanupAfterLoad(adEnv)
for _, run in ipairs(runs()) do
if run.stopped then
for _, vehicleId in ipairs(run.vehicleIds or {}) do
local vehicle = ADRunsController.findVehicleById(adEnv, vehicleId)
local module = vehicle ~= nil and vehicle.ad ~= nil and vehicle.ad.stateModule or nil
local controlled = vehicle ~= nil and vehicle.getIsControlled ~= nil and vehicle:getIsControlled()
if module ~= nil and not module:isActive() and not controlled and #ADHitch.poolParkIds(adEnv, vehicle) > 0
and not ADFlowWagons.isPoolBusy(vehicle, vehicleId) then
Logging.info(ADFlowWagons.LOG_PREFIX .. "flow '%s' er stoppet: '%s' tømmer og sætter sin vogn på plads", tostring(run.name),
tostring(ADRuns.vehicleLabel(vehicle)))
pcall(ADFlowWagons.homeVehicle, adEnv, vehicle, run.name, "afterLoad")
end
end
end
end
end
local emptyTimer = 0
function ADFlowWagons.tick(adEnv, dt)
if adEnv == nil or ADRunsController.store == nil then return end
if ADHitch ~= nil and ADHitch.onManualAttach == nil then ADHitch.onManualAttach = ADFlowWagons.onManualAttach end
nowMs = nowMs + dt
emptyTimer = emptyTimer + dt
if emptyTimer >= 2000 then
emptyTimer = 0
local okOrphan, errOrphan = pcall(tickOrphans, adEnv, 2000)
if not okOrphan then Logging.warning(ADFlowWagons.LOG_PREFIX .. "redskaber uden opgave: %s", tostring(errOrphan)) end
local okEmpty, errEmpty = pcall(tickEmptying, adEnv)
if not okEmpty then Logging.warning(ADFlowWagons.LOG_PREFIX .. "tømning: %s", tostring(errEmpty)) end
end
if not cleanedUp then
sinceLoad = sinceLoad + dt
if sinceLoad >= ADFlowWagons.AFTER_LOAD_MS then
cleanedUp = true
local ok, err = pcall(cleanupAfterLoad, adEnv)
if not ok then Logging.warning(ADFlowWagons.LOG_PREFIX .. "oprydning efter indlæsning: %s", tostring(err)) end
local okAdopt, errAdopt = pcall(adoptOrphansAfterLoad, adEnv)
if not okAdopt then Logging.warning(ADFlowWagons.LOG_PREFIX .. "redskaber uden opgave efter indlæsning: %s", tostring(errAdopt)) end
local okPrune, errPrune = pcall(ADRunsController.pruneMissingVehicles, adEnv)
if not okPrune then Logging.warning(ADFlowWagons.LOG_PREFIX .. "oprydning af solgte traktorer: %s", tostring(errPrune)) end
end
end
rememberTimer = rememberTimer + dt
buildingTimer = buildingTimer + dt
if rememberTimer >= ADFlowWagons.REMEMBER_MS then
rememberTimer = 0
rememberAll(adEnv)
retryWaiting(adEnv)
end
if buildingTimer >= ADFlowWagons.BUILDING_CHECK_MS then
buildingTimer = 0
checkBuildings(adEnv)
end
end
+168
View File
@@ -0,0 +1,168 @@
-- AD Guide
-- Fælles guide-motor (ren Lua, ingen spil-API) for "Opsæt mark" og "Opsæt flow": ét trin ad gangen,
-- trin der ikke er relevante springes over, tælling "Trin 3 af 7" over de synlige trin, og [Ret] der
-- åbner ét enkelt trin. Motoren kender ikke marker eller flows; en trin-definition giver rækkefølgen og
-- hvornår et trin vises:
-- def = {order = {stepId, ...}, isVisible = function(stepId, guide) -> bool}
-- Guiden er en tabel {def, draft, context, step, single, returnTo, snapshot}; alle funktioner returnerer
-- en ny guide (kladden kopieres), så [Annullér] aldrig har ændret noget.
ADGuide = {}
-- Kopi af rene data (tabeller uden cykler); funktioner og tal/tekster deles.
function ADGuide.deepCopy(value)
if type(value) ~= "table" then return value end
local copy = {}
for key, item in pairs(value) do copy[key] = ADGuide.deepCopy(item) end
return copy
end
-- Ny liste med item tilføjet (on) eller fjernet; aldrig dubletter.
function ADGuide.toggleItem(list, item, on)
local result, found = {}, false
for _, value in ipairs(list or {}) do
if value == item then
found = true
if on then table.insert(result, value) end
else
table.insert(result, value)
end
end
if on and not found then table.insert(result, item) end
return result
end
local function clone(guide, changes)
local copy = {}
for key, value in pairs(guide) do copy[key] = value end
for key, value in pairs(changes or {}) do
if value == false and (key == "snapshot") then copy[key] = nil else copy[key] = value end
end
return copy
end
local function isVisible(guide, step)
return guide.def.isVisible == nil or guide.def.isVisible(step, guide) == true
end
function ADGuide.visibleSteps(guide)
local steps = {}
for _, step in ipairs(guide.def.order) do
if isVisible(guide, step) then table.insert(steps, step) end
end
return steps
end
local function orderIndex(guide, step)
for index, id in ipairs(guide.def.order) do
if id == step then return index end
end
return 0
end
-- Første synlige trin efter (dir = 1) eller før (dir = -1) det aktuelle i rækkefølgen.
local function neighbour(guide, dir)
local order = guide.def.order
local index = orderIndex(guide, guide.step) + dir
while index >= 1 and index <= #order do
if isVisible(guide, order[index]) then return order[index] end
index = index + dir
end
return nil
end
-- options = {step = id, single = bool, returnTo = "close"|"summary"}
function ADGuide.new(def, draft, context, options)
options = options or {}
local guide = {def = def, draft = draft, context = context, step = options.step,
single = options.single == true, returnTo = options.returnTo or "close"}
if guide.step == nil then
guide.step = ADGuide.visibleSteps(guide)[1] or def.order[1]
end
if guide.single then guide.snapshot = ADGuide.deepCopy(draft) end
return guide
end
-- index (nil hvis det aktuelle trin ikke er synligt), total synlige trin.
function ADGuide.position(guide)
local steps = ADGuide.visibleSteps(guide)
local index = nil
for position, step in ipairs(steps) do
if step == guide.step then index = position end
end
return index, #steps
end
-- Sidste trin: ingen synlige efter, eller et [Ret]-trin der gemmer direkte.
function ADGuide.isLast(guide)
if guide.single and guide.returnTo == "close" then return true end
if guide.single then return false end
return neighbour(guide, 1) == nil
end
-- Næste: event "done" når der gemmes (sidste trin, eller [Ret] fra fanens oversigt).
function ADGuide.next(guide)
if guide.single then
if guide.returnTo == "summary" then
return clone(guide, {step = "summary", single = false, snapshot = false}), nil
end
return guide, "done"
end
local step = neighbour(guide, 1)
if step == nil then return guide, "done" end
return clone(guide, {step = step}), nil
end
-- Tilbage: event "cancel" når guiden lukkes (første trin, eller [Ret] fra fanens oversigt).
-- I et [Ret]-trin fra guidens eget oversigts-trin fortrydes trinnets ændringer.
function ADGuide.back(guide)
if guide.single then
if guide.returnTo == "summary" then
return clone(guide, {step = "summary", single = false, draft = guide.snapshot or guide.draft, snapshot = false}), nil
end
return guide, "cancel"
end
local step = neighbour(guide, -1)
if step == nil then return guide, "cancel" end
return clone(guide, {step = step}), nil
end
-- [Ret]: åbn kun ét trin. returnTo "summary" (guidens oversigt) eller "close" (gem og luk).
function ADGuide.jump(guide, step, returnTo)
return clone(guide, {step = step, single = true, returnTo = returnTo or "close", snapshot = ADGuide.deepCopy(guide.draft)})
end
function ADGuide.withDraft(guide, draft)
return clone(guide, {draft = draft})
end
function ADGuide.withContext(guide, context)
return clone(guide, {context = context})
end
-- label = {key = "...", args = {...}} (oversættes) eller {text = "..."} (færdig tekst); valgfri prefix
-- (færdig tekst foran, fx "Mælk: " foran den oversatte politik).
function ADGuide.labelText(label, getText)
if type(label) ~= "table" then return "" end
if label.prefix ~= nil then
local rest = {}
for key, value in pairs(label) do if key ~= "prefix" then rest[key] = value end end
return tostring(label.prefix) .. ADGuide.labelText(rest, getText)
end
if label.text ~= nil then return tostring(label.text) end
local template = getText(label.key)
if label.args == nil or #label.args == 0 then return tostring(template) end
local unpackFn = table.unpack or unpack
local ok, text = pcall(string.format, template, unpackFn(label.args))
return ok and text or tostring(template)
end
-- Resumé-linje {step, title, parts} -> "Afgrøde: Behold det der gror, så igen".
function ADGuide.lineText(line, getText)
local parts = {}
for _, part in ipairs(line.parts or {}) do
local text = ADGuide.labelText(part, getText)
if text ~= "" then table.insert(parts, text) end
end
return ADGuide.labelText(line.title, getText) .. ": " .. (#parts > 0 and table.concat(parts, ", ") or "-")
end
+591
View File
@@ -0,0 +1,591 @@
-- AD Hitch
-- Kobler redskaber på og af med AutoDrive.
-- "Connect <navn>" (eller "Kobl <navn>"): markøren ligger lige under vognens trækøje med et bakkespor ned til
-- den. Når en traktor har kørt AD "Kør til" dertil og står stille, kobles det redskab på, som spillet selv finder
-- inden for koblingsafstand (samme kald som Courseplay bruger til skærebord: updateVehiclesInAttachRange +
-- attachImplementFromInfo).
-- "Afkobl <navn>" (eller "Kobl af", "Detach", "Disconnect"): ved ankomst kobles redskaberne BAG traktoren af;
-- frontvægt og frontlæsser bliver siddende.
-- ADHitch.fetch()/ADHitch.drop() er indgangen for flows/markarbejde: kør til markøren, kobl på/af, meld tilbage.
ADHitch = {}
ADHitch.LOG_PREFIX = "ADSmartPickup: kobling: "
-- afkobling tjekkes først, så "Kobl af X" ikke læses som "Kobl <af X>"
ADHitch.MARKER_PREFIXES = {
{prefix = "afkobl", action = "detach"},
{prefix = "kobl af", action = "detach"},
{prefix = "detach", action = "detach"},
{prefix = "disconnect", action = "detach"},
{prefix = "connect", action = "attach"},
{prefix = "kobl", action = "attach"},
}
ADHitch.TIMEOUT_REASON = {attach = "noAttachable", detach = "nothingToDetach", arrive = "notArrived"}
ADHitch.ARRIVE_DISTANCE = 5 -- m fra markøren, AD standser traktoren tæt på waypointet
ADHitch.DETACH_ARRIVE_DISTANCE = 8 -- afkobling: langt redskab (valse) står med roden >5 m inde i båsen
ADHitch.STILL_SPEED = 1 -- km/t
ADHitch.START_GRACE_MS = 3000 -- så længe må AD være om at komme i gang efter fetch/drop
ADHitch.ACT_TIMEOUT_MS = 8000 -- så længe forsøges kobling efter ankomst
ADHitch.DETACH_TIMEOUT_MS = 20000 -- afkobling: redskabet skal måske foldes ud først (plov: "unfold the tool")
ADHitch.TICK_MS = 250
ADHitch.ROUTE_WAIT_MS = 10000 -- pause ved almindelige markører midt i en tur
ADHitch.ROOMY_ATTACH_DISTANCE = 3 -- m: koblingsrækkevidde når spillets egen ikke rækker
ADHitch.CREPT_EXTRA_DISTANCE = 8 -- m: ekstra ankomstafstand når traktoren har bakket hen til vognen
-- ---- ren logik ----
-- "Connect Bredal" -> "attach", "Bredal"; "Afkobl" -> "detach", ""; andre navne -> nil
function ADHitch.parseMarker(name)
if type(name) ~= "string" then return nil end
local trimmed = name:match("^%s*(.-)%s*$")
local lower = trimmed:lower()
for _, entry in ipairs(ADHitch.MARKER_PREFIXES) do
local prefix = entry.prefix
if lower == prefix then return entry.action, "" end
if lower:sub(1, #prefix + 1) == prefix .. " " then
return entry.action, (trimmed:sub(#prefix + 2):match("^%s*(.-)$"))
end
end
return nil
end
-- implements = {{z = position i traktorens rum, ...}} -> kun dem bag traktoren
function ADHitch.rearImplements(implements)
local rear = {}
for _, implement in ipairs(implements or {}) do
if implement.z ~= nil and implement.z < 0 then table.insert(rear, implement) end
end
return rear
end
-- konsol-argumenter "Connect Bredal > Start > Afkobl Bredal > Start" -> {"Connect Bredal", "Start", ...}
function ADHitch.parseTrip(...)
local steps = {}
for part in (table.concat({...}, " ") .. ">"):gmatch("([^>]*)>") do
local name = part:match("^%s*(.-)%s*$")
if name ~= "" then table.insert(steps, name) end
end
return steps
end
-- markørnavn -> attach / detach / arrive (almindelig markør: bare køre dertil)
function ADHitch.stepAction(name)
return ADHitch.parseMarker(name) or "arrive"
end
-- Spillerens egen AD "Kør til": markørens navn (Connect/Afkobl) først, ellers er puljeredskabets egen parkering
-- stedet vognen sættes af. -> "attach" | "detach" | nil
function ADHitch.manualAction(parsedAction, isPoolImplementPark)
if parsedAction ~= nil then return parsedAction end
return isPoolImplementPark and "detach" or nil
end
-- I en tur: en almindelig markør, der er AD-parkeringen for redskabet bag traktoren, er stedet vognen sættes af.
function ADHitch.resolveAction(action, isImplementPark)
if action == "arrive" and isImplementPark then return "detach" end
return action
end
-- pause efter et trin: ved almindelige markører undervejs holdes der, ikke efter sidste trin
function ADHitch.waitAfter(action, index, count)
if action == "arrive" and index < count then return ADHitch.ROUTE_WAIT_MS end
return 0
end
-- markers = {{name, id}} -> markøren med navnet (store/små bogstaver ligegyldigt)
function ADHitch.findMarker(markers, name)
if type(name) ~= "string" then return nil end
local wanted = name:lower()
for _, marker in pairs(markers or {}) do
if type(marker.name) == "string" and marker.name:lower() == wanted then return marker end
end
return nil
end
-- Hvor tæt på markøren sættet skal være: afkobling ved egen parkering godtager længere redskaber (in-game 29/9:
-- valsen stod 5 m inde i båsen -> 'stoppedAway'); efter bak hen til vognen udvides koblingsafstanden.
function ADHitch.arriveDistanceFor(action, crept)
if action == "detach" then return ADHitch.DETACH_ARRIVE_DISTANCE end
if crept then return ADHitch.ARRIVE_DISTANCE + ADHitch.CREPT_EXTRA_DISTANCE end
return ADHitch.ARRIVE_DISTANCE
end
-- state = {adActive, distance, speed, arriveDistance?} (arriveDistance: udvidet, når traktoren har bakket hen til vognen)
local function arriveDistanceOf(state)
return state.arriveDistance or ADHitch.ARRIVE_DISTANCE
end
function ADHitch.hasArrived(state)
return not state.adActive and state.distance <= arriveDistanceOf(state) and state.speed <= ADHitch.STILL_SPEED
end
function ADHitch.newJob(markerId, markerName, action)
return {markerId = markerId, markerName = markerName, action = action, state = "driving", elapsed = 0,
actElapsed = 0, seenActive = false}
end
-- Afkobling bekræftes: "done" først når intet sidder bag traktoren efter et forsøg. -> "attempt" | "done" | "nothing"
function ADHitch.detachStep(rearCount, attempted)
if rearCount > 0 then return "attempt" end
return attempted and "done" or "nothing"
end
-- Et skridt i jobbet. succeeded = lykkedes kobling/afkobling i denne tick (kun relevant i "acting").
-- Tilstande: driving -> acting -> done | failed (reason: stoppedAway / noAttachable / nothingToDetach)
function ADHitch.step(job, state, dt, succeeded)
if job.state == "driving" then
job.elapsed = job.elapsed + dt
if state.adActive then
job.seenActive = true
elseif ADHitch.hasArrived(state) then
job.state = "acting"
elseif state.distance > arriveDistanceOf(state) and (job.seenActive or job.elapsed >= ADHitch.START_GRACE_MS) then
job.state, job.reason = "failed", string.format("stoppedAway (%.0f m fra markøren)", state.distance)
end
elseif job.state == "acting" then
if succeeded then
job.state = "done"
else
job.actElapsed = job.actElapsed + dt
local limit = job.action == "detach" and ADHitch.DETACH_TIMEOUT_MS or ADHitch.ACT_TIMEOUT_MS
if job.actElapsed >= limit then
job.state, job.reason = "failed", ADHitch.TIMEOUT_REASON[job.action]
end
end
end
return job
end
-- Hjemtur: hvert puljeredskabs parkering (der sættes det af), så traktorens egen parkering.
function ADHitch.homeRoute(implementParkIds, tractorParkId)
local route, seen = {}, {}
for _, id in ipairs(implementParkIds or {}) do
if not seen[id] then seen[id] = true table.insert(route, id) end
end
if tractorParkId ~= nil then table.insert(route, tractorParkId) end
return route
end
-- ---- spil ----
local jobs = setmetatable({}, {__mode = "k"}) -- [vehicle] = job (+ onDone)
local tickTimer = 0
local function vehicleName(vehicle)
if type(vehicle) ~= "table" then return tostring(vehicle) end
return vehicle.getFullName ~= nil and vehicle:getFullName() or tostring(vehicle.configFileName)
end
local function stateModule(vehicle)
return vehicle.ad ~= nil and vehicle.ad.stateModule or nil
end
local function vehicleState(adEnv, vehicle, markerId)
local module = stateModule(vehicle)
local wayPoint = adEnv.ADGraphManager:getWayPointById(markerId)
-- nærmeste led i sættet: bakker AD med vogn, er det vognen der ender på markøren, ikke traktoren
-- (in-game 28/9: vognen stod på Bredal, traktoren et vognslæb væk -> "stoppedAway")
local distance = math.huge
local parts = {vehicle}
if vehicle.getChildVehicles ~= nil then
local ok, children = pcall(vehicle.getChildVehicles, vehicle)
if ok and children ~= nil then parts = children end
end
for _, part in pairs(parts) do
if wayPoint ~= nil and part.rootNode ~= nil then
local x, _, z = getWorldTranslation(part.rootNode)
distance = math.min(distance, math.sqrt((x - wayPoint.x) ^ 2 + (z - wayPoint.z) ^ 2))
end
end
return {
adActive = module ~= nil and module:isActive(),
distance = distance,
speed = vehicle.getLastSpeed ~= nil and vehicle:getLastSpeed() or 0,
}
end
-- Kobler det redskab på, som spillet finder inden for koblingsafstand. -> ok, redskab eller årsag
function ADHitch.tryAttach(vehicle)
local spec = vehicle.spec_attacherJoints
if spec == nil or AttacherJoints == nil or AttacherJoints.updateVehiclesInAttachRange == nil then
return false, "noJoints"
end
-- spillets egen rækkevidde først, så en rummeligere (spillet retter selv vognen ind ved koblingen)
AttacherJoints.updateVehiclesInAttachRange(vehicle, AttacherJoints.MAX_ATTACH_DISTANCE_SQ,
AttacherJoints.MAX_ATTACH_ANGLE, true)
local info = spec.attachableInfo
if (info == nil or info.attachable == nil) and ADHitch.ROOMY_ATTACH_DISTANCE ^ 2 > AttacherJoints.MAX_ATTACH_DISTANCE_SQ then
AttacherJoints.updateVehiclesInAttachRange(vehicle, ADHitch.ROOMY_ATTACH_DISTANCE ^ 2, AttacherJoints.MAX_ATTACH_ANGLE, true)
info = spec.attachableInfo
end
if info == nil or info.attachable == nil then return false, "noAttachable" end
local attacher = info.attacherVehicle or vehicle
local farmId = vehicle.getActiveFarm ~= nil and vehicle:getActiveFarm() or vehicle:getOwnerFarmId()
local allowed, warning = info.attachable:isAttachAllowed(farmId, attacher)
if not allowed then return false, tostring(warning or "notAllowed") end
attacher:attachImplementFromInfo(info)
return true, info.attachable
end
-- Redskaber koblet direkte på traktoren, med deres placering langs traktoren (z > 0 foran, z < 0 bagved).
local function attachedImplements(vehicle)
local list = {}
if vehicle.getAttachedImplements == nil then return list end
for _, implement in ipairs(vehicle:getAttachedImplements()) do
local jointDesc = vehicle.spec_attacherJoints.attacherJoints[implement.jointDescIndex]
local node = jointDesc ~= nil and (jointDesc.jointTransform or jointDesc.rootNode) or nil
if implement.object ~= nil and node ~= nil then
local _, _, z = localToLocal(node, vehicle.rootNode, 0, 0, 0)
table.insert(list, {object = implement.object, z = z, name = vehicleName(implement.object)})
end
end
return list
end
-- Er markøren (waypoint-id) AD-parkeringen for et af redskaberne bag traktoren?
local function isImplementPark(adEnv, vehicle, markerId)
for _, implement in ipairs(ADHitch.rearImplements(attachedImplements(vehicle))) do
local advd = implement.object.advd
local index = advd ~= nil and advd.parkDestination or nil
local marker = type(index) == "number" and index >= 1 and adEnv.ADGraphManager:getMapMarkerById(index) or nil
if marker ~= nil and marker.id == markerId then return true end
end
return false
end
-- Efter påkobling: fold foldbare redskaber sammen til vejen (CP folder selv ud på marken).
-- Klar til vej: s = {turnedOn, lowered, unfolded, canFold} -> liste af "turnOff" | "raise" | "fold"
function ADHitch.stowActions(s)
local actions = {}
if s.turnedOn then table.insert(actions, "turnOff") end
if s.lowered then table.insert(actions, "raise") end
if s.unfolded and s.canFold then table.insert(actions, "fold") end
return actions
end
-- Alle redskaber på maskinen (også forrest og bag et andet redskab): {object, attacher, jointDescIndex}
local function allImplements(vehicle, list)
list = list or {}
if vehicle == nil or vehicle.getAttachedImplements == nil then return list end
for _, implement in ipairs(vehicle:getAttachedImplements()) do
if implement.object ~= nil then
table.insert(list, {object = implement.object, attacher = vehicle, jointDescIndex = implement.jointDescIndex})
allImplements(implement.object, list)
end
end
return list
end
-- Stop af mark/flow: sluk, løft og fold alle redskaber til transport. -> antal redskaber der fik noget gjort
function ADHitch.stowForTransport(vehicle)
local count = 0
for _, item in ipairs(allImplements(vehicle)) do
local object = item.object
local foldDirection = object.spec_foldable ~= nil and -(object.spec_foldable.turnOnFoldDirection or 1) or nil
local actions = ADHitch.stowActions({
turnedOn = object.getIsTurnedOn ~= nil and object:getIsTurnedOn(),
lowered = object.getIsLowered ~= nil and object:getIsLowered(true),
unfolded = object.getIsUnfolded ~= nil and object:getIsUnfolded() and foldDirection ~= nil,
canFold = foldDirection ~= nil and object.setFoldDirection ~= nil
and (object.getIsFoldAllowed == nil or object:getIsFoldAllowed(foldDirection, false)),
})
for _, action in ipairs(actions) do
if action == "turnOff" and object.setIsTurnedOn ~= nil then
pcall(object.setIsTurnedOn, object, false)
elseif action == "raise" and item.attacher.setJointMoveDown ~= nil and item.jointDescIndex ~= nil then
pcall(item.attacher.setJointMoveDown, item.attacher, item.jointDescIndex, false)
elseif action == "fold" then
pcall(object.setFoldDirection, object, foldDirection)
end
end
if #actions > 0 then count = count + 1 end
end
return count
end
-- Som stowForTransport, men logget og pcall'et (til stop-vejene).
function ADHitch.stow(vehicle, why)
local ok, count = pcall(ADHitch.stowForTransport, vehicle)
if not ok then
Logging.warning(ADHitch.LOG_PREFIX .. "'%s': kunne ikke gøre redskaber klar til vej: %s", vehicleName(vehicle), tostring(count))
elseif count > 0 then
Logging.info(ADHitch.LOG_PREFIX .. "'%s': %d redskab(er) slukket, løftet og foldet (%s)", vehicleName(vehicle), count, tostring(why))
end
end
function ADHitch.foldForTransport(vehicle)
for _, implement in ipairs(ADHitch.rearImplementsOf(vehicle)) do
local object = implement.object
local unfolded = object.getIsUnfolded ~= nil and object:getIsUnfolded()
if unfolded and object.spec_foldable ~= nil and object.setFoldDirection ~= nil then
object:setFoldDirection(-(object.spec_foldable.turnOnFoldDirection or 1))
end
end
end
function ADHitch.rearImplementsOf(vehicle)
return ADHitch.rearImplements(attachedImplements(vehicle))
end
local function parkIdOf(adEnv, vehicle)
local index = vehicle.advd ~= nil and vehicle.advd.parkDestination or nil
local marker = type(index) == "number" and index >= 1 and adEnv.ADGraphManager:getMapMarkerById(index) or nil
return marker ~= nil and marker.id or nil
end
-- Kobler redskaberne bag traktoren af. -> ok, navne eller årsag. Med job bekræftes afkoblingen ved næste tick
-- (ok først når intet sidder bagpå); uden job (konsol) meldes ok straks efter kaldet.
function ADHitch.tryDetach(vehicle, job)
local rear = ADHitch.rearImplements(attachedImplements(vehicle))
local step = ADHitch.detachStep(#rear, job ~= nil and job.detachNames ~= nil)
if step == "done" then return true, job.detachNames end
if step == "nothing" then return false, "nothingToDetach" end
local names = {}
for _, implement in ipairs(rear) do
local object = implement.object
local allowed, warning = true, nil
if object.isDetachAllowed ~= nil then allowed, warning = object:isDetachAllowed() end
if not allowed and object.spec_foldable ~= nil and object.setFoldDirection ~= nil then
-- plove o.l. må kun kobles af udfoldet (in-game 28/9: "You first need to unfold the tool") — fold ud
-- og prøv igen ved næste tick, mens foldningen kører
object:setFoldDirection(object.spec_foldable.turnOnFoldDirection or 1)
return false, "unfolding: " .. tostring(warning)
end
if not allowed then return false, tostring(warning or "detachNotAllowed") end
vehicle:detachImplementByObject(object)
table.insert(names, implement.name)
end
if job == nil then return true, table.concat(names, ", ") end
if job.detachNames ~= nil then
job.detachRetries = (job.detachRetries or 0) + 1
if job.detachRetries == 1 then
Logging.warning(ADHitch.LOG_PREFIX .. "'%s': '%s' sidder der stadig efter afkobling — prøver igen", vehicleName(vehicle), table.concat(names, ", "))
end
end
job.detachNames = job.detachNames or table.concat(names, ", ")
return false, "stillAttached: " .. table.concat(names, ", ")
end
local function start(adEnv, vehicle, markerId, action, onDone)
local marker = adEnv.ADGraphManager:getMapMarkerByWayPointId(markerId)
local job = ADHitch.newJob(markerId, marker ~= nil and marker.name or tostring(markerId), action)
job.onDone = onDone
jobs[vehicle] = job
ADFieldWork.startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, markerId)
Logging.info(ADHitch.LOG_PREFIX .. "'%s' kører til '%s'", vehicleName(vehicle), job.markerName)
return job
end
-- Kørende kobl-på-job for traktoren (til bakningen de sidste meter), ellers nil.
function ADHitch.pendingAttach(vehicle)
local job = jobs[vehicle]
if job ~= nil and job.action == "attach" and job.state == "driving" then return job end
return nil
end
-- Afbryd et hente-/koble-job (Stop alt, høsten slut, spilleren overtager): intet kobles på bagefter, og
-- onDone kaldes ikke. -> true hvis der var et job.
function ADHitch.cancel(vehicle)
local had = jobs[vehicle] ~= nil
jobs[vehicle] = nil
if ADPool ~= nil and ADPool.setReturning ~= nil then ADPool.setReturning(vehicle, false) end
if had and vehicle.stopAutoDrive ~= nil then pcall(vehicle.stopAutoDrive, vehicle) end
return had
end
-- Kør til en Connect-markør og kobl på. onDone(vehicle, ok, redskab eller årsag) kaldes én gang.
function ADHitch.fetch(adEnv, vehicle, markerId, onDone)
return start(adEnv, vehicle, markerId, "attach", onDone)
end
-- Kør til en Afkobl-markør og kobl af. onDone(vehicle, ok, navne eller årsag) kaldes én gang.
function ADHitch.drop(adEnv, vehicle, markerId, onDone)
return start(adEnv, vehicle, markerId, "detach", onDone)
end
-- Tur: markørerne køres i rækkefølge. Connect = kobl på, Afkobl eller vognens egen AD-parkering = kobl af,
-- andre = kør dertil (og hold
-- ROUTE_WAIT_MS, hvis det ikke er sidste trin). Stopper ved første fejl. onDone(vehicle, ok, årsag) til sidst.
local waits = {} -- {vehicle, leftMs, continue}
function ADHitch.route(adEnv, vehicle, markerIds, onDone)
local function runStep(index)
if index > #markerIds then
Logging.info(ADHitch.LOG_PREFIX .. "'%s' er færdig med turen (%d trin)", vehicleName(vehicle), #markerIds)
if onDone ~= nil then pcall(onDone, vehicle, true) end
return
end
local marker = adEnv.ADGraphManager:getMapMarkerByWayPointId(markerIds[index])
local action = ADHitch.resolveAction(ADHitch.stepAction(marker ~= nil and marker.name or nil),
isImplementPark(adEnv, vehicle, markerIds[index]))
start(adEnv, vehicle, markerIds[index], action, function(_, ok, detail)
if not ok then
Logging.warning(ADHitch.LOG_PREFIX .. "'%s' stopper turen ved trin %d", vehicleName(vehicle), index)
if onDone ~= nil then pcall(onDone, vehicle, false, detail) end
return
end
local waitMs = ADHitch.waitAfter(action, index, #markerIds)
if waitMs > 0 then
Logging.info(ADHitch.LOG_PREFIX .. "'%s' holder %d s ved '%s'", vehicleName(vehicle), waitMs / 1000,
marker ~= nil and marker.name or "?")
table.insert(waits, {vehicle = vehicle, leftMs = waitMs, continue = function() runStep(index + 1) end})
else
runStep(index + 1)
end
end)
end
runStep(1)
end
-- Puljeredskaber bag traktoren sættes af på deres parkering, og traktoren kører hjem til sin egen.
-- Kun redskaber med en Connect-markør til deres parkering er puljeredskaber; andre bliver siddende.
-- Parkeringer (waypoint-id) for puljeredskaberne bag traktoren (tom = ingen puljeredskaber bag).
function ADHitch.poolParkIds(adEnv, tractor)
local markers = adEnv.ADGraphManager:getMapMarkers()
local parkIds = {}
for _, implement in ipairs(ADHitch.rearImplementsOf(tractor)) do
local parkId = parkIdOf(adEnv, implement.object)
local park = parkId ~= nil and adEnv.ADGraphManager:getMapMarkerByWayPointId(parkId) or nil
if park ~= nil and ADPool.connectFor(park.name, markers) ~= nil then table.insert(parkIds, parkId) end
end
return parkIds
end
function ADHitch.returnHome(adEnv, tractor, onDone)
local parkIds = ADHitch.poolParkIds(adEnv, tractor)
if #parkIds == 0 then return false end
local route = ADHitch.homeRoute(parkIds, parkIdOf(adEnv, tractor))
ADPool.setReturning(tractor, true)
ADHitch.route(adEnv, tractor, route, function(vehicle, ok, detail)
ADPool.setReturning(vehicle, false)
if onDone ~= nil then onDone(vehicle, ok, detail) end
end)
return true
end
local function tickWaits(elapsed)
for i = #waits, 1, -1 do
local wait = waits[i]
wait.leftMs = wait.leftMs - elapsed
if wait.leftMs <= 0 then
table.remove(waits, i)
wait.continue()
end
end
end
-- Spilleren sender selv en traktor til en Connect-/Afkobl-markør med AD "Kør til": den kobles også.
local function watchManualTrips(adEnv)
for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do
local module = stateModule(vehicle)
if jobs[vehicle] == nil and vehicle.spec_attacherJoints ~= nil and module ~= nil and module:isActive()
and module:getMode() == adEnv.AutoDrive.MODE_DRIVETO then
local marker = module:getFirstMarker()
local action = nil
if marker ~= nil then
local isPoolPark = false
for _, parkId in ipairs(ADHitch.poolParkIds(adEnv, vehicle)) do
if parkId == marker.id then isPoolPark = true end
end
action = ADHitch.manualAction(ADHitch.parseMarker(marker.name), isPoolPark)
end
if action ~= nil then jobs[vehicle] = ADHitch.newJob(marker.id, marker.name, action) end
end
end
end
local DONE_TEXT = {attach = "koblede '%s' på", detach = "koblede '%s' af", arrive = "er fremme%s"}
local FAIL_TEXT = {attach = "kunne ikke koble på", detach = "kunne ikke koble af", arrive = "nåede ikke frem"}
local function finish(vehicle, job, ok, detail, adEnv)
jobs[vehicle] = nil
if ok then
Logging.info(ADHitch.LOG_PREFIX .. "'%s' " .. DONE_TEXT[job.action] .. " ved '%s'", vehicleName(vehicle),
vehicleName(detail), job.markerName)
else
Logging.warning(ADHitch.LOG_PREFIX .. "'%s' %s ved '%s': %s", vehicleName(vehicle), FAIL_TEXT[job.action],
job.markerName, tostring(detail))
end
if job.onDone ~= nil then
pcall(job.onDone, vehicle, ok, detail)
elseif ok and job.action == "attach" and ADHitch.onManualAttach ~= nil then
-- spillerens egen tur til Connect: lad fx flowet starte traktoren bagefter
local okHook, err = pcall(ADHitch.onManualAttach, adEnv, vehicle, detail)
if not okHook then Logging.warning(ADHitch.LOG_PREFIX .. "efter manuel kobling: %s", tostring(err)) end
end
end
local ACTIONS = {attach = ADHitch.tryAttach, detach = ADHitch.tryDetach, arrive = function() return true, "" end}
function ADHitch.tick(adEnv, dt)
if adEnv == nil or adEnv.ADGraphManager == nil then return end
tickTimer = tickTimer + dt
if tickTimer < ADHitch.TICK_MS then return end
local elapsed = tickTimer
tickTimer = 0
tickWaits(elapsed)
watchManualTrips(adEnv)
-- kopi: et færdigt trin starter næste trin (nyt job på samme maskine) midt i løkken
local current = {}
for vehicle, job in pairs(jobs) do current[vehicle] = job end
for vehicle, job in pairs(current) do
local succeeded, detail = false, nil
if job.state == "acting" then
succeeded, detail = ACTIONS[job.action](vehicle, job)
if not succeeded then job.lastReason = detail end
end
local state = vehicleState(adEnv, vehicle, job.markerId)
state.arriveDistance = ADHitch.arriveDistanceFor(job.action, job.crept)
ADHitch.step(job, state, elapsed, succeeded)
if job.state == "done" then
finish(vehicle, job, true, detail, adEnv)
elseif job.state == "failed" then
finish(vehicle, job, false, job.lastReason or job.reason, adEnv)
end
end
end
-- Konsol: "spuConnect" / "spuDetach" på den maskine spilleren sidder i.
local function currentVehicle()
return g_localPlayer ~= nil and g_localPlayer.getCurrentVehicle ~= nil and g_localPlayer:getCurrentVehicle() or nil
end
function ADHitch:consoleConnect()
local vehicle = currentVehicle()
if vehicle == nil then return "spuConnect: sæt dig i en traktor først" end
local ok, detail = ADHitch.tryAttach(vehicle)
if ok then return "spuConnect: koblede " .. vehicleName(detail) .. " på" end
return "spuConnect: intet koblet på (" .. tostring(detail) .. ")"
end
function ADHitch:consoleDetach()
local vehicle = currentVehicle()
if vehicle == nil then return "spuDetach: sæt dig i en traktor først" end
local ok, detail = ADHitch.tryDetach(vehicle)
if ok then return "spuDetach: koblede " .. tostring(detail) .. " af" end
return "spuDetach: intet koblet af (" .. tostring(detail) .. ")"
end
-- "spuTrip Connect Bredal > Start > Bredal > Start": maskinen spilleren sidder i kører turen
-- (spilleren kan blive siddende; AD kører også med spilleren i førerhuset).
function ADHitch:consoleTrip(...)
local vehicle = currentVehicle()
if vehicle == nil then return "spuTrip: sæt dig i en traktor først" end
local adEnv = ADFieldSilos ~= nil and ADFieldSilos.adEnv or nil
if adEnv == nil then return "spuTrip: AutoDrive er ikke klar endnu" end
local names = ADHitch.parseTrip(...)
if #names == 0 then return "spuTrip: brug fx spuTrip Connect Bredal > Start > Bredal > Start" end
local markers, ids = adEnv.ADGraphManager:getMapMarkers(), {}
for _, name in ipairs(names) do
local marker = ADHitch.findMarker(markers, name)
if marker == nil then return "spuTrip: ukendt markør '" .. name .. "'" end
table.insert(ids, marker.id)
end
ADHitch.route(adEnv, vehicle, ids)
return "spuTrip: " .. vehicleName(vehicle) .. " kører " .. table.concat(names, " > ")
end
if addConsoleCommand ~= nil then
addConsoleCommand("spuConnect", "Kobler nærmeste redskab på den maskine du sidder i", "consoleConnect", ADHitch)
addConsoleCommand("spuTrip", "Kør en tur: spuTrip Connect Bredal > Start > Bredal > Start", "consoleTrip", ADHitch)
addConsoleCommand("spuDetach", "Kobler redskaberne bag den maskine du sidder i af", "consoleDetach", ADHitch)
end
+193
View File
@@ -0,0 +1,193 @@
-- AD Hitch Creep
-- Bak de sidste meter hen til vognen: når AD "Kør til" når en Connect-markør, og vognens trækøje står 3-8 m
-- lige bag traktorens krog, lægges en lille bakke-opgave i AD's kø før stop. Traktoren bakker i krybefart og
-- standser, så snart spillet ser vognen inden for koblingsrækkevidde; ADHitch kobler derefter på som før.
-- AD's egen bak-kollisionssensor bruges ikke: den ser vognen selv som forhindring.
ADHitchCreep = {}
ADHitchCreep.LOG_PREFIX = "ADSmartPickup: kobling: "
ADHitchCreep.ROOMY_ATTACH_DISTANCE = 3 -- m: herinden for kobler ADHitch direkte (samme som ADHitch.ROOMY_ATTACH_DISTANCE)
ADHitchCreep.CREEP_MAX_DISTANCE = 8 -- m: længere væk end det bakkes ikke — markøren ligger forkert
ADHitchCreep.CREEP_MAX_LATERAL = 1.5 -- m til siden: mere end det rammer en lige bakning ikke trækøjet
ADHitchCreep.CREEP_TIMEOUT_MS = 25000
ADHitchCreep.CREEP_SPEED = 2 -- km/t
ADHitchCreep.AWAY_TOLERANCE = 1 -- m: længere væk end ved start = den bakker forkert
ADHitchCreep.RANGE_CHECK_MS = 250
-- ---- ren logik ----
-- offset = {x, z}: vognens trækøje i traktorens rum set fra krogen (z < 0 = bagved) -> "attach" | "creep" | "none"
function ADHitchCreep.plan(offset)
if offset == nil then return "none" end
local distance = math.sqrt(offset.x ^ 2 + offset.z ^ 2)
if distance <= ADHitchCreep.ROOMY_ATTACH_DISTANCE then return "attach" end
if offset.z >= 0 or distance > ADHitchCreep.CREEP_MAX_DISTANCE or math.abs(offset.x) > ADHitchCreep.CREEP_MAX_LATERAL then
return "none"
end
return "creep"
end
-- s = {inRange, distance, startDistance, elapsedMs, passed} -> "done" | "drive" | "giveUp"
function ADHitchCreep.step(s)
if s.inRange then return "done" end
if (s.elapsedMs or 0) >= ADHitchCreep.CREEP_TIMEOUT_MS then return "giveUp" end
if s.passed then return "giveUp" end
if s.distance > s.startDistance + ADHitchCreep.AWAY_TOLERANCE then return "giveUp" end
return "drive"
end
-- ---- spil ----
local function worldPos(node)
local x, y, z = getWorldTranslation(node)
return {x = x, y = y, z = z}
end
local function vehicleName(vehicle)
return vehicle ~= nil and vehicle.getName ~= nil and vehicle:getName() or "?"
end
-- Traktorens ledige bagerste krog-noder.
local function freeRearJoints(vehicle)
local spec = vehicle.spec_attacherJoints
if spec == nil or spec.attacherJoints == nil or vehicle.rootNode == nil then return {} end
local used = {}
for _, implement in ipairs(spec.attachedImplements or {}) do
if implement.jointDescIndex ~= nil then used[implement.jointDescIndex] = true end
end
local joints = {}
for index, joint in ipairs(spec.attacherJoints) do
if not used[index] and joint.jointTransform ~= nil then
local p = worldPos(joint.jointTransform)
local _, _, lz = worldToLocal(vehicle.rootNode, p.x, p.y, p.z)
if lz < 0 then table.insert(joints, joint.jointTransform) end
end
end
return joints
end
-- Vognens trækøje i traktorens rum set fra krogen.
local function offsetOf(vehicle, jointNode, targetNode)
local j, t = worldPos(jointNode), worldPos(targetNode)
local x, _, z = worldDirectionToLocal(vehicle.rootNode, t.x - j.x, 0, t.z - j.z)
return {x = x, z = z}
end
-- Nærmeste frakoblede redskab (samme gård) med et trækøje bag en ledig krog. -> {wagon, joint, target, offset, plan} | nil
function ADHitchCreep.findTarget(vehicle)
local joints = freeRearJoints(vehicle)
if #joints == 0 or g_currentMission == nil or g_currentMission.vehicleSystem == nil then return nil end
local farmId = vehicle.getOwnerFarmId ~= nil and vehicle:getOwnerFarmId() or nil
local best, bestDistance = nil, math.huge
for _, object in pairs(g_currentMission.vehicleSystem.vehicles or {}) do
local attachable = object.spec_attachable
if object ~= vehicle and attachable ~= nil and object.spec_motorized == nil and attachable.inputAttacherJoints ~= nil
and (object.getAttacherVehicle == nil or object:getAttacherVehicle() == nil)
and (farmId == nil or object.getOwnerFarmId == nil or object:getOwnerFarmId() == farmId) then
for _, input in ipairs(attachable.inputAttacherJoints) do
if input.node ~= nil then
for _, joint in ipairs(joints) do
local offset = offsetOf(vehicle, joint, input.node)
local distance = math.sqrt(offset.x ^ 2 + offset.z ^ 2)
if distance < bestDistance and distance <= ADHitchCreep.CREEP_MAX_DISTANCE + 2 then
bestDistance = distance
best = {wagon = object, joint = joint, target = input.node, offset = offset}
end
end
end
end
end
end
if best ~= nil then best.plan = ADHitchCreep.plan(best.offset) end
return best
end
local function isInRange(vehicle, wagon)
local spec = vehicle.spec_attacherJoints
if spec == nil or AttacherJoints == nil or AttacherJoints.updateVehiclesInAttachRange == nil then return false end
AttacherJoints.updateVehiclesInAttachRange(vehicle, ADHitchCreep.ROOMY_ATTACH_DISTANCE ^ 2, AttacherJoints.MAX_ATTACH_ANGLE, true)
local info = spec.attachableInfo
return info ~= nil and info.attachable == wagon
end
-- AD-opgave (samme metoder som AD's AbstractTask).
local CreepTask = {}
CreepTask.__index = CreepTask
function CreepTask.new(adEnv, vehicle, target)
return setmetatable({adEnv = adEnv, vehicle = vehicle, target = target, elapsedMs = 0, checkMs = 0, inRange = false}, CreepTask)
end
function CreepTask:setUp()
local offset = offsetOf(self.vehicle, self.target.joint, self.target.target)
self.startDistance = math.sqrt(offset.x ^ 2 + offset.z ^ 2)
Logging.info(ADHitchCreep.LOG_PREFIX .. "'%s' bakker %.1f m hen til '%s' (%.1f m til siden)", vehicleName(self.vehicle),
self.startDistance, vehicleName(self.target.wagon), offset.x)
end
function CreepTask:finish(action, distance)
local sdm = self.vehicle.ad.specialDrivingModule
if sdm ~= nil and sdm.stopVehicle ~= nil then sdm:stopVehicle() end
if action == "done" then
Logging.info(ADHitchCreep.LOG_PREFIX .. "'%s' står ved '%s' — kobler på", vehicleName(self.vehicle), vehicleName(self.target.wagon))
else
Logging.warning(ADHitchCreep.LOG_PREFIX .. "'%s' opgav at bakke hen til '%s' (%.1f m tilbage efter %d s)", vehicleName(self.vehicle),
vehicleName(self.target.wagon), distance, math.floor(self.elapsedMs / 1000))
end
local taskModule = self.adEnv.ADTaskModule
self.vehicle.ad.taskModule:setCurrentTaskFinished(taskModule ~= nil and taskModule.DONT_PROPAGATE or 1)
end
function CreepTask:update(dt)
self.elapsedMs = self.elapsedMs + dt
self.checkMs = self.checkMs + dt
if self.target.wagon.getAttacherVehicle ~= nil and self.target.wagon:getAttacherVehicle() ~= nil then
return self:finish("giveUp", 0) -- en anden har taget vognen
end
local offset = offsetOf(self.vehicle, self.target.joint, self.target.target)
local distance = math.sqrt(offset.x ^ 2 + offset.z ^ 2)
if self.checkMs >= ADHitchCreep.RANGE_CHECK_MS then
self.checkMs = 0
self.inRange = isInRange(self.vehicle, self.target.wagon)
end
local action = ADHitchCreep.step({inRange = self.inRange, distance = distance, startDistance = self.startDistance,
elapsedMs = self.elapsedMs, passed = offset.z > 0.5})
if action ~= "drive" then return self:finish(action, distance) end
local autoDrive, vehicle = self.adEnv.AutoDrive, self.vehicle
local p = worldPos(self.target.target)
local lx, lz = autoDrive.getDriveDirection(vehicle, p.x, p.y, p.z)
local sdm = vehicle.ad.specialDrivingModule
if sdm ~= nil and sdm.releaseVehicle ~= nil then sdm:releaseVehicle() end
if vehicle.startMotor ~= nil and not vehicle:getIsMotorStarted() and vehicle:getCanMotorRun() then vehicle:startMotor() end
-- som AD's ADSpecialDrivingModule:driveReverse (ikke-guidet): retning spejles ved bakning
autoDrive.driveInDirection(vehicle, dt, 30, 0.3, 0.2, 20, true, false, -lx, -lz, ADHitchCreep.CREEP_SPEED, 1)
end
function CreepTask:abort() end
function CreepTask:finished() end
function CreepTask:getInfoText() return "Bakker hen til vognen" end
function CreepTask:getI18nInfo() return "" end
function CreepTask:getExcludedVehiclesForCollisionCheck() return {self.target.wagon} end
local isInstalled = false
-- DriveToMode:handleFinishedTask lægger StopAndDisableADTask i køen; bakningen lægges ind lige før.
function ADHitchCreep.install(adEnv)
local mode = adEnv ~= nil and adEnv.DriveToMode or nil
if isInstalled or mode == nil or mode.handleFinishedTask == nil or ADHitch == nil or ADHitch.pendingAttach == nil then return end
isInstalled = true
local original = mode.handleFinishedTask
mode.handleFinishedTask = function(self)
local ok, err = pcall(function()
local job = self.driveToDestinationTask ~= nil and ADHitch.pendingAttach(self.vehicle) or nil
if job == nil or job.markerId ~= self.destinationID then return end
local target = ADHitchCreep.findTarget(self.vehicle)
if target == nil or target.plan ~= "creep" then return end
job.crept = true
self.vehicle.ad.taskModule:addTask(CreepTask.new(adEnv, self.vehicle, target))
end)
if not ok then Logging.warning(ADHitchCreep.LOG_PREFIX .. "bakning til vognen: %s", tostring(err)) end
return original(self)
end
end
+15 -1
View File
@@ -97,6 +97,19 @@ function ADLoadSwap.rankDumpCandidates(candidates, avoidMarkers)
return ranked
end
-- Flydende varer lægges kun i tanke/siloer, aldrig automatisk i en fabriks aflæsning: fabrikkerne har ofte en
-- rist med præcis aflæsning, som en gyllevogns udløb bag på ikke rammer (in-game 26/9: 1 mio. l gylle holdt
-- ved AIO'ens rist og spærrede vejen). Vælger brugeren selv fabrikken som lager, gælder valget.
ADLoadSwap.LIQUIDS = {LIQUIDMANURE = true, DIGESTATE = true, MILK = true, BUFFALOMILK = true, GOATMILK = true,
SHEEPMILK = true, WATER = true, LIQUIDFERTILIZER = true, HERBICIDE = true, METHANE = true, DIESEL = true}
function ADLoadSwap.isLiquidName(name)
return name ~= nil and ADLoadSwap.LIQUIDS[string.upper(tostring(name))] == true
end
function ADLoadSwap.isLiquid(fillType)
local name = g_fillTypeManager ~= nil and g_fillTypeManager.getFillTypeNameByIndex ~= nil and g_fillTypeManager:getFillTypeNameByIndex(fillType) or nil
return ADLoadSwap.isLiquidName(name)
end
function ADLoadSwap.findDumpMarker(adEnv, vehicle, fillType, rest, homeWayPointId, isReachable, excludedPlaceables, avoidMarkers, allowFactories)
if vehicle.rootNode == nil then
return nil, "køretøjet har ingen position"
@@ -166,7 +179,8 @@ 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)
-- tilfældig start (0-2 s): ventende vogne tjekker ikke alle i samme frame
wait.swapTimerMs = (wait.swapTimerMs or math.random(0, ADLoadSwap.CHECK_INTERVAL_MS)) + (dt or 0)
if wait.swapTimerMs < ADLoadSwap.CHECK_INTERVAL_MS then
return false
end
+34
View File
@@ -0,0 +1,34 @@
-- AD Manual Transport
-- Brede høstere (mejetærsker med skærebord) kan ikke køre på vejene: spilleren kører dem til marken, monterer
-- skærebordet og stiger ud; moden overtager derfra. Rene regler (testbare uden spillet); spil-delen ligger i
-- adFieldWork.
ADManualTransport = {}
ADManualTransport.REMIND_MS = 300000 -- påmindelse hvert 5. minut, mens moden venter på spilleren
-- Må høsteren vælges til marken? Det monterede skærebord/optager skal kunne tage afgrøden. Undtagelse: en manuel
-- høster UDEN skærebord monteret (det monteres først på marken — tjekkes ved overtagelse). En roeoptager har sin
-- optager fast på og kan aldrig tage hvede (in-game 27/9: F111 valgte roeoptageren).
function ADManualTransport.acceptsHarvester(candidate, fruit)
if fruit == nil then return false end
local fruits = candidate.fruits or {}
if candidate.manual and next(fruits) == nil then return true end
return fruits[fruit] == true
end
-- Klar til overtagelse? s = {atField, fruits = {[fruit] = true}, fruit, controlled, forced}.
-- Returnerer true, eller false + grund: "notThere" | "noHeader" | "wrongHeader" | "playerInside".
-- forced ("Start nu"): sted og spiller springes over, skærebordet tjekkes stadig.
function ADManualTransport.readiness(s)
if not s.forced and not s.atField then return false, "notThere" end
local fruits = s.fruits or {}
if next(fruits) == nil then return false, "noHeader" end
if s.fruit == nil or not fruits[s.fruit] then return false, "wrongHeader" end
if not s.forced and s.controlled then return false, "playerInside" end
return true
end
function ADManualTransport.shouldRemind(lastAt, now)
return lastAt == nil or now - lastAt >= ADManualTransport.REMIND_MS
end
+146
View File
@@ -0,0 +1,146 @@
-- AD Month View
-- Rene regler for fanen Måned: hvilke marker har arbejde i gang, hvor langt er de, hvad kommer bagefter,
-- og lukker vinduet (høst/såning) ved månedsskiftet? GUI'et oversætter nøglerne; spillet kaldes kun via
-- ctx-funktionerne (testbart uden FS).
ADMonthView = {}
ADMonthView.PERIODS = 12
-- Trin der kræver afgrøden eller såvinduet: sorteres før pleje/klargøring
ADMonthView.MAIN_STAGES = {harvesting = true, mowing = true, sowing = true}
-- Trin før såningen i klargøringskæden (lukker såvinduet, bliver marken ikke sået)
ADMonthView.BEFORE_SOWING = {mowing = true, tedding = true, raking = true, collecting = true, harvesting = true,
strawCollecting = true, baling = true, baleCollecting = true, mulching = true,
liming = true, plowing = true, cultivating = true, sowing = true}
function ADMonthView.nextPeriod(period)
if type(period) ~= "number" or period < 1 then return 1 end
return period % ADMonthView.PERIODS + 1
end
-- Visse FS25-sæsondata gemmer overgangene som lister af {startState, endState}; strukturen er ikke
-- dokumenteret, så alle tabeller i periodens data gennemsøges. Ukendt = nej (ingen falsk alarm).
local function findsTransition(data, fromState, toState, depth)
if type(data) ~= "table" or depth > 3 then return false end
if data.startState == fromState and data.endState == toState then return true end
for _, value in pairs(data) do
if type(value) == "table" and findsTransition(value, fromState, toState, depth + 1) then return true end
end
return false
end
-- Visner afgrøden (stadie state) ved overgangen til periode nextPeriod?
function ADMonthView.withersIn(fruit, nextPeriod, state)
if fruit == nil or type(state) ~= "number" or state <= 0 then return false end
local withered = fruit.witheredState
if type(withered) ~= "number" or withered <= 0 then return false end
local periods = fruit.growthDataSeasonal ~= nil and fruit.growthDataSeasonal.periods or nil
local data = periods ~= nil and periods[nextPeriod] or nil
return findsTransition(data, state, withered, 0)
end
-- Såvindue for en afgrøde: "closes" (kan sås nu, ikke næste måned), "open" (også næste måned), "closed".
function ADMonthView.sowWindow(plantableIn, fruitName, period)
if fruitName == nil or fruitName == "" or plantableIn == nil then return "open" end
local now = plantableIn(fruitName, period)
if not now then return "closed" end
return plantableIn(fruitName, ADMonthView.nextPeriod(period)) and "open" or "closes"
end
-- Er marken i gang (vises i tabellen)? Venter/færdig og passiv "klar" (manuel uden Start nu) er ikke.
function ADMonthView.isActive(field)
if field == nil then return false end
if field.stage == "idle" or field.stage == "done" then return false end
if field.stage == "ready" then return ADFieldModel.shouldStart(field) end
return true
end
-- Næste trin i kæden (uden at ændre marken). nil = intet (pleje går tilbage til venter, sidste trin -> færdig).
function ADMonthView.nextStage(field, info)
if field.stage == "ready" then
local stages = ADFieldModel.workStages(ADFieldModel.applyTarget(field.plan, info.kind, info.fruitName), info.kind, info.needs)
return stages[1]
end
if ADFieldModel.isCare(field.stage) then return nil end
local nextField = ADFieldModel.advance(field, info.needs)
if nextField.stage == "done" or nextField.stage == "idle" then return nil end
return nextField.stage
end
-- Vindue for en aktiv mark: "harvestCloses" | "lastSowMonth" | "ok".
-- ctx = {period, plantableIn(name, period), withers(info, nextPeriod)}
function ADMonthView.window(field, info, ctx)
local stage = field.stage
local cropStage = stage == "harvesting" or stage == "mowing" or stage == "ready"
if cropStage and info.kind ~= "bare" and ctx.withers ~= nil and ctx.withers(info, ADMonthView.nextPeriod(ctx.period)) then
return "harvestCloses"
end
local sowsLater = field.plan ~= nil and field.plan.sow and ADMonthView.BEFORE_SOWING[stage]
if sowsLater and ADMonthView.sowWindow(ctx.plantableIn, ADFieldModel.sowFruit(field), ctx.period) == "closes" then
return "lastSowMonth"
end
return "ok"
end
-- Fremdrift 0-1. Høst/slåning: andelen af HELE arealet der stadig står (standingRatio) — readyRatio duer ikke,
-- for slået/høstet afgrøde forsvinder også fra dens nævner (in-game 27/9 F90: halvt slået = 0 %), og
-- Courseplays tal er kun positionen i kurset (starter forfra på 0 ved genstart). Øvrige trin: Courseplay.
function ADMonthView.progress(field, info, cpProgress)
local cropStage = field.stage == "harvesting" or field.stage == "mowing"
if cropStage and type(info.standingRatio) == "number" then
return math.max(0, math.min(1, 1 - info.standingRatio))
end
if type(cpProgress) == "number" then return math.max(0, math.min(1, cpProgress)) end
if cropStage and type(info.readyRatio) == "number" then
return math.max(0, math.min(1, 1 - info.readyRatio))
end
return nil
end
local function priority(row)
if row.window ~= "ok" then return 0 end
if ADMonthView.MAIN_STAGES[row.stage] then return 1 end
return 2
end
-- Hele fanen: {rows = {...}, critical = n, upcoming = {...}}.
-- infos = ADFields.list(), fieldsById = ADFieldJobs.state.fields,
-- ctx = {period, plantableIn(name, period), withers(info, nextPeriod), cpProgress(fieldId) -> 0-1|nil}
function ADMonthView.build(infos, fieldsById, ctx)
local rows, upcoming, critical = {}, {}, 0
for _, info in ipairs(infos or {}) do
local field = fieldsById[info.id]
if field ~= nil and ADMonthView.isActive(field) then
local row = {id = info.id, stage = field.stage, crop = info.cropTitle, sowFruit = nil,
progress = ADMonthView.progress(field, info, ctx.cpProgress ~= nil and ctx.cpProgress(info.id) or nil),
nextStage = ADMonthView.nextStage(field, info), window = ADMonthView.window(field, info, ctx)}
if field.stage == "sowing" or (info.kind == "bare" and ADMonthView.BEFORE_SOWING[field.stage]) then
row.sowFruit = field.plan.sow and ADFieldModel.sowFruit(field) or nil
end
if row.window ~= "ok" then critical = critical + 1 end
table.insert(rows, row)
elseif field ~= nil and info.kind == "bare" and (field.stage == "idle" or field.stage == "done") then
local target = field.plan.targetFruit
if target ~= nil and target ~= "" and ctx.plantableIn ~= nil and ctx.plantableIn(target, ctx.period) then
table.insert(upcoming, {id = info.id, fruit = target,
closes = ADMonthView.sowWindow(ctx.plantableIn, target, ctx.period) == "closes"})
end
end
end
table.sort(rows, function(a, b)
local pa, pb = priority(a), priority(b)
if pa ~= pb then return pa < pb end
return a.id < b.id
end)
table.sort(upcoming, function(a, b)
if a.closes ~= b.closes then return a.closes end
return a.id < b.id
end)
return {rows = rows, critical = critical, upcoming = upcoming}
end
-- "50 %" / "–"
function ADMonthView.percentText(progress)
if progress == nil then return "–" end
return string.format("%d %%", math.floor(progress * 100 + 0.5))
end
+168
View File
@@ -0,0 +1,168 @@
-- AD Names
-- Egne bygningsnavne (FS25 kan ikke omdøbe bygninger): ADSmartPickup_names.xml i savegame-mappen,
-- <ADSmartPickupNames><building id="placeable…" name="Gårdsilo 10"/></ADSmartPickupNames>.
-- Moden tilføjer kun navne (nye bygninger / Omdøb-knappen) og ændrer aldrig et eksisterende navn af sig selv.
ADNames = {}
ADNames.FILE_NAME = "ADSmartPickup_names.xml"
ADNames.ROOT = "ADSmartPickupNames"
ADNames.byId = nil
-- records = {{id, name}} -> {[id] = name}; tomme/ugyldige springes over
function ADNames.fromRecords(records)
local byId = {}
for _, record in ipairs(records or {}) do
local id, name = record.id, record.name
if type(id) == "string" and id ~= "" and type(name) == "string" and name:match("%S") then byId[id] = name end
end
return byId
end
local function getPath()
local path = ADRunsStorage ~= nil and ADRunsStorage.getPath() or nil
if path == nil then return nil end
return (path:gsub(ADRunsStorage.FILE_NAME .. "$", ADNames.FILE_NAME))
end
function ADNames.load()
ADNames.byId = {}
local path = getPath()
if path == nil or not fileExists(path) then return end
local xmlFile = XMLFile.load("ADSmartPickupNames", path)
if xmlFile == nil then return end
local records = {}
xmlFile:iterate(ADNames.ROOT .. ".building", function(_, key)
table.insert(records, {id = xmlFile:getString(key .. "#id"), name = xmlFile:getString(key .. "#name")})
end)
xmlFile:delete()
ADNames.byId = ADNames.fromRecords(records)
local count = 0
for _ in pairs(ADNames.byId) do count = count + 1 end
Logging.info("ADSmartPickup: %d egne bygningsnavne indlæst", count)
end
-- Eget navn for bygningen (placeable), ellers nil.
function ADNames.forPlaceable(placeable)
if placeable == nil or placeable.uniqueId == nil then return nil end
if ADNames.byId == nil then pcall(ADNames.load) end
return (ADNames.byId or {})[tostring(placeable.uniqueId)]
end
function ADNames.save()
local path = getPath()
if path == nil then return false end
local xmlFile = XMLFile.create("ADSmartPickupNames", path, ADNames.ROOT)
if xmlFile == nil then return false end
local ids = {}
for id in pairs(ADNames.byId or {}) do table.insert(ids, id) end
table.sort(ids)
for index, id in ipairs(ids) do
local key = string.format("%s.building(%d)", ADNames.ROOT, index - 1)
xmlFile:setString(key .. "#id", id)
xmlFile:setString(key .. "#name", ADNames.byId[id])
end
xmlFile:save()
xmlFile:delete()
return true
end
-- Omdøb (knappen i bygningsfanerne): gemmes straks; tomt navn = tilbage til butiksnavnet.
function ADNames.set(id, name)
if ADNames.byId == nil then pcall(ADNames.load) end
local trimmed = type(name) == "string" and name:match("^%s*(.-)%s*$") or ""
ADNames.byId[id] = trimmed ~= "" and trimmed or nil
pcall(ADNames.save)
Logging.info("ADSmartPickup: bygning %s omdøbt til '%s'", tostring(id), trimmed)
if ADBuildings ~= nil and ADBuildings.invalidate ~= nil then ADBuildings.invalidate() end
end
local function position(placeable)
local node = placeable ~= nil and placeable.rootNode or nil
if node == nil then return nil end
local x, _, z = getWorldTranslation(node)
return x, z
end
local function animalTypeName(placeable)
local ok, name = pcall(function()
local index = placeable:getAnimalTypeIndex()
local animalType = g_currentMission.animalSystem:getTypeByIndex(index)
return animalType ~= nil and animalType.name or nil
end)
return ok and name or nil
end
local function rawName(placeable)
local ok, name = pcall(placeable.getName, placeable)
return ok and name ~= nil and tostring(name) or nil
end
-- Mark-markører ("F118") fra AutoDrive: {{name, x, z}}
local function fieldMarkers(adEnv)
local result = {}
for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do
if tostring(marker.name):match("^F%d+$") then
local wayPoint = adEnv.ADGraphManager:getWayPointById(marker.id)
if wayPoint ~= nil then table.insert(result, {name = marker.name, x = wayPoint.x, z = wayPoint.z}) end
end
end
return result
end
-- Nye bygninger (uden navn) får et navn efter ADNaming; kaldes når bygningslisten bygges. Retter navnene i
-- listen med det samme og gemmer filen, hvis der kom nye.
function ADNames.autoName(adEnv, buildings)
if ADNames.byId == nil then pcall(ADNames.load) end
local existing = ADNames.byId or {}
local missing = false
for _, building in ipairs(buildings) do if existing[building.id] == nil and building.kind ~= "sell" then missing = true break end end
if not missing then return end
-- gårdens klynge: valgte gårdlagre i marksilo-flowet + siloer der allerede hedder "Gårdsilo …", og naboer ≤ 60 m
local silos, farm, queue = {}, {}, {}
for _, building in ipairs(buildings) do
if building.kind == "silo" then
local x, z = position(building.placeable)
if x ~= nil then
local silo = {id = building.id, x = x, z = z}
table.insert(silos, silo)
local isStore = ADFieldSilos ~= nil and ADFieldSiloConfig ~= nil and ADFieldSiloConfig.hasStore(ADFieldSilos.config, building.id)
if isStore or tostring(existing[building.id] or ""):match("^Gårdsilo") then farm[building.id] = true; table.insert(queue, silo) end
end
end
end
while #queue > 0 do
local current = table.remove(queue)
for _, silo in ipairs(silos) do
if not farm[silo.id] and (silo.x - current.x) ^ 2 + (silo.z - current.z) ^ 2 <= 3600 then farm[silo.id] = true; table.insert(queue, silo) end
end
end
local fields = fieldMarkers(adEnv)
local inputs = {}
for _, building in ipairs(buildings) do
if existing[building.id] == nil then
local x, z = position(building.placeable)
local nearest = nil
if x ~= nil and building.kind == "silo" then
for _, field in ipairs(fields) do
local distance = math.sqrt((field.x - x) ^ 2 + (field.z - z) ^ 2)
if nearest == nil or distance < nearest.distance then nearest = {name = field.name, distance = distance} end
end
end
table.insert(inputs, {id = building.id, kind = building.kind, animal = building.kind == "husbandry" and animalTypeName(building.placeable) or nil,
storeName = rawName(building.placeable), inFarmCluster = farm[building.id] == true, nearestField = nearest})
end
end
local added = ADNaming.assign(inputs, existing)
local count = 0
for id, name in pairs(added) do
existing[id] = name
count = count + 1
Logging.info("ADSmartPickup: ny bygning navngivet '%s' (%s)", name, id)
end
if count == 0 then return end
ADNames.byId = existing
for _, building in ipairs(buildings) do
if added[building.id] ~= nil then building.name = added[building.id] end
end
pcall(ADNames.save)
end
+89
View File
@@ -0,0 +1,89 @@
-- AD Naming
-- Regler for automatiske navne på bygninger uden eget navn (rene funktioner; spil-delen samler input i adNames).
-- Stalde efter dyretype, siloer efter gårdens klynge/nærmeste mark, palleskure nummereret, fabrikker efter
-- butiksnavnet. Numrene fortsætter fra de navne, der allerede findes ("Svinestald 1-5" -> "Svinestald 6").
ADNaming = {}
ADNaming.ANIMAL_PREFIX = {COW = "Kostald", PIG = "Svinestald", SHEEP = "Fårestald", GOAT = "Gedestald",
CHICKEN = "Hønsehus", HORSE = "Hestestald"}
ADNaming.FIELD_SILO_DISTANCE = 200
local function escape(text) return (tostring(text):gsub("[%^%$%(%)%%%.%[%]%*%+%-%?]", "%%%0")) end
-- Højeste "prefix N" blandt navnene + 1.
function ADNaming.nextNumber(names, prefix)
local highest, pattern = 0, "^" .. escape(prefix) .. " (%d+)$"
for _, name in ipairs(names) do
local number = tonumber(tostring(name):match(pattern))
if number ~= nil and number > highest then highest = number end
end
return highest + 1
end
local function contains(names, wanted)
for _, name in ipairs(names) do if name == wanted then return true end end
return false
end
-- Et fast navn der kan være brugt: "X", ellers "X 2", "X 3" ...
local function uniqueFixed(names, base)
if not contains(names, base) then return base end
return string.format("%s %d", base, math.max(2, ADNaming.nextNumber(names, base)))
end
-- Navn for én bygning (nil = navngives ikke, fx salgssteder).
-- b = {kind, animal, storeName, inFarmCluster, nearestField = {name, distance}}
local function nameFor(b, names)
if b.kind == "husbandry" then
local prefix = ADNaming.ANIMAL_PREFIX[tostring(b.animal or ""):upper()] or "Stald"
return string.format("%s %d", prefix, ADNaming.nextNumber(names, prefix))
elseif b.kind == "silo" then
if b.inFarmCluster then return string.format("Gårdsilo %d", ADNaming.nextNumber(names, "Gårdsilo")) end
local field = b.nearestField
if field ~= nil and field.name ~= nil and (field.distance or math.huge) <= ADNaming.FIELD_SILO_DISTANCE then
return uniqueFixed(names, field.name .. " marksilo")
end
return string.format("Silo %d", ADNaming.nextNumber(names, "Silo"))
elseif b.kind == "objectStorage" then
return string.format("Palleskur %d", ADNaming.nextNumber(names, "Palleskur"))
elseif b.kind == "sell" then
return nil
end
if b.storeName == nil or b.storeName == "" then return nil end
return uniqueFixed(names, b.storeName)
end
-- Navne til bygninger der ikke har et endnu. existing = {[id] = navn}; returnerer kun de nye {[id] = navn}.
function ADNaming.assign(buildings, existing)
local names, result = {}, {}
for _, name in pairs(existing or {}) do table.insert(names, name) end
for _, b in ipairs(buildings or {}) do
if b.id ~= nil and (existing or {})[b.id] == nil then
local name = nameFor(b, names)
if name ~= nil then
result[b.id] = name
table.insert(names, name)
end
end
end
return result
end
-- Markmaskiner: navn efter rolle (høstere efter type: "beet" = roe-/kartoffeloptager, "forage" = finsnitter).
ADNaming.ROLE_PREFIX = {collector = "Læssevogn", mower = "Skårlægger", tedder = "Vender", rake = "River",
baler = "Ballepresser", baleCollector = "Ballesamler", seeder = "Såmaskine", roller = "Tromle",
weeder = "Ukrudtsmaskine", fertilizer = "Gødningsspreder", limer = "Kalkspreder", herbicide = "Sprøjte",
mulcher = "Stubhakker", cultivator = "Kultivator", plow = "Plov", unloader = "Tømmevogn"}
ADNaming.HARVESTER_PREFIX = {beet = "Roeoptager", forage = "Finsnitter"}
function ADNaming.fleetName(role, subtype, names)
local prefix
if role == "harvester" then
prefix = ADNaming.HARVESTER_PREFIX[subtype] or "Mejetærsker"
else
prefix = ADNaming.ROLE_PREFIX[role]
end
if prefix == nil then return nil end
return string.format("%s %d", prefix, ADNaming.nextNumber(names or {}, prefix))
end
+39 -4
View File
@@ -14,6 +14,8 @@ ADOutbound = {}
ADOutbound.LOG_PREFIX = "ADSmartPickup: "
-- Holdende traktorer spørger planlæggeren så tit (opslag over alle markører).
ADOutbound.CHECK_INTERVAL_MS = 5000
-- spred over HELE intervallet, så genplanlægningerne (og cache-genberegninger) fordeles jævnt
ADOutbound.CHECK_JITTER_MS = 5000
-- "Kør uanset mængde": stiger vognens fyldning ikke så længe ved kilden, køres der med det der er.
ADOutbound.NO_PROGRESS_MS = 15000
-- Objektlager der ikke leverer en palle inden så længe regnes som fastlåst (kendt spilfejl).
@@ -25,6 +27,9 @@ ADOutbound.NOTIFICATION_MS = 8000
-- spawnet, forkert farm, ...): kilden udelukkes for varen i EMPTY_SOURCE_TTL_MS og turen afsluttes.
ADOutbound.EMPTY_SOURCE_MS = 30000
ADOutbound.EMPTY_SOURCE_TTL_MS = 300000
-- kilde der gav lidt og så intet mere (fx paller uden for Universal Autoloads rækkevidde): hviler så længe,
-- så de andre kilder kommer til (in-game 26/9: æg-vognen kørte til samme hønsehus tre gange i træk)
ADOutbound.THIN_SOURCE_REST_MS = 180000
-- Autoload-bil på vej til aflæsning: stropperne løsnes så mange meter FØR markøren. Markøren står
-- hvor næsen stopper, og en lastbils lad har passeret salgstriggeren dér; AutoDrive løsner selv
-- først ved markøren ("AL unload = center"), hvilket er for sent for en lastbil.
@@ -42,6 +47,11 @@ local warned = setmetatable({}, {__mode = "k"})
local emptyUntil = {}
local isInstalled = false
-- Glem alle "tom/hvilende kilde"-markeringer (tests; og når runs genindlæses).
function ADOutbound.resetEmptySources()
emptyUntil = {}
end
local function now()
if g_currentMission ~= nil and type(g_currentMission.time) == "number" then
return g_currentMission.time
@@ -176,6 +186,12 @@ local function findSellQuote(adEnv, vehicle, product, sellMarkers, sellAtPercent
if unreachableName ~= nil then
return nil, unreachableName
end
-- ingen pris over grænsen: det bedste salgssted der kan nås (bruges af "sælg altid over X %")
for _, quote in ipairs(quotes) do
if homeId == nil or ADWaitPool.hasRouteBothWays(adEnv, vehicle, quote.wayPointId, homeId) then
return quote, nil
end
end
return quotes[1], nil
end
@@ -241,7 +257,9 @@ local function findStoreMarker(adEnv, vehicle, product, capacity, sources, liter
return {wayPointId = chosen.id, name = tostring(chosen.name), free = liters}
end
local avoid = ADRunsController ~= nil and ADRunsController.getPickupMarkers ~= nil and ADRunsController.getPickupMarkers() or nil
local marker = ADLoadSwap.findDumpMarker(adEnv, vehicle, product.fillType, liters, nil, isReachable, ADSources.getSourcePlaceables(sources), avoid, true)
-- flydende varer (gylle, digestat, mælk ...) kun i tanke/siloer — ikke automatisk i en fabriks rist
local allowFactories = not (ADLoadSwap.isLiquid ~= nil and ADLoadSwap.isLiquid(product.fillType))
local marker = ADLoadSwap.findDumpMarker(adEnv, vehicle, product.fillType, liters, nil, isReachable, ADSources.getSourcePlaceables(sources), avoid, allowFactories)
if marker == nil then
return nil
end
@@ -346,6 +364,7 @@ function ADOutbound.buildPlanInput(adEnv, run, vehicle)
hasAutoload = hasAutoload,
sellAtPercent = run.sellAtPercent,
storeAbovePercent = run.storeAbovePercent,
sellAbovePercent = run.sellAbovePercent,
collectRound = run.collectRound == true,
products = products,
}
@@ -474,7 +493,17 @@ local function startHold(adEnv, vehicle, run, reasons, sourcesById)
local isReachable = function(candidate)
return ADWaitPool.hasRouteTo(adEnv, vehicle, candidate.marker.id)
end
local candidate = ADWaitPool.pickFree(adEnv, vehicle, run.waitPoolGroup, isReachable)
-- "All": nærmest flowets egne kilder, ikke nærmest traktoren (in-game 29/9: Milk Store ved sukkerroeknuseren)
local anchorX, anchorZ = nil, nil
if run.waitPoolGroup == nil or run.waitPoolGroup == "" then
local points = {}
for _, id in ipairs(run.sourceWayPointIds or {}) do
local wayPoint = adEnv.ADGraphManager:getWayPointById(id)
if wayPoint ~= nil then table.insert(points, {x = wayPoint.x, z = wayPoint.z}) end
end
anchorX, anchorZ = ADWaitPool.anchorOf(points)
end
local candidate = ADWaitPool.pickFree(adEnv, vehicle, run.waitPoolGroup, isReachable, anchorX, anchorZ)
local marker, source = nil, nil
if candidate ~= nil then
ADWaitPool.claim(candidate.marker.id, vehicle)
@@ -490,7 +519,8 @@ local function startHold(adEnv, vehicle, run, reasons, sourcesById)
warnOnce(adEnv, vehicle, "noWait", string.format("'%s' har intet ledigt Wait-punkt med vejnet (run '%s'); holder ved kilden %s", tostring(ADRuns.vehicleLabel(vehicle)), tostring(run.name), tostring(marker.name)))
end
local previous = holds[vehicle]
holds[vehicle] = {markerId = marker.id, markerName = tostring(marker.name), reasons = reasons, timerMs = 0}
-- tilfældig start (0-2 s) så holdende vogne ikke genplanlægger i samme frame (hak hvert 5. s, 26/9)
holds[vehicle] = {markerId = marker.id, markerName = tostring(marker.name), reasons = reasons, timerMs = math.random(0, ADOutbound.CHECK_JITTER_MS)}
local message = string.format("'%s' holder ved %s: %s", tostring(ADRuns.vehicleLabel(vehicle)), tostring(marker.name), table.concat(reasons, "; "))
if previous == nil or previous.lastLogged ~= message then
Logging.info(ADOutbound.LOG_PREFIX .. "%s", message)
@@ -753,7 +783,12 @@ function ADOutbound.beforeLoadUpdate(adEnv, task, dt)
return true
end
if state.noProgressMs > ADOutbound.NO_PROGRESS_MS and level > 0 and not spawnPending then
Logging.info(ADOutbound.LOG_PREFIX .. "'%s': ingen mere last ved %s; kører med %.0f l", tostring(ADRuns.vehicleLabel(vehicle)), tostring(source ~= nil and source.name or state.current), level)
local rests = now() + ADOutbound.THIN_SOURCE_REST_MS
for _, fillType in ipairs(state.trip.fillTypes) do
local key = tostring(state.current) .. ":" .. tostring(fillType)
if emptyUntil[key] == nil or emptyUntil[key] < rests then emptyUntil[key] = rests end
end
Logging.info(ADOutbound.LOG_PREFIX .. "'%s': ingen mere last ved %s; kører med %.0f l (kilden hviler %d min, så de andre kommer til)", tostring(ADRuns.vehicleLabel(vehicle)), tostring(source ~= nil and source.name or state.current), level, math.floor(ADOutbound.THIN_SOURCE_REST_MS / 60000))
state.noProgressMs, state.completed = 0, true
task:finished()
return true
+3 -1
View File
@@ -68,6 +68,8 @@ function ADOutboundPlanner.evaluate(product, input)
storageHigh = storagePercent >= (input.storeAbovePercent or 80),
percent = percent,
priceOk = percent ~= nil and percent >= (input.sellAtPercent or 90),
-- "sælg altid over X %": kilden er så fuld, at der sælges uanset pris
overflow = (input.sellAbovePercent or 0) > 0 and storagePercent >= input.sellAbovePercent,
}
end
@@ -123,7 +125,7 @@ local function collectCandidates(input)
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 and canSellWithoutReserve(e, input) then
if wantsSell(product.policy) and (e.priceOk or e.overflow) and product.quote ~= nil and canSellWithoutReserve(e, input) 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 or (input.feedMode and e.available >= ADOutboundPlanner.FEED_MIN_LITERS)) and product.store ~= nil then
+485
View File
@@ -0,0 +1,485 @@
-- AD Pool
-- Maskinpulje: plove, kultivatorer, såmaskiner … holder frakoblet på deres AD-parkering "X", og en ledig
-- solo-traktor henter dem via markøren "Connect X", når markarbejdet mangler en færdig kombination.
-- Ren logik øverst (testes med luajit), spil-adapter nederst.
ADPool = {}
ADPool.LOG_PREFIX = "ADSmartPickup: pulje: "
ADPool.QUARANTINE_MS = 600000 -- redskab der ikke kunne kobles på, springes over så længe
-- ---- ren logik ----
-- Rolle for et frakoblet redskab ud fra dets egne specs (samme prioritet som markflåden); en ren vogn med
-- tip/snegl er tømmevogn.
function ADPool.roleOf(specs)
local role = ADFieldFleet.roleFromSpecs(specs)
if role ~= nil and role ~= "unloader" then return role end
if specs.spec_trailer and specs.spec_dischargeable then return "unloader" end
return role
end
-- Butiksdata er enten et tal eller en tabel pr. konfiguration: største tal, ellers nil.
function ADPool.numberOf(value)
if type(value) == "number" then return value end
if type(value) ~= "table" then return nil end
local best = nil
for _, entry in pairs(value) do
if type(entry) == "number" and (best == nil or entry > best) then best = entry end
end
return best
end
-- Mangler et af tallene, tillades det (intet må gå i stå på manglende data).
function ADPool.powerOk(tractorHp, neededHp)
if tractorHp == nil or neededHp == nil or tractorHp <= 0 or neededHp <= 0 then return true end
return tractorHp >= neededHp
end
-- Butikskategori: markredskaber kun bag traktorer (tractorsS/M/L …) — ikke lastbil/Unimog, læssere o.l.
-- Tømmevogne må alle trække. Ukendt kategori tillades (intet må gå i stå på manglende data).
ADPool.ANY_PULLER_ROLES = {unloader = true}
function ADPool.categoryOk(category, role)
if category == nil or ADPool.ANY_PULLER_ROLES[role] then return true end
return tostring(category):lower():find("^tractors") ~= nil
end
local function suits(tractor, implement, role)
return ADPool.powerOk(tractor.hp, implement.neededHp) and ADPool.categoryOk(tractor.category, role)
end
-- Markøren "Connect <parkering>" for en parkering (navneregel).
function ADPool.connectFor(parkName, markers)
if type(parkName) ~= "string" then return nil end
local wanted = parkName:lower()
for _, marker in pairs(markers or {}) do
local action, target = ADHitch.parseMarker(marker.name)
if action == "attach" and target ~= nil and target:lower() == wanted then return marker end
end
return nil
end
local function hasRole(item, role)
return item.role == role or (item.roles ~= nil and item.roles[role] == true)
end
local function distanceSq(ax, az, bx, bz)
return (ax - bx) ^ 2 + (az - bz) ^ 2
end
local function nearest(items, x, z, keep)
local best, bestDistance = nil, math.huge
for _, item in ipairs(items) do
if keep == nil or keep(item) then
local distance = distanceSq(item.x, item.z, x, z)
if distance < bestDistance then best, bestDistance = item, distance end
end
end
return best
end
-- Når opgaven slutter, sættes puljeredskabet af — også når AutoDrive ellers selv ville parkere (påfyldte
-- sæt): AD parkerer ved redskabets plads med redskabet på. Aldrig ved manuel transport.
-- Bedste pasform: den mindste traktor med hk nok; afstand afgør kun mellem traktorer inden for FIT_BAND af den
-- mindste. Over OVERSIZE_FACTOR x behovet kun når intet andet passer; ukendt traktor-hk efter de kendte.
-- Redskab uden hk-data: nærmeste (intet at matche på). tractors = allerede filtrerede kandidater.
ADPool.FIT_BAND = 1.15
ADPool.OVERSIZE_FACTOR = 2.5
function ADPool.bestFit(tractors, implement, role)
local fitting = {}
for _, tractor in ipairs(tractors or {}) do
if suits(tractor, implement, role) then table.insert(fitting, tractor) end
end
local needed = implement.neededHp
if needed == nil or needed <= 0 then return nearest(fitting, implement.x, implement.z) end
local sized, oversized, unknown = {}, {}, {}
for _, tractor in ipairs(fitting) do
if tractor.hp == nil or tractor.hp <= 0 then table.insert(unknown, tractor)
elseif tractor.hp > needed * ADPool.OVERSIZE_FACTOR then table.insert(oversized, tractor)
else table.insert(sized, tractor) end
end
local pool = #sized > 0 and sized or (#unknown > 0 and unknown or oversized)
if pool ~= unknown then
local smallest = math.huge
for _, tractor in ipairs(pool) do smallest = math.min(smallest, tractor.hp) end
local band = {}
for _, tractor in ipairs(pool) do
if tractor.hp <= smallest * ADPool.FIT_BAND then table.insert(band, tractor) end
end
pool = band
end
return nearest(pool, implement.x, implement.z)
end
function ADPool.shouldReturnHome(assignment)
return not assignment.manual
end
-- Kun maskiner uden egen arbejdsrolle (almindelige traktorer) henter puljeredskaber — ikke mejetærskere,
-- selvkørende sprøjter o.l., selv om de har koblingspunkter.
function ADPool.canPull(role)
return role == nil
end
-- Hvem fik koblingen til at fejle? Kom traktoren ikke frem, er det traktoren (ingen AD-rute), ellers redskabet.
function ADPool.failureBlames(reason)
if tostring(reason):find("^stoppedAway") ~= nil then return "tractor" end
return "implement"
end
-- Efter koblingen: refill = markarbejdets påfyldningsplan (nil = intet at fylde).
function ADPool.nextDrive(refill)
if refill == nil or not refill.empty then return "drive" end
if refill.refillId ~= nil then return "load" end
return "empty"
end
-- Puljen bruges til opgaver med ét sæt (også tømmevogne ved høst), ikke når marken har egne faste maskiner
-- til rollen.
function ADPool.mayUsePool(decision, hasOwnForRole)
return (decision.count or 1) == 1 and not hasOwnForRole
end
-- Kan puljevognen bære varen? (implement.carries = {[fillType] = true}; nil vare = intet krav)
function ADPool.canCarry(implement, fillType)
if fillType == nil then return true end
return implement.carries ~= nil and implement.carries[fillType] == true
end
-- Status til flådefanen. s = {parkName, hasConnect, attachedTo, quarantineMin, reserved} -> nøgle, argument
function ADPool.implementStatus(s)
if s.parkName == nil then return "noPark" end
if not s.hasConnect then return "noConnect", s.parkName end
if s.attachedTo ~= nil then return "attached", s.attachedTo end
if s.flowName ~= nil then return "flow", s.flowName end
if (s.quarantineMin or 0) > 0 then return "quarantine", s.quarantineMin end
if s.reserved then return "reserved" end
return "parked", s.parkName
end
-- s = {hasPark, enabled, controlled, returning, fieldId, busy, offNetwork, hitched} -> nøgle, argument
function ADPool.tractorStatus(s)
if not s.hasPark then return "noPark" end
if s.controlled then return "player" end
if s.returning then return "returning" end
if s.fieldId ~= nil then return "job", s.fieldId end
if s.busy then return "busy" end
if not s.enabled then return "notInFleet" end
if s.offNetwork then return "offNetwork" end
if s.hitched then return "hitched" end
return "free"
end
-- Redskab med rollen nærmest marken, derefter nærmeste ledige traktor med nok hk til redskabet.
-- blocked = {[id] = true} (reserverede og redskaber i karantæne). accept (valgfri): implement -> bool.
function ADPool.pairFor(role, fieldX, fieldZ, implements, tractors, blocked, accept)
blocked = blocked or {}
local free = {}
for _, tractor in ipairs(tractors or {}) do
if not blocked[tractor.id] and not tractor.offNetwork then table.insert(free, tractor) end
end
local candidates = {}
for _, implement in ipairs(implements or {}) do
if hasRole(implement, role) and not blocked[implement.id] and (accept == nil or accept(implement)) then
table.insert(candidates, implement)
end
end
if #candidates == 0 then return nil, "noPoolImplement" end
if #free == 0 then return nil, "noPoolTractor" end
table.sort(candidates, function(a, b)
return distanceSq(a.x, a.z, fieldX, fieldZ) < distanceSq(b.x, b.z, fieldX, fieldZ)
end)
local weakest = nil
for _, implement in ipairs(candidates) do
local tractor = ADPool.bestFit(free, implement, role)
if tractor ~= nil then return {implement = implement, tractor = tractor} end
if weakest == nil or (implement.neededHp or 0) < (weakest.neededHp or 0) then weakest = implement end
end
return nil, "tooWeak", {weakest ~= nil and weakest.neededHp or 0, weakest ~= nil and weakest.name or "?"}
end
-- Alle frie puljeredskaber med rollen, hver parret med den nærmeste endnu ubrugte traktor med nok hk
-- (marksilo-flowet planlægger over mange vogne på én gang).
function ADPool.pairAll(role, implements, tractors, blocked)
blocked = blocked or {}
local used, result = {}, {}
for _, implement in ipairs(implements or {}) do
if hasRole(implement, role) and not blocked[implement.id] then
local open = {}
for _, t in ipairs(tractors or {}) do
if not used[t.id] and not blocked[t.id] and not t.offNetwork then table.insert(open, t) end
end
local tractor = ADPool.bestFit(open, implement, role)
if tractor ~= nil then
used[tractor.id] = true
table.insert(result, {implement = implement, tractor = tractor})
end
end
end
return result
end
-- Ventende marker prøver igen på fælles taktslag (ikke hver sin 30 s fra eget forsøg): så udløber de i samme
-- tick, og køens rækkefølge (længst ventende først) afgør hvem der får den næste ledige traktor.
function ADPool.alignedRetry(nowMs, periodMs)
return (math.floor(nowMs / periodMs) + 1) * periodMs
end
-- Ventende marker først (længst ventende først, lige -> lavest id); resten bagefter i oprindelig rækkefølge.
function ADPool.queueOrder(infos, waitingSince)
local waiting, rest = {}, {}
for _, info in ipairs(infos or {}) do
if waitingSince ~= nil and waitingSince[info.id] ~= nil then table.insert(waiting, info) else table.insert(rest, info) end
end
table.sort(waiting, function(a, b)
local wa, wb = waitingSince[a.id], waitingSince[b.id]
if wa ~= wb then return wa < wb end
return a.id < b.id
end)
for _, info in ipairs(rest) do table.insert(waiting, info) end
return waiting
end
-- Reservationer (i hukommelsen): owner -> {id, ...}. Karantæne: id -> udløbstid.
local reservations, quarantined = {}, {}
function ADPool.reserve(owner, ids)
reservations[owner] = ids
end
function ADPool.release(owner)
reservations[owner] = nil
end
function ADPool.quarantine(id, nowMs)
quarantined[id] = nowMs + ADPool.QUARANTINE_MS
end
function ADPool.quarantineMinutes(id, nowMs)
local untilMs = quarantined[id]
if untilMs == nil or nowMs >= untilMs then return 0 end
return math.ceil((untilMs - nowMs) / 60000)
end
function ADPool.isReserved(id)
for _, ids in pairs(reservations) do
for _, reserved in ipairs(ids) do if reserved == id then return true end end
end
return false
end
function ADPool.blocked(nowMs)
local result = {}
for _, ids in pairs(reservations) do
for _, id in ipairs(ids) do result[id] = true end
end
for id, untilMs in pairs(quarantined) do
if nowMs < untilMs then result[id] = true end
end
return result
end
-- Traktorer på hjemtur (sætter redskab af, kører hjem): optaget for markarbejdet til de er hjemme.
local returning = setmetatable({}, {__mode = "k"})
function ADPool.setReturning(vehicle, isOn)
returning[vehicle] = isOn and true or nil
end
function ADPool.isReturning(vehicle)
return returning[vehicle] == true
end
-- ---- spil ----
local function parkMarker(adEnv, vehicle)
local index = vehicle.advd ~= nil and vehicle.advd.parkDestination or nil
if type(index) ~= "number" or index < 1 then return nil end
return adEnv.ADGraphManager:getMapMarkerById(index)
end
local function positionOf(vehicle)
local x, _, z = getWorldTranslation(vehicle.rootNode)
return x, z
end
-- Butiksdata: redskabets krævede effekt (hk). specs indlæses først, når butikken har vist varen.
local neededHpCache = {} -- configFileName -> hk eller false (butiksdata ændrer sig ikke i et spil)
local function neededHpOf(vehicle)
if g_storeManager == nil or vehicle.configFileName == nil then return nil end
local cached = neededHpCache[vehicle.configFileName]
if cached ~= nil then return cached or nil end
local value = ADPool.readNeededHp(vehicle)
neededHpCache[vehicle.configFileName] = value or false
return value
end
function ADPool.readNeededHp(vehicle)
local item = g_storeManager:getItemByXMLFilename(vehicle.configFileName)
if item == nil then return nil end
if item.specs == nil and StoreItemUtil ~= nil and StoreItemUtil.loadSpecsFromXML ~= nil then
pcall(StoreItemUtil.loadSpecsFromXML, item)
end
return item.specs ~= nil and ADPool.numberOf(item.specs.neededPower) or nil
end
local tractorHpCache = setmetatable({}, {__mode = "k"}) -- vehicle -> hk eller false
-- Traktorens effekt i hk: motorens topeffekt (kW) omregnet; ellers butiksdata. Gemmes pr. traktor.
local function tractorHpOf(vehicle)
local cached = tractorHpCache[vehicle]
if cached ~= nil then return cached or nil end
local value = ADPool.readTractorHp(vehicle)
tractorHpCache[vehicle] = value or false
return value
end
function ADPool.readTractorHp(vehicle)
local motor = vehicle.spec_motorized ~= nil and vehicle.spec_motorized.motor or nil
local kw = motor ~= nil and motor.peakMotorPower or nil
if type(kw) == "number" and kw > 0 then return kw * 1.35962 end
if g_storeManager == nil or vehicle.configFileName == nil then return nil end
local item = g_storeManager:getItemByXMLFilename(vehicle.configFileName)
return item ~= nil and item.specs ~= nil and ADPool.numberOf(item.specs.power) or nil
end
-- Butikskategori (fx "TRACTORSM", "TRUCKS") eller nil.
local function categoryOf(vehicle)
if g_storeManager == nil or vehicle.configFileName == nil then return nil end
local item = g_storeManager:getItemByXMLFilename(vehicle.configFileName)
return item ~= nil and item.categoryName or nil
end
-- Varer og samlet kapacitet i redskabets fyldenheder (tømmevogne).
function ADPool.cargoOf(vehicle)
local carries, capacity = {}, 0
local units = vehicle.spec_fillUnit ~= nil and vehicle.spec_fillUnit.fillUnits or {}
for index in ipairs(units) do
capacity = capacity + (vehicle:getFillUnitCapacity(index) or 0)
for fillType in pairs(vehicle:getFillUnitSupportedFillTypes(index) or {}) do carries[fillType] = true end
end
return carries, capacity
end
-- Ikke bundet af et kørende flow (traktorer i stoppede flows er ledige).
function ADPool.isFreeFromFlows(vehicle)
if ADFlowWagons ~= nil then return ADFlowWagons.isFreeTractor(vehicle) end
return ADRunsController.getRunForVehicle(vehicle) == nil
end
local function isAttached(vehicle)
return vehicle.getAttacherVehicle ~= nil and vehicle:getAttacherVehicle() ~= nil
end
-- Traktorens egen rolle uden de redskaber der hænger på (mejetærsker/selvkørende har en, en traktor ikke).
function ADPool.ownRole(vehicle)
local specs = {}
for _, name in ipairs(ADCpBridge.SPECS) do
if vehicle[name] ~= nil then specs[name] = true end
end
return ADFieldFleet.roleFromSpecs(specs)
end
-- Puljeredskaber (frakoblet, parkering "X", markør "Connect X") og puljetraktorer.
-- isBusy(vehicle): markarbejdets optaget-regel (opgave, AD/CP kører, lånt af marksilo, på hjemtur).
function ADPool.collect(adEnv, isBusy)
local markers = adEnv.ADGraphManager:getMapMarkers()
local farmId = g_currentMission:getFarmId()
local implements, tractors = {}, {}
-- spillets egen liste: frakoblede redskaber er ikke nødvendigvis i AutoDrive's køretøjsliste
for _, vehicle in pairs(g_currentMission.vehicleSystem.vehicles) do
local owned = vehicle.getOwnerFarmId ~= nil and vehicle:getOwnerFarmId() == farmId
if owned and vehicle.spec_motorized == nil and vehicle.spec_attachable ~= nil and not isAttached(vehicle) then
local park = parkMarker(adEnv, vehicle)
local connect = park ~= nil and ADPool.connectFor(park.name, markers) or nil
local specs = connect ~= nil and ADCpBridge.getSpecs(vehicle) or nil
local role = specs ~= nil and ADPool.roleOf(specs) or nil
local held = role ~= nil and ADFlowWagons ~= nil and ADFlowWagons.isHeldWagon(ADRunsController.getVehicleId(vehicle))
if role ~= nil and not held then
local x, z = positionOf(vehicle)
local carries, capacity = ADPool.cargoOf(vehicle)
table.insert(implements, {carries = carries, capacity = capacity,
id = ADRunsController.getVehicleId(vehicle), vehicle = vehicle, name = ADRuns.vehicleLabel(vehicle),
role = role, roles = specs.sprayRoles or ADFieldFleet.rolesFor(role), x = x, z = z, width = ADCpBridge.getWorkWidth(vehicle),
neededHp = neededHpOf(vehicle), connectId = connect.id, parkId = park.id,
})
end
end
end
local state = ADFieldJobs.state
for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do
local id = ADRunsController.getVehicleId(vehicle)
local park = parkMarker(adEnv, vehicle)
local controlled = vehicle.getIsControlled ~= nil and vehicle:getIsControlled()
if id ~= nil and park ~= nil and vehicle.spec_attacherJoints ~= nil and #ADHitch.rearImplementsOf(vehicle) == 0
and ADPool.isFreeFromFlows(vehicle) and not controlled and not isBusy(vehicle)
and not ADPool.isReturning(vehicle) and ADFieldFleet.isEnabled(state.fleetMode, state.fleet, id)
and ADPool.canPull(ADPool.ownRole(vehicle)) then
local x, z = positionOf(vehicle)
-- uden for AD-vejnettet: den kan ikke køre ud efter redskabet (ville sende redskaber i karantæne)
local networkDistance = ADFieldWork.distanceToNetwork(adEnv, x, z)
table.insert(tractors, {id = id, vehicle = vehicle, name = ADRuns.vehicleLabel(vehicle), x = x, z = z,
hp = tractorHpOf(vehicle), category = categoryOf(vehicle), parkId = park.id,
offNetwork = networkDistance ~= nil and networkDistance > ADFieldWork.MAX_OFF_NETWORK})
end
end
return implements, tractors
end
-- Oversigt til flådefanen: alle redskaber med en markrolle (også dem der mangler parkering/Connect, så
-- spilleren kan se hvorfor) og alle traktorer uden egen arbejdsrolle, der ikke hører til et flow.
function ADPool.overview(adEnv)
local markers = adEnv.ADGraphManager:getMapMarkers()
local farmId = g_currentMission:getFarmId()
local nowMs = ADFieldJobs.now or 0
local implements, tractors = {}, {}
for _, vehicle in pairs(g_currentMission.vehicleSystem.vehicles) do
local owned = vehicle.getOwnerFarmId ~= nil and vehicle:getOwnerFarmId() == farmId
if owned and vehicle.spec_motorized == nil and vehicle.spec_attachable ~= nil then
local role = ADPool.roleOf(ADCpBridge.getSpecs(vehicle))
if role ~= nil then
local id = ADRunsController.getVehicleId(vehicle)
local park = parkMarker(adEnv, vehicle)
local attacher = isAttached(vehicle) and vehicle:getAttacherVehicle() or nil
local key, arg = ADPool.implementStatus({
parkName = park ~= nil and park.name or nil,
hasConnect = park ~= nil and ADPool.connectFor(park.name, markers) ~= nil,
attachedTo = attacher ~= nil and ADRuns.vehicleLabel(attacher) or nil,
quarantineMin = id ~= nil and ADPool.quarantineMinutes(id, nowMs) or 0,
reserved = id ~= nil and ADPool.isReserved(id),
flowName = id ~= nil and ADFlowWagons ~= nil and ADFlowWagons.isHeldWagon(id)
and tostring(ADFlowWagons.ownerOf(ADRunsController.store.runs, id).name) or nil,
})
table.insert(implements, {id = id, label = ADRuns.vehicleLabel(vehicle), role = role, status = key, arg = arg})
end
end
end
local state = ADFieldJobs.state
for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do
local id = ADRunsController.getVehicleId(vehicle)
-- en traktor med et redskab der giver den en markrolle, står allerede i flåden som kombination
if id ~= nil and vehicle.spec_attacherJoints ~= nil and ADPool.isFreeFromFlows(vehicle)
and ADPool.canPull(ADPool.ownRole(vehicle)) and ADFieldFleet.roleFromSpecs(ADCpBridge.getSpecs(vehicle)) == nil then
local x, z = positionOf(vehicle)
local distance = ADFieldWork.distanceToNetwork(adEnv, x, z)
local assignment = state.assignments[id]
local rear = ADHitch.rearImplementsOf(vehicle)
local key, arg = ADPool.tractorStatus({
hasPark = parkMarker(adEnv, vehicle) ~= nil,
enabled = ADFieldFleet.isEnabled(state.fleetMode, state.fleet, id),
controlled = vehicle.getIsControlled ~= nil and vehicle:getIsControlled(),
returning = ADPool.isReturning(vehicle),
fieldId = assignment ~= nil and assignment.fieldId or nil,
busy = ADFieldWork.isBusyVehicle(vehicle, id),
offNetwork = distance ~= nil and distance > ADFieldWork.MAX_OFF_NETWORK,
hitched = #rear > 0,
})
table.insert(tractors, {id = id, label = ADRuns.vehicleLabel(vehicle), hp = tractorHpOf(vehicle), status = key, arg = arg,
enabled = ADFieldFleet.isEnabled(state.fleetMode, state.fleet, id)})
end
end
return implements, tractors
end
+123
View File
@@ -0,0 +1,123 @@
-- AD Profiler
-- Måler Smart Pickups indgange (AutoDrive-hooks, update-løkken, GUI) i spillet: kald, samlet tid og det
-- værste enkeltkald pr. minut, plus det værste frame i alt. Tager noget over REPORT_MIN_MS, skrives top-listen
-- i log.txt ("ADSmartPickup: profil …"), så et hak kan føres tilbage til præcis hvilken del der gjorde det.
-- Indpakningen sker på modul-tabellernes felter; hooksene slår funktionerne op ved hvert kald, så de måles.
ADProfiler = {}
ADProfiler.WINDOW_MS = 60000
ADProfiler.REPORT_MIN_MS = 2
ADProfiler.TOP = 6
ADProfiler.stats = {}
ADProfiler.frameMs = 0
ADProfiler.worstFrameMs = 0
ADProfiler.windowStart = nil
ADProfiler.depth = 0
ADProfiler.installed = false
function ADProfiler.clock()
local ok, ms = pcall(function() return netGetTime() end)
if ok and type(ms) == "number" then return ms end
return nil
end
local function record(name, started, ...)
local finished = ADProfiler.clock()
ADProfiler.depth = ADProfiler.depth - 1
if started ~= nil and finished ~= nil then
local spent = finished - started
local stat = ADProfiler.stats[name]
if stat == nil then
stat = {calls = 0, total = 0, max = 0}
ADProfiler.stats[name] = stat
end
stat.calls = stat.calls + 1
stat.total = stat.total + spent
if spent > stat.max then stat.max = spent end
-- kun yderste måling tæller i frame-summen (indlejrede måles også hver for sig)
if ADProfiler.depth == 0 then ADProfiler.frameMs = ADProfiler.frameMs + spent end
end
return ...
end
-- Pak tbl[key] ind under navnet name (gør intet hvis funktionen ikke findes).
function ADProfiler.wrap(tbl, key, name)
if type(tbl) ~= "table" or type(tbl[key]) ~= "function" then return end
local original = tbl[key]
tbl[key] = function(...)
ADProfiler.depth = ADProfiler.depth + 1
return record(name, ADProfiler.clock(), original(...))
end
end
-- Kaldes én gang pr. frame fra ADSmartPickup:update: afslutter frame-summen og rapporterer pr. minut.
function ADProfiler.frame(now)
if ADProfiler.frameMs > ADProfiler.worstFrameMs then ADProfiler.worstFrameMs = ADProfiler.frameMs end
ADProfiler.frameMs = 0
-- (en fejl i en indpakket funktion springer record over; dybden nulstilles derfor hvert frame)
ADProfiler.depth = 0
ADProfiler.windowStart = ADProfiler.windowStart or now
if now - ADProfiler.windowStart < ADProfiler.WINDOW_MS then return end
ADProfiler.report()
ADProfiler.windowStart = now
end
function ADProfiler.report()
local rows = {}
for name, stat in pairs(ADProfiler.stats) do
table.insert(rows, {name = name, stat = stat})
end
table.sort(rows, function(a, b) return a.stat.max > b.stat.max end)
local worst = rows[1] ~= nil and rows[1].stat.max or 0
if worst >= ADProfiler.REPORT_MIN_MS or ADProfiler.worstFrameMs >= ADProfiler.REPORT_MIN_MS then
local parts = {}
for index = 1, math.min(ADProfiler.TOP, #rows) do
local stat = rows[index].stat
table.insert(parts, string.format("%s max %.1f ms (%d kald, i alt %.0f ms)", rows[index].name, stat.max, stat.calls, stat.total))
end
Logging.info("ADSmartPickup: profil sidste minut — værste frame %.1f ms: %s", ADProfiler.worstFrameMs, table.concat(parts, "; "))
end
ADProfiler.stats = {}
ADProfiler.worstFrameMs = 0
end
-- Indpak alle kendte indgange. Kaldes én gang efter at hooksene er installeret.
function ADProfiler.install()
if ADProfiler.installed then return end
ADProfiler.installed = true
local targets = {
{ADUnloadWait, "beforeUpdate", "aflæsning/Wait (pr. vogn)"},
{ADUnloadWait, "adoptOrphans", "genoptag ventende"},
{ADUnloadWait, "releaseInactive", "slip standsede"},
{ADOutbound, "beforeLoadUpdate", "udkørsel pålæsning (pr. vogn)"},
{ADOutbound, "beforeUnloadUpdate", "udkørsel aflæsning (pr. vogn)"},
{ADOutbound, "beforeLoadFinished", "udkørsel runde"},
{ADOutbound, "choosePickup", "udkørsel turvalg"},
{ADOutbound, "holdSupplyVehicle", "forsyning hold"},
{ADOutbound, "releaseInactive", "udkørsel slip"},
{ADLoadSwap, "tryFromWait", "læs-bytte fra Wait"},
{ADSmartPickup, "shouldWaitForLoading", "vent ved pålæsning"},
{ADSmartPickup, "choosePickup", "forsyning kildevalg"},
{ADSmartPickup, "findSupplyPickup", "forsyning kildesøgning"},
{ADRunsController, "applyVehicleNames", "traktornavne"},
{ADFieldJobs, "tick", "markarbejde tick"},
{ADFields, "scanStep", "markscanning"},
{ADFieldWork, "follow", "markarbejde sæt"},
{ADFieldWork, "dispatch", "markarbejde udsendelse"},
{ADFieldWork, "getRigs", "markflåde"},
{ADBuildings, "list", "bygningsliste"},
{ADSources, "getFarmInventory", "gårdens lagre"},
{ADWaitPool, "hasRouteTo", "rutetjek"},
{ADWaitPool, "hasRouteBothWays", "rutetjek frem+tilbage"},
{ADChain, "evaluate", "kæde"},
{ADChainData, "collect", "kæde: læs gården"},
}
for _, target in ipairs(targets) do
ADProfiler.wrap(target[1], target[2], target[3])
end
if SmartPickupFrame ~= nil then
ADProfiler.wrap(SmartPickupFrame, "refreshLive", "menu opdatering")
ADProfiler.wrap(SmartPickupFrame, "rebuild", "menu genopbygning")
end
end
+83
View File
@@ -17,6 +17,9 @@ 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
-- "Sælg altid over X %": kildens lager over grænsen sælges uanset pris (0 = fra). Så hober f.eks. gylle
-- ved BGA'en sig ikke op, når prisen er lav.
ADRuns.DEFAULT_SELL_ABOVE_PERCENT, ADRuns.MIN_SELL_ABOVE_PERCENT, ADRuns.MAX_SELL_ABOVE_PERCENT = 0, 0, 100
local function copyList(list)
local copy = {}
@@ -39,6 +42,37 @@ local function clampRange(value, default, min, max)
return math.max(min, math.min(max, math.floor(number)))
end
-- Flow-vogne: {[traktorId] = vognId} (maskinpulje — flowet henter sin egen vogn).
-- Start alle pr. flow: isRunning (traktorer kører AD), stopped (pause fra Stop alt / Stop).
-- -> "queue" (start traktorerne), "resume" (kører allerede: kun pausen ophæves), "skip".
-- In-game 29/9: flows på pause hvis traktorer kørte videre på en gemt AD-tur blev talt som 'kørte allerede',
-- forblev stoppede, og traktoren kørte vognen hjem lige efter at have hentet den.
function ADRuns.startAllAction(isRunning, stopped)
if not isRunning then return "queue" end
return stopped and "resume" or "skip"
end
function ADRuns.copyWagons(wagons)
local copy = {}
for tractorId, wagonId in pairs(wagons or {}) do copy[tractorId] = wagonId end
return copy
end
function ADRuns.serializeWagons(wagons)
local keys = {}
for tractorId in pairs(wagons or {}) do table.insert(keys, tractorId) end
table.sort(keys)
local parts = {}
for _, tractorId in ipairs(keys) do table.insert(parts, tractorId .. "=" .. wagons[tractorId]) end
return table.concat(parts, ";")
end
function ADRuns.parseWagons(text)
local wagons = {}
for tractorId, wagonId in string.gmatch(tostring(text or ""), "([^=;]+)=([^=;]+)") do wagons[tractorId] = wagonId end
return wagons
end
local function copyRun(run)
return {
id = run.id,
@@ -51,6 +85,7 @@ local function copyRun(run)
products = copyProducts(run.products),
sellAtPercent = run.sellAtPercent,
storeAbovePercent = run.storeAbovePercent,
sellAbovePercent = run.sellAbovePercent,
collectRound = run.collectRound,
targetWayPointId = run.targetWayPointId,
targetWayPointIds = copyList(run.targetWayPointIds),
@@ -60,6 +95,9 @@ local function copyRun(run)
loops = run.loops,
returnBelowPercent = run.returnBelowPercent,
vehicleIds = copyList(run.vehicleIds),
stopped = run.stopped == true,
wagons = ADRuns.copyWagons(run.wagons),
chain = run.chain or "",
}
end
@@ -98,6 +136,7 @@ function ADRuns.createRun(store, name, kind)
products = {},
sellAtPercent = ADRuns.DEFAULT_SELL_AT_PERCENT,
storeAbovePercent = ADRuns.DEFAULT_STORE_ABOVE_PERCENT,
sellAbovePercent = ADRuns.DEFAULT_SELL_ABOVE_PERCENT,
collectRound = false,
targetWayPointId = nil,
targetWayPointIds = {},
@@ -107,6 +146,7 @@ function ADRuns.createRun(store, name, kind)
loops = 0,
returnBelowPercent = ADRuns.DEFAULT_RETURN_BELOW_PERCENT,
vehicleIds = {},
chain = "",
}
new.nextId = new.nextId + 1
table.insert(new.runs, run)
@@ -164,7 +204,9 @@ function ADRuns.updateRun(store, runId, changes)
if changes.products ~= nil then run.products = copyProducts(changes.products) end
if changes.sellAtPercent ~= nil then run.sellAtPercent = clampRange(changes.sellAtPercent, ADRuns.DEFAULT_SELL_AT_PERCENT, ADRuns.MIN_SELL_AT_PERCENT, ADRuns.MAX_SELL_AT_PERCENT) end
if changes.storeAbovePercent ~= nil then run.storeAbovePercent = clampRange(changes.storeAbovePercent, ADRuns.DEFAULT_STORE_ABOVE_PERCENT, ADRuns.MIN_STORE_ABOVE_PERCENT, ADRuns.MAX_STORE_ABOVE_PERCENT) end
if changes.sellAbovePercent ~= nil then run.sellAbovePercent = clampRange(changes.sellAbovePercent, ADRuns.DEFAULT_SELL_ABOVE_PERCENT, ADRuns.MIN_SELL_ABOVE_PERCENT, ADRuns.MAX_SELL_ABOVE_PERCENT) end
if changes.collectRound ~= nil then run.collectRound = changes.collectRound == true end
if changes.chain ~= nil then run.chain = tostring(changes.chain) end
return new
end
@@ -308,10 +350,43 @@ function ADRuns.unassignVehicle(store, vehicleId)
break
end
end
-- traktoren forlader flowet: dens huskede vogn hører ikke længere til flowet
if run.wagons ~= nil then run.wagons[vehicleId] = nil end
end
return new
end
-- Traktorer der ikke findes længere (solgt/byttet) fjernes fra deres flow. exists(vehicleId) -> bool.
-- Mangler ALLE flowenes traktorer, er indlæsningen nok ikke færdig/gået galt: intet fjernes.
-- -> ny store, {{runName, vehicleId}} over de fjernede
function ADRuns.pruneMissingVehicles(store, exists)
local missing, total = {}, 0
for _, run in ipairs(store.runs) do
for _, id in ipairs(run.vehicleIds) do
total = total + 1
if not exists(id) then table.insert(missing, {runName = run.name, vehicleId = id}) end
end
end
if #missing == 0 or #missing == total then return store, {} end
local new = store
for _, entry in ipairs(missing) do new = ADRuns.unassignVehicle(new, entry.vehicleId) end
return new, missing
end
-- Én besked pr. berørt flow efter pruneMissingVehicles: {runName, removed, left, critical} (critical = flowet
-- står uden traktorer og virker ikke). I samme rækkefølge som flowene.
function ADRuns.pruneNotices(storeAfter, removed)
local byName = {}
for _, entry in ipairs(removed or {}) do byName[entry.runName] = (byName[entry.runName] or 0) + 1 end
local notices = {}
for _, run in ipairs(storeAfter.runs) do
if byName[run.name] ~= nil then
table.insert(notices, {runName = run.name, removed = byName[run.name], left = #run.vehicleIds, critical = #run.vehicleIds == 0})
end
end
return notices
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)
@@ -364,7 +439,11 @@ function ADRuns.toRecords(store)
products = ADRuns.serializeProducts(run.products),
sellAt = run.sellAtPercent,
storeAbove = run.storeAbovePercent,
sellAbove = run.sellAbovePercent or 0,
collectRound = run.collectRound and 1 or 0,
stopped = run.stopped and 1 or 0,
wagons = ADRuns.serializeWagons(run.wagons),
chain = run.chain or "",
}
end
return records
@@ -407,6 +486,7 @@ function ADRuns.fromRecords(records)
products = ADRuns.parseProducts(record.products),
sellAtPercent = clampRange(record.sellAt, ADRuns.DEFAULT_SELL_AT_PERCENT, ADRuns.MIN_SELL_AT_PERCENT, ADRuns.MAX_SELL_AT_PERCENT),
storeAbovePercent = clampRange(record.storeAbove, ADRuns.DEFAULT_STORE_ABOVE_PERCENT, ADRuns.MIN_STORE_ABOVE_PERCENT, ADRuns.MAX_STORE_ABOVE_PERCENT),
sellAbovePercent = clampRange(record.sellAbove, ADRuns.DEFAULT_SELL_ABOVE_PERCENT, ADRuns.MIN_SELL_ABOVE_PERCENT, ADRuns.MAX_SELL_ABOVE_PERCENT),
collectRound = tonumber(record.collectRound) == 1,
targetWayPointId = targets[1],
targetWayPointIds = targets,
@@ -416,6 +496,9 @@ function ADRuns.fromRecords(records)
loops = clampLoops(record.loops),
returnBelowPercent = clampPercent(record.returnBelow),
vehicleIds = copyList(record.vehicleIds),
stopped = tonumber(record.stopped) == 1,
wagons = ADRuns.parseWagons(record.wagons),
chain = record.chain or "",
})
store.nextId = math.max(store.nextId, id + 1)
end
+248 -27
View File
@@ -86,10 +86,11 @@ end
function ADRunsController.deleteRun(runId, adEnv)
local run = ADRuns.getRun(ADRunsController.store, runId)
if run ~= nil and adEnv ~= nil then
if run ~= nil and adEnv ~= nil and not run.stopped then
for _, vehicleId in ipairs(run.vehicleIds) do
ADRunsController.stopIfRunning(ADRunsController.findVehicleById(adEnv, vehicleId), "run slettet")
end
if ADFlowWagons ~= nil then pcall(ADFlowWagons.sendHome, adEnv, run) end
end
ADRunsController.store = ADRuns.deleteRun(ADRunsController.store, runId)
end
@@ -256,6 +257,11 @@ function ADRunsController.applyVehicleNamesThrottled(adEnv, dt)
if nameTimerMs < ADRunsController.NAME_INTERVAL_MS then return end
nameTimerMs = 0
ADRunsController.applyVehicleNames(adEnv)
-- markmaskiner uden eget navn (stadig modelnavnet) får et rollenavn
if ADFieldWork ~= nil and ADFieldWork.autoNameFleet ~= nil and ADFieldJobs ~= nil and ADFieldJobs.loaded then
local ok, err = pcall(ADFieldWork.autoNameFleet, adEnv)
if not ok then Logging.warning(ADRunsController.LOG_PREFIX .. "navne til markflåden fejlede: %s", tostring(err)) end
end
end
-- ---- varer ----
@@ -372,24 +378,49 @@ function ADRunsController.chooseTarget(adEnv, run, vehicle)
end
local farmId = ADSmartPickup.getFarmId(vehicle)
local ingredients = ADRunsController.getRunIngredients(adEnv, run, vehicle)
local bestId, bestNeed = nil, nil
local bestId, bestNeed, bestInbound = nil, nil, 0
for _, entry in ipairs(getTargetStations(adEnv, run, farmId)) do
-- læs andre traktorer er på vej med til stalden, tæller med — så fordeler de sig på staldene
local pending = ADSmartPickup.getPending ~= nil and ADSmartPickup.getPending(entry.wayPointId, vehicle) or nil
local ranked = ADSmartPickup.getTargetNeedsRanked(entry.station, ingredients, farmId, pending)
local need = ranked[1]
if need ~= nil and need.freeSpace >= (ADSupplyPlanner.MIN_FREE_LITERS or 1000) then
-- færrest andre vogne på vej først: et drop (drive-on-bunker, trug) tager én ad gangen, så tre
-- vogne mod det mindst fyldte mål holder i kø mens de andre mål står tomme (in-game 26/9)
local inbound = ADSmartPickup.countEnRoute ~= nil and ADSmartPickup.countEnRoute(entry.wayPointId, vehicle) or 0
local isBetter = bestNeed == nil
or (need.fits == true and bestNeed.fits ~= true)
or (need.fits == bestNeed.fits and need.score > bestNeed.score)
or inbound < bestInbound
or (inbound == bestInbound and need.fits == true and bestNeed.fits ~= true)
or (inbound == bestInbound and need.fits == bestNeed.fits and need.score > bestNeed.score)
if isBetter then
bestId, bestNeed = entry.wayPointId, need
bestId, bestNeed, bestInbound = entry.wayPointId, need, inbound
end
end
end
return bestId or targets[1]
end
-- AutoDrive genstarter aktive traktorer efter indlæsning uden om run-starten: fyldte run-traktorer
-- registreres "på vej" til deres nuværende mål (ellers ser mål-valget 0 vogne på vej overalt).
ADRunsController.ADOPT_INTERVAL_MS = 5000
local adoptTimerMs = 0
function ADRunsController.adoptLoadedThrottled(adEnv, dt)
adoptTimerMs = adoptTimerMs + (dt or 0)
if adEnv == nil or adoptTimerMs < ADRunsController.ADOPT_INTERVAL_MS then return end
adoptTimerMs = 0
for _, vehicle in pairs(getFarmVehiclesById(adEnv)) do
local run = ADRunsController.getRunForVehicle(vehicle)
local stateModule = vehicle.ad ~= nil and vehicle.ad.stateModule or nil
if run ~= nil and (run.kind or "supply") == "supply" and stateModule ~= nil and stateModule:isActive()
and ADSmartPickup.getEnRoute(vehicle) == nil then
local second = stateModule.getSecondMarker ~= nil and stateModule:getSecondMarker() or nil
if second ~= nil and ADSmartPickup.registerCargo(adEnv, vehicle, second.id, ADRunsController.getRunIngredients(adEnv, run, vehicle)) then
Logging.info(ADRunsController.LOG_PREFIX .. "'%s' har læs på vej til %s (overtaget efter start/indlæsning)", tostring(ADRuns.vehicleLabel(vehicle)), tostring(second.name or second.id))
end
end
end
end
-- Fire traktorer kan hedde det samme; løbenummeret er pladsen i farmens AD-køretøjsliste.
function ADRunsController.getVehicleLabel(adEnv, vehicle)
local label, model = tostring(ADRuns.vehicleLabel(vehicle)), tostring(vehicle:getName())
@@ -812,7 +843,9 @@ function ADRunsController.validateVehicle(adEnv, run, vehicle)
return result
end
if not intersects(ADRunsController.getRunIngredients(adEnv, run, vehicle), carriable) then
result.reason = "cannotCarry"
-- AutoLoad slået til: AutoDrive regner vognen som ren palle-/ballevogn (in-game 27/9: tipvogn med UAL
-- aktiv blev afvist i Goat Food som "kan ikke bære") — sig det, så spilleren kan slå det fra
result.reason = result.isAutoload and "autoloadOn" or "cannotCarry"
return result
end
if isFarFromNetwork(adEnv, vehicle) then
@@ -886,6 +919,7 @@ local function setSetting(adEnv, vehicle, settingName, value)
end
end
end
ADRunsController.setSetting = setSetting
-- 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)
@@ -969,6 +1003,10 @@ local function applyRunToVehicle(adEnv, run, vehicle, ingredients)
local targetId = ADRunsController.chooseTarget(adEnv, run, vehicle) or run.targetWayPointId
local dump = ADRunsController.findWrongCargoDump(adEnv, run, vehicle, targetId)
stateModule:setSecondMarkerByWayPointId(targetId)
-- allerede fyldt: registrér læsset på vej, så næste traktor i samme start vælger et andet drop
if dump == nil and ADSmartPickup.registerCargo ~= nil then
ADSmartPickup.registerCargo(adEnv, vehicle, targetId, ingredients)
end
if dump ~= nil then
if ADUnloadWait ~= nil and ADUnloadWait.setDumpTarget ~= nil then
ADUnloadWait.setDumpTarget(vehicle, dump.marker.id)
@@ -1035,41 +1073,174 @@ end
-- Pr. tildelt traktor: validér, skriv AD-tilstand, start. Afviste traktorer springes over
-- og logges med årsag (samme nøgle som fanen viser).
-- Start én traktor i et run: valider, anvend run'et og start AutoDrive. Returnerer resultat-posten.
function ADRunsController.startVehicle(adEnv, run, vehicle)
-- solo-traktor med en husket vogn: hent den først (valideringen ville afvise en traktor uden vogn)
if ADFlowWagons ~= nil then
local okWagon, handled, wagonResult = pcall(ADFlowWagons.beforeStart, adEnv, run, vehicle)
if okWagon and handled then return wagonResult end
if not okWagon then Logging.warning(ADRunsController.LOG_PREFIX .. "flow-vogn: %s", tostring(handled)) end
end
local validation = ADRunsController.validateVehicle(adEnv, run, vehicle)
local result = {vehicle = vehicle, ok = validation.ok, reason = validation.reason, missingBuilding = validation.missingBuilding}
if not validation.ok then
Logging.info(ADRunsController.LOG_PREFIX .. "run '%s': '%s' sprunget over (%s)", tostring(run.name), tostring(ADRuns.vehicleLabel(vehicle)), tostring(validation.reason))
return result
end
local ingredients = ADRunsController.getRunIngredients(adEnv, run, vehicle)
local ok, err = pcall(applyAndStart, adEnv, run, vehicle, ingredients)
if ok then
ADRunsController.runningRuns[run.id] = true
local second = vehicle.ad.stateModule.getSecondMarker ~= nil and vehicle.ad.stateModule:getSecondMarker() or nil
local target = run.kind == "outbound" and ("kilde " .. tostring(run.sourceWayPointIds[1]))
or ("markør " .. tostring(second ~= nil and second.id or run.targetWayPointId))
Logging.info(ADRunsController.LOG_PREFIX .. "run '%s': '%s' startet mod %s", tostring(run.name), tostring(ADRuns.vehicleLabel(vehicle)), target)
else
result.ok, result.reason = false, "error"
Logging.warning(ADRunsController.LOG_PREFIX .. "run '%s': kunne ikke starte '%s': %s", tostring(run.name), tostring(ADRuns.vehicleLabel(vehicle)), tostring(err))
end
return result
end
function ADRunsController.startRun(adEnv, runId)
local run = ADRuns.getRun(ADRunsController.store, runId)
local results = {}
if run == nil then
return results
end
run.stopped = false
local vehiclesById = getFarmVehiclesById(adEnv)
local started = 0
for _, vehicleId in ipairs(run.vehicleIds) do
local vehicle = vehiclesById[vehicleId]
if vehicle ~= nil then
table.insert(results, ADRunsController.startVehicle(adEnv, run, vehicle))
end
end
return results
end
-- ---- Start alle i kø ----
-- Én traktor ad gangen med STAGGER_MS mellemrum: ellers kører de sammen fra parkeringspladsen (in-game 27/9).
ADRunsController.STAGGER_MS = 10000
ADRunsController.startQueue = {}
local staggerTimerMs = 0
function ADRunsController.clearStartQueue()
ADRunsController.startQueue = {}
end
-- Lægger alle traktorer i flows der ikke kører i kø; ugyldige (fx uden vogn) springes over med det samme.
-- Returnerer {queued, alreadyRunning, skipped = {resultater}}.
function ADRunsController.queueStartAll(adEnv)
local summary = {queued = 0, alreadyRunning = 0, skipped = {}}
local vehiclesById = getFarmVehiclesById(adEnv)
local queue = {}
for _, run in ipairs(ADRunsController.store.runs) do
if #run.vehicleIds > 0 then
local action = ADRuns.startAllAction(ADRunsController.getRunStatus(adEnv, run).isRunning, run.stopped == true)
if action ~= "queue" then
summary.alreadyRunning = summary.alreadyRunning + 1
if action == "resume" then run.stopped = false end
else
run.stopped = false
for _, vehicleId in ipairs(run.vehicleIds) do
local vehicle = vehiclesById[vehicleId]
if vehicle ~= nil then
local validation = ADRunsController.validateVehicle(adEnv, run, vehicle)
local result = {vehicle = vehicle, ok = validation.ok, reason = validation.reason, missingBuilding = validation.missingBuilding}
if not validation.ok then
Logging.info(ADRunsController.LOG_PREFIX .. "run '%s': '%s' sprunget over (%s)", tostring(run.name), tostring(ADRuns.vehicleLabel(vehicle)), tostring(validation.reason))
end
if validation.ok then
local ingredients = ADRunsController.getRunIngredients(adEnv, run, vehicle)
local ok, err = pcall(applyAndStart, adEnv, run, vehicle, ingredients)
if ok then
started = started + 1
local target = run.kind == "outbound" and ("kilde " .. tostring(run.sourceWayPointIds[1])) or ("markør " .. tostring(run.targetWayPointId))
Logging.info(ADRunsController.LOG_PREFIX .. "run '%s': '%s' startet mod %s", tostring(run.name), tostring(ADRuns.vehicleLabel(vehicle)), target)
-- solo-traktor med husket vogn: henter den ved start (valideringen ser den uden vogn)
if validation.ok or (ADFlowWagons ~= nil and (validation.reason == "noTrailer" or ADFlowWagons.hasWagonFor(run, vehicleId))) then
table.insert(queue, {runId = run.id, vehicleId = vehicleId})
else
result.ok, result.reason = false, "error"
Logging.warning(ADRunsController.LOG_PREFIX .. "run '%s': kunne ikke starte '%s': %s", tostring(run.name), tostring(ADRuns.vehicleLabel(vehicle)), tostring(err))
table.insert(summary.skipped, {vehicle = vehicle, ok = false, reason = validation.reason, missingBuilding = validation.missingBuilding})
end
end
table.insert(results, result)
end
end
if started > 0 then
ADRunsController.runningRuns[runId] = true
end
return results
end
ADRunsController.startQueue = queue
ADRunsController.startQueueTotal = #queue
staggerTimerMs = ADRunsController.STAGGER_MS -- første startes ved næste tick
summary.queued = #queue
return summary
end
-- Stop alt og parkér (Oversigt): flows, markarbejde og marksilo-lån stoppes, køretøjerne sendes til deres
-- AD-parkeringspunkt, og moden sættes på pause (gemmes), så intet starter igen før "Start alle".
-- Høstere med manuel transport parkeres ikke (må ikke køre på vejen med skærebord). Returnerer
-- {stopped, parked, noPark = {navne}, manual = {navne}}.
function ADRunsController.stopAllAndPark(adEnv)
local summary = {stopped = 0, parked = 0, noPark = {}, manual = {}}
ADRunsController.clearStartQueue()
local toPark, seen = {}, {}
local vehiclesById = getFarmVehiclesById(adEnv)
for _, run in ipairs(ADRunsController.store.runs) do
for _, vehicleId in ipairs(run.vehicleIds) do
local vehicle = vehiclesById[vehicleId]
if vehicle ~= nil and not seen[vehicle] then
seen[vehicle] = true
if isVehicleActive(vehicle) then
stopVehicle(vehicle)
summary.stopped = summary.stopped + 1
end
table.insert(toPark, vehicle)
end
end
ADRunsController.runningRuns[run.id] = nil
run.stopped = true
end
if ADFieldWork ~= nil and ADFieldWork.stopAllAssignments ~= nil then
for _, stopped in ipairs(ADFieldWork.stopAllAssignments(adEnv) or {}) do
summary.stopped = summary.stopped + 1
if stopped.pooled then
summary.parked = summary.parked + 1
elseif stopped.manual then
table.insert(summary.manual, tostring(ADRuns.vehicleLabel(stopped.vehicle)))
elseif not seen[stopped.vehicle] then
seen[stopped.vehicle] = true
table.insert(toPark, stopped.vehicle)
end
end
end
if ADFieldSilos ~= nil and ADFieldSilos.stopAll ~= nil then pcall(ADFieldSilos.stopAll, adEnv) end
if ADFieldJobs ~= nil and ADFieldJobs.setPaused ~= nil then ADFieldJobs.setPaused(true) end
for _, vehicle in ipairs(toPark) do
if ADHitch ~= nil and ADHitch.stow ~= nil and ADRunsController.getRunForVehicle(vehicle) == nil then ADHitch.stow(vehicle, "stop alt") end
-- flow-traktor: tøm vognen, sæt den af på sin plads og kør hjem (ADFlowWagons klarer hele turen)
local okHome, pooled = false, false
if ADFlowWagons ~= nil and ADRunsController.getRunForVehicle(vehicle) ~= nil then
okHome = pcall(ADFlowWagons.homeVehicle, adEnv, vehicle)
pooled = okHome
elseif ADHitch ~= nil then
okHome, pooled = pcall(ADHitch.returnHome, adEnv, vehicle)
end
local park = not (okHome and pooled) and ADFieldWork ~= nil and ADFieldWork.parkMarkerId ~= nil and ADFieldWork.parkMarkerId(adEnv, vehicle) or nil
if okHome and pooled then
summary.parked = summary.parked + 1
elseif park ~= nil then
local ok = pcall(ADFieldWork.startAd, adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, park, nil)
if ok then summary.parked = summary.parked + 1 end
else
table.insert(summary.noPark, tostring(ADRuns.vehicleLabel(vehicle)))
end
end
Logging.info(ADRunsController.LOG_PREFIX .. "Stop alt og parkér: %d stoppet, %d til parkering, %d uden parkeringspunkt, %d høstere hentes manuelt — pause",
summary.stopped, summary.parked, #summary.noPark, #summary.manual)
return summary
end
-- Starter næste traktor i køen, når der er gået STAGGER_MS siden forrige.
function ADRunsController.processStartQueue(adEnv, dt)
if #ADRunsController.startQueue == 0 then return end
staggerTimerMs = staggerTimerMs + (dt or 0)
if staggerTimerMs < ADRunsController.STAGGER_MS then return end
staggerTimerMs = 0
local item = table.remove(ADRunsController.startQueue, 1)
local run = ADRuns.getRun(ADRunsController.store, item.runId)
local vehicle = run ~= nil and getFarmVehiclesById(adEnv)[item.vehicleId] or nil
if run ~= nil and vehicle ~= nil and not isVehicleActive(vehicle) then
ADRunsController.startVehicle(adEnv, run, vehicle)
end
end
-- En kørende traktor der fjernes fra sit run (eller hvis run slettes) kører run'ets job —
@@ -1089,6 +1260,11 @@ function ADRunsController.stopRun(adEnv, runId)
if run == nil then
return stopped
end
-- allerede stoppet: traktorerne kan være lånt ud af maskinpuljen — de må ikke trækkes væk midt i et job
if run.stopped then
ADRunsController.runningRuns[runId] = nil
return stopped
end
local vehiclesById = getFarmVehiclesById(adEnv)
for _, vehicleId in ipairs(run.vehicleIds) do
local vehicle = vehiclesById[vehicleId]
@@ -1099,6 +1275,12 @@ function ADRunsController.stopRun(adEnv, runId)
end
end
ADRunsController.runningRuns[runId] = nil
-- stoppet flow: vognene på plads og traktorerne hjem (ledige i maskinpuljen, vognene er stadig flowets)
run.stopped = true
if ADFlowWagons ~= nil then
local okHome, err = pcall(ADFlowWagons.sendHome, adEnv, run)
if not okHome then Logging.warning(ADRunsController.LOG_PREFIX .. "flow-vogne hjem: %s", tostring(err)) end
end
return stopped
end
@@ -1113,11 +1295,15 @@ function ADRunsController.getRunStatus(adEnv, run)
running = running + 1
end
end
local isRunning = ADRunsController.runningRuns[run.id] == true and running > 0
-- startet fra fanen i denne session ELLER ikke stoppet af spilleren (efter genindlæsning er runningRuns tom,
-- men traktorerne kører videre — in-game 29/9: listen viste [Start] ved kørende flows)
local isRunning = (ADRunsController.runningRuns[run.id] == true or run.stopped == false) and running > 0
if ADRunsController.runningRuns[run.id] and running == 0 then
ADRunsController.runningRuns[run.id] = nil
end
return {running = running, total = #run.vehicleIds, isRunning = isRunning}
-- canStop: Stop-knappen — også når et ikke-stoppet flow holder stille med vogne på / henter / venter på sin vogn
local canStop = isRunning or (not run.stopped and ADFlowWagons ~= nil and ADFlowWagons.hasActivity(run, vehiclesById))
return {running = running, total = #run.vehicleIds, isRunning = isRunning, canStop = canStop}
end
-- Start alle (Oversigt): flows der allerede kører røres ikke, så et halvt kørende gårdbillede kan
@@ -1126,8 +1312,10 @@ function ADRunsController.startAllRuns(adEnv)
local summary = {runsStarted = 0, alreadyRunning = 0, vehiclesStarted = 0, results = {}}
for _, run in ipairs(ADRunsController.store.runs) do
if #run.vehicleIds > 0 then
if ADRunsController.getRunStatus(adEnv, run).isRunning then
local action = ADRuns.startAllAction(ADRunsController.getRunStatus(adEnv, run).isRunning, run.stopped == true)
if action ~= "queue" then
summary.alreadyRunning = summary.alreadyRunning + 1
if action == "resume" then run.stopped = false end
else
local startedHere = 0
for _, result in ipairs(ADRunsController.startRun(adEnv, run.id)) do
@@ -1287,6 +1475,14 @@ function ADRunsController.save()
if ADFieldJobs ~= nil and ADFieldJobs.loaded then
pcall(ADFieldJobs.save)
end
if ADFieldSilos ~= nil then pcall(ADFieldSilos.save) end
-- kædens tilstand (tændte mellemled, pausede kæder, afviste forslag) — samme gem-regel som navnefilen
if ADChain ~= nil and ADChain.save ~= nil then pcall(ADChain.save) end
-- navnefilen skal skrives under spillets gem: FS25 genskriver savegame-mappen, og filer ingen skriver
-- under gemningen forsvinder (in-game 27/9: ADSmartPickup_names.xml blev slettet ved første gem)
if ADNames ~= nil and ADNames.byId ~= nil then pcall(ADNames.save) end
-- maskiner hvor CP kører nu, så de kan genstartes efter indlæsning (samme gem-regel som navnefilen)
if ADCpResume ~= nil and ADFieldSilos ~= nil and ADFieldSilos.adEnv ~= nil then pcall(ADCpResume.save, ADFieldSilos.adEnv) end
return saved
end
@@ -1298,7 +1494,11 @@ function ADRunsController.load(adEnv)
return
end
ADRunsController.store = store
if ADNames ~= nil then pcall(ADNames.load) end
pcall(ADRunsController.migrateBuildings, adEnv)
if ADFieldSilos ~= nil then pcall(ADFieldSilos.load) end
if ADChain ~= nil and ADChain.load ~= nil then pcall(ADChain.load) end
if ADCpResume ~= nil then pcall(ADCpResume.load) end
local vehiclesById = getFarmVehiclesById(adEnv)
for _, run in ipairs(store.runs) do
for _, vehicleId in ipairs(run.vehicleIds) do
@@ -1309,6 +1509,27 @@ function ADRunsController.load(adEnv)
end
end
-- Én gang efter indlæsning (køretøjerne er klar): traktorer der ikke findes længere fjernes fra flowene.
function ADRunsController.pruneMissingVehicles(adEnv)
local vehiclesById = getFarmVehiclesById(adEnv)
if next(vehiclesById) == nil then return end
local store, removed = ADRuns.pruneMissingVehicles(ADRunsController.store, function(id) return vehiclesById[id] ~= nil end)
if #removed == 0 then return end
ADRunsController.store = store
for _, entry in ipairs(removed) do
Logging.info(ADRunsController.LOG_PREFIX .. "run '%s': køretøj %s findes ikke længere — fjernet fra flowet", tostring(entry.runName), tostring(entry.vehicleId))
end
ADRunsController.save()
-- besked på skærmen (højre side); rød når flowet står uden traktorer
if ADFieldJobs ~= nil and ADFieldJobs.notify ~= nil and g_i18n ~= nil then
for _, notice in ipairs(ADRuns.pruneNotices(store, removed)) do
local text = notice.critical and string.format(g_i18n:getText("spu_prune_empty"), notice.runName)
or string.format(g_i18n:getText("spu_prune_removed"), notice.runName, notice.removed, notice.left)
ADFieldJobs.notify(text, notice.critical)
end
end
end
function ADRunsController.installSaveHook()
if ItemSystem == nil or ItemSystem.save == nil or Utils == nil then
return
+9
View File
@@ -46,7 +46,12 @@ function ADRunsStorage.writeRecords(xmlFile, records)
xmlFile:setString(key .. "#products", record.products or "")
xmlFile:setInt(key .. "#sellAt", record.sellAt or ADRuns.DEFAULT_SELL_AT_PERCENT)
xmlFile:setInt(key .. "#storeAbove", record.storeAbove or ADRuns.DEFAULT_STORE_ABOVE_PERCENT)
xmlFile:setInt(key .. "#sellAbove", record.sellAbove or 0)
xmlFile:setInt(key .. "#collectRound", record.collectRound or 0)
-- maskinpulje: flowet stoppet + husket vogn pr. traktor
xmlFile:setInt(key .. "#stopped", record.stopped or 0)
xmlFile:setString(key .. "#wagons", record.wagons or "")
xmlFile:setString(key .. "#chain", record.chain or "")
for vehicleIndex, vehicleId in ipairs(record.vehicleIds) do
xmlFile:setString(string.format("%s.vehicle(%d)#id", key, vehicleIndex - 1), vehicleId)
end
@@ -74,7 +79,11 @@ function ADRunsStorage.readRecords(xmlFile)
products = xmlFile:getString(key .. "#products"),
sellAt = xmlFile:getInt(key .. "#sellAt"),
storeAbove = xmlFile:getInt(key .. "#storeAbove"),
sellAbove = xmlFile:getInt(key .. "#sellAbove"),
collectRound = xmlFile:getInt(key .. "#collectRound"),
stopped = xmlFile:getInt(key .. "#stopped"),
wagons = xmlFile:getString(key .. "#wagons"),
chain = xmlFile:getString(key .. "#chain"),
vehicleIds = {},
}
xmlFile:iterate(key .. ".vehicle", function(_, vehicleKey)
+133 -8
View File
@@ -10,14 +10,30 @@
ADSmartPickup = {}
-- ADCache-opslag der også virker uden adCache (tests)
local function cacheGet(bucket, key, ttlMs, fn)
if ADCacheGet == nil then return fn() end
return ADCacheGet(bucket, key, ttlMs, fn)
end
ADSmartPickup.dir = g_currentModDirectory
if ADSmartPickup.dir ~= nil and source ~= nil then
source(ADSmartPickup.dir .. "gui/SmartPickupFrame.lua")
source(ADSmartPickup.dir .. "gui/SmartPickupSupplyTab.lua")
source(ADSmartPickup.dir .. "gui/SmartPickupOutboundTab.lua")
source(ADSmartPickup.dir .. "gui/SmartPickupStorageTab.lua")
source(ADSmartPickup.dir .. "gui/SmartPickupStorageView.lua")
source(ADSmartPickup.dir .. "gui/SmartPickupFlowTab.lua")
source(ADSmartPickup.dir .. "gui/SmartPickupFieldTab.lua")
source(ADSmartPickup.dir .. "gui/SmartPickupMonthTab.lua")
source(ADSmartPickup.dir .. "gui/SmartPickupFieldSiloTab.lua")
source(ADSmartPickup.dir .. "gui/SmartPickupChainTab.lua")
source(ADSmartPickup.dir .. "gui/SmartPickupGuide.lua")
source(ADSmartPickup.dir .. "gui/SmartPickupFieldGuide.lua")
source(ADSmartPickup.dir .. "gui/SmartPickupFlowGuide.lua")
source(ADSmartPickup.dir .. "gui/SmartPickupEditPanel.lua")
source(ADSmartPickup.dir .. "gui/SmartPickupAttentionFlow.lua")
end
-- Markøren er dér hvor trækkerens næse stopper; læssetriggeren ligger et
@@ -216,14 +232,21 @@ local function getUnloadStationAtMarker(adEnv, wayPoint, farmId)
end
-- Station ved et AutoDrive-waypoint (bruges af runs-controlleren og læs-byttet); nil uden station.
-- Markør -> station er stabil (stationer og markører flytter sig sjældent): caches 10 s (ADCache).
ADSmartPickup.STATION_CACHE_MS = 30000
function ADSmartPickup.getLoadStationAtWayPointId(adEnv, wayPointId, farmId)
return cacheGet("loadStation", tostring(wayPointId) .. ":" .. tostring(farmId), ADSmartPickup.STATION_CACHE_MS, function()
local wayPoint = adEnv.ADGraphManager:getWayPointById(wayPointId)
return wayPoint ~= nil and getLoadStationAtMarker(adEnv, wayPoint, farmId) or nil
end)
end
function ADSmartPickup.getUnloadStationAtWayPointId(adEnv, wayPointId, farmId)
return cacheGet("unloadStation", tostring(wayPointId) .. ":" .. tostring(farmId), ADSmartPickup.STATION_CACHE_MS, function()
local wayPoint = adEnv.ADGraphManager:getWayPointById(wayPointId)
return wayPoint ~= nil and getUnloadStationAtMarker(adEnv, wayPoint, farmId) or nil
end)
end
-- Løst opslag: nærmeste læssestation inden for maxDistance, uanset køreretning og "bag næsen".
@@ -243,7 +266,7 @@ function ADSmartPickup.getLoadStationNearWayPointId(adEnv, wayPointId, farmId, m
end
end
end
return best
return best, best ~= nil and bestDistance or nil
end
-- Siloens EGET indhold. Udvidelses-siloer (isExtension) deler lager med naboer
@@ -352,18 +375,26 @@ end
-- Bedste kilde der har varen: mindst fyldte først for lagervarer, fyldeste først for
-- varer hvis tanke løber fulde (ADSupplyPlanner). Siloer med varen i EGET lager går
-- altid forud for dem der kun kan låne fra naboer. Returnerer et valg eller nil.
local function findBestSource(markerStations, fillTypes, farmId)
local getLoadCapacity -- defineret længere nede (læs-kapacitet til kildevalget)
local function findBestSource(markerStations, fillTypes, farmId, loadCapacity)
local prefersFullest = ADSupplyPlanner.prefersFullestSource(getFillTypeNames(fillTypes))
local ample = ADSupplyPlanner.ampleLevel(loadCapacity)
local best = {[true] = {}, [false] = {}}
for _, entry in ipairs(markerStations) do
local level, isOwn = getStationFillLevel(entry.station, fillTypes, farmId)
if level >= ADSmartPickup.MIN_USEFUL_LITERS and not isProtectedProductionInput(entry.station, fillTypes)
and ADSupplyPlanner.isBetterSource(level, best[isOwn].level, prefersFullest) then
and ADSupplyPlanner.isBetterSource(level, best[isOwn].level, prefersFullest, ample) then
best[isOwn] = {level = level, marker = entry.marker, station = entry.station, isOwn = isOwn}
end
end
local choice = best[true].marker ~= nil and best[true] or best[false]
-- eget lager går forud — men ikke en næsten tom egen tank frem for et rigeligt nabolager
local own, other = best[true], best[false]
local choice = own
if own.marker == nil or (not prefersFullest and other.marker ~= nil and own.level < ample and other.level >= ample) then
choice = other
end
if choice.marker == nil then
return nil
end
@@ -392,7 +423,7 @@ function ADSmartPickup.findLeastFilledPickup(adEnv, driver)
return nil
end
local groupMarkers = adEnv.ADGraphManager:getMapMarkersInGroup(firstMarker.group)
return findBestSource(collectMarkerStations(adEnv, groupMarkers, farmId, nil), fillTypes, farmId)
return findBestSource(collectMarkerStations(adEnv, groupMarkers, farmId, nil), fillTypes, farmId, getLoadCapacity(adEnv, driver))
end
-- Lagrene bag aflæsningsstationen; produktionspunkter uden targetStorages læses direkte.
@@ -452,7 +483,7 @@ end
local factoryFallbackLogged = setmetatable({}, {__mode = "k"})
local function logFactoryFallbackOnce(driver, station)
local placeable = station.owningPlaceable
local name = placeable ~= nil and placeable.getName ~= nil and placeable:getName() or "fabrik"
local name = (ADNames ~= nil and ADNames.forPlaceable(placeable)) or (placeable ~= nil and placeable.getName ~= nil and placeable:getName()) or "fabrik"
local message = string.format("'%s': %s har ingen ønskede produktioner (tændte eller auto-slukkede pga. mangel) med varer vognen kan bære; bruger dit varevalg", tostring(ADRuns.vehicleLabel(driver)), tostring(name))
if factoryFallbackLogged[driver] ~= message then
factoryFallbackLogged[driver] = message
@@ -639,6 +670,20 @@ function ADSmartPickup.setEnRoute(vehicle, wayPointId, fillType, liters)
enRoute[vehicle] = {wayPointId = wayPointId, fillType = fillType, liters = liters}
end
function ADSmartPickup.getEnRoute(vehicle)
return enRoute[vehicle]
end
-- Fyldt run-traktor -> "på vej" til mål targetId, så mål-valget for de næste fordeler dem.
function ADSmartPickup.registerCargo(adEnv, vehicle, targetId, ingredients)
if targetId == nil or ADUnloadWait == nil or ADUnloadWait.getRestLoad == nil then return false end
local restFillType, restLiters = ADUnloadWait.getRestLoad(adEnv, vehicle)
local fillType, liters = ADSupplyPlanner.cargoFor(restFillType, restLiters, ingredients)
if fillType == nil then return false end
ADSmartPickup.setEnRoute(vehicle, targetId, fillType, liters)
return true
end
function ADSmartPickup.clearEnRoute(vehicle)
enRoute[vehicle] = nil
end
@@ -658,6 +703,15 @@ function ADSmartPickup.getPending(wayPointId, excludeVehicle)
return pending
end
-- Antal ANDRE traktorer på vej til målet (målvalget fordeler vognene på flere drops).
function ADSmartPickup.countEnRoute(wayPointId, excludeVehicle)
local count = 0
for vehicle, entry in pairs(enRoute) do
if vehicle ~= excludeVehicle and entry.wayPointId == wayPointId then count = count + 1 end
end
return count
end
-- pending (valgfri): læs på vej til målet, lægges oven i (fanen og mål-valget mellem stalde).
function ADSmartPickup.getTargetNeedsRanked(station, fillTypes, farmId, pending)
local needs = getTargetNeeds(station, fillTypes, farmId, {})
@@ -676,7 +730,7 @@ function ADSmartPickup.getTargetNeedsRanked(station, fillTypes, farmId, pending)
end
-- Vognens samlede kapacitet (liter); nil hvis AutoDrive ikke kan opgøre den.
local function getLoadCapacity(adEnv, driver)
getLoadCapacity = function(adEnv, driver)
if adEnv.AutoDrive.getAllUnits == nil or adEnv.AutoDrive.getAllFillLevels == nil then
return nil
end
@@ -744,7 +798,7 @@ function ADSmartPickup.findSupplyPickup(adEnv, driver, ingredients, targetWayPoi
-- bedste foder først: lavere foder kun når truget er nødlidende (ADSupplyPlanner.isFoodAllowed)
local bestFood = ADSupplyPlanner.bestFoodPriority ~= nil and ADSupplyPlanner.bestFoodPriority(ranked) or nil
for _, need in ipairs(ranked) do
local choice = (ADSupplyPlanner.isFoodAllowed == nil or ADSupplyPlanner.isFoodAllowed(need, bestFood)) and findBestSource(markerStations, {need.fillType}, farmId) or nil
local choice = (ADSupplyPlanner.isFoodAllowed == nil or ADSupplyPlanner.isFoodAllowed(need, bestFood)) and findBestSource(markerStations, {need.fillType}, farmId, loadCapacity) or nil
if choice ~= nil then
return {
markerIndex = choice.markerIndex,
@@ -1095,6 +1149,9 @@ local function installHook(adEnv)
if ADOutbound ~= nil and ADOutbound.install ~= nil then
ADOutbound.install(adEnv)
end
if ADHitchCreep ~= nil and ADHitchCreep.install ~= nil then
ADHitchCreep.install(adEnv)
end
Logging.info(ADSmartPickup.LOG_PREFIX .. "aktiv (mindst fyldte silo med varen; flere valgte varer = behovsstyret forsyning; Wait-punkter for rest-læs; udkørsel til silo/salg)")
end
@@ -1146,10 +1203,33 @@ local function insertInGameMenuPage(frame, pageName, iconFile, iconUVs, predicat
end
inGameMenu[pageName] = frame
inGameMenu.pagingElement:addElement(frame)
-- Lige over spillets Indstillinger (brugerønske 29/9). Siden flyttes på ALLE tre lister med samme indeks
-- (sider, pagerens sider, pageFrames) som FS25_FarmOperationsDashboard — at flytte kun nogle af dem gav
-- tidligere forskudt fane↔side. Uden Indstillinger-side: sidst som før.
local target = 0
for i, child in ipairs(inGameMenu.pagingElement.elements) do
if inGameMenu.pageSettings ~= nil and child == inGameMenu.pageSettings then
target = i
break
end
end
local function moveTo(list, matches)
if target == 0 or list == nil then return end
for i, child in ipairs(list) do
if matches(child) then
table.remove(list, i)
table.insert(list, target, child)
return
end
end
end
moveTo(inGameMenu.pagingElement.elements, function(child) return child == frame end)
moveTo(inGameMenu.pagingElement.pages, function(child) return child.element == frame end)
inGameMenu.pagingElement:updateAbsolutePosition()
inGameMenu.pagingElement:updatePageMapping()
inGameMenu:registerPage(frame, position, predicate)
inGameMenu:addPageTab(frame, iconFile, iconUVs)
moveTo(inGameMenu.pageFrames, function(child) return child == frame end)
if frame.updateAbsolutePosition ~= nil then
frame:updateAbsolutePosition()
end
@@ -1184,11 +1264,17 @@ function ADSmartPickup:loadMap()
end
function ADSmartPickup:update(dt)
if ADProfiler ~= nil and ADProfiler.installed then
local now = ADProfiler.clock()
if now ~= nil then pcall(ADProfiler.frame, now) end
end
if not isHooked then
local adEnv = getAutoDriveEnv()
if adEnv ~= nil and g_currentMission ~= nil and g_currentMission.storageSystem ~= nil then
installHook(adEnv)
isHooked = true
if ADProfiler ~= nil then pcall(ADProfiler.install) end
if ADCache ~= nil then ADCache.enabled = true end
if ADRunsController ~= nil then
pcall(ADRunsController.load, adEnv)
pcall(ADRunsController.installSaveHook)
@@ -1206,6 +1292,9 @@ function ADSmartPickup:update(dt)
if ADOutbound ~= nil and ADOutbound.releaseInactive ~= nil then
pcall(ADOutbound.releaseInactive)
end
if ADRunsController ~= nil and ADRunsController.processStartQueue ~= nil then
pcall(ADRunsController.processStartQueue, getAutoDriveEnv(), dt)
end
if ADRunsController ~= nil and ADRunsController.applyVehicleNamesThrottled ~= nil then
pcall(ADRunsController.applyVehicleNamesThrottled, getAutoDriveEnv(), dt)
end
@@ -1215,11 +1304,47 @@ function ADSmartPickup:update(dt)
Logging.warning(ADSmartPickup.LOG_PREFIX .. "markarbejde fejlede: %s", tostring(errFields))
end
end
if ADFieldSilos ~= nil then
local okSilos, errSilos = pcall(ADFieldSilos.tick, getAutoDriveEnv(), dt)
if not okSilos then
Logging.warning(ADSmartPickup.LOG_PREFIX .. "marksilo-flow fejlede: %s", tostring(errSilos))
end
end
if ADChain ~= nil then
local okChain, errChain = pcall(ADChain.tick, getAutoDriveEnv(), dt)
if not okChain then
Logging.warning(ADSmartPickup.LOG_PREFIX .. "kæde fejlede: %s", tostring(errChain))
end
end
if ADFlowWagons ~= nil then
local okFlow, errFlow = pcall(ADFlowWagons.tick, getAutoDriveEnv(), dt)
if not okFlow then Logging.warning(ADSmartPickup.LOG_PREFIX .. "flow-vogne fejlede: %s", tostring(errFlow)) end
end
if ADHitch ~= nil then
local okHitch, errHitch = pcall(ADHitch.tick, getAutoDriveEnv(), dt)
if not okHitch then
Logging.warning(ADSmartPickup.LOG_PREFIX .. "kobling fejlede: %s", tostring(errHitch))
end
end
if ADStuckDiag ~= nil then pcall(ADStuckDiag.tick, dt) end
if ADRunsController ~= nil and ADRunsController.adoptLoadedThrottled ~= nil then
pcall(ADRunsController.adoptLoadedThrottled, getAutoDriveEnv(), dt)
end
local ok, err = pcall(restoreStoppedVehicles)
if not ok then
Logging.warning(ADSmartPickup.LOG_PREFIX .. "kunne ikke gendanne varevalg: %s", tostring(err))
supplyState = setmetatable({}, {__mode = "k"})
end
if ADStatusHud ~= nil then pcall(ADStatusHud.update, dt, getAutoDriveEnv()) end
end
-- Statusvinduet på skærmen (adStatusHud.lua): tegner kun den cachede tekst.
function ADSmartPickup:draw()
if ADStatusHud ~= nil then pcall(ADStatusHud.draw) end
end
function ADSmartPickup:deleteMap()
if ADStatusHud ~= nil then pcall(ADStatusHud.delete) end
end
addModEventListener(ADSmartPickup)
+143 -23
View File
@@ -5,6 +5,13 @@
ADSources = {}
-- ADCache-opslag der også virker uden adCache (tests)
local function cacheGet(bucket, key, ttlMs, fn)
if ADCacheGet == nil then return fn() end
return ADCacheGet(bucket, key, ttlMs, fn)
end
ADSources.LOG_PREFIX = "ADSmartPickup: "
-- Objektlager tættere end dette på markøren regnes som markørens (samme som triggere).
ADSources.MAX_DISTANCE = 40
@@ -17,10 +24,6 @@ local function getHusbandryPalletSpawners(placeable)
return spec ~= nil and spec.fillTypeIndexToPalletSpawner or nil
end
local function firstSpawnerNode(spawner)
return spawner ~= nil and spawner.spawnPlaces ~= nil and spawner.spawnPlaces[1] ~= nil and spawner.spawnPlaces[1].startNode or nil
end
local function distance2(ax, az, bx, bz)
local dx, dz = ax - bx, az - bz
@@ -36,6 +39,8 @@ local function getNodePosition(node)
end
local function getPlaceableName(placeable, fallback)
local own = ADNames ~= nil and ADNames.forPlaceable(placeable) or nil
if own ~= nil then return own end
if placeable ~= nil and placeable.getName ~= nil then
local ok, name = pcall(placeable.getName, placeable)
if ok and name ~= nil then
@@ -123,7 +128,8 @@ local function findPlaceableNear(wayPoint, farmId, predicate)
end
end
end
return best
-- (afstand i meter som 2. værdi)
return best, best ~= nil and math.sqrt(bestDistance) or nil
end
local function isObjectStorage(placeable)
@@ -136,6 +142,12 @@ end
-- {kind = "factory"|"silo"|"objectStorage", placeable, station|nil, wayPointId, name} eller nil.
function ADSources.identify(adEnv, wayPointId, farmId)
return cacheGet("identify", tostring(wayPointId) .. ":" .. tostring(farmId), 10000, function()
return ADSources.identifyUncached(adEnv, wayPointId, farmId)
end)
end
function ADSources.identifyUncached(adEnv, wayPointId, farmId)
local wayPoint = adEnv.ADGraphManager:getWayPointById(wayPointId)
if wayPoint == nil then
return nil
@@ -164,9 +176,15 @@ function ADSources.identify(adEnv, wayPointId, farmId)
end
-- Det strenge opslag (trigger bag næsen) passer til silorækker; en mælketank eller fabrik ligger
-- ofte ved siden af markøren. Løst opslag inden for 40 m — men aldrig for siloer.
-- stald med egne pallespawnere (hønsehus: æg) — markøren står ved spawneren, ingen læssestation
local palletBarn, palletBarnDistance = findPlaceableNear(wayPoint, farmId, function(placeable) return placeable.getHusbandryFillLevel ~= nil and getHusbandryPalletSpawners(placeable) ~= nil end)
if placeable == nil and ADSmartPickup.getLoadStationNearWayPointId ~= nil then
local loose = ADSmartPickup.getLoadStationNearWayPointId(adEnv, wayPointId, farmId, ADSources.MAX_DISTANCE)
local loose, looseDistance = ADSmartPickup.getLoadStationNearWayPointId(adEnv, wayPointId, farmId, ADSources.MAX_DISTANCE)
local loosePlaceable = loose ~= nil and loose.owningPlaceable or nil
-- et nærmere hønsehus vinder over en fjernere læssestation (gyllestation 23 m fra "Æg Pickup 1")
if palletBarn ~= nil and looseDistance ~= nil and palletBarnDistance ~= nil and palletBarnDistance < looseDistance then
loosePlaceable = nil
end
if loosePlaceable ~= nil and loosePlaceable.spec_silo == nil then
if getProductionPoint(loosePlaceable) ~= nil then
return {kind = "factory", placeable = loosePlaceable, station = loose, wayPointId = wayPointId, name = getPlaceableName(loosePlaceable, markerName)}
@@ -175,8 +193,6 @@ function ADSources.identify(adEnv, wayPointId, farmId)
end
end
end
-- stald med egne pallespawnere (hønsehus: æg) — markøren står ved spawneren, ingen læssestation
local palletBarn = findPlaceableNear(wayPoint, farmId, function(placeable) return placeable.getHusbandryFillLevel ~= nil and getHusbandryPalletSpawners(placeable) ~= nil end)
if palletBarn ~= nil then
return {kind = "husbandry", placeable = palletBarn, station = nil, wayPointId = wayPointId, name = getPlaceableName(palletBarn, markerName)}
end
@@ -241,29 +257,99 @@ local function getPalletContent(vehicle)
return nil, 0
end
-- Liter af varen på paller der står ved fabrikkens spawner (pallespawneren tømmer lageret
-- ud på jorden løbende, så det er dér varen fysisk er).
local function getPalletsNearSpawner(placeable, fillType, farmId, spawnerNode)
local productionPoint = getProductionPoint(placeable)
local spawner = productionPoint ~= nil and productionPoint.palletSpawner or nil
local node = spawnerNode or (spawner ~= nil and spawner.spawnPlaces ~= nil and spawner.spawnPlaces[1] ~= nil and spawner.spawnPlaces[1].startNode) or placeable.rootNode
local sx, sz = getNodePosition(node)
if sx == nil then
return 0
-- Spawnerens område som segmenter {ax, az, bx, bz}: hver spawnPlace fra start til slut (startNode/endNode,
-- eller PlacementUtil-felterne startX/startZ + dirX/dirZ*width). Et hønsehus har to rækker; måles kun
-- til første række, ligger den anden række tættere på naboens start end husets egen.
local function spawnerSegments(spawner)
local segments = {}
for _, place in ipairs(spawner ~= nil and spawner.spawnPlaces or {}) do
local ax, az = getNodePosition(place.startNode)
local bx, bz = getNodePosition(place.endNode)
if ax == nil and place.startX ~= nil and place.startZ ~= nil then
ax, az = place.startX, place.startZ
if place.dirX ~= nil and place.dirZ ~= nil and place.width ~= nil then
bx, bz = ax + place.dirX * place.width, az + place.dirZ * place.width
end
end
if ax ~= nil then table.insert(segments, {ax, az, bx or ax, bz or az}) end
end
return segments
end
-- Kvadreret afstand fra (x, z) til nærmeste punkt på et af segmenterne.
local function segmentsDistance2(segments, x, z)
local best = math.huge
for _, s in ipairs(segments) do
local ax, az, bx, bz = s[1], s[2], s[3], s[4]
local dx, dz = bx - ax, bz - az
local len2 = dx * dx + dz * dz
local t = len2 > 0 and math.max(0, math.min(1, ((x - ax) * dx + (z - az) * dz) / len2)) or 0
local d = distance2(x, z, ax + dx * t, az + dz * t)
if d < best then best = d end
end
return best
end
-- Alle pallespawnere der kan lave varen (stalde med spawner for fillType, fabrikkers pallespawner) som
-- {placeable, segments}. Står bygningerne tættere end PALLET_RADIUS (hønsehuse på række), tilhører en
-- palle den spawner hvis område er nærmest — ellers tæller et tomt hus naboernes paller med.
local function findRivalSpawners(fillType)
local rivals = {}
local placeables = g_currentMission ~= nil and g_currentMission.placeableSystem ~= nil and g_currentMission.placeableSystem.placeables or {}
for _, placeable in pairs(placeables) do
local husbandrySpawners = getHusbandryPalletSpawners(placeable)
local productionPoint = getProductionPoint(placeable)
local spawner = husbandrySpawners ~= nil and husbandrySpawners[fillType] or (productionPoint ~= nil and productionPoint.palletSpawner or nil)
local segments = spawnerSegments(spawner)
if #segments > 0 then table.insert(rivals, {placeable = placeable, segments = segments}) end
end
return rivals
end
local function isNearestSpawner(rivals, placeable, own, x, z)
for _, rival in ipairs(rivals) do
if rival.placeable ~= placeable and segmentsDistance2(rival.segments, x, z) < own then return false end
end
return true
end
ADSources.lastPalletNote = {}
-- Liter af varen på paller ved spawneren (pallespawneren tømmer lageret ud på jorden løbende, så
-- det er dér varen fysisk er). spawner = staldens spawner for varen; nil = fabrikkens pallespawner.
local function getPalletsNearSpawner(placeable, fillType, farmId, spawner)
local productionPoint = getProductionPoint(placeable)
local segments = spawnerSegments(spawner or (productionPoint ~= nil and productionPoint.palletSpawner or nil))
if #segments == 0 then
local rx, rz = getNodePosition(placeable.rootNode)
if rx == nil then return 0, 0 end
segments = {{rx, rz, rx, rz}}
end
local rivals = cacheGet("palletSpawners", fillType, 10000, function() return findRivalSpawners(fillType) end)
local vehicles = g_currentMission ~= nil and g_currentMission.vehicleSystem ~= nil and g_currentMission.vehicleSystem.vehicles or {}
local total, count = 0, 0
local total, count, lost = 0, 0, 0
local radius2 = ADSources.PALLET_RADIUS * ADSources.PALLET_RADIUS
for _, vehicle in pairs(vehicles) do
if isPalletVehicle(vehicle) and (vehicle.getOwnerFarmId == nil or vehicle:getOwnerFarmId() == farmId) then
local palletFillType, level = getPalletContent(vehicle)
local x, z = getNodePosition(vehicle.rootNode)
if palletFillType == fillType and x ~= nil and distance2(sx, sz, x, z) <= radius2 then
local own = (palletFillType == fillType and x ~= nil) and segmentsDistance2(segments, x, z) or math.huge
if own <= radius2 then
if isNearestSpawner(rivals, placeable, own, x, z) then
total = total + level
count = count + 1
else
lost = lost + 1
end
end
end
end
-- diagnose: paller inden for rækkevidde, men alle tildelt en nabo (højst én gang pr. minut pr. bygning)
local now = g_currentMission ~= nil and g_currentMission.time or nil
if count == 0 and lost > 0 and now ~= nil and (ADSources.lastPalletNote[placeable] or -math.huge) + 60000 < now then
ADSources.lastPalletNote[placeable] = now
Logging.info(ADSources.LOG_PREFIX .. "%s: %d paller inden for %d m tilhører nabo-spawnere (0 egne)", getPlaceableName(placeable, "?"), lost, ADSources.PALLET_RADIUS)
end
return total, count
end
@@ -395,7 +481,7 @@ local function getHusbandryPalletProducts(source, farmId)
local products = {}
local spec = source.placeable.spec_husbandryPallets
for fillType, spawner in pairs(getHusbandryPalletSpawners(source.placeable) or {}) do
local liters, count = getPalletsNearSpawner(source.placeable, fillType, farmId, firstSpawnerNode(spawner))
local liters, count = getPalletsNearSpawner(source.placeable, fillType, farmId, spawner)
local capacity = spec ~= nil and spec.capacities ~= nil and spec.capacities[fillType] or 0
local product = newProduct(fillType, liters, liters, math.max(capacity or 0, liters), true)
product.palletLiters, product.palletCount = liters, count
@@ -552,6 +638,10 @@ end
-- Markører hvis aflæsningsstation er et rigtigt salgssted: { {marker, station} }.
function ADSources.getSellMarkers(adEnv, farmId)
return cacheGet("sellMarkers", tostring(farmId), 10000, function() return ADSources.findSellMarkers(adEnv, farmId) end)
end
function ADSources.findSellMarkers(adEnv, farmId)
local result = {}
for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do
local station = ADSmartPickup.getUnloadStationAtWayPointId(adEnv, marker.id, farmId)
@@ -852,16 +942,17 @@ local function addAnimalProduction(rates, placeable)
end
-- {[fillType] = {consumption, production}} i liter pr. døgn for gårdens fabrikker og stalde.
function ADSources.getFarmRates(adEnv, farmId)
-- feedOnly: kun staldenes forbrug (foder, halm, vand) — marksilo-flowets foderreserve.
function ADSources.getFarmRates(adEnv, farmId, feedOnly)
local placeables = g_currentMission ~= nil and g_currentMission.placeableSystem ~= nil and g_currentMission.placeableSystem.placeables or {}
local rates = {}
for _, placeable in pairs(placeables) do
if isOwnedBy(placeable, farmId) then
if getProductionPoint(placeable) ~= nil then
addFactoryRates(rates, placeable)
if not feedOnly then addFactoryRates(rates, placeable) end
elseif placeable.getHusbandryFillLevel ~= nil then
addHusbandryRates(rates, placeable)
pcall(addAnimalProduction, rates, placeable)
if not feedOnly then pcall(addAnimalProduction, rates, placeable) end
end
end
end
@@ -872,6 +963,31 @@ function ADSources.getFarmRates(adEnv, farmId)
return rates
end
-- Én stalds forbrug (foder efter trugets indhold, halm, vand): {[fillType] = liter} kun for consumption > 0.
-- Rå rate uden timeAdjustment (som getHusbandryOutputs).
function ADSources.getHusbandryConsumption(placeable)
local rates, result = {}, {}
if placeable == nil then return result end
pcall(addHusbandryRates, rates, placeable)
for fillType, rate in pairs(rates) do
if (rate.consumption or 0) > 0 then result[fillType] = rate.consumption end
end
return result
end
-- Én stalds produktion (mælk, gødning, ...): {[fillType] = liter} kun for production > 0. Spillets rå rate
-- (pr. måned = én 24-timers dag) UDEN timeAdjustment — samme enhed som fabrikkernes cyklusser pr. måned i kæden.
function ADSources.getHusbandryOutputs(placeable)
local rates, outputs = {}, {}
if placeable == nil then return outputs end
pcall(addHusbandryRates, rates, placeable) -- giver kun forbrug; med for fuldstændighedens skyld
pcall(addAnimalProduction, rates, placeable)
for fillType, rate in pairs(rates) do
if (rate.production or 0) > 0 then outputs[fillType] = rate.production end
end
return outputs
end
-- Bedste pris lige nu blandt alle spillets salgssteder der tager varen (uanset markør/vejnet):
-- {price, top} pr. liter; top = prisen ved årets højeste periode. nil når ingen tager varen.
function ADSources.getMarketQuote(fillType)
@@ -999,6 +1115,10 @@ end
-- { {name, kind = "factory"|"silo"|"objectStorage"|"husbandry", entries = {{fillType, name, title, level, capacity, isOutput, count}}} }
-- kun gårdens bygninger med indhold; sorteret på slags, dernæst navn.
function ADSources.getFarmInventory(adEnv, farmId)
return cacheGet("inventory", tostring(farmId), 2000, function() return ADSources.buildFarmInventory(adEnv, farmId) end)
end
function ADSources.buildFarmInventory(adEnv, farmId)
local placeables = g_currentMission ~= nil and g_currentMission.placeableSystem ~= nil and g_currentMission.placeableSystem.placeables or {}
local result = {}
for _, placeable in pairs(placeables) do
+473
View File
@@ -0,0 +1,473 @@
-- AD Status Hud
-- Statusvinduet på skærmen (i stil med Simple Inspector): slås til/fra med input-handlingen SPU_TOGGLE_STATUS
-- (standard numpad *). Øjebliksbilledet bygges af modens egne statusfunktioner (samme som fanerne) højst
-- hvert sekund i update() og kun mens vinduet er tændt; draw() tegner kun de cachede linjer og bredder.
-- Synligheden gemmes pr. bruger i modSettings/FS25_ADSmartPickup/status.xml (standard skjult).
-- Kører kun på en klient (ikke dedikeret server). Alle spilkald er beskyttet; fejl logges én gang pr. nøgle.
ADStatusHud = {}
ADStatusHud.LOG_PREFIX = "ADSmartPickup: status: "
ADStatusHud.REFRESH_MS = 1000
ADStatusHud.STUCK_WARN_MS = 120000 -- stået stille så længe (uden at Smart Pickup holder den) = advarsel
ADStatusHud.TEXT_SIZE_PX = 14
ADStatusHud.MARGIN_PX = 8
ADStatusHud.SCREEN_OFFSET_PX = 16 -- afstand til skærmens højre kant
ADStatusHud.TOP_Y = 1 - 0.12 -- øverste kant (normaliseret); under spillets info-boks
ADStatusHud.SETTINGS_DIR = "modSettings/FS25_ADSmartPickup/"
ADStatusHud.SETTINGS_FILE = "status.xml"
ADStatusHud.SETTINGS_ROOT = "adSmartPickupStatus"
ADStatusHud.HEADER_COLOR = {1, 1, 1, 1}
ADStatusHud.visible = false
ADStatusHud.lines = {} -- {{text, level, header, color, width}}
ADStatusHud.layout = nil -- {x, y, width, height, textSize, marginX, marginY, padding}
local timerMs = 0
local settingsLoaded = false
local overlays = nil
local loggedErrors = {}
local registerToggleActionRef -- sat nederst (tastens handler til krogen)
-- ---- hjælpere ----
local function logOnce(key, err)
if loggedErrors[key] then return end
loggedErrors[key] = true
if Logging ~= nil and Logging.warning ~= nil then
Logging.warning(ADStatusHud.LOG_PREFIX .. "%s fejlede: %s", tostring(key), tostring(err))
end
end
local function guarded(key, fn, ...)
local ok, result = pcall(fn, ...)
if not ok then
logOnce(key, result)
return nil
end
return result
end
local function textOf(key)
if g_i18n == nil then return key end
local ok, text = pcall(g_i18n.getText, g_i18n, key)
return ok and text or key
end
function ADStatusHud.isClient()
local mission = g_currentMission
if mission == nil or mission.getIsClient == nil then return false end
local ok, isClient = pcall(mission.getIsClient, mission)
return ok and isClient == true
end
-- Fanernes egne tekst-/statusfunktioner ligger som metoder på SmartPickupFrame og bruger kun self.i18n:
-- en let "frame" med spillets i18n genbruger dem uden at røre menuen. "Start afvist" (lastStartFailed, rødt flow)
-- huskes på den rigtige ESC-fane (ADSmartPickup.guiFrame): tabellen deles, så vinduet viser samme farve som
-- Flows-listen (flowLevel rydder en afvisning når flowet kører — det samme som fanen selv gør).
local function frameProxy()
if SmartPickupFrame == nil then return nil end
local real = ADSmartPickup ~= nil and ADSmartPickup.guiFrame or nil
local failed = {}
if type(real) == "table" then
if type(real.lastStartFailed) ~= "table" then real.lastStartFailed = {} end
failed = real.lastStartFailed
end
return setmetatable({i18n = g_i18n, lastStartFailed = failed}, {__index = SmartPickupFrame})
end
local function fillTitle(fillType)
if fillType == nil or g_fillTypeManager == nil then return "?" end
local title = g_fillTypeManager.getFillTypeTitleByIndex ~= nil and g_fillTypeManager:getFillTypeTitleByIndex(fillType)
or g_fillTypeManager:getFillTypeNameByIndex(fillType)
return tostring(title or "?")
end
-- ---- øjebliksbillede ----
local WAGON_ACTIONS = {
fetching = {key = "spu_status_act_fetchWagon", level = "waiting"},
waiting = {key = "spu_status_act_waitWagon", level = "waiting"},
emptying = {key = "spu_status_act_emptying", level = "running"},
homing = {key = "spu_status_act_homing", level = "running"},
}
-- Traktorens handling som kort tekst + niveau (ADRunsController.getVehicleActivity som Flows-fanen).
local function vehicleAction(adEnv, vehicle)
local wagonState = ADFlowWagons ~= nil and ADFlowWagons.statusOf ~= nil and ADFlowWagons.statusOf(vehicle) or nil
if wagonState ~= nil and WAGON_ACTIONS[wagonState] ~= nil then
return textOf(WAGON_ACTIONS[wagonState].key), WAGON_ACTIONS[wagonState].level
end
local activity = ADRunsController.getVehicleActivity(adEnv, vehicle)
local place = tostring(activity.place or "?")
if activity.kind == "stopped" then
return textOf("spu_activity_stopped"), "stopped"
elseif activity.kind == "waiting" then
return string.format(textOf("spu_status_act_waiting"), place), "waiting"
elseif activity.kind == "holding" then
return string.format(textOf("spu_status_act_holding"), place), "waiting"
elseif activity.kind == "empty" and activity.fillType ~= nil then
return string.format(textOf("spu_status_act_fetch"), fillTitle(activity.fillType), place), "running"
end
return string.format(textOf("spu_status_act_driving"), place), "running"
end
-- Fyld i % af hele vogntoget (AutoDrives egne opgørelser, som kildevalget bruger); nil uden kapacitet.
local function fillPercent(adEnv, vehicle)
local autoDrive = adEnv.AutoDrive
if autoDrive == nil or autoDrive.getAllUnits == nil or autoDrive.getAllFillLevels == nil then return nil end
local level, capacity = autoDrive.getAllFillLevels(autoDrive.getAllUnits(vehicle))
if type(level) ~= "number" or type(capacity) ~= "number" or capacity <= 0 then return nil end
return math.max(0, math.min(100, level / capacity * 100))
end
local function farmVehiclesById(adEnv)
local byId = {}
for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do
local id = ADRunsController.getVehicleId(vehicle)
if id ~= nil then byId[id] = vehicle end
end
return byId
end
-- Én linje pr. traktor i et kørende flow (flows der kan stoppes: startet, eller holder vogn/henter).
function ADStatusHud.flowEntries(adEnv, proxy)
local entries = {}
if ADRunsController == nil or ADRunsController.store == nil or ADFlowView == nil then return entries end
local byId = farmVehiclesById(adEnv)
for _, run in ipairs(ADFlowView.orderedRuns(ADRunsController.store.runs or {})) do
local status = ADRunsController.getRunStatus(adEnv, run)
if status.canStop then
local runLevel = proxy ~= nil and proxy.flowLevel ~= nil and guarded("flowLevel", proxy.flowLevel, proxy, adEnv, run) or nil
for _, vehicleId in ipairs(run.vehicleIds or {}) do
local vehicle = byId[vehicleId]
if vehicle ~= nil then
local action, level = vehicleAction(adEnv, vehicle)
-- farven følger flowets niveau som Flows-listen (venter/fejl), ellers traktorens egen handling
if runLevel == "error" or runLevel == "waiting" then level = runLevel end
table.insert(entries, {vehicle = tostring(ADRuns.vehicleLabel(vehicle)), action = action,
fillPercent = guarded("fillPercent", fillPercent, adEnv, vehicle), level = level})
end
end
end
end
return entries
end
-- Én linje pr. mark med aktivt markarbejde (samme statustekst og farve som Marker-fanen).
function ADStatusHud.fieldEntries(proxy)
local entries = {}
if ADFields == nil or ADFieldJobs == nil or ADFieldModel == nil or proxy == nil then return entries end
for _, info in ipairs(ADFields.list()) do
local field = ADFieldJobs.getField(info.id)
if field ~= nil and (ADFieldModel.isWorking(field) or ADFieldJobs.awaitingPlayer(info.id) ~= nil) then
local names = {}
for _, assignment in ipairs(ADFieldJobs.assignmentsFor(info.id)) do
table.insert(names, ADRuns.vehicleLabel(assignment.vehicle))
end
table.insert(entries, {
fieldId = info.id,
work = proxy:fieldStatusText(field, info, true),
vehicle = #names > 0 and ADFlowView.joinCounted(names, ", ", nil) or nil,
progress = SmartPickupFrame.monthCpProgress ~= nil and guarded("fieldProgress", SmartPickupFrame.monthCpProgress, info.id) or nil,
level = proxy:fieldLevel(info.id),
})
end
end
return entries
end
-- Kæder med manglende led + ventende forslag + slukkede nødvendige fabrikker (samme punkter og tekster som
-- "Kræver handling"; de to sidste står under Advarsler).
function ADStatusHud.chainEntries(proxy)
local chains, suggestions = {}, {}
local result = {chains = chains, suggestions = suggestions, dependencies = {}}
if ADChain == nil or ADChain.last == nil or ADStorageStats == nil or ADStorageStats.chainEntries == nil or proxy == nil then
return result
end
for _, entry in ipairs(ADStorageStats.chainEntries(ADChain.last)) do
if entry.kind == "chainProblem" then
table.insert(chains, {text = proxy:alertText(entry), level = "error"})
elseif entry.kind == "chainSuggest" then
table.insert(suggestions, {text = proxy:alertText(entry), level = "waiting"})
elseif entry.kind == "dependencyOff" then
table.insert(result.dependencies, {text = proxy:alertText(entry), level = "error"})
end
end
return result
end
-- Solgt/slettet køretøj (ADStuckDiag kan holde det et øjeblik efter salget).
local function isGone(vehicle)
if vehicle == nil or vehicle.isDeleted then return true end
if vehicle.rootNode == nil then return true end
return entityExists ~= nil and not entityExists(vehicle.rootNode)
end
-- Gårdens køretøjer der har stået stille over grænsen, uden at Smart Pickup selv holder dem (Wait/udkørsel).
function ADStatusHud.stuckEntries()
local entries = {}
if ADStuckDiag == nil or ADStuckDiag.stillVehicles == nil then return entries end
local farmId = g_currentMission ~= nil and g_currentMission.getFarmId ~= nil and g_currentMission:getFarmId() or nil
for _, still in ipairs(ADStuckDiag.stillVehicles(ADStatusHud.STUCK_WARN_MS)) do
local vehicle = still.vehicle
local owned = not isGone(vehicle) and (farmId == nil or (vehicle.getOwnerFarmId ~= nil and vehicle:getOwnerFarmId() == farmId))
local held = owned and ((ADUnloadWait ~= nil and ADUnloadWait.getWaitFor ~= nil and ADUnloadWait.getWaitFor(vehicle) ~= nil)
or (ADOutbound ~= nil and ADOutbound.getHoldFor ~= nil and ADOutbound.getHoldFor(vehicle) ~= nil))
if owned and not held then
table.insert(entries, {text = string.format(textOf("spu_status_stuck"), tostring(ADRuns.vehicleLabel(vehicle)),
math.floor(still.ms / 60000)), level = "error"})
end
end
return entries
end
function ADStatusHud.snapshot(adEnv)
local proxy = frameProxy()
local snapshot = {flows = {}, fields = {}, chains = {}, warnings = {}}
if adEnv ~= nil then
snapshot.flows = guarded("flows", ADStatusHud.flowEntries, adEnv, proxy) or {}
end
snapshot.fields = guarded("fields", ADStatusHud.fieldEntries, proxy) or {}
local chainResult = guarded("chains", ADStatusHud.chainEntries, proxy) or {}
snapshot.chains = chainResult.chains or {}
for _, entry in ipairs(chainResult.dependencies or {}) do table.insert(snapshot.warnings, entry) end
for _, entry in ipairs(guarded("stuck", ADStatusHud.stuckEntries) or {}) do table.insert(snapshot.warnings, entry) end
for _, entry in ipairs(chainResult.suggestions or {}) do table.insert(snapshot.warnings, entry) end
return snapshot
end
-- ---- layout og cache (kun i update) ----
local function gameInfoDisplay()
local hud = g_currentMission ~= nil and g_currentMission.hud or nil
return hud ~= nil and hud.gameInfoDisplay or nil
end
-- Størrelser i skærmkoordinater, skaleret som spillets egen HUD (Simple Inspector-mønster).
local function measureUnits()
local display = gameInfoDisplay()
if display == nil or display.scalePixelToScreenHeight == nil or display.scalePixelToScreenVector == nil then return nil end
local textSize = display:scalePixelToScreenHeight(ADStatusHud.TEXT_SIZE_PX)
local marginX, marginY = display:scalePixelToScreenVector({ADStatusHud.MARGIN_PX, ADStatusHud.MARGIN_PX})
local offsetX = display:scalePixelToScreenVector({ADStatusHud.SCREEN_OFFSET_PX, 0})
return textSize, marginX, marginY, offsetX
end
-- Øverste kant: fast under info-boksen; ligger info-boksens position lavere, rykkes vinduet ned under den.
local function topY(marginY)
local top = ADStatusHud.TOP_Y
local display = gameInfoDisplay()
if display ~= nil and display.getPosition ~= nil then
local ok, _, y = pcall(display.getPosition, display)
if ok and type(y) == "number" and y > 0.5 and y < 1 then top = math.min(top, y - marginY) end
end
return top
end
function ADStatusHud.rebuild(adEnv)
local lines = ADStatusView.lines(ADStatusHud.snapshot(adEnv), textOf, ADStatusView.MAX_LINES)
local textSize, marginX, marginY, offsetX = measureUnits()
if textSize == nil then
-- spillets info-boks (skalering) findes ikke: vinduet kan ikke måles og tegnes
logOnce("measure", "gameInfoDisplay mangler eller kan ikke skalere")
ADStatusHud.lines, ADStatusHud.layout = {}, nil
return
end
local maxWidth = 0
for _, line in ipairs(lines) do
setTextBold(line.header == true)
line.width = getTextWidth(textSize, line.text)
line.color = line.header and ADStatusHud.HEADER_COLOR or (ADFlowView.LEVEL_COLORS[line.level] or ADStatusHud.HEADER_COLOR)
if line.width > maxWidth then maxWidth = line.width end
end
setTextBold(false)
local width = maxWidth + 2 * marginX
local height = #lines * textSize + 2 * marginY
local top = topY(marginY)
ADStatusHud.lines = lines
ADStatusHud.layout = {x = 1 - offsetX - width, y = top - height, top = top, width = width, height = height,
textSize = textSize, marginX = marginX, marginY = marginY, padding = marginX * 0.5}
end
-- ---- overlays ----
local function createOverlays()
if overlays ~= nil then return overlays end
if g_overlayManager == nil or HUD == nil or HUD.COLOR == nil or HUD.COLOR.BACKGROUND == nil then return nil end
local r, g, b, a = unpack(HUD.COLOR.BACKGROUND)
local created = {
middle = g_overlayManager:createOverlay("gui.gameInfo_middle", 0, 0, 0, 0),
left = g_overlayManager:createOverlay("gui.gameInfo_left", 0, 0, 0, 0),
right = g_overlayManager:createOverlay("gui.gameInfo_right", 0, 0, 0, 0),
}
for _, overlay in pairs(created) do overlay:setColor(r, g, b, a) end
overlays = created
return overlays
end
local function deleteOverlays()
if overlays == nil then return end
for _, overlay in pairs(overlays) do
if overlay.delete ~= nil then pcall(overlay.delete, overlay) end
end
overlays = nil
end
-- ---- indstillinger (pr. bruger, ikke i savegamet) ----
local function settingsPath()
if getUserProfileAppPath == nil then return nil end
return getUserProfileAppPath() .. ADStatusHud.SETTINGS_DIR .. ADStatusHud.SETTINGS_FILE
end
function ADStatusHud.loadSettings()
settingsLoaded = true
local path = settingsPath()
if path == nil or fileExists == nil or not fileExists(path) then return end
local xmlFile = XMLFile.load("ADSmartPickupStatus", path)
if xmlFile == nil then return end
ADStatusHud.visible = xmlFile:getBool(ADStatusHud.SETTINGS_ROOT .. "#visible", false) == true
xmlFile:delete()
end
function ADStatusHud.saveSettings()
local path = settingsPath()
if path == nil then return end
if createFolder ~= nil then
createFolder(getUserProfileAppPath() .. "modSettings/")
createFolder(getUserProfileAppPath() .. ADStatusHud.SETTINGS_DIR)
end
local xmlFile = XMLFile.create("ADSmartPickupStatus", path, ADStatusHud.SETTINGS_ROOT)
if xmlFile == nil then
logOnce("saveSettings", "kunne ikke oprette " .. path)
return
end
xmlFile:setBool(ADStatusHud.SETTINGS_ROOT .. "#visible", ADStatusHud.visible == true)
xmlFile:save()
xmlFile:delete()
end
-- ---- offentlige kald fra moden ----
function ADStatusHud.toggle()
ADStatusHud.visible = not ADStatusHud.visible
timerMs = ADStatusHud.REFRESH_MS -- byg straks ved næste update
if not ADStatusHud.visible then ADStatusHud.lines, ADStatusHud.layout = {}, nil end
guarded("saveSettings", ADStatusHud.saveSettings)
end
-- Action-event-callback (target = ADStatusHud).
function ADStatusHud.onToggleAction()
ADStatusHud.toggle()
end
function ADStatusHud.update(dt, adEnv)
if not ADStatusHud.isClient() then return end
if not settingsLoaded then
guarded("loadSettings", ADStatusHud.loadSettings)
timerMs = ADStatusHud.REFRESH_MS
end
if not ADStatusHud.visible then return end
timerMs = timerMs + (dt or 0)
if timerMs < ADStatusHud.REFRESH_MS then return end
timerMs = 0
guarded("rebuild", ADStatusHud.rebuild, adEnv)
end
-- Tegnes ikke ved åben menu/dialog, skjult HUD, søvn eller åben chat.
local function shouldSkipDraw()
if g_gui ~= nil and g_gui:getIsGuiVisible() then return true end
if g_noHudModeEnabled then return true end
local mission = g_currentMission
local hud = mission ~= nil and mission.hud or nil
if hud == nil or hud.isVisible == false then return true end
if g_sleepManager ~= nil and g_sleepManager.getIsSleeping ~= nil and g_sleepManager:getIsSleeping() then return true end
if hud.chatDisplay ~= nil and hud.chatDisplay.getVisible ~= nil and hud.chatDisplay:getVisible() then return true end
return false
end
local function render(layout, bg)
bg.middle:setDimension(layout.width, layout.height)
bg.middle:setPosition(layout.x, layout.y)
bg.middle:render()
bg.left:setDimension(layout.padding, layout.height)
bg.left:setPosition(layout.x - layout.padding, layout.y)
bg.left:render()
bg.right:setDimension(layout.padding, layout.height)
bg.right:setPosition(layout.x + layout.width, layout.y)
bg.right:render()
setTextAlignment(RenderText.ALIGN_LEFT)
setTextVerticalAlignment(RenderText.VERTICAL_ALIGN_TOP)
local textX, textY = layout.x + layout.marginX, layout.top - layout.marginY
for _, line in ipairs(ADStatusHud.lines) do
local color = line.color
setTextBold(line.header == true)
setTextColor(color[1], color[2], color[3], color[4])
renderText(textX, textY, layout.textSize, line.text)
textY = textY - layout.textSize
end
end
function ADStatusHud.draw()
if not ADStatusHud.visible or ADStatusHud.layout == nil or #ADStatusHud.lines == 0 then return end
if shouldSkipDraw() then return end
local ok0, bg = pcall(createOverlays)
if not ok0 or bg == nil then
-- uden baggrund tegnes intet (g_overlayManager/HUD.COLOR mangler eller createOverlay fejlede)
logOnce("overlays", ok0 and "g_overlayManager eller HUD.COLOR.BACKGROUND mangler" or bg)
return
end
local ok, err = pcall(render, ADStatusHud.layout, bg)
-- tekststate nulstilles altid, også efter en fejl (andre mods/spillet tegner videre med den)
setTextColor(1, 1, 1, 1)
setTextAlignment(RenderText.ALIGN_LEFT)
setTextVerticalAlignment(RenderText.VERTICAL_ALIGN_BASELINE)
setTextBold(false)
if not ok then logOnce("draw", err) end
end
function ADStatusHud.delete()
deleteOverlays()
if PlayerInputComponent ~= nil and PlayerInputComponent.spuStatusToggleHandler == registerToggleActionRef then
PlayerInputComponent.spuStatusToggleHandler = nil
end
ADStatusHud.lines, ADStatusHud.layout = {}, nil
settingsLoaded = false
timerMs = 0
end
-- ---- tast: global spiller-handling (til fods og i køretøjer) ----
-- PlayerInputComponent.registerGlobalPlayerActionEvents kaldes ved hvert kontekstskift (til fods / i køretøj);
-- samme mønster som FS25_Courseplay (CP_OPEN_INGAME_MENU) og FS25_EasyDevControls. Hjælpeteksten skjules i F1.
local function registerToggleAction(component)
if not ADStatusHud.isClient() then return end
local player = component ~= nil and component.player or nil
if player ~= nil and player.isOwner == false then return end
if g_inputBinding == nil or InputAction == nil or InputAction.SPU_TOGGLE_STATUS == nil then return end
local _, eventId = g_inputBinding:registerActionEvent(InputAction.SPU_TOGGLE_STATUS, ADStatusHud, ADStatusHud.onToggleAction,
false, true, false, true)
if eventId ~= nil then g_inputBinding:setActionEventTextVisibility(eventId, false) end
end
registerToggleActionRef = function(component) guarded("registerAction", registerToggleAction, component) end
-- Spillets klasse overlever en genindlæsning af savegamet, moden gør ikke: krogen lægges kun én gang (markør på
-- klassen) og kalder den aktuelle indlæsnings handler, så en gammel ADStatusHud aldrig registrerer tasten igen.
function ADStatusHud.installInputHook()
if PlayerInputComponent == nil or PlayerInputComponent.registerGlobalPlayerActionEvents == nil or Utils == nil then return false end
PlayerInputComponent.spuStatusToggleHandler = registerToggleActionRef
if PlayerInputComponent.spuStatusToggleHooked then return true end
PlayerInputComponent.registerGlobalPlayerActionEvents = Utils.appendedFunction(PlayerInputComponent.registerGlobalPlayerActionEvents,
function(component, ...)
local handler = PlayerInputComponent.spuStatusToggleHandler
if handler ~= nil then handler(component) end
end)
PlayerInputComponent.spuStatusToggleHooked = true
return true
end
if PlayerInputComponent ~= nil and Utils ~= nil then
ADStatusHud.installInputHook()
end
+143
View File
@@ -0,0 +1,143 @@
-- AD Status View
-- Ren logik for statusvinduet på skærmen (slås til/fra med en tast, se adStatusHud.lua): øjebliksbilledet
-- -> færdige linjer {text, level, header}. Sektioner i fast rækkefølge Flows/Marker/Kæder/Advarsler, kun med
-- indhold; højst maxLines linjer (resten "+k mere"); hver tekst højst MAX_CHARS tegn (UTF-8-sikkert, "..").
--
-- snapshot = {
-- flows = {{vehicle, action, fillPercent (0-100|nil), level}},
-- fields = {{fieldId, work, vehicle|nil, progress (0-1|nil), level}},
-- chains = {{text, level}}, -- standard rød
-- warnings = {{text, level}}, -- standard gul
-- }
-- level = "running"|"waiting"|"error"|"stopped" (farver: ADFlowView.LEVEL_COLORS); overskrifter har level "header".
ADStatusView = {}
ADStatusView.MAX_LINES = 18
ADStatusView.MAX_CHARS = 48
ADStatusView.VEHICLE_CHARS = 18 -- traktornavnet må ikke æde hele linjen
ADStatusView.MIN_FLEX_CHARS = 6
ADStatusView.SEPARATOR = " · "
ADStatusView.ELLIPSIS = ".."
local LEVELS = {running = true, waiting = true, error = true, stopped = true}
local SECTIONS = {
{key = "flows", header = "spu_status_flows", defaultLevel = "running"},
{key = "fields", header = "spu_status_fields", defaultLevel = "running"},
{key = "chains", header = "spu_status_chains", defaultLevel = "error"},
{key = "warnings", header = "spu_status_warnings", defaultLevel = "waiting"},
}
-- ---- UTF-8-sikker længde/afkortning (æøå er flere bytes) ----
function ADStatusView.textLength(text)
local count = 0
for _ in tostring(text):gmatch("[^\128-\191]") do count = count + 1 end
return count
end
local function utf8Prefix(text, chars)
local result, count = {}, 0
for char in text:gmatch("[^\128-\191][\128-\191]*") do
if count >= chars then break end
table.insert(result, char)
count = count + 1
end
return table.concat(result)
end
function ADStatusView.fit(text, chars)
text = tostring(text or "")
if ADStatusView.textLength(text) <= chars then return text end
local ellipsis = ADStatusView.ELLIPSIS
return utf8Prefix(text, math.max(0, chars - ADStatusView.textLength(ellipsis))) .. ellipsis
end
-- Dele samlet med " · "; delen på flexIndex får den plads der er tilbage, så fx fyldprocenten bevares.
local function joinFitted(parts, flexIndex)
local sep = ADStatusView.SEPARATOR
local fixed = ADStatusView.textLength(sep) * (#parts - 1)
for index, part in ipairs(parts) do
if index ~= flexIndex then fixed = fixed + ADStatusView.textLength(part) end
end
local budget = math.max(ADStatusView.MIN_FLEX_CHARS, ADStatusView.MAX_CHARS - fixed)
local fitted = {}
for index, part in ipairs(parts) do
fitted[index] = index == flexIndex and ADStatusView.fit(part, budget) or part
end
return ADStatusView.fit(table.concat(fitted, sep), ADStatusView.MAX_CHARS)
end
local function percentText(value)
return string.format("%d %%", math.floor(value + 0.5))
end
-- ---- linjer pr. sektion ----
local function flowText(entry)
local parts = {ADStatusView.fit(entry.vehicle or "?", ADStatusView.VEHICLE_CHARS), tostring(entry.action or "")}
if type(entry.fillPercent) == "number" then table.insert(parts, percentText(entry.fillPercent)) end
return joinFitted(parts, 2)
end
local function fieldText(entry)
local parts = {string.format("F%s %s", tostring(entry.fieldId or "?"), tostring(entry.work or ""))}
if entry.vehicle ~= nil and entry.vehicle ~= "" then
table.insert(parts, ADStatusView.fit(entry.vehicle, ADStatusView.VEHICLE_CHARS))
end
if type(entry.progress) == "number" then table.insert(parts, percentText(entry.progress * 100)) end
return joinFitted(parts, 1)
end
local function plainText(entry)
return ADStatusView.fit(entry.text or "", ADStatusView.MAX_CHARS)
end
local TEXT_OF_SECTION = {flows = flowText, fields = fieldText, chains = plainText, warnings = plainText}
local function levelOf(entry, default)
return LEVELS[entry.level] and entry.level or default
end
-- Alle linjer uden loft.
local function allLines(snapshot, textOf)
local lines = {}
for _, section in ipairs(SECTIONS) do
local entries = snapshot[section.key] or {}
if #entries > 0 then
table.insert(lines, {text = ADStatusView.fit(textOf(section.header), ADStatusView.MAX_CHARS), level = "header", header = true})
for _, entry in ipairs(entries) do
table.insert(lines, {text = TEXT_OF_SECTION[section.key](entry), level = levelOf(entry, section.defaultLevel), header = false})
end
end
end
return lines
end
-- Afkort til maxLines: sidste plads bliver "+k mere" (k = skjulte indholdslinjer); en overskrift uden
-- indhold lige før afkortningen droppes.
local function capLines(lines, maxLines, textOf)
if #lines <= maxLines then return lines end
local kept = {}
for index = 1, math.max(0, maxLines - 1) do kept[index] = lines[index] end
while #kept > 0 and kept[#kept].header do table.remove(kept) end
local hidden = 0
for index = #kept + 1, #lines do
if not lines[index].header then hidden = hidden + 1 end
end
local ok, text = pcall(string.format, textOf("spu_status_more"), hidden)
table.insert(kept, {text = ADStatusView.fit(ok and text or ("+" .. hidden), ADStatusView.MAX_CHARS), level = "stopped", header = false})
return kept
end
function ADStatusView.lines(snapshot, textOf, maxLines)
snapshot = snapshot or {}
textOf = textOf or function(key) return key end
maxLines = math.max(1, math.floor(maxLines or ADStatusView.MAX_LINES))
local lines = allLines(snapshot, textOf)
if #lines == 0 then
return {{text = ADStatusView.fit(textOf("spu_status_idle"), ADStatusView.MAX_CHARS), level = "stopped", header = false}}
end
return capLines(lines, maxLines, textOf)
end
+311
View File
@@ -108,3 +108,314 @@ function ADStorageStats.filterKind(inventory, kind)
end
return result
end
-- ---- vareoversigt (v1.52): én række pr. vare, der folder sig ud til bygningerne ----
-- Tærskler for farverne: døgn til tom (gårdens netto), fyldning i % (produceret vare / stald-trug).
ADStorageStats.DAYS_RED, ADStorageStats.DAYS_YELLOW = 1, 3
ADStorageStats.FULL_RED, ADStorageStats.FULL_YELLOW = 95, 85
ADStorageStats.BARN_LOW_RED, ADStorageStats.BARN_LOW_YELLOW = 10, 25
-- "Sælg nu": prisen er mindst 90 % af årets top, og partiet er værd at køre med
ADStorageStats.SELL_RATIO, ADStorageStats.SELL_MIN_VALUE = 0.9, 10000
local function percentOf(level, capacity)
return (capacity or 0) > 0 and math.floor(100 * (level or 0) / capacity + 0.5) or nil
end
-- inventory (fanens bygninger) -> { {fillType, title, level, capacity, percent, count, isOutput,
-- buildings = {{name, placeable, level, capacity, percent, count, slots, isOutput}}} } — varer efter titel,
-- bygninger med mest først. count (paller) summeres kun når alle bygninger melder paller.
function ADStorageStats.groupByFillType(inventory, titleOf)
local byType, groups = {}, {}
for _, building in ipairs(inventory or {}) do
for _, item in ipairs(building.entries or {}) do
local group = byType[item.fillType]
if group == nil then
group = {fillType = item.fillType, title = tostring(titleOf(item.fillType) or item.fillType),
level = 0, capacity = 0, count = 0, isOutput = false, buildings = {}}
byType[item.fillType] = group
table.insert(groups, group)
end
group.level = group.level + (item.level or 0)
group.capacity = group.capacity + (item.capacity or 0)
group.count = (group.count ~= nil and item.count ~= nil) and group.count + item.count or nil
group.isOutput = group.isOutput or item.isOutput == true
-- skur uden pladsgrænse har kapacitet 0: så er varens samlede procent ukendt
if (item.capacity or 0) <= 0 and (item.level or 0) > 0 then group.unbounded = true end
local percent = percentOf(item.level, item.capacity)
if item.count ~= nil and (item.slots or 0) > 0 then percent = percentOf(item.count, item.slots) end
table.insert(group.buildings, {name = building.name, placeable = building.placeable, level = item.level or 0,
capacity = item.capacity or 0, percent = percent, count = item.count, slots = item.slots, isOutput = item.isOutput == true})
end
end
for _, group in ipairs(groups) do
group.percent = not group.unbounded and percentOf(group.level, group.capacity) or nil
table.sort(group.buildings, function(a, b)
if a.level ~= b.level then return a.level > b.level end
return tostring(a.name) < tostring(b.name)
end)
end
table.sort(groups, function(a, b)
if a.title ~= b.title then return a.title < b.title end
return a.fillType < b.fillType
end)
return groups
end
-- inventory -> {[fillType] = kapacitet i liter} for hele gården
function ADStorageStats.farmCapacities(inventory)
local totals = {}
for _, building in ipairs(inventory or {}) do
for _, entry in ipairs(building.entries or {}) do
totals[entry.fillType] = (totals[entry.fillType] or 0) + (entry.capacity or 0)
end
end
return totals
end
function ADStorageStats.emptyLevel(days)
if days == nil then return nil end
if days < ADStorageStats.DAYS_RED then return "red" end
if days < ADStorageStats.DAYS_YELLOW then return "yellow" end
return nil
end
-- Fuldt er kun et problem for noget der bliver produceret (fyldes løbende); en fuld lagersilo er bare lager.
function ADStorageStats.fullLevel(percent, producing)
if not producing or percent == nil then return nil end
if percent >= ADStorageStats.FULL_RED then return "red" end
if percent >= ADStorageStats.FULL_YELLOW then return "yellow" end
return nil
end
local SEVERITY_RANK = {red = 2, yellow = 1}
function ADStorageStats.worst(a, b)
return (SEVERITY_RANK[a] or 0) >= (SEVERITY_RANK[b] or 0) and a or b
end
-- Én bygnings bjælke: stald-trug der tømmes (ikke mælk/gylle) er rødt når næsten tomt; produceret
-- vare/fabriksoutput er rødt når næsten fuldt.
function ADStorageStats.buildingLevel(building, kind, isStableOutput)
local percent = building.percent
if percent == nil then return nil end
if kind == "husbandry" and not isStableOutput then
if percent <= ADStorageStats.BARN_LOW_RED then return "red" end
if percent <= ADStorageStats.BARN_LOW_YELLOW then return "yellow" end
return nil
end
return ADStorageStats.fullLevel(percent, building.isOutput == true or isStableOutput)
end
-- group fra groupByFillType; farm = {level, capacity} for varen på hele gården (nil = kun fanens tal);
-- rate = {consumption, production} pr. døgn; quote = {price, top} pr. liter.
function ADStorageStats.groupStats(group, farm, rate, quote)
local farmLevel = farm ~= nil and farm.level or group.level
local farmCapacity = farm ~= nil and farm.capacity or group.capacity
local consumption = rate ~= nil and rate.consumption or 0
local production = rate ~= nil and rate.production or 0
local net = production - consumption
local stat = {}
for key, value in pairs(group) do stat[key] = value end
stat.farmLevel, stat.farmCapacity = farmLevel, farmCapacity
stat.consumption, stat.production, stat.net = consumption, production, net
stat.days = consumption > production and farmLevel / (consumption - production) or nil
stat.daysToFull = net > 0 and (farmCapacity or 0) > 0 and math.max(0, farmCapacity - farmLevel) / net or nil
stat.producing = group.isOutput == true or net > 0
if quote ~= nil and quote.price ~= nil then
stat.valueNow = math.floor(group.level * quote.price + 0.5)
stat.valueTop = math.floor(group.level * (quote.top or quote.price) + 0.5)
stat.priceRatio = (quote.top or 0) > 0 and quote.price / quote.top or nil
end
stat.emptyLevel = ADStorageStats.emptyLevel(stat.days)
stat.fullLevel = ADStorageStats.fullLevel(group.percent, stat.producing)
stat.severity = ADStorageStats.worst(stat.emptyLevel, stat.fullLevel)
return stat
end
-- Rangering: rød tom > rød fuld > gul tom > gul fuld > sælg nu (største beløb først).
-- Kortest tid til tom / mest fyldt vinder inden for samme trin.
local function attentionEntries(stat)
local entries = {}
if stat.emptyLevel ~= nil then
local base = stat.emptyLevel == "red" and 4000 or 2000
table.insert(entries, {kind = "empty", level = stat.emptyLevel, stat = stat, score = base - math.min(999, (stat.days or 0) * 100)})
end
if stat.fullLevel ~= nil then
local base = stat.fullLevel == "red" and 3000 or 1000
table.insert(entries, {kind = "full", level = stat.fullLevel, stat = stat, score = base + (stat.percent or 0)})
end
-- sælg kun det gården ikke selv æder af
if stat.priceRatio ~= nil and stat.priceRatio >= ADStorageStats.SELL_RATIO and (stat.valueNow or 0) >= ADStorageStats.SELL_MIN_VALUE
and (stat.net or 0) >= 0 then
table.insert(entries, {kind = "sell", level = "green", stat = stat, score = math.min(999, stat.valueNow / 1e6)})
end
return entries
end
function ADStorageStats.attentionList(stats, max)
local list = {}
for _, stat in ipairs(stats or {}) do
for _, entry in ipairs(attentionEntries(stat)) do table.insert(list, entry) end
end
return ADStorageStats.rankAttention(list, max)
end
-- Kæde-forslag ligger ikke på en vare (ingen stat): navnet til den stabile rækkefølge tages fra teksten.
local function entryName(entry)
return tostring(entry.stat ~= nil and entry.stat.title or entry.text)
end
function ADStorageStats.rankAttention(list, max)
table.sort(list, function(a, b)
if a.score ~= b.score then return a.score > b.score end
return entryName(a) < entryName(b)
end)
local result = {}
for index = 1, math.min(#list, max or #list) do result[index] = list[index] end
return result
end
-- "Kræver handling" fra kæden (ADChain.last): ÉT gult punkt "Kæde: N forslag" (klik -> fanen Kæder), så
-- forslagene ikke fylder listen. Rangeres under alle røde punkter (>= 3000), øverst blandt de gule, over "sælg nu".
ADStorageStats.CHAIN_SCORE = 2000
-- Manglende led: ALLE kædeproblemer i ÉN rød post ("Kæder: N manglende led", klik -> fanen Kæder med kæderne
-- foldet ud), så en gård med mange kæder (fx fermenteringssiloer) ikke fylder "Kræver handling". N = unikke
-- (kæde, vare). Score CHAIN_PROBLEM_SCORE + alvor (0-99): under "rød fuld" (3000-3100), så lagerets røde poster
-- altid vises først, og over de gule (<= 2000). Alvor = manglende andel af behovet; kredsløb = 99.
ADStorageStats.CHAIN_PROBLEM_SCORE = 2900
local function severity(problems)
local missing, need = 0, 0
for _, problem in ipairs(problems) do
if problem.kind == "cycle" then return 99 end
missing, need = missing + (problem.detail or 0), need + (problem.need or 0)
end
if need <= 0 then return 99 end
return math.floor(99 * math.min(1, missing / need))
end
local function chainProblemEntries(last)
local known = {}
for _, root in ipairs(last.chain ~= nil and last.chain.roots or {}) do known[root.key] = true end
local problems, seen, rootKeys, rootSeen = {}, {}, {}, {}
for _, problem in ipairs(last.chain ~= nil and last.chain.problems or {}) do
if problem.rootKey ~= nil and known[problem.rootKey] then
table.insert(problems, problem)
seen[tostring(problem.rootKey) .. "|" .. tostring(problem.fillType)] = true
if not rootSeen[problem.rootKey] then
rootSeen[problem.rootKey] = true
table.insert(rootKeys, problem.rootKey)
end
end
end
if #problems == 0 then return {} end
local count = 0
for _ in pairs(seen) do count = count + 1 end
table.sort(rootKeys, function(a, b) return tostring(a) < tostring(b) end)
return {{kind = "chainProblem", level = "red", score = ADStorageStats.CHAIN_PROBLEM_SCORE + severity(problems), count = count,
rootKeys = rootKeys, text = string.format("Kæder: %d", count)}}
end
-- "Slukket men nødvendig" (ADChain.last.dependency.offNeeded): højst MAX_DEPENDENCY_ENTRIES post (den mest hastende
-- fabrik; more = antal skjulte, vist som "+k"). Score DEPENDENCY_SCORE + hast (0-99, færre lagerdage = højere;
-- ukendt = 50): over "rød fuld" (3000-3100), under "rød tom" (3900-4000).
ADStorageStats.DEPENDENCY_SCORE = 3700
ADStorageStats.MAX_DEPENDENCY_ENTRIES = 1
local function urgency(days)
if days == nil then return 50 end
if days == math.huge then return 0 end
return math.max(0, 99 - math.min(99, math.floor(days)))
end
function ADStorageStats.dependencyEntries(last)
local entries = {}
for _, item in ipairs(last ~= nil and last.dependency ~= nil and last.dependency.offNeeded or {}) do
local name = tostring(item.buildingName or item.building or "?")
table.insert(entries, {kind = "dependencyOff", level = "red", score = ADStorageStats.DEPENDENCY_SCORE + urgency(item.days),
building = item.building, buildingName = name, fillType = item.fillType, onlyChain = item.onlyChain == true, text = name})
end
entries = ADStorageStats.rankAttention(entries)
local result = {}
for index = 1, math.min(#entries, ADStorageStats.MAX_DEPENDENCY_ENTRIES) do result[index] = entries[index] end
if #entries > #result then result[#result].more = #entries - #result end
return result
end
function ADStorageStats.chainEntries(last)
if last == nil then return {} end
local result = chainProblemEntries(last)
for _, entry in ipairs(ADStorageStats.dependencyEntries(last)) do table.insert(result, entry) end
local count = 0
for _, action in ipairs(last.actions or {}) do
if action.mode == "suggest" then count = count + 1 end
end
if count > 0 then
table.insert(result, {kind = "chainSuggest", level = "yellow", score = ADStorageStats.CHAIN_SCORE, count = count,
text = string.format("Kæde: %d", count)})
end
return result
end
-- Varepunkterne (allerede rangeret) + kædens punkter, samme loft.
function ADStorageStats.mergeAttention(attention, chainEntries, max)
local list = {}
for _, entry in ipairs(attention or {}) do table.insert(list, entry) end
for _, entry in ipairs(chainEntries or {}) do table.insert(list, entry) end
return ADStorageStats.rankAttention(list, max)
end
function ADStorageStats.kpis(stats)
local kpi = {valueNow = 0, gain = 0, emptyCount = 0, fullCount = 0}
for _, stat in ipairs(stats or {}) do
kpi.valueNow = kpi.valueNow + (stat.valueNow or 0)
kpi.gain = kpi.gain + math.max(0, (stat.valueTop or 0) - (stat.valueNow or 0))
if stat.emptyLevel ~= nil then kpi.emptyCount = kpi.emptyCount + 1 end
if stat.fullLevel ~= nil then kpi.fullCount = kpi.fullCount + 1 end
end
return kpi
end
-- procent -> bjælkens brøk i [0,1]
function ADStorageStats.fraction(percent)
if percent == nil then return 0 end
return math.max(0, math.min(1, percent / 100))
end
-- ---- "kræver handling" -> flow ----
-- Bygninger der BRUGER varen (fabrik-input, stald-foder) — silo/objektlager lagrer kun. -> {{name, placeable, level}}
function ADStorageStats.consumersOf(inventory, fillType)
local result = {}
for _, building in ipairs(inventory or {}) do
if building.kind == "factory" or building.kind == "husbandry" then
for _, entry in ipairs(building.entries or {}) do
if entry.fillType == fillType and not entry.isOutput then
table.insert(result, {name = building.name, placeable = building.placeable, level = entry.level or 0})
end
end
end
end
return result
end
-- Hjælper et flow? Et flow flytter kun varer: det nytter når varen ligger andre steder end hos forbrugerne.
-- s = {farmLevel, consumerLevel, perDay, consumerCount, existingRunId} -> "open" | "flow" | "produce" | "none"
ADStorageStats.MIN_FLOW_LITERS = 5000
ADStorageStats.MIN_FLOW_DAY_SHARE = 0.25 -- mindst en kvart dags forbrug skal kunne flyttes
function ADStorageStats.flowAdvice(s)
if (s.consumerCount or 0) == 0 then return "none" end
if s.existingRunId ~= nil then return "open" end
local elsewhere = (s.farmLevel or 0) - (s.consumerLevel or 0)
local needed = math.max(ADStorageStats.MIN_FLOW_LITERS, (s.perDay or 0) * ADStorageStats.MIN_FLOW_DAY_SHARE)
return elsewhere >= needed and "flow" or "produce"
end
-- "Sælg nu" -> udkørsels-flow: s = {sourceCount, existingRunId} -> "open" | "flow" | "none"
function ADStorageStats.sellAdvice(s)
if (s.sourceCount or 0) == 0 then return "none" end
if s.existingRunId ~= nil then return "open" end
return "flow"
end
+89
View File
@@ -0,0 +1,89 @@
-- AD Stuck Diag
-- Diagnose: et aktivt AutoDrive-køretøj der har holdt stille længe, logges med AutoDrives egen grund
-- (forhindring, AD-trafik på ruten + hvem, pause, mål nået) og om Smart Pickup holder det (Wait/udkørsel).
-- Kun læsning — ændrer intet. Logges én gang pr. ændret tilstand.
ADStuckDiag = {}
ADStuckDiag.CHECK_INTERVAL_MS = 10000
ADStuckDiag.STILL_MS = 20000
ADStuckDiag.STILL_SPEED = 0.00028 -- samme grænse som AD's egen stoppedTimer (m/ms)
local stillMs = setmetatable({}, {__mode = "k"})
local lastText = setmetatable({}, {__mode = "k"})
local timerMs = 0
local function name(vehicle)
if vehicle == nil then return "-" end
local ok, text = pcall(vehicle.getName, vehicle)
return ok and tostring(text) or "?"
end
local function taskName(vehicle)
local taskModule = vehicle.ad.taskModule
local task = taskModule ~= nil and taskModule.activeTask or nil
if task == nil then return "ingen" end
return tostring(task.taskType or "ukendt")
end
-- Tekst der beskriver hvorfor køretøjet står (nil hvis ukendt/ikke relevant).
function ADStuckDiag.describe(vehicle)
local ad = vehicle.ad
local cdm, dpm, sdm = ad.collisionDetectionModule, ad.drivePathModule, ad.specialDrivingModule
local wayPoints, current = nil, nil
if dpm ~= nil and dpm.getWayPoints ~= nil then wayPoints, current = dpm:getWayPoints() end
local parts = {
string.format("opgave=%s", taskName(vehicle)),
string.format("mål=%s", tostring(ad.stateModule.getFirstMarkerId ~= nil and ad.stateModule:getFirstMarkerId() or "?")),
string.format("wp=%s/%s", tostring(current), tostring(wayPoints ~= nil and #wayPoints or nil)),
string.format("pause=%s", tostring(dpm ~= nil and dpm.isPaused)),
string.format("målNået=%s", tostring(dpm ~= nil and dpm.isTargetReached ~= nil and dpm:isTargetReached())),
string.format("forhindring=%s", tostring(cdm ~= nil and cdm.detectedObstable)),
string.format("trafik=%s", cdm ~= nil and cdm.trafficVehicle ~= nil and name(cdm.trafficVehicle) or "-"),
string.format("stopper=%s blokeret=%s", tostring(sdm ~= nil and sdm.isStoppingVehicle ~= nil and sdm:isStoppingVehicle()),
tostring(sdm ~= nil and sdm.isBlocked)),
}
local wait = ADUnloadWait ~= nil and ADUnloadWait.getWaitFor ~= nil and ADUnloadWait.getWaitFor(vehicle) or nil
local hold = ADOutbound ~= nil and ADOutbound.getHoldFor ~= nil and ADOutbound.getHoldFor(vehicle) or nil
local pickup = ADSmartPickup ~= nil and ADSmartPickup.getActivePickup ~= nil and ADSmartPickup.getActivePickup(vehicle) or nil
table.insert(parts, string.format("SP: wait=%s hold=%s pickup=%s",
wait ~= nil and tostring(wait.markerId or wait.wayPointId or "ja") or "-",
hold ~= nil and tostring(hold.markerId or "ja") or "-",
pickup ~= nil and tostring(pickup.wayPointId or pickup.markerId or "ja") or "-"))
return table.concat(parts, " ")
end
-- Kun læsning (statusvinduet): køretøjer der har holdt stille mindst minMs -> {{vehicle, ms}}, længst først.
function ADStuckDiag.stillVehicles(minMs)
local list = {}
for vehicle, ms in pairs(stillMs) do
if ms >= (minMs or ADStuckDiag.STILL_MS) then table.insert(list, {vehicle = vehicle, ms = ms}) end
end
table.sort(list, function(a, b) return a.ms > b.ms end)
return list
end
function ADStuckDiag.tick(dt)
timerMs = timerMs + (dt or 0)
if timerMs < ADStuckDiag.CHECK_INTERVAL_MS then return end
local elapsed = timerMs
timerMs = 0
local vehicles = g_currentMission ~= nil and g_currentMission.vehicleSystem ~= nil and g_currentMission.vehicleSystem.vehicles or {}
for _, vehicle in pairs(vehicles) do
local ad = vehicle.ad
local active = ad ~= nil and ad.stateModule ~= nil and ad.stateModule:isActive()
if active and (vehicle.lastSpeedReal or 0) < ADStuckDiag.STILL_SPEED then
stillMs[vehicle] = (stillMs[vehicle] or 0) + elapsed
else
stillMs[vehicle], lastText[vehicle] = nil, nil
end
if (stillMs[vehicle] or 0) >= ADStuckDiag.STILL_MS then
local ok, text = pcall(ADStuckDiag.describe, vehicle)
text = ok and text or ("diagnose fejlede: " .. tostring(text))
if text ~= lastText[vehicle] then
lastText[vehicle] = text
Logging.info("ADSmartPickup: STÅR STILLE %d s '%s': %s", math.floor(stillMs[vehicle] / 1000), name(vehicle), text)
end
end
end
end
+29 -1
View File
@@ -29,16 +29,33 @@ function ADSupplyPlanner.prefersFullestSource(fillTypeNames)
return true
end
-- "Rigelig" kilde: kan levere mindst MIN_LOAD_SHARE af et læs (mindst MIN_AMPLE_LITERS).
ADSupplyPlanner.MIN_LOAD_SHARE = 0.25
ADSupplyPlanner.MIN_AMPLE_LITERS = 5000
function ADSupplyPlanner.ampleLevel(loadCapacity)
return math.max(ADSupplyPlanner.MIN_AMPLE_LITERS, (loadCapacity or 0) * ADSupplyPlanner.MIN_LOAD_SHARE)
end
-- Er en kilde med dette niveau bedre end den hidtil bedste (nil = ingen endnu)?
function ADSupplyPlanner.isBetterSource(level, bestLevel, prefersFullest)
-- Lagervarer: mindst fyldte først, men kun blandt rigelige kilder — en næsten tom tank der fyldes løbende
-- (gæringssilo) ville ellers altid vinde og give et par tusind liter pr. tur (in-game 29/9). Ingen rigelige:
-- den fyldeste. ample = nil: gammel regel.
function ADSupplyPlanner.isBetterSource(level, bestLevel, prefersFullest, ample)
if bestLevel == nil then
return true
end
if prefersFullest then
return level > bestLevel
end
if ample == nil then
return level < bestLevel
end
local isAmple, bestIsAmple = level >= ample, bestLevel >= ample
if isAmple ~= bestIsAmple then return isAmple end
if isAmple then return level < bestLevel end
return level > bestLevel
end
function ADSupplyPlanner.copyList(list)
local copy = {}
@@ -345,3 +362,14 @@ function ADSupplyPlanner.parallelFoodShare(groups, fillLevels, capacity, fillTyp
end
return nil
end
-- Læs der allerede ligger på vognen, når et run startes (eller AutoDrive genstarter traktoren efter
-- indlæsning): tæller som "på vej" til målet, hvis varen hører til run'et. Returnerer fillType, liter
-- eller nil. (in-game 27/9: tre fyldte biogas-traktorer kørte alle til Drop 1 — ingen var registreret.)
function ADSupplyPlanner.cargoFor(fillType, liters, ingredients)
if fillType == nil or type(liters) ~= "number" or liters <= 0 then return nil end
for _, ingredient in ipairs(ingredients or {}) do
if ingredient == fillType then return fillType, liters end
end
return nil
end
+58
View File
@@ -0,0 +1,58 @@
-- AD Tip Side
-- Tipvogne med flere tip-sider (fx Farmtech EDK 650 Unrealistic: bag / kornluge / venstre / højre) tillader kun
-- AI-aflæsning (allowAIDischarge) på nogle af udløbene. Står vognen på en side uden, åbner AD aflæsningen ved
-- målet, men intet løber ud — vognen holder med fuldt læs (in-game 29/9: Chicken Food 2 med 786.712 l hvede på
-- højre side ved en Farma med 10 mio. l fri; Disponibel Vogn 3 fuld af løg). Når AD kører en vogn, skiftes den til
-- en side, AI må aflæsse fra.
ADTipSide = {}
ADTipSide.LOG_PREFIX = "ADSmartPickup: tip-side: "
-- ---- ren logik ----
-- sides = {{node = udløbsindeks}}, allowAI = {[udløbsindeks] = true}, current = nuværende side
-- -> sideindeks der skal vælges, eller nil (nuværende er fin / ingen side tilladt / ukendt)
function ADTipSide.choose(sides, allowAI, current)
local currentSide = (sides or {})[current]
if currentSide ~= nil and (allowAI or {})[currentSide.node] then return nil end
for index, side in ipairs(sides or {}) do
if (allowAI or {})[side.node] then return index end
end
return nil
end
-- ---- spil ----
local warned = setmetatable({}, {__mode = "k"})
local function sidesOf(object)
local spec = object.spec_trailer
local dis = object.spec_dischargeable
if spec == nil or dis == nil or spec.tipSides == nil or #spec.tipSides < 2 then return nil end
local sides, allowAI = {}, {}
for index, side in ipairs(spec.tipSides) do sides[index] = {node = side.dischargeNodeIndex} end
for index, node in ipairs(dis.dischargeNodes or {}) do
if node.allowAIDischarge then allowAI[index] = true end
end
return sides, allowAI, spec.preferedTipSideIndex
end
-- Alle tipvogne bag traktoren: skift til en AI-tilladt side (kun mens vognen ikke tipper).
function ADTipSide.ensure(vehicle)
local children = vehicle.getChildVehicles ~= nil and vehicle:getChildVehicles() or {}
for _, object in pairs(children) do
if object ~= vehicle and object.setPreferedTipSide ~= nil then
local sides, allowAI, current = sidesOf(object)
local tipping = Trailer ~= nil and object.getTipState ~= nil and object:getTipState() ~= Trailer.TIPSTATE_CLOSED
local index = sides ~= nil and not tipping and ADTipSide.choose(sides, allowAI, current) or nil
if index ~= nil then
object:setPreferedTipSide(index)
if not warned[object] then
warned[object] = true
Logging.info(ADTipSide.LOG_PREFIX .. "'%s': tip-side %s -> %d (AutoDrive må kun aflæse fra den side)",
tostring(object.getName ~= nil and object:getName() or "?"), tostring(current), index)
end
end
end
end
end
+31 -3
View File
@@ -255,6 +255,9 @@ end
-- AD ser — vognens indhold, målets fri plads, AD's aflæsningstilstand og vognens dischargeState.
ADUnloadWait.DEBUG_INTERVAL_MS = 15000
local debugTimers = setmetatable({}, {__mode = "k"})
local debugStuck = setmetatable({}, {__mode = "k"}) -- vehicle -> {level, since}: læsset uændret siden
ADUnloadWait.STUCK_WITH_ROOM_MS = 30000
local function debugAtTarget(adEnv, task, dt)
local vehicle = task.vehicle
local targetReached = vehicle.ad.drivePathModule:isTargetReached()
@@ -268,8 +271,15 @@ local function debugAtTarget(adEnv, task, dt)
return
end
local free, capacity = ADSmartPickup.getTargetFreeCapacity(adEnv, vehicle, fillType)
-- kun den tilstand vi vil se: målet fuldt/afvist, og traktoren venter ikke ved et Wait-punkt
if waits[vehicle] ~= nil or (free ~= nil and free >= ADUnloadWait.MIN_FREE_TO_UNLOAD) then
-- ved målet MED plads, men læsset falder ikke (in-game 29/9: EDK 650 ved TMR Unload/Biogas Drop) — også det
-- vil vi se. Ellers kun: målet fuldt/afvist, og traktoren venter ikke ved et Wait-punkt.
local stuck = debugStuck[vehicle]
if stuck == nil or rest < stuck.level - 1 then
stuck = {level = rest, since = g_time or 0}
debugStuck[vehicle] = stuck
end
local stuckWithRoom = targetReached and (g_time or 0) - stuck.since >= ADUnloadWait.STUCK_WITH_ROOM_MS
if waits[vehicle] ~= nil or (free ~= nil and free >= ADUnloadWait.MIN_FREE_TO_UNLOAD and not stuckWithRoom) then
return
end
local trailerModule = vehicle.ad.trailerModule
@@ -295,6 +305,12 @@ function ADUnloadWait.beforeUpdate(adEnv, task, dt)
if ADOutbound ~= nil and ADOutbound.isOutboundVehicle ~= nil and ADOutbound.isOutboundVehicle(task.vehicle) then
return
end
-- Tømning efter stop (ADFlowWagons): ingen Wait-pulje eller læs-bytte — den skal tømme og hjem
-- (in-game 29/9: Goat Food blev sendt til Cow Wait efter Stop alt)
if ADFlowWagons ~= nil and ADFlowWagons.isEmptying ~= nil and ADFlowWagons.isEmptying(task.vehicle) then
if waits[task.vehicle] ~= nil then release(task.vehicle) end
return
end
pcall(debugAtTarget, adEnv, task, dt)
local wait = waits[task.vehicle]
if wait ~= nil then
@@ -309,7 +325,7 @@ end
-- stående for evigt. Her genoptages ventningen for aktive traktorer i et forsynings-run hvis
-- aflæsningsopgave peger på en Wait-markør: hjem = run'ets mål, og reservationen sættes igen.
-- Kaldes fra ADSmartPickup:update (throttlet).
ADUnloadWait.ADOPT_INTERVAL_MS = 5000
ADUnloadWait.ADOPT_INTERVAL_MS = 5300
local adoptTimerMs = ADUnloadWait.ADOPT_INTERVAL_MS
function ADUnloadWait.adoptOrphans(adEnv)
@@ -410,6 +426,9 @@ function ADUnloadWait.getClaims(adEnv)
return ADWaitPool.getClaims(adEnv)
end
ADUnloadWait.TIP_CHECK_MS = 2000
local tipChecked = setmetatable({}, {__mode = "k"})
function ADUnloadWait.install(adEnv)
local task, autoDrive = adEnv.UnloadAtDestinationTask, adEnv.AutoDrive
if isInstalled or task == nil or task.update == nil or autoDrive == nil or autoDrive.getSetting == nil then
@@ -422,6 +441,15 @@ function ADUnloadWait.install(adEnv)
local originalUpdate, originalFinished, originalGetSetting = task.update, task.finished, autoDrive.getSetting
task.update = function(self, dt)
-- alle aflæsningsture (også udkørsel og tømning): tipvognen på en side AD må aflæsse fra (EDK 650, 29/9)
if ADTipSide ~= nil and self.vehicle ~= nil then
local now = g_time or 0
if (tipChecked[self.vehicle] or -math.huge) + ADUnloadWait.TIP_CHECK_MS <= now then
tipChecked[self.vehicle] = now
local okTip, errTip = pcall(ADTipSide.ensure, self.vehicle)
if not okTip then Logging.warning(ADUnloadWait.LOG_PREFIX .. "tip-side: %s", tostring(errTip)) end
end
end
local ok, err = pcall(ADUnloadWait.beforeUpdate, adEnv, self, dt)
if not ok then
Logging.warning(ADUnloadWait.LOG_PREFIX .. "fejl i Wait-punkt-logik, AutoDrive fortsætter selv: %s", tostring(err))
+135 -12
View File
@@ -5,6 +5,13 @@
ADWaitPool = {}
-- ADCache-opslag der også virker uden adCache (tests)
local function cacheGet(bucket, key, ttlMs, fn)
if ADCacheGet == nil then return fn() end
return ADCacheGet(bucket, key, ttlMs, fn)
end
ADWaitPool.LOG_PREFIX = "ADSmartPickup: "
-- sættes af den der installerer opgave-hooks (task.STATE_DRIVING); 2 er AD's værdi
ADWaitPool.STATE_DRIVING = 2
@@ -14,6 +21,10 @@ ADWaitPool.claims = setmetatable({}, {__mode = "v"})
-- poolGroup: run'ets Wait-mappe; tom/nil = alle Wait-mapper.
function ADWaitPool.getCandidates(adEnv, poolGroup)
return cacheGet("waitCandidates", tostring(poolGroup or ""), 10000, function() return ADWaitPool.findCandidates(adEnv, poolGroup) end)
end
function ADWaitPool.findCandidates(adEnv, poolGroup)
local candidates = {}
for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do
local isInPool = poolGroup == nil or poolGroup == "" or marker.group == poolGroup
@@ -57,26 +68,47 @@ ADWaitPool.OCCUPIED_RADIUS = 6
-- Positioner for ANDRE motoriserede køretøjer (ikke vognen selv eller dens redskaber) — til at se om et
-- Wait-punkt fysisk er optaget, også uden reservation (fx lige efter en omplanlægning).
-- Alle motoriserede køretøjers positioner, delt mellem opslag i 0,5 s (ikke én runde pr. vogn pr. opslag).
local function allVehiclePositions()
return cacheGet("vehiclePositions", "all", 500, function()
local list = {}
local system = g_currentMission ~= nil and g_currentMission.vehicleSystem or nil
for _, other in pairs(system ~= nil and system.vehicles or {}) do
if other.spec_motorized ~= nil and other.rootNode ~= nil then
local ok, x, _, z = pcall(getWorldTranslation, other.rootNode)
if ok and x ~= nil then table.insert(list, {vehicle = other, x = x, z = z}) end
end
end
return list
end)
end
local function otherVehiclePositions(vehicle)
local positions = {}
local system = g_currentMission ~= nil and g_currentMission.vehicleSystem or nil
local own = {}
if vehicle.getChildVehicles ~= nil then
local ok, children = pcall(vehicle.getChildVehicles, vehicle)
for _, child in pairs(ok and children or {}) do own[child] = true end
end
own[vehicle] = true
for _, other in pairs(system ~= nil and system.vehicles or {}) do
if not own[other] and other.spec_motorized ~= nil and other.rootNode ~= nil then
local ok, x, _, z = pcall(getWorldTranslation, other.rootNode)
if ok and x ~= nil then table.insert(positions, {x = x, z = z}) end
end
for _, entry in ipairs(allVehiclePositions()) do
if not own[entry.vehicle] then table.insert(positions, entry) end
end
return positions
end
function ADWaitPool.pickFree(adEnv, vehicle, poolGroup, isReachable)
-- Midtpunkt af punkter {x, z} (fx flowets kilde-markører) -> x, z eller nil.
function ADWaitPool.anchorOf(points)
if points == nil or #points == 0 then return nil end
local sx, sz = 0, 0
for _, point in ipairs(points) do sx, sz = sx + point.x, sz + point.z end
return sx / #points, sz / #points
end
-- anchorX/anchorZ (valgfri): vælg nærmest dette punkt i stedet for traktoren (flow med "All": nærmest kilderne).
function ADWaitPool.pickFree(adEnv, vehicle, poolGroup, isReachable, anchorX, anchorZ)
local x, z = getVehiclePosition(vehicle)
if anchorX ~= nil and anchorZ ~= nil then x, z = anchorX, anchorZ end
if x == nil then
return nil
end
@@ -124,11 +156,42 @@ local function graphReaches(adEnv, vehicle, targetId)
return false
end
function ADWaitPool.hasRouteTo(adEnv, vehicle, wayPointId)
local graph = adEnv.ADGraphManager
if graph.getPathTo == nil then
return true
-- Rutesvar gemmes ROUTE_CACHE_MS pr. mål og 10 m-område: holdende vogne spørger hvert 5. s for hver
-- kandidat, og AD's stifinding (+ reserve-søgningen) gennem hele vejnettet gav et hak hvert 5. s
-- (in-game 26/9). Vejnettet ændrer sig ikke så hurtigt.
ADWaitPool.ROUTE_CACHE_MS = 30000
ADWaitPool.ROUTE_CELL_M = 10
ADWaitPool.SLOW_ROUTE_MS = 3
local routeCache = {}
local routeCacheSize = 0
ADWaitPool.ROUTE_CACHE_MAX = 2000
local function remember(key, result, at)
if routeCache[key] == nil then
routeCacheSize = routeCacheSize + 1
if routeCacheSize > ADWaitPool.ROUTE_CACHE_MAX then
routeCache, routeCacheSize = {}, 1
end
end
routeCache[key] = {result = result, at = at}
end
local function clockMs()
local ok, ms = pcall(function() return netGetTime() end)
if ok and type(ms) == "number" then return ms end
return nil
end
local function routeKey(vehicle, wayPointId)
if vehicle.rootNode == nil or getWorldTranslation == nil then return nil end
local ok, x, _, z = pcall(getWorldTranslation, vehicle.rootNode)
if not ok or x == nil then return nil end
local cell = ADWaitPool.ROUTE_CELL_M
return string.format("%s:%d:%d", tostring(wayPointId), math.floor(x / cell), math.floor(z / cell))
end
local function computeRoute(adEnv, vehicle, wayPointId)
local graph = adEnv.ADGraphManager
if pathReaches(graph:getPathTo(vehicle, wayPointId), wayPointId) then
return true
end
@@ -142,13 +205,73 @@ function ADWaitPool.hasRouteTo(adEnv, vehicle, wayPointId)
return false
end
function ADWaitPool.hasRouteTo(adEnv, vehicle, wayPointId)
local graph = adEnv.ADGraphManager
if graph.getPathTo == nil then
return true
end
local now = clockMs()
local key = now ~= nil and routeKey(vehicle, wayPointId) or nil
local cached = key ~= nil and routeCache[key] or nil
if cached ~= nil and now - cached.at < ADWaitPool.ROUTE_CACHE_MS then
return cached.result
end
local result = computeRoute(adEnv, vehicle, wayPointId)
if key ~= nil then
local spent = (clockMs() or now) - now
remember(key, result, now)
if spent > ADWaitPool.SLOW_ROUTE_MS and Logging ~= nil and Logging.info ~= nil
and (ADWaitPool.lastSlowLog == nil or now - ADWaitPool.lastSlowLog > 60000) then
ADWaitPool.lastSlowLog = now
Logging.info("ADSmartPickup: rutetjek til markør %s tog %.1f ms (%s) — gemmes nu i %d s", tostring(wayPointId), spent, result and "vej" or "ingen vej", ADWaitPool.ROUTE_CACHE_MS / 1000)
end
end
return result
end
-- Rute mellem to markører: afhænger ikke af vognens position, så svaret gemmes længe (5 min). Profiler
-- 26/9: "rutetjek frem+tilbage" tog 30-39 ms pr. kald og blev regnet igen hver gang vognen havde flyttet sig
-- 10 m — det gav hak på 44-67 ms hvert minut.
ADWaitPool.MARKER_ROUTE_MS = 300000
local markerRoutes = {}
function ADWaitPool.hasMarkerRoute(adEnv, fromWayPointId, toWayPointId)
local graph = adEnv.ADGraphManager
if graph.pathFromTo == nil then return true end
local key = tostring(fromWayPointId) .. ">" .. tostring(toWayPointId)
local now = clockMs()
local cached = markerRoutes[key]
if cached ~= nil and now ~= nil and now - cached.at < ADWaitPool.MARKER_ROUTE_MS then
return cached.result
end
local result = pathReaches(graph:pathFromTo(fromWayPointId, toWayPointId), toWayPointId)
if now ~= nil then markerRoutes[key] = {result = result, at = now + math.random() * ADWaitPool.MARKER_ROUTE_MS * 0.5} end
return result
end
function ADWaitPool.resetMarkerRoutes()
markerRoutes = {}
end
function ADWaitPool.hasRouteBothWays(adEnv, vehicle, toWayPointId, homeWayPointId)
local graph = adEnv.ADGraphManager
if graph.getPathTo == nil or graph.pathFromTo == nil then
return true
end
return pathReaches(graph:getPathTo(vehicle, toWayPointId), toWayPointId)
-- kendt hjem (kilden): vognen kører hjem -> mål -> hjem; markør til markør, gemt længe
if homeWayPointId ~= nil then
return ADWaitPool.hasMarkerRoute(adEnv, homeWayPointId, toWayPointId)
and ADWaitPool.hasMarkerRoute(adEnv, toWayPointId, homeWayPointId)
end
local now = clockMs()
local key = now ~= nil and routeKey(vehicle, tostring(toWayPointId) .. ">" .. tostring(homeWayPointId)) or nil
local cached = key ~= nil and routeCache[key] or nil
if cached ~= nil and now - cached.at < ADWaitPool.ROUTE_CACHE_MS then
return cached.result
end
local result = pathReaches(graph:getPathTo(vehicle, toWayPointId), toWayPointId)
and pathReaches(graph:pathFromTo(toWayPointId, homeWayPointId), homeWayPointId)
if key ~= nil then remember(key, result, now) end
return result
end
-- Står AD midt i en (blokeret) af-/pålæsning, stoppes den pænt først — ellers kører
@@ -0,0 +1,157 @@
-- Smart Pickup — "Kræver handling" -> flow (v1.60)
-- En "løber tør"-linje i lagerfanerne kan klikkes: findes der et flow der leverer varen til forbrugerne, åbnes
-- det; ligger varen andre steder på gården, åbnes "Nyt flow" udfyldt (ind til forbrugerne, varen valgt); ellers
-- siges det at et flow ikke hjælper (producér/køb). Afgørelsen er ADStorageStats.flowAdvice (testet).
-- Funktionerne lægges på SmartPickupFrame (indlæses efter SmartPickupFrame.lua).
-- Lagerfanen for en bygningstype (TAB_KINDS), fx "factory" -> fanen Fabrikker.
function SmartPickupFrame.tabOfKind(kind)
for tab, tabKind in pairs(SmartPickupFrame.TAB_KINDS or {}) do
if tabKind == kind then return tab end
end
return SmartPickupFrame.TAB_OVERVIEW
end
local function fillTypeName(fillType)
return g_fillTypeManager ~= nil and g_fillTypeManager:getFillTypeNameByIndex(fillType) or tostring(fillType)
end
-- Forsyningsflow der allerede leverer varen til en af forbrugerne -> run eller nil.
local function existingSupplyRun(consumerIds, name)
for _, run in ipairs(ADRunsController.store.runs) do
if run.kind ~= "outbound" then
local carries = run.ingredientMode ~= "manual"
for _, ingredient in ipairs(run.ingredients or {}) do
if ingredient == name then carries = true end
end
if carries then
for _, id in ipairs(run.targetBuildingIds or {}) do
if consumerIds[id] then return run end
end
end
end
end
return nil
end
-- Udkørselsflow der allerede sælger varen fra en af bygningerne -> run eller nil.
local function existingSellRun(sourceIds, name)
for _, run in ipairs(ADRunsController.store.runs) do
if run.kind == "outbound" then
local sells = false
for _, product in ipairs(run.products or {}) do
if product.fillType == name and (product.policy == "sell" or product.policy == "sellElseStore") then sells = true end
end
if sells then
for _, id in ipairs(run.sourceBuildingIds or {}) do
if sourceIds[id] then return run end
end
end
end
end
return nil
end
-- "Sælg nu": kilder = fanens bygninger med varen.
local function sellAdviceFor(stat)
local sourceIds, ids = {}, {}
for _, building in ipairs(stat.buildings or {}) do
local id = building.placeable ~= nil and ADBuildings.getId(building.placeable) or nil
if id ~= nil and not sourceIds[id] then
sourceIds[id] = true
table.insert(ids, id)
end
end
local name = fillTypeName(stat.fillType)
local run = existingSellRun(sourceIds, name)
local advice = ADStorageStats.sellAdvice({sourceCount = #ids, existingRunId = run ~= nil and run.id or nil})
return advice, {buildingIds = ids, run = run, name = name, stat = stat, sell = true}
end
-- entry fra ADStorageStats.attentionList -> advice ("open"|"flow"|"produce"|"none"), info
function SmartPickupFrame:attentionAdvice(entry)
-- kæde-forslag og manglende led har ingen vare (stat): klik fører til fanen Kæder
if entry ~= nil and (entry.kind == "chainSuggest" or entry.kind == "chainProblem") then return "chain", nil end
-- slukket men nødvendig: fanen Fabrikker (Kæder når kun en kæde bruger varen)
if entry ~= nil and entry.kind == "dependencyOff" then return entry.onlyChain and "chain" or "factories", nil end
if entry ~= nil and entry.kind == "sell" and entry.stat ~= nil then return sellAdviceFor(entry.stat) end
if entry == nil or entry.kind ~= "empty" or entry.stat == nil then return "none", nil end
local stat = entry.stat
local consumers = ADStorageStats.consumersOf(self.lastFullInventory, stat.fillType)
local consumerIds, consumerLevel, ids = {}, 0, {}
for _, consumer in ipairs(consumers) do
consumerLevel = consumerLevel + consumer.level
local id = consumer.placeable ~= nil and ADBuildings.getId(consumer.placeable) or nil
if id ~= nil and not consumerIds[id] then
consumerIds[id] = true
table.insert(ids, id)
end
end
local name = fillTypeName(stat.fillType)
local run = existingSupplyRun(consumerIds, name)
local advice = ADStorageStats.flowAdvice({farmLevel = stat.farmLevel or stat.level, consumerLevel = consumerLevel,
perDay = stat.consumption, consumerCount = #ids, existingRunId = run ~= nil and run.id or nil})
return advice, {consumers = consumers, buildingIds = ids, run = run, name = name, stat = stat}
end
function SmartPickupFrame:attentionHint(advice, info)
if advice == "chain" then return self.i18n:getText("spu_attention_hintChain") end
if advice == "factories" then return self.i18n:getText("spu_attention_hintFactories") end
if advice == "open" then return string.format(self.i18n:getText("spu_attention_hintOpen"), tostring(info.run.name)) end
if advice == "flow" then return self.i18n:getText(info.sell and "spu_attention_hintSellFlow" or "spu_attention_hintFlow") end
if advice == "produce" then return self.i18n:getText("spu_attention_hintProduce") end
return ""
end
function SmartPickupFrame:onAttentionClick(entry)
self:safe(function(frame)
if entry ~= nil and (entry.kind == "chainSuggest" or entry.kind == "chainProblem") then
-- manglende led: kæderne med problemer foldes ud, så problemteksten står under den røde linje
if entry.kind == "chainProblem" then
frame.chainExpanded = frame.chainExpanded or {}
for _, rootKey in ipairs(entry.rootKeys or {}) do frame.chainExpanded[rootKey] = true end
end
frame:switchTab(SmartPickupFrame.TAB_CHAINS)
return
end
if entry ~= nil and entry.kind == "dependencyOff" then
frame:switchTab(entry.onlyChain and SmartPickupFrame.TAB_CHAINS or SmartPickupFrame.tabOfKind("factory"))
return
end
local adEnv = frame.getAdEnv()
local advice, info = frame:attentionAdvice(entry)
if adEnv == nil or advice == "none" then return end
if advice == "open" then
frame.selectedRunId = info.run.id
frame:switchTab(SmartPickupFrame.TAB_FLOWS)
frame:openEditPanel("flow", info.run.id)
return
end
if advice == "produce" then
local text = string.format(frame.i18n:getText("spu_attention_produce"), tostring(info.stat.title),
SmartPickupFrame.formatCompact(info.stat.farmLevel or 0))
frame.statusOverride, frame.statusOverrideTicks = text, SmartPickupFrame.STATUS_OVERRIDE_TICKS
if ADFieldJobs ~= nil and ADFieldJobs.notify ~= nil then ADFieldJobs.notify(text, false) end
return
end
local guide
if info.sell then
-- udfyldt "Nyt flow": ud af bygningerne med varen, varen til salg
local name = string.format(frame.i18n:getText("spu_attention_sellFlowName"), tostring(info.stat.title))
guide = ADFlowGuide.openNew(name, {})
guide.draft.kind = "outbound"
guide.draft.sourceBuildingIds = info.buildingIds
guide.draft.products = {{fillType = info.name, policy = "sell"}}
else
-- udfyldt "Nyt flow": ind til forbrugerne, kun denne vare; guiden åbnes på oversigten (Gem/Start eller [Ret])
local name = string.format(frame.i18n:getText("spu_attention_flowName"), tostring(info.stat.title), tostring(info.consumers[1].name))
guide = ADFlowGuide.openNew(name, {})
guide.draft.targetBuildingIds = info.buildingIds
guide.draft.ingredientMode = "manual"
guide.draft.ingredients = {info.name}
end
guide = ADGuide.new(ADFlowGuide.DEF, guide.draft, frame:flowGuideContext(adEnv, guide.draft), {step = "summary"})
frame:switchTab(SmartPickupFrame.TAB_FLOWS)
frame:openGuide("flow", guide)
end)
end
@@ -0,0 +1,115 @@
-- Smart Pickup — fanen Kæder
-- Produktionskæderne som træ: rod (produktion spilleren har tændt) -> varer -> råvarer, med kilde, behov pr.
-- måned og flowstatus; afgrødeforslag kan godkendes/afvises og en rod sættes på pause. Rækkerne bygges af
-- ADChainView (testet); her kun kloning, farve og klik. Et resultat yngre end CHAIN_MAX_AGE_MS (fra kædens eget
-- tick eller en tidligere visning) genbruges; ellers evalueres (visning, handler ikke). Klik evaluerer altid.
SmartPickupFrame.CHAIN_MAX_AGE_MS = 30000
function SmartPickupFrame:chainLast(adEnv, force)
local age = ADChain.lastAge ~= nil and ADChain.lastAge() or math.huge
if force or ADChain.last == nil or age >= SmartPickupFrame.CHAIN_MAX_AGE_MS then
ADChain.evaluate(adEnv)
end
return ADChain.last
end
function SmartPickupFrame:chainTitle(last, name)
local title = last ~= nil and last.titles ~= nil and last.titles[name] or nil
return title or self:fruitTitle(name)
end
-- Klik på en kæde-knap: udfør, evaluér straks (ny visning) og genopbyg. Et forslag der ikke længere findes
-- (approve/dismiss -> false) meldes i statuslinjen som i "Kræver handling".
function SmartPickupFrame:onChainAction(action)
local ok = true
if action.kind == "approve" then
ok = ADChain.approve(action.fieldId)
elseif action.kind == "dismiss" then
ok = ADChain.dismiss(action.fieldId)
elseif action.kind == "pause" or action.kind == "resume" then
ADChain.setPaused(action.rootKey, action.kind == "pause")
end
if ok == false then
local text = self.i18n:getText("spu_chain_gone")
self.statusOverride, self.statusOverrideTicks = text, SmartPickupFrame.STATUS_OVERRIDE_TICKS
if ADFieldJobs ~= nil and ADFieldJobs.notify ~= nil then ADFieldJobs.notify(text, false) end
end
local adEnv = self.getAdEnv()
if adEnv ~= nil then self:chainLast(adEnv, true) end
self:requestRebuild()
end
-- Klik på rodens linje uden for knappen: fold ud/sammen (huskes kun mens spillet kører).
function SmartPickupFrame:toggleChainRoot(rootKey)
self.chainExpanded = self.chainExpanded or {}
self.chainExpanded[rootKey] = not self.chainExpanded[rootKey] or nil
self:requestRebuild()
end
-- Hvilken celle blev der klikket i? Højeste celle hvis venstre kant ligger til venstre for klikket.
local function clickedCell(frame, cells)
if frame.lastClickX == nil then return nil end
for index = #cells, 1, -1 do
local cell = cells[index]
if cell ~= nil and cell.absPosition ~= nil and frame.lastClickX >= cell.absPosition[1] then return index end
end
return nil
end
-- Række med prik + c1..c5 (flowListPrefab). Knapper er celler (entry.actions[celle], som [Start]/[Stop] i
-- Flows-listen); et klik andetsteds på en rodlinje folder den ud/sammen.
function SmartPickupFrame:addChainRow(entry)
local unpackFn = table.unpack or unpack
local row = self:addRow(self.flowListPrefab, {kind = "chainRow"})
local cells = {}
for index = 1, 5 do cells[index] = row:getDescendantByName("c" .. index) end
SmartPickupFrame.setCells(cells, entry.cells)
local dot = row:getDescendantByName("dot")
local color = entry.level ~= nil and ADFlowView.LEVEL_COLORS[entry.level] or nil
if dot ~= nil and dot.setImageColor ~= nil and color ~= nil then dot:setImageColor(nil, unpackFn(color)) end
-- kun rod og varer har en status-prik (overskrift, forslag og auto-skift har ingen)
if dot ~= nil and dot.setVisible ~= nil and entry.kind ~= "root" and entry.kind ~= "node" then dot:setVisible(false) end
if cells[4] ~= nil and cells[4].setTextColor ~= nil and color ~= nil and entry.level ~= "running" and entry.kind ~= "suggest" then
cells[4]:setTextColor(unpackFn(color))
end
local actions, toggle = entry.actions or {}, entry.toggle
if next(actions) ~= nil or toggle ~= nil then
self:makeRowClickable(row, function(frame)
local action = actions[clickedCell(frame, cells) or 0]
if action ~= nil then
frame:onChainAction(action)
elseif toggle ~= nil then
frame:toggleChainRoot(toggle)
end
end)
end
return row
end
-- Manglende led: problemteksten som (ombrudt) tekstrække i rød under varelinjen.
function SmartPickupFrame:addChainProblemRow(entry)
local unpackFn = table.unpack or unpack
local color = ADFlowView.LEVEL_COLORS ~= nil and ADFlowView.LEVEL_COLORS[entry.level] or nil
for _, line in ipairs(ADFlowView.wrapText(entry.text, SmartPickupFrame.TEXT_ROW_CHARS)) do
local title = self:addRow(self.textPrefab, {kind = "text"}):getDescendantByName("title")
title:setText(line)
if color ~= nil and title.setTextColor ~= nil then title:setTextColor(unpackFn(color)) end
end
end
function SmartPickupFrame:fillChainRows(adEnv)
local i18n = self.i18n
local last = self:chainLast(adEnv, false)
local rows = ADChainView.rows(last, function(name) return self:chainTitle(last, name) end,
SmartPickupFrame.formatCompact, function(key) return i18n:getText(key) end, self.chainExpanded or {})
for _, entry in ipairs(rows) do
if entry.kind == "info" or entry.kind == "empty" then
self:addTextRow(entry.text)
elseif entry.kind == "problem" then
self:addChainProblemRow(entry)
else
self:addChainRow(entry)
end
end
end
@@ -0,0 +1,194 @@
-- Smart Pickup — redigeringsvindue (v1.58)
-- Klik på en række i Flows/Fields åbner et panel oven på listen. Indholdet tegnes af den eksisterende
-- resumé-/guide-kode i vinduets egen ScrollingLayout (SmartPickupFrame:withRowTarget). Knapper i bunden via
-- menuButtonInfo: Luk (MENU_BACK = ESC), Start/Stop, Slet. Hver art registrerer sig i EDIT_PANEL_KINDS[kind] =
-- {title, exists, fill, status, startStop, startStopText, canDelete, delete} (se Flows-/Fields-fanen).
-- Funktionerne lægges på SmartPickupFrame (indlæses efter SmartPickupFrame.lua).
SmartPickupFrame.EDIT_PANEL_KINDS = SmartPickupFrame.EDIT_PANEL_KINDS or {}
SmartPickupFrame.EDIT_PANEL_WIDTH, SmartPickupFrame.EDIT_PANEL_HEIGHT = 0.80, 0.90 -- andel af listens flade
SmartPickupFrame.EDIT_PANEL_HEADER_PX = 80
SmartPickupFrame.EDIT_PANEL_ROW_PX = 860 -- rækkerne er ~835 px brede
function SmartPickupFrame:initEditPanelButtons(i18n)
self.panelCloseButtonInfo = {inputAction = InputAction.MENU_BACK, text = i18n:getText("spu_panel_close"), callback = function() self:onClickPanelClose() end}
self.panelStartButtonInfo = {inputAction = InputAction.MENU_ACTIVATE, text = i18n:getText("spu_button_start"), callback = function() self:onClickPanelStartStop() end}
self.panelDeleteButtonInfo = {inputAction = InputAction.MENU_CANCEL, text = i18n:getText("spu_button_delete"), callback = function() self:onClickPanelDelete() end}
self.panelButtons = {self.panelCloseButtonInfo, self.panelStartButtonInfo, self.panelDeleteButtonInfo}
self.panelButtonsNoDelete = {self.panelCloseButtonInfo, self.panelStartButtonInfo}
self.panelGuideButtonCache = setmetatable({}, {__mode = "k"})
end
-- Vinduets knaplinje (uden Slet for arter der ikke kan slettes).
function SmartPickupFrame:getPanelButtons()
local hooks = self:editPanelHooks()
return (hooks ~= nil and hooks.canDelete) and self.panelButtons or self.panelButtonsNoDelete
end
function SmartPickupFrame:editPanelHooks()
return self.editPanelOpen and SmartPickupFrame.EDIT_PANEL_KINDS[self.editPanelKind] or nil
end
-- Guidens knapper i vinduet: Tilbage (ESC) lukker trinnet/vinduet i stedet for hele menuen.
function SmartPickupFrame:panelGuideButtons()
local source = self:getGuideButtons()
local cached = self.panelGuideButtonCache[source]
if cached == nil then
cached = {}
for index, info in ipairs(source) do
cached[index] = info == self.backButtonInfo and self.panelCloseButtonInfo or info
end
self.panelGuideButtonCache[source] = cached
end
return cached
end
-- Sæt størrelse FØR position: setSize kan genberegne den absolutte position ud fra forælderen (review 29/9).
-- -> true hvis elementet flyttede sig.
local function place(element, x, y, w, h)
if w ~= nil then element:setSize(w, h) end
local pos = element.absPosition
local moved = pos == nil or math.abs(pos[1] - x) > 1e-6 or math.abs(pos[2] - y) > 1e-6
element:setAbsolutePosition(x, y)
return moved
end
-- Vinduet dækker listens flade; panelet er centreret i den (absolutte koordinater, y opad). Kaldes ved åbning,
-- ved hver opbygning og hvert sekund, så en layout-gennemgang i menuen ikke flytter det væk.
function SmartPickupFrame:layoutEditPanel()
local box = self.boxLayout
if box == nil or box.absPosition == nil or box.absSize == nil then return end
local x, y, w, h = box.absPosition[1], box.absPosition[2], box.absSize[1], box.absSize[2]
place(self.editBackdrop, x, y, w, h)
-- så bredt som rækkerne (~835 px) + margen, ikke bredere end listen
local pw = math.min(w * SmartPickupFrame.EDIT_PANEL_WIDTH, SmartPickupFrame.EDIT_PANEL_ROW_PX * g_pixelSizeScaledX)
local ph = h * SmartPickupFrame.EDIT_PANEL_HEIGHT
local px, py = x + (w - pw) / 2, y + (h - ph) / 2
place(self.editPanel, px, py, pw, ph)
local header = SmartPickupFrame.EDIT_PANEL_HEADER_PX * g_pixelSizeScaledY
local margin = 20 * g_pixelSizeScaledX
place(self.editTitle, px + margin, py + ph - header * 0.45)
place(self.editStatus, px + margin, py + ph - header * 0.85)
if place(self.editLayout, px, py, pw, ph - header) then
self.editLayout:invalidateLayout()
end
end
function SmartPickupFrame:openEditPanel(kind, id)
if SmartPickupFrame.EDIT_PANEL_KINDS[kind] == nil or self.editOverlay == nil then return end
self.editPanelOpen, self.editPanelKind, self.editPanelId = true, kind, id
self:closeGuide()
self.editOverlay:setVisible(true)
-- listen skjules bag vinduet: dens tekster skinnede igennem panelet (in-game 29/9)
self.boxLayout:setVisible(false)
self:layoutEditPanel()
self:requestRebuild()
end
-- Lukker uden genopbygning (fanebyt, lukket menu, slettet post).
function SmartPickupFrame:hideEditPanel()
self.editPanelOpen, self.editPanelKind, self.editPanelId = false, nil, nil
self:clearPanelRows()
if self.editOverlay ~= nil then self.editOverlay:setVisible(false) end
if self.boxLayout ~= nil then self.boxLayout:setVisible(true) end
end
function SmartPickupFrame:closeEditPanel()
if not self.editPanelOpen then return end
self:closeGuide()
self:hideEditPanel()
self:requestRebuild()
end
-- Kaldes fra rebuild (efter listen): vinduets rækker genopbygges i editLayout.
function SmartPickupFrame:fillEditPanel(adEnv)
self:clearPanelRows()
local hooks = self:editPanelHooks()
if hooks == nil then return end
if not hooks.exists(self, self.editPanelId) then
self.statusOverride = self.i18n:getText("spu_panel_gone")
self.statusOverrideTicks = SmartPickupFrame.STATUS_OVERRIDE_TICKS
self:closeGuide()
self:hideEditPanel()
return
end
self:layoutEditPanel()
self.editTitle:setText(hooks.title(self, self.editPanelId))
self:withRowTarget(self.editLayout, function(frame)
if frame.guide ~= nil then
frame:fillGuideRows(adEnv)
else
hooks.fill(frame, adEnv, frame.editPanelId)
end
end)
self:colorLayout(self.editLayout)
self.editLayout:invalidateLayout()
self:refreshEditPanel(adEnv)
end
-- Hvert sekund: status i niveaufarve + knaptekster.
function SmartPickupFrame:refreshEditPanel(adEnv)
local hooks = self:editPanelHooks()
if hooks == nil or adEnv == nil then return end
if not hooks.exists(self, self.editPanelId) then
self.statusOverride = self.i18n:getText("spu_panel_gone")
self.statusOverrideTicks = SmartPickupFrame.STATUS_OVERRIDE_TICKS
self:closeEditPanel()
return
end
self:layoutEditPanel()
local status = hooks.status(self, adEnv, self.editPanelId)
self.editStatus:setText(tostring(status.text or ""))
local color = ADFlowView.LEVEL_COLORS[status.level] or ADFlowView.LEVEL_COLORS.stopped
if self.editStatus.setTextColor ~= nil then
self.editStatus:setTextColor(color[1], color[2], color[3], color[4])
end
self.panelStartButtonInfo.text = hooks.startStopText(self, adEnv, self.editPanelId)
self.panelStartButtonInfo.disabled = hooks.canStart ~= nil and not hooks.canStart(self, self.editPanelId)
end
-- ESC mens vinduet er åbent: menuen spørger requestClose; vinduet lukkes (trin først), menuen bliver.
-- Kalder spillet OGSÅ knappens callback for samme tryk, må det ikke lukke to trin (panelClosedAt).
SmartPickupFrame.PANEL_ESC_DEBOUNCE_MS = 250
function SmartPickupFrame:requestClose(callback)
if self.editPanelOpen then
self:onClickPanelClose()
return false
end
if g_time ~= nil and self.panelClosedAt ~= nil and g_time - self.panelClosedAt < SmartPickupFrame.PANEL_ESC_DEBOUNCE_MS then
return false
end
return SmartPickupFrame:superClass().requestClose(self, callback)
end
function SmartPickupFrame:onClickPanelClose()
if g_time ~= nil and self.panelClosedAt ~= nil and g_time - self.panelClosedAt < SmartPickupFrame.PANEL_ESC_DEBOUNCE_MS then
return
end
self.panelClosedAt = g_time
self:safe(function(frame)
-- et åbent [Edit]-trin lukkes først (kladden kasseres som ved Annullér)
if frame.guide ~= nil then
frame:closeGuide()
frame:requestRebuild()
return
end
frame:closeEditPanel()
end)
end
function SmartPickupFrame:onClickPanelStartStop()
self:safe(function(frame)
local hooks = frame:editPanelHooks()
if hooks ~= nil then hooks.startStop(frame, frame.editPanelId) end
frame:requestRebuild()
end)
end
function SmartPickupFrame:onClickPanelDelete()
self:safe(function(frame)
local hooks = frame:editPanelHooks()
if hooks ~= nil and hooks.canDelete then hooks.delete(frame, frame.editPanelId) end
end)
end
@@ -0,0 +1,242 @@
-- Smart Pickup — guiden "Opsæt mark" og fanen Markers knapper
-- Henter det spil-afhængige (afgrøder der kan sås, slåafgrøder, halm, maskiner, marksiloer, siloer i nærheden)
-- som `context` til ADFieldGuide og gemmer guidens resultat via ADFieldJobs. Selve trin-skærmen er fælles
-- (gui/SmartPickupGuide.lua). Funktionerne lægges på SmartPickupFrame (indlæses efter SmartPickupFrame.lua).
-- lagervalget viser de nærmeste siloer (plus valgte, marksiloer og gæringssiloer)
SmartPickupFrame.FIELD_STORE_ROWS = 8
function SmartPickupFrame:initFieldButtons(i18n)
self.fieldStartButtonInfo = {inputAction = InputAction.MENU_ACTIVATE, text = i18n:getText("spu_field_button_start"), callback = function() self:onClickFieldStartStop() end}
self.fieldSetupButtonInfo = {inputAction = InputAction.MENU_EXTRA_1, text = i18n:getText("spu_guide_field_open"), callback = function() self:onClickFieldSetup() end}
self.fieldFleetButtonInfo = {inputAction = InputAction.MENU_EXTRA_2, text = i18n:getText("spu_guide_fleet_open"), callback = function() self:onClickFieldFleet(true) end}
self.fieldFleetCloseButtonInfo = {inputAction = InputAction.MENU_EXTRA_2, text = i18n:getText("spu_guide_fleet_close"), callback = function() self:onClickFieldFleet(false) end}
-- v1.59: listen har Start/Stop pr. række; Opsæt mark ligger i redigeringsvinduet
self.fieldButtons = {self.backButtonInfo, self.fieldFleetButtonInfo}
self.fieldFleetButtons = {self.backButtonInfo, self.fieldFleetCloseButtonInfo}
end
function SmartPickupFrame:getFieldButtons()
return self.fieldView == "fleet" and self.fieldFleetButtons or self.fieldButtons
end
-- ---- context til ADFieldGuide ----
-- {[fieldId] = rigIds} for alle marker
function SmartPickupFrame.rigIdsByField()
local byField = {}
for fieldId, field in pairs(ADFieldJobs.state.fields) do byField[fieldId] = field.plan ~= nil and field.plan.rigIds or {} end
return byField
end
local function fruitTypes()
return g_fruitTypeManager ~= nil and g_fruitTypeManager.fruitTypes or {}
end
-- Afgrøder der kan sås, slåafgrøder (navne) og afgrøder med halm (skår efter mejetærskeren).
function SmartPickupFrame:fruitContext()
local sowable, straw, mowable = {}, {}, {}
for _, fruit in ipairs(fruitTypes()) do
if fruit.allowsSeeding ~= false and fruit.fillType ~= nil and fruit.name ~= nil then
table.insert(sowable, {name = fruit.name, title = tostring(fruit.fillType.title or fruit.name)})
end
local ok, hasStraw = pcall(function() return fruit.hasWindrow == true or fruit.windrowName ~= nil end)
if ok and hasStraw and fruit.name ~= nil then straw[fruit.name] = true end
end
if ADFields ~= nil and g_fruitTypeManager ~= nil then
for index in pairs(ADFields.getMowable()) do
local fruit = g_fruitTypeManager:getFruitTypeByIndex(index)
if fruit ~= nil and fruit.name ~= nil then mowable[fruit.name] = true end
end
end
return sowable, mowable, straw
end
-- Markens vare ud fra kladdens plan (ensilage -> skår, hø -> tørskår, høst -> afgrøden); nil på bar mark.
local function fieldFillType(info, plan)
if info.kind == "mowable" then
return ADFieldModel.product(plan, "mowable") == "hay" and info.hay or info.windrow
end
if info.kind == "harvest" then return info.harvestFillType end
return nil
end
-- Siloer der tager markens vare: marksilo først, gæringssiloer, så de nærmeste (samme regel som før).
function SmartPickupFrame:fieldStoreContext(adEnv, info, plan, chosenIds)
local fillType = fieldFillType(info, plan)
local farmId = g_currentMission:getFarmId()
local fieldSilos, silosById = {}, {}
for _, building in ipairs(ADFields.fieldSilos(adEnv, farmId, info)) do
local markers = ADBuildings.getMarkers ~= nil and ADBuildings.getMarkers(building, "unload") or {}
table.insert(fieldSilos, {id = building.id, name = tostring(building.name),
text = markers[1] ~= nil and tostring(markers[1].name) or tostring(building.name)})
silosById[building.id] = true
end
if fillType == nil then return nil, 0, fieldSilos end
local chosen = {}
for _, id in ipairs(chosenIds or {}) do chosen[id] = true end
local fermenterIds = {}
if info.kind == "mowable" and ADFieldModel.product(plan, "mowable") == "silage" then
for _, building in ipairs(ADFieldJobs.getFermenters(adEnv, farmId, fillType, info.x, info.z)) do fermenterIds[building.id] = true end
end
local entries = {}
for _, building in ipairs(ADBuildings.list(adEnv, farmId)) do
local isSilo = building.kind == "silo" and ADRunsController.canStoreAt(adEnv, building, {{fillType = fillType, isPallet = false}})
if chosen[building.id] or isSilo or fermenterIds[building.id] then
local distance = nil
local node = building.placeable ~= nil and building.placeable.rootNode or nil
if node ~= nil and info.x ~= nil then
local bx, _, bz = getWorldTranslation(node)
distance = math.sqrt((bx - info.x) ^ 2 + (bz - info.z) ^ 2)
end
table.insert(entries, {id = building.id, building = building, distance = distance, chosen = chosen[building.id] == true,
fieldSilo = silosById[building.id] == true, fermenter = fermenterIds[building.id] == true})
end
end
local shown, hidden = ADFieldView.storeRows(entries, SmartPickupFrame.FIELD_STORE_ROWS)
local stores = {}
for _, entry in ipairs(shown) do
local building = entry.building
local markers = ADBuildings.getMarkers ~= nil and ADBuildings.getMarkers(building, "unload") or {}
-- det der skelner rækkerne først (rækken afkorter bagfra): markør, afstand, art — så bygningens navn
local label = (markers[1] ~= nil and tostring(markers[1].name) or tostring(building.name)) .. ", " .. ADFieldView.distanceText(entry.distance)
if entry.fieldSilo then label = label .. " (" .. self.i18n:getText("spu_field_fieldSilo") .. ")" end
if entry.fermenter then label = label .. " (" .. self.i18n:getText("spu_field_fermenter") .. ")" end
if markers[1] ~= nil then label = label .. " — " .. tostring(building.name) end
table.insert(stores, {id = building.id, text = label})
end
return stores, hidden, fieldSilos
end
-- Markerne til trin 1 ("F22 — Hvede — Venter").
function SmartPickupFrame:fieldPickList()
local fields = {}
for _, row in ipairs(ADFieldView.rows(ADFields.list(), ADFieldJobs.state.fields)) do
local crop = ADFieldView.cropText(row.info, function(key) return self.i18n:getText(key) end)
table.insert(fields, {id = row.info.id, text = string.format("F%d — %s — %s", row.info.id, crop, self:fieldStatusText(row.field, row.info, true))})
end
return fields
end
-- context for en mark (info) med kladdens plan; info nil = trin 1 uden valgt mark.
function SmartPickupFrame:fieldGuideContext(adEnv, info, plan, chosenStoreIds)
local sowable, mowable, straw = self:fruitContext()
local context = {info = info or {}, sowable = sowable, mowable = mowable, straw = straw,
rigIdsByField = SmartPickupFrame.rigIdsByField(), fieldSilos = {}, storeHidden = 0, fields = {}, rigs = {}}
-- marklisten bruges kun i trin 1 (guiden åbnet uden valgt mark)
if info == nil then context.fields = self:fieldPickList() end
for _, rig in ipairs(ADFieldJobs.getRigs(adEnv)) do
table.insert(context.rigs, {id = rig.id, role = rig.role, text = string.format("%s — %s", tostring(rig.label), self:roleText(rig.role))})
end
if info ~= nil then
context.stores, context.storeHidden, context.fieldSilos = self:fieldStoreContext(adEnv, info, plan or {}, chosenStoreIds)
end
return context
end
-- ---- hooks til den fælles guide-skærm ----
local function chosenStoreIds(guide)
local ids = {}
for _, id in ipairs(guide.draft.storeIds or {}) do table.insert(ids, id) end
for _, id in ipairs(guide.draft.plan ~= nil and guide.draft.plan.storeBuildingIds or {}) do table.insert(ids, id) end
return ids
end
SmartPickupFrame.GUIDE_HOOKS = SmartPickupFrame.GUIDE_HOOKS or {}
SmartPickupFrame.GUIDE_HOOKS.field = {
model = ADFieldGuide,
tab = SmartPickupFrame.TAB_FIELDS,
cannotAdvanceKey = "spu_guide_pickField",
heading = function(frame, guide)
if guide.draft.fieldId == nil then return frame.i18n:getText("spu_guide_field_titleNone") end
return string.format(frame.i18n:getText("spu_guide_field_title"), guide.draft.fieldId)
end,
context = function(frame, adEnv, guide)
local fieldId = guide.draft.fieldId
if fieldId == nil then return frame:fieldGuideContext(adEnv, nil) end
local info = ADFields.getInfo(fieldId)
-- marken er solgt/bygget over mens guiden var åben
if info == nil or info.cultivable == false then return nil end
return frame:fieldGuideContext(adEnv, info, guide.draft.plan, chosenStoreIds(guide))
end,
choose = function(frame, id, value)
if id ~= "field" then return false end
local info = ADFields.getInfo(value)
if info == nil then return true end
local field = ADFieldJobs.getField(value)
frame.selectedFieldId = value
frame.statusOverride = nil
frame.guide = ADFieldGuide.pickField(frame.guide, field, frame:fieldGuideContext(frame.getAdEnv(), info, field.plan, field.plan.storeBuildingIds))
return true
end,
canAdvance = function(guide) return guide.draft.fieldId ~= nil end,
startKey = function() return "spu_guide_runAuto" end,
-- mål-afgrøde kører altid automatisk: ingen "Gem uden at starte"
showSaveOnly = function(guide) return (guide.draft.plan.targetFruit or "") == "" end,
save = function(frame, guide, start) frame:saveFieldGuide(guide, start) end,
afterRows = function(frame, adEnv, guide)
if guide.step ~= "summary" or guide.draft.fieldId == nil then return end
local info = guide.context.info
if info ~= nil and info.markerId == nil then
frame:addTextRow(" " .. string.format(frame.i18n:getText("spu_guide_noMarker"), guide.draft.fieldId))
end
end,
}
-- Gem: mål-afgrøde (hvis ændret), planen, og faste maskiner (fjernes fra andre marker).
function SmartPickupFrame:saveFieldGuide(guide, start)
local result = ADFieldGuide.result(guide, start)
if result.fieldId == nil then return end
if result.targetChanged then ADFieldJobs.setTarget(result.fieldId, result.targetFruit) end
ADFieldJobs.setPlan(result.fieldId, result.plan)
-- markens form ændret siden kurserne blev lavet: gemte Courseplay-kurser til marken glemmes
local fieldInfo = ADFields.getInfo(result.fieldId)
if fieldInfo ~= nil and ADCourseCache ~= nil then ADCourseCache.checkField(result.fieldId, fieldInfo.shapeSig) end
local byField = SmartPickupFrame.rigIdsByField()
for fieldId, ids in pairs(ADFieldGuide.applyRigs(byField, result.fieldId, result.rigIds)) do
if table.concat(byField[fieldId] or {}, " ") ~= table.concat(ids, " ") then
ADFieldJobs.setPlan(fieldId, {rigIds = ids})
end
end
ADFieldJobs.log(result.fieldId, "guide gemt: mode=%s mål=%s faste=%s lager=%s", tostring(result.plan.mode), result.targetFruit ~= "" and result.targetFruit or "-",
table.concat(result.rigIds, " "), table.concat(result.plan.storeBuildingIds or {}, " "))
self.selectedFieldId = result.fieldId
self.statusOverride = string.format(self.i18n:getText("spu_guide_saved"), result.fieldId)
self.statusOverrideTicks = SmartPickupFrame.STATUS_OVERRIDE_TICKS
end
-- ---- knapper og [Ret] ----
-- [Opsæt mark]: den valgte mark med de nuværende valg, ellers trin 1 (vælg mark).
function SmartPickupFrame:onClickFieldSetup()
self:safe(function(frame)
local adEnv = frame.getAdEnv()
if adEnv == nil then return end
local info = frame.selectedFieldId ~= nil and ADFields.getInfo(frame.selectedFieldId) or nil
frame.fieldView = nil
if info == nil then
frame:openGuide("field", ADFieldGuide.openPicker(frame:fieldGuideContext(adEnv, nil)))
return
end
local field = ADFieldJobs.getField(info.id)
frame:openGuide("field", ADFieldGuide.open(field, frame:fieldGuideContext(adEnv, info, field.plan, field.plan.storeBuildingIds)))
end)
end
-- [Ret] på en resumé-linje i fanens oversigt: kun det ene trin; [Gem] gemmer og går tilbage.
function SmartPickupFrame:openFieldStep(fieldId, step)
local adEnv = self.getAdEnv()
local info = ADFields.getInfo(fieldId)
if adEnv == nil or info == nil then return end
local field = ADFieldJobs.getField(fieldId)
local context = self:fieldGuideContext(adEnv, info, field.plan, field.plan.storeBuildingIds)
self:openGuide("field", ADFieldGuide.open(field, context, {step = step, single = true, returnTo = "close"}))
end
function SmartPickupFrame:onClickFieldFleet(show)
self:safe(function(frame)
frame.fieldView = show and "fleet" or nil
frame:requestRebuild()
end)
end
@@ -0,0 +1,117 @@
-- Smart Pickup — fanen Marksiloer
-- Opsætning (til/fra, maks. vogne, mindste mængde, kør til produktion + foderreserve, gårdlagre) og status for marksilo-flowet.
-- Klik går til onClickFieldSiloRow; reglerne ligger i ADFieldSilos/ADFieldSiloPlanner.
SmartPickupFrame.FIELDSILO_ROW_KINDS = {fsEnabled = true, fsMaxVehicles = true, fsMinLiters = true, fsStore = true,
fsToProduction = true, fsReserveDays = true}
local function numberTexts(from, to)
local texts = {}
for n = from, to do table.insert(texts, tostring(n)) end
return texts
end
local function literTexts()
local texts = {}
for _, liters in ipairs(ADFieldSiloConfig.MIN_LITERS_CHOICES) do table.insert(texts, string.format("%d l", liters)) end
return texts
end
local function dayTexts(i18n)
local texts = {}
for _, days in ipairs(ADFieldSiloConfig.RESERVE_DAYS_CHOICES) do table.insert(texts, string.format(i18n:getText("spu_fs_days"), days)) end
return texts
end
local function indexOf(list, value)
for index, entry in ipairs(list) do if entry == value then return index end end
return 1
end
-- Valg med flere værdier: productPrefab (MultiTextOption). ingredientPrefab er en BinaryOption med kun to sider.
function SmartPickupFrame:addFieldSiloChoiceRow(kind, title, texts, state)
local element = self:addRow(self.productPrefab, {kind = kind})
local option = element:getDescendantByName("option")
option:setTexts(texts)
option:setState(state, false)
element:getDescendantByName("title"):setText(title)
local tooltip = option:getDescendantByName("ignore")
if tooltip ~= nil then tooltip:setText("") end
return option
end
function SmartPickupFrame:fillFieldSiloRows(adEnv)
local i18n, cfg = self.i18n, ADFieldSilos.config
local onOff = {i18n:getText("ui_no"), i18n:getText("ui_yes")}
self:addToggleRow("fsEnabled", i18n:getText("spu_fs_enabled"), onOff, cfg.enabled)
self:addFieldSiloChoiceRow("fsMaxVehicles", i18n:getText("spu_fs_maxVehicles"), numberTexts(1, ADFieldSiloConfig.MAX_VEHICLES), cfg.maxVehicles)
self:addFieldSiloChoiceRow("fsMinLiters", i18n:getText("spu_fs_minLiters"), literTexts(), indexOf(ADFieldSiloConfig.MIN_LITERS_CHOICES, cfg.minLiters))
self:addToggleRow("fsToProduction", i18n:getText("spu_fs_toProduction"), onOff, cfg.toProduction)
if cfg.toProduction then
self:addFieldSiloChoiceRow("fsReserveDays", i18n:getText("spu_fs_reserveDays"), dayTexts(i18n), indexOf(ADFieldSiloConfig.RESERVE_DAYS_CHOICES, cfg.reserveDays))
end
local plan = ADFieldSilos.lastPlan
local waiting = 0
for _, entry in ipairs(plan ~= nil and plan.status or {}) do if entry.state == "ok" then waiting = waiting + 1 end end
self:addTextRow(string.format(i18n:getText("spu_fs_status"), ADFieldSilos.loanCount(), cfg.maxVehicles, waiting))
if #cfg.storeIds == 0 then self:addTextRow(i18n:getText("spu_fs_noStores")) end
local why = ADFieldSilos.lastVehicleReasons
if waiting > 0 and ADFieldSilos.loanCount() < cfg.maxVehicles and why ~= nil and why.eligible == 0 then
self:addTextRow(string.format(i18n:getText("spu_fs_noVehicle"), why.noPark, why.busy, why.controlled, why.notEnabled))
end
if why ~= nil and (why.harvest or 0) > 0 then
self:addTextRow(string.format(i18n:getText("spu_fs_harvestHeld"), why.harvest))
end
self:addTextRow(i18n:getText("spu_fs_silosTitle"))
if plan == nil or #plan.status == 0 then
self:addTextRow(" " .. i18n:getText("spu_fs_nothing"))
else
self:addCellRow(self.fieldPrefab, {i18n:getText("spu_fs_col_silo"), i18n:getText("spu_col_crop"), i18n:getText("spu_fs_col_liters"),
i18n:getText("spu_fs_col_onWay"), i18n:getText("spu_fs_col_state"), ""})
for _, entry in ipairs(plan.status) do
local desc = g_fillTypeManager:getFillTypeByIndex(entry.fillType)
local title = desc ~= nil and (desc.title or desc.name) or tostring(entry.fillType)
local state = i18n:getText("spu_fs_state_" .. entry.state)
if entry.state == "noFarmStore" then state = string.format(state, title) end
if entry.toProduction then state = state .. " " .. i18n:getText("spu_fs_toFactory") end
self:addCellRow(self.fieldPrefab, {tostring(entry.siloName), title, SmartPickupFrame.formatCompact(entry.liters or 0),
SmartPickupFrame.formatCompact(entry.onWay or 0), state, ""})
end
end
self:addTextRow(i18n:getText("spu_fs_storesTitle"))
local farmId = g_currentMission:getFarmId()
for _, building in ipairs(ADBuildings.list(adEnv, farmId)) do
if building.kind == "silo" then
local option = self:addToggleRow("fsStore", tostring(building.name), onOff, ADFieldSiloConfig.hasStore(cfg, building.id))
self.rowData[option] = {kind = "fsStore", buildingId = building.id}
end
end
end
function SmartPickupFrame:onClickFieldSiloRow(state, data)
self:safe(function(frame)
local cfg = ADFieldSilos.config
if data.kind == "fsEnabled" then
cfg = ADFieldSiloConfig.with(cfg, {enabled = state == SmartPickupFrame.STATE_ON})
elseif data.kind == "fsMaxVehicles" then
cfg = ADFieldSiloConfig.with(cfg, {maxVehicles = state})
elseif data.kind == "fsMinLiters" then
cfg = ADFieldSiloConfig.with(cfg, {minLiters = ADFieldSiloConfig.MIN_LITERS_CHOICES[state]})
elseif data.kind == "fsToProduction" then
cfg = ADFieldSiloConfig.with(cfg, {toProduction = state == SmartPickupFrame.STATE_ON})
elseif data.kind == "fsReserveDays" then
cfg = ADFieldSiloConfig.with(cfg, {reserveDays = ADFieldSiloConfig.RESERVE_DAYS_CHOICES[state]})
elseif data.kind == "fsStore" then
cfg = ADFieldSiloConfig.toggleStore(cfg, data.buildingId)
end
ADFieldSilos.setConfig(cfg)
pcall(ADFieldSilos.save)
local adEnv = frame.getAdEnv()
if adEnv ~= nil then pcall(ADFieldSilos.planNow, adEnv) end
frame:requestRebuild()
end)
end
+222 -227
View File
@@ -1,16 +1,12 @@
-- Smart Pickup — fanen Marker
-- Tabel over alle gårdens marker (Mark · Afgrøde · Plan · Kørsel · Status · Maskiner); klik på en række
-- viser markens detaljer under tabellen: plan efter markens art (slåafgrøde, høst, bar), maskiner pr. trin,
-- aflæsningsvogne, efter høst, såning, pleje, tving lager, jordtilstand. Nederst markflåden med flueben.
-- Al logik ligger i ADFieldJobs/ADFieldWork/ADFieldModel/ADFieldView.
-- Oversigt: tabel over alle gårdens marker (Mark · Afgrøde · Plan · Kørsel · Status · Maskiner); klik på en
-- række viser markens resumé (én linje pr. emne — klik på en linje for at rette netop det trin), hele
-- statusteksten og markøren. [Opsæt mark] åbner guiden (gui/SmartPickupFieldGuide.lua + SmartPickupGuide.lua),
-- [Markflåde] viser den fælles markflåde. Al logik ligger i ADFieldJobs/ADFieldModel/ADFieldView/ADFieldGuide.
-- Funktionerne lægges på SmartPickupFrame (indlæses efter SmartPickupFrame.lua).
SmartPickupFrame.FIELD_ROW_KINDS = {
fieldProduct = true, fieldMode = true, fieldRake = true, fieldStore = true, fleetVehicle = true,
fieldRigs = true, fieldUnloaders = true, fieldMulch = true, fieldLime = true, fieldSoil = true,
fieldSow = true, fieldSowFruit = true, fieldRoll = true, fieldFertilize = true, fieldWeed = true,
fieldTarget = true, fleetMode = true, fieldStraw = true,
}
-- Markflådens rækker (vehiclePrefab/ingredientPrefab) routes til onClickFieldRow.
SmartPickupFrame.FIELD_ROW_KINDS = {fleetVehicle = true, fleetMode = true, fleetManual = true}
SmartPickupFrame.ROLE_KEYS = {
mower = "spu_field_role_mower", tedder = "spu_field_role_tedder", rake = "spu_field_role_rake",
collector = "spu_field_role_collector", harvester = "spu_field_role_harvester", unloader = "spu_field_role_unloader",
@@ -19,15 +15,6 @@ SmartPickupFrame.ROLE_KEYS = {
weeder = "spu_field_role_weeder", fertilizer = "spu_field_role_fertilizer",
baler = "spu_field_role_baler", baleCollector = "spu_field_role_baleCollector",
}
SmartPickupFrame.SOIL_VALUES = {"auto", "cultivate", "plow", "none"}
-- simple Ja/Nej-valg på planen: rækketype -> plan-felt
SmartPickupFrame.PLAN_TOGGLES = {
fieldRake = "rake", fieldMulch = "mulch", fieldLime = "lime", fieldSow = "sow", fieldRoll = "roll",
fieldFertilize = "fertilize", fieldWeed = "weed",
}
-- valg med flere muligheder: rækketype -> plan-felt
SmartPickupFrame.PLAN_CHOICES = {fieldRigs = "rigs", fieldUnloaders = "unloaders", fieldSoil = "soil", fieldSowFruit = "sowFruit", fieldStraw = "straw"}
SmartPickupFrame.STRAW_VALUES = {"chop", "collect", "bale"}
-- ---- tekster ----
@@ -53,11 +40,32 @@ function SmartPickupFrame:soilText(info)
return table.concat(texts, ", ")
end
function SmartPickupFrame:fieldStatusText(field, info)
-- Kort årsag til tabellens statuskolonne ("bredde ukendt" i stedet for hele forklaringen, som står i
-- markens detaljer). Fritekst-årsager (fx "… er tom for X") forkortes til "se detaljer".
function SmartPickupFrame:fieldReasonShort(reason)
if type(reason) ~= "table" then return nil end
if reason.text ~= nil then return self.i18n:getText("spu_field_reasonShort_text") end
local shortKey = reason.key ~= nil and string.gsub(reason.key, "^spu_field_reason_", "spu_field_reasonShort_") or nil
if shortKey ~= nil then
local okHas, has = pcall(function() return self.i18n.hasText == nil or self.i18n:hasText(shortKey) end)
if okHas and has then return self.i18n:getText(shortKey) end
end
return self:fieldReasonText(reason)
end
-- short = tabellens korte udgave (trin + kort årsag); ellers hele teksten (markens detaljer).
function SmartPickupFrame:fieldStatusText(field, info, short)
local text = self.i18n:getText(ADFieldView.statusKey(field, info))
if info.markerId == nil then
return string.format(text, info.id)
end
if short then
if (field.stage == "idle" or field.stage == "ready") and info.kind ~= "bare" and info.readyRatio ~= nil then
text = string.format("%s %d %%", text, math.floor((info.readyRatio or 0) * 100 + 0.5))
end
local reason = self:fieldReasonShort(field.reason)
return reason ~= nil and (text .. ": " .. reason) or text
end
if (field.stage == "idle" or field.stage == "ready") and info.kind ~= "bare" and info.readyRatio ~= nil then
text = string.format("%s %d %%", text, math.floor((info.readyRatio or 0) * 100 + 0.5))
end
@@ -74,18 +82,6 @@ function SmartPickupFrame:fieldStatusText(field, info)
return text
end
-- Hvad moden gør med marken frem mod mål-afgrøden (én linje under valget).
function SmartPickupFrame:targetSummary(field, info)
local target = self:fruitTitle(field.plan.targetFruit)
if info.kind == "bare" then
return string.format(self.i18n:getText("spu_field_target_bare"), target)
end
if info.fruitName == field.plan.targetFruit and info.kind == "mowable" then
return string.format(self.i18n:getText("spu_field_target_keep"), target)
end
return string.format(self.i18n:getText("spu_field_target_switch"), tostring(info.cropTitle or "-"), target)
end
function SmartPickupFrame:fruitTitle(name)
local fruit = g_fruitTypeManager ~= nil and name ~= nil and name ~= "" and g_fruitTypeManager:getFruitTypeByName(name) or nil
return fruit ~= nil and fruit.fillType ~= nil and tostring(fruit.fillType.title) or tostring(name)
@@ -108,209 +104,135 @@ function SmartPickupFrame:fieldCells(row)
for _, assignment in ipairs(ADFieldJobs.assignmentsFor(info.id)) do
table.insert(names, ADRuns.vehicleLabel(assignment.vehicle))
end
local name = string.format("%s%sF%d", info.id == self.selectedFieldId and "> " or "", info.markerId == nil and "! " or "", info.id)
local crop = info.kind == "bare" and self.i18n:getText("spu_field_bare") or (info.cropTitle or "-")
local name = string.format("%sF%d", info.markerId == nil and "! " or "", info.id)
local crop = ADFieldView.cropText(info, function(key) return self.i18n:getText(key) end)
return {
name, crop, self:fieldPlanText(field, info),
self.i18n:getText(field.plan.mode == "auto" and "spu_field_mode_auto" or "spu_field_mode_manual"),
self:fieldStatusText(field, info),
self:fieldStatusText(field, info, true) .. ((ADFieldJobs.state.paused and not ADFieldJobs.isFieldResumed(info.id)
and ADFieldModel.isWorking(field)) and (" " .. self.i18n:getText("spu_field_pausedSuffix")) or ""),
ADFlowView.joinCounted(names, ", ", "-"),
self:fieldActionText(info.id),
}
end
-- ---- rækker ----
-- Valg med flere muligheder (klon af productPrefab): values[i] er værdien for texts[i].
function SmartPickupFrame:addChoiceRow(kind, title, texts, values, current)
local row = self:addRow(self.productPrefab, {kind = kind, values = values})
local option = row:getDescendantByName("option")
option:setTexts(texts)
local index = 1
for position, value in ipairs(values) do
if value == current then index = position end
end
option:setState(index, false)
row:getDescendantByName("title"):setText(title)
return option
end
local function numberTexts(from, to)
local texts, values = {}, {}
for value = from, to do
table.insert(texts, tostring(value))
table.insert(values, value)
end
return texts, values
end
-- Afgrøder der kan sås (første valg: samme som den høstede / ingen mål-afgrøde).
function SmartPickupFrame:sowFruitChoices(firstKey)
local texts, values = {self.i18n:getText(firstKey or "spu_field_sowSame")}, {""}
for _, fruit in ipairs(g_fruitTypeManager ~= nil and g_fruitTypeManager.fruitTypes or {}) do
if fruit.allowsSeeding ~= false and fruit.fillType ~= nil and fruit.name ~= nil then
table.insert(texts, tostring(fruit.fillType.title or fruit.name))
table.insert(values, fruit.name)
end
end
return texts, values
end
-- ---- opbygning ----
function SmartPickupFrame:fillFieldRows(adEnv)
self.fieldRows = {}
if not self.editPanelOpen and self:hasGuideOn(SmartPickupFrame.TAB_FIELDS) and self:fillGuideRows(adEnv) then return end
if self.fieldView == "fleet" then
self:fillFleetRows(adEnv)
return
end
local i18n = self.i18n
local infos = ADFields.list()
if #infos == 0 then
self:addTextRow(i18n:getText("spu_field_scanning"))
else
self:addTextRow(i18n:getText("spu_field_hint"))
self:addCellRow(self.fieldPrefab, {i18n:getText("spu_col_field"), i18n:getText("spu_col_crop"), i18n:getText("spu_col_plan"), i18n:getText("spu_col_mode"), i18n:getText("spu_col_status"), i18n:getText("spu_col_tractors")})
-- Stop alt-pausen gemmes med savegamet: sig det tydeligt her (in-game 29/9: "jeg har ikke trykket stop")
if ADFieldJobs.state.paused then self:addTextRow(i18n:getText("spu_field_pausedBanner")) end
-- markflåden på "kun med flueben" uden flueben: intet kan sendes ud (in-game 26/9 — alle marker "no machine")
if ADFieldJobs.state.fleetMode == "ticked" and next(ADFieldJobs.state.fleet or {}) == nil then
self:addTextRow(i18n:getText("spu_field_fleetEmpty"))
end
self:addCellRow(self.fieldListPrefab, {i18n:getText("spu_col_field"), i18n:getText("spu_col_crop"), i18n:getText("spu_col_plan"), i18n:getText("spu_col_mode"), i18n:getText("spu_col_status"), i18n:getText("spu_col_tractors"), ""})
for _, row in ipairs(ADFieldView.rows(infos, ADFieldJobs.state.fields)) do
local cells = self:addCellRow(self.fieldPrefab, self:fieldCells(row))
local cells = self:addCellRow(self.fieldListPrefab, self:fieldCells(row))
local fieldId = row.info.id
if cells[1] ~= nil and cells[1].parent ~= nil then
local actionCell = cells[7]
self:makeRowClickable(cells[1].parent, function(frame)
-- klik på [Start]/[Stop]-feltet = handling (kun denne mark); ellers åbn redigeringsvinduet
if frame.lastClickX ~= nil and actionCell ~= nil and actionCell.absPosition ~= nil and frame.lastClickX >= actionCell.absPosition[1] then
frame:toggleField(fieldId)
else
frame.selectedFieldId = fieldId
frame:requestRebuild()
frame:openEditPanel("field", fieldId)
end
end)
end
table.insert(self.fieldRows, {id = fieldId, cells = cells})
end
end
local selected = self.selectedFieldId ~= nil and ADFields.getInfo(self.selectedFieldId) or nil
if selected ~= nil then
self:fillFieldDetail(adEnv, selected)
end
self:fillFleetRows(adEnv)
self:refreshFieldLevels()
end
function SmartPickupFrame:fillFieldDetail(adEnv, info)
-- Prik og statusfarve pr. mark i listen.
function SmartPickupFrame:refreshFieldLevels()
local unpackFn = table.unpack or unpack
for _, entry in ipairs(self.fieldRows or {}) do
local color = ADFlowView.LEVEL_COLORS[self:fieldLevel(entry.id)]
local row = entry.cells[1] ~= nil and entry.cells[1].parent or nil
local dot = row ~= nil and row:getDescendantByName("dot") or nil
if dot ~= nil and dot.setImageColor ~= nil then dot:setImageColor(nil, unpackFn(color)) end
if entry.cells[5] ~= nil and entry.cells[5].setTextColor ~= nil then entry.cells[5]:setTextColor(unpackFn(color)) end
end
end
-- Arbejder marken reelt (ikke bare et plan-trin under Stop alt-pause)?
function SmartPickupFrame:fieldIsActive(id, field)
field = field or ADFieldJobs.getField(id)
return field ~= nil and ADFieldView.isActiveWork({working = ADFieldModel.isWorking(field),
paused = ADFieldJobs.state.paused == true, resumed = ADFieldJobs.isFieldResumed(id)})
end
-- [Start] kræver en markør (Stop/Overtag altid muligt når marken arbejder).
function SmartPickupFrame:fieldCanStart(id)
local field, info = ADFieldJobs.getField(id), ADFields.getInfo(id)
if field == nil or info == nil then return false end
return ADFieldView.canStart({hasMarker = info.markerId ~= nil,
working = self:fieldIsActive(id, field) or ADFieldJobs.awaitingPlayer(id) ~= nil})
end
function SmartPickupFrame:fieldLevel(id)
local field, info = ADFieldJobs.getField(id), ADFields.getInfo(id)
if field == nil or info == nil then return "stopped" end
local reason = type(field.reason) == "table" and field.reason.key or nil
return ADFieldView.fieldStatus({hasMarker = info.markerId ~= nil, working = ADFieldModel.isWorking(field),
paused = ADFieldJobs.state.paused == true, resumed = ADFieldJobs.isFieldResumed(id),
awaiting = ADFieldJobs.awaitingPlayer(id) ~= nil,
startRequested = ADFieldJobs.isStartRequested ~= nil and ADFieldJobs.isStartRequested(id) or false,
reasonKey = reason})
end
-- forButton: knaplinjens tekst (Start/Stop) i stedet for listens [Start]/[Stop].
function SmartPickupFrame:fieldActionText(id, forButton)
local field = ADFieldJobs.getField(id)
if ADFieldJobs.awaitingPlayer(id) ~= nil then return self.i18n:getText("spu_field_button_takeover") end
local isWorking = self:fieldIsActive(id, field)
if forButton then return self.i18n:getText(isWorking and "spu_field_button_stop" or "spu_field_button_start") end
return self.i18n:getText(isWorking and "spu_flowList_stop" or "spu_flowList_start")
end
-- Den valgte marks resumé: én klikbar linje pr. emne ([Ret] åbner kun det trin), hele status, markør og
-- advarsel om marksilo der ikke tømmes hjem.
function SmartPickupFrame:fillFieldOverview(adEnv, info)
local i18n = self.i18n
local field = ADFieldJobs.getField(info.id)
local plan = field.plan
local yesNo = {i18n:getText("ui_no"), i18n:getText("ui_yes")}
local crop = info.kind == "bare" and i18n:getText("spu_field_bare") or tostring(info.cropTitle or "-")
local crop = ADFieldView.cropText(info, function(key) return i18n:getText(key) end)
if not self.editPanelOpen then
self:addTextRow(string.format(i18n:getText("spu_field_section"), info.id, crop))
local targetTexts, targetValues = self:sowFruitChoices("spu_field_target_none")
self:addChoiceRow("fieldTarget", i18n:getText("spu_field_target"), targetTexts, targetValues, plan.targetFruit or "")
local hasTarget = plan.targetFruit ~= nil and plan.targetFruit ~= ""
if hasTarget then
self:addTextRow(" " .. self:targetSummary(field, info))
else
self:addToggleRow("fieldMode", i18n:getText("spu_field_mode"), {i18n:getText("spu_field_mode_manual"), i18n:getText("spu_field_mode_auto")}, plan.mode == "auto")
end
local rigTexts, rigValues = numberTexts(1, ADFieldModel.MAX_RIGS)
self:addChoiceRow("fieldRigs", i18n:getText("spu_field_rigs"), rigTexts, rigValues, plan.rigs or 1)
-- græs-valgene (ensilage/hø, riv) også når målet er en slåafgrøde (fx græs på en kornmark)
local targetMowable = false
if hasTarget and ADFields ~= nil and g_fruitTypeManager ~= nil then
local targetFruit = g_fruitTypeManager:getFruitTypeByName(plan.targetFruit)
targetMowable = targetFruit ~= nil and ADFields.getMowable()[targetFruit.index] ~= nil
self:addTextRow(" " .. i18n:getText("spu_guide_sum_hint"))
local context = self:fieldGuideContext(adEnv, info, field.plan, field.plan.storeBuildingIds)
local guide = ADFieldGuide.open(field, context)
local getText = function(key) return i18n:getText(key) end
local fieldId = info.id
for _, line in ipairs(ADFieldGuide.summary(guide)) do
local step = line.step
self:addLinkRow(" " .. ADGuide.lineText(line, getText) .. i18n:getText("spu_guide_editSuffix"), function(frame)
frame:openFieldStep(fieldId, step)
end)
end
if info.kind == "mowable" or targetMowable then
local isHay = ADFieldModel.product(plan, "mowable") == "hay"
self:addToggleRow("fieldProduct", i18n:getText("spu_field_plan"), {i18n:getText("spu_field_plan_silage"), i18n:getText("spu_field_plan_hay")}, isHay)
self:addToggleRow("fieldRake", i18n:getText("spu_field_rake"), yesNo, plan.rake == true)
end
-- klargøring: høst-/bare marker, og græsmarker der skal skifte til en anden mål-afgrøde
if info.kind ~= "mowable" or (hasTarget and info.fruitName ~= plan.targetFruit) then
if info.kind == "harvest" then
local unloaderTexts, unloaderValues = numberTexts(1, ADFieldModel.MAX_UNLOADERS)
self:addChoiceRow("fieldUnloaders", i18n:getText("spu_field_unloaders"), unloaderTexts, unloaderValues, plan.unloaders or 1)
local strawTexts = {}
for _, value in ipairs(SmartPickupFrame.STRAW_VALUES) do table.insert(strawTexts, i18n:getText("spu_field_straw_" .. value)) end
self:addChoiceRow("fieldStraw", i18n:getText("spu_field_straw"), strawTexts, SmartPickupFrame.STRAW_VALUES, plan.straw or "chop")
end
self:addTextRow(i18n:getText(info.kind == "harvest" and "spu_field_section_after" or "spu_field_section_prep"))
self:addToggleRow("fieldMulch", i18n:getText("spu_field_mulch"), yesNo, plan.mulch == true)
local soilTexts = {}
for _, value in ipairs(SmartPickupFrame.SOIL_VALUES) do table.insert(soilTexts, i18n:getText("spu_field_soil_" .. value)) end
self:addChoiceRow("fieldSoil", i18n:getText("spu_field_soil"), soilTexts, SmartPickupFrame.SOIL_VALUES, plan.soil or "auto")
if not hasTarget then
self:addToggleRow("fieldSow", i18n:getText("spu_field_sow"), yesNo, plan.sow == true)
if plan.sow then
local fruitTexts, fruitValues = self:sowFruitChoices()
self:addChoiceRow("fieldSowFruit", i18n:getText("spu_field_sowFruit"), fruitTexts, fruitValues, plan.sowFruit or "")
end
end
if hasTarget or plan.sow then
self:addToggleRow("fieldRoll", i18n:getText("spu_field_roll"), yesNo, plan.roll == true)
end
end
self:addTextRow(i18n:getText("spu_field_section_care"))
self:addToggleRow("fieldFertilize", i18n:getText("spu_field_fertilize"), yesNo, plan.fertilize ~= false)
self:addToggleRow("fieldLime", i18n:getText("spu_field_lime"), yesNo, plan.lime ~= false)
if info.kind ~= "mowable" then
self:addToggleRow("fieldWeed", i18n:getText("spu_field_weed"), yesNo, plan.weed ~= false)
end
local soil = self:soilText(info)
self:addTextRow(" " .. string.format(i18n:getText("spu_field_soilState"), soil ~= "" and soil or i18n:getText("spu_field_soilOk")))
self:addTextRow(" " .. string.format(i18n:getText("spu_field_statusFull"), self:fieldStatusText(field, info)))
if info.markerId ~= nil then
self:addTextRow(string.format(i18n:getText("spu_field_marker"), tostring(info.markerName), info.markerDistance or 0))
else
self:addTextRow(" " .. string.format(i18n:getText("spu_field_noMarker"), info.id))
end
if field.mowWidth ~= nil then
self:addTextRow(string.format(i18n:getText("spu_field_mowWidth"), field.mowWidth))
end
if field.plan.rake and field.windrowOffset ~= nil then
self:addTextRow(string.format(i18n:getText("spu_field_windrowOffset"), math.abs(field.windrowOffset),
i18n:getText(math.abs(field.windrowOffset) <= 0.05 and "spu_field_side_center" or (field.windrowOffset > 0 and "spu_field_side_left" or "spu_field_side_right"))))
end
self:fillFieldStoreRows(adEnv, info, field)
end
-- Tving lager: siloer der tager markens vare (skår/tørskår/høst); ved ensilage også gæringssiloer.
function SmartPickupFrame:fillFieldStoreRows(adEnv, info, field)
local i18n = self.i18n
local product = ADFieldModel.product(field.plan, info.kind)
local fillType
if info.kind == "mowable" then
fillType = product == "hay" and info.hay or info.windrow
else
fillType = info.harvestFillType
end
if fillType == nil then return end
self:addTextRow(i18n:getText("spu_field_section_store"))
local chosen = {}
for _, id in ipairs(field.plan.storeBuildingIds) do chosen[id] = true end
local farmId = g_currentMission:getFarmId()
local fieldSilos = {}
for _, building in ipairs(ADFields.fieldSilos(adEnv, farmId, info)) do fieldSilos[building.id] = building end
local fermenterIds = {}
if info.kind == "mowable" and product == "silage" then
for _, building in ipairs(ADFieldJobs.getFermenters(adEnv, farmId, fillType, info.x, info.z)) do fermenterIds[building.id] = true end
end
local count = 0
for _, building in ipairs(ADBuildings.list(adEnv, farmId)) do
local isSilo = building.kind == "silo" and ADRunsController.canStoreAt(adEnv, building, {{fillType = fillType, isPallet = false}})
if chosen[building.id] or isSilo or fermenterIds[building.id] then
count = count + 1
local row = self:addRow(self.ingredientPrefab, {kind = "fieldStore", buildingId = building.id})
local suffix = fieldSilos[building.id] ~= nil and (" (" .. i18n:getText("spu_field_fieldSilo") .. ")") or ""
if fermenterIds[building.id] then suffix = suffix .. " (" .. i18n:getText("spu_field_fermenter") .. ")" end
row:getDescendantByName("title"):setText(" " .. tostring(building.name) .. suffix)
row:getDescendantByName("option"):setState(chosen[building.id] and SmartPickupFrame.STATE_ON or SmartPickupFrame.STATE_OFF, false)
end
end
if count == 0 then
self:addTextRow(" " .. i18n:getText("spu_field_noStore"))
elseif #field.plan.storeBuildingIds == 0 then
self:addTextRow(" " .. i18n:getText((info.kind == "mowable" and product == "silage") and "spu_field_storeAutoSilage" or "spu_field_storeAuto"))
end
-- marksiloer skal tømmes hjem af et udkørselsflow
for id, building in pairs(fieldSilos) do
local emptied = false
for _, run in ipairs(ADRunsController.store.runs) do
for _, sourceId in ipairs(run.kind == "outbound" and run.sourceBuildingIds or {}) do
if sourceId == id then emptied = true end
end
end
if not emptied then
self:addTextRow(" " .. string.format(i18n:getText("spu_field_fieldSiloNotEmptied"), tostring(building.name)))
end
self:addTextRow(" " .. string.format(i18n:getText("spu_guide_noMarker"), info.id))
end
-- marksilo-advarsler vises ikke her: de gælder ikke marken, og Field Silos-fanen tømmer marksiloerne selv
end
-- Ekstra oplysning pr. sæt i markflåden (afgrøder, frø, strigle/sprøjte, bredde over 50 m, mark).
@@ -326,18 +248,28 @@ function SmartPickupFrame:rigDetails(rig)
elseif rig.role == "weeder" then
table.insert(parts, i18n:getText(rig.mechanical and "spu_field_rig_mechanical" or "spu_field_rig_herbicide"))
end
if (rig.width or 0) > ADFieldModel.CP_MAX_WIDTH then table.insert(parts, i18n:getText("spu_field_tooWideShort")) end
-- samme grænse som udsendelsen (50,0 m med afrunding er ikke "over 50 m")
if (rig.width or 0) > ADFieldModel.CP_MAX_WIDTH + ADFieldModel.WIDTH_TOLERANCE then table.insert(parts, i18n:getText("spu_field_tooWideShort")) end
if (rig.width or 0) < ADFieldModel.MIN_WORK_WIDTH and not ADFieldModel.NO_WIDTH_ROLES[rig.role] then table.insert(parts, i18n:getText("spu_field_widthUnknownShort")) end
-- fast på en mark (valgt under markens "Faste maskiner") og hvor den arbejder nu
for fieldId, field in pairs(ADFieldJobs.state.fields) do
for _, id in ipairs(field.plan ~= nil and field.plan.rigIds or {}) do
if id == rig.id then table.insert(parts, string.format(i18n:getText("spu_field_dedicatedElsewhere"), tostring(fieldId))) end
end
end
local assignment = ADFieldJobs.state.assignments[rig.id]
if assignment ~= nil then table.insert(parts, string.format("F%d", assignment.fieldId)) end
if assignment ~= nil then table.insert(parts, string.format(i18n:getText("spu_field_rig_working"), tostring(assignment.fieldId))) end
return #parts > 0 and (" — " .. table.concat(parts, " — ")) or ""
end
-- Markflåden (egen visning): alle maskiner med flueben, "fast på F22", "i gang på F108", bredde-advarsler.
function SmartPickupFrame:fillFleetRows(adEnv)
local i18n = self.i18n
self:addTextRow(i18n:getText("spu_field_section_fleet"))
local useAll = ADFieldJobs.state.fleetMode ~= "ticked"
self:addToggleRow("fleetMode", i18n:getText("spu_field_fleetAll"), {i18n:getText("ui_no"), i18n:getText("ui_yes")}, useAll)
local rigs = ADFieldJobs.getRigs(adEnv)
local rigs = {}
for _, rig in ipairs(ADFieldJobs.getRigs(adEnv)) do table.insert(rigs, rig) end
table.sort(rigs, function(a, b)
if a.role ~= b.role then return a.role < b.role end
return ADFlowView.naturalLess(a.label, b.label)
@@ -347,15 +279,55 @@ function SmartPickupFrame:fillFleetRows(adEnv)
local title = string.format(" %s — %s, %.1f m%s", tostring(rig.label), self:roleText(rig.role), rig.width or 0, self:rigDetails(rig))
row:getDescendantByName("title"):setText(title)
row:getDescendantByName("option"):setState(rig.enabled and SmartPickupFrame.STATE_ON or SmartPickupFrame.STATE_OFF, false)
if rig.role == "harvester" then
-- Ja = må køre selv på vejen (som resten af flåden: ja = må sendes ud); Nej = manuel transport —
-- for bred med skærebord, spilleren kører den til marken (in-game 27/9: omvendt betydning forvirrede)
local option = self:addToggleRow("fleetManual", string.format(i18n:getText("spu_field_manualTransport"), tostring(rig.label)),
{i18n:getText("ui_no"), i18n:getText("ui_yes")}, not rig.manual)
self.rowData[option] = {kind = "fleetManual", vehicleId = rig.id}
end
end
if #rigs == 0 then
self:addTextRow(" " .. i18n:getText("spu_field_noRigs"))
end
if ADPool ~= nil and ADPool.overview ~= nil then self:fillPoolRows(adEnv) end
end
-- Maskinpuljen: solo-traktorer (flueben som resten af flåden) og redskaber med status og hvorfor de ikke er med.
function SmartPickupFrame:fillPoolRows(adEnv)
local i18n = self.i18n
local ok, implements, tractors = pcall(ADPool.overview, adEnv)
if not ok then return end
local byLabel = function(a, b) return ADFlowView.naturalLess(a.label, b.label) end
table.sort(tractors, byLabel)
table.sort(implements, function(a, b)
if a.role ~= b.role then return a.role < b.role end
return byLabel(a, b)
end)
self:addTextRow(i18n:getText("spu_pool_section_tractors"))
for _, tractor in ipairs(tractors) do
local row = self:addRow(self.vehiclePrefab, {kind = "fleetVehicle", vehicleId = tractor.id})
local status = string.format(i18n:getText("spu_pool_tractor_" .. tractor.status), tostring(tractor.arg))
local hp = tractor.hp ~= nil and string.format(", %.0f hk", tractor.hp) or ""
row:getDescendantByName("title"):setText(string.format(" %s%s — %s", tostring(tractor.label), hp, status))
row:getDescendantByName("option"):setState(tractor.enabled and SmartPickupFrame.STATE_ON or SmartPickupFrame.STATE_OFF, false)
end
if #tractors == 0 then self:addTextRow(" " .. i18n:getText("spu_pool_noTractors")) end
self:addTextRow(i18n:getText("spu_pool_section_implements"))
for _, implement in ipairs(implements) do
local status = string.format(i18n:getText("spu_pool_implement_" .. implement.status), tostring(implement.arg))
self:addTextRow(string.format(" %s — %s: %s", tostring(implement.label), self:roleText(implement.role), status))
end
if #implements == 0 then self:addTextRow(" " .. i18n:getText("spu_pool_noImplements")) end
end
-- ---- live ----
function SmartPickupFrame:refreshFieldRows(adEnv)
if (not self.editPanelOpen and self:hasGuideOn(SmartPickupFrame.TAB_FIELDS)) or self.fieldView == "fleet" then
self:setStatusText(self.statusOverride or "")
return
end
local rowsById = {}
for _, row in ipairs(ADFieldView.rows(ADFields.list(), ADFieldJobs.state.fields)) do rowsById[row.info.id] = row end
local working, total = 0, 0
@@ -364,47 +336,32 @@ function SmartPickupFrame:refreshFieldRows(adEnv)
if row ~= nil then
SmartPickupFrame.setCells(entry.cells, self:fieldCells(row))
total = total + 1
if ADFieldModel.isWorking(row.field) then working = working + 1 end
if self:fieldIsActive(entry.id, row.field) then working = working + 1 end
end
end
self:refreshFieldLevels()
self:setStatusText(self.statusOverride or string.format(self.i18n:getText("spu_field_status"), working, total))
local selected = self.selectedFieldId ~= nil and ADFieldJobs.getField(self.selectedFieldId) or nil
local info = self.selectedFieldId ~= nil and ADFields.getInfo(self.selectedFieldId) or nil
local isWorking = selected ~= nil and ADFieldModel.isWorking(selected)
self.fieldStartButtonInfo.text = self.i18n:getText(isWorking and "spu_field_button_stop" or "spu_field_button_start")
local awaiting = self.selectedFieldId ~= nil and ADFieldJobs.awaitingPlayer(self.selectedFieldId) ~= nil
self.fieldStartButtonInfo.text = self.i18n:getText(awaiting and "spu_field_button_takeover"
or (isWorking and "spu_field_button_stop" or "spu_field_button_start"))
self.fieldStartButtonInfo.disabled = selected == nil or info == nil or (not isWorking and info.markerId == nil)
end
-- ---- klik ----
-- ---- klik (markflåden) ----
function SmartPickupFrame:onClickFieldRow(state, data)
self:safe(function(frame)
local isOn = state == SmartPickupFrame.STATE_ON
local id = frame.selectedFieldId
if data.kind == "fleetVehicle" then
ADFieldJobs.toggleFleet(data.vehicleId)
elseif data.kind == "fleetMode" then
ADFieldJobs.setFleetMode(isOn and "all" or "ticked")
elseif id == nil then
return
elseif data.kind == "fieldTarget" then
ADFieldJobs.setTarget(id, data.values ~= nil and data.values[state] or "")
elseif SmartPickupFrame.PLAN_CHOICES[data.kind] ~= nil then
local value = data.values ~= nil and data.values[state] or nil
if value ~= nil then ADFieldJobs.setPlan(id, {[SmartPickupFrame.PLAN_CHOICES[data.kind]] = value}) end
elseif data.kind == "fieldProduct" then
ADFieldJobs.setPlan(id, {product = isOn and "hay" or "silage"})
elseif data.kind == "fieldMode" then
ADFieldJobs.setPlan(id, {mode = isOn and "auto" or "manual"})
elseif SmartPickupFrame.PLAN_TOGGLES[data.kind] ~= nil then
ADFieldJobs.setPlan(id, {[SmartPickupFrame.PLAN_TOGGLES[data.kind]] = isOn})
elseif data.kind == "fieldStore" then
local ids = {}
for _, buildingId in ipairs(ADFieldJobs.getField(id).plan.storeBuildingIds) do
if buildingId ~= data.buildingId then table.insert(ids, buildingId) end
end
if isOn then table.insert(ids, data.buildingId) end
ADFieldJobs.setPlan(id, {storeBuildingIds = ids})
elseif data.kind == "fleetManual" then
local wantManual = not isOn -- Ja = kører selv på vejen
if ((ADFieldJobs.state.manual or {})[data.vehicleId] == true) ~= wantManual then ADFieldJobs.toggleManual(data.vehicleId) end
end
frame:requestRebuild()
end)
@@ -412,13 +369,29 @@ end
-- Start nu: pleje hvis marken trænger (og vokser), ellers hele kæden (høst/slå, eller klargøring på bar mark).
function SmartPickupFrame:onClickFieldStartStop()
self:toggleField(self.selectedFieldId)
end
-- Start/Stop/Overtag for én mark (rækkens felt, vinduets knap og knaplinjen).
function SmartPickupFrame:toggleField(id)
self:safe(function(frame)
local id = frame.selectedFieldId
if id == nil then return end
if id == nil or not frame:fieldCanStart(id) then return end
local field = ADFieldJobs.getField(id)
if ADFieldModel.isWorking(field) then
if ADFieldJobs.awaitingPlayer(id) ~= nil then
-- manuel transport: overtag nu (skærebordet tjekkes stadig)
ADFieldJobs.requestTakeover(id)
frame.statusOverride = string.format(frame.i18n:getText("spu_field_takeoverRequested"), id)
frame.statusOverrideTicks = SmartPickupFrame.STATUS_OVERRIDE_TICKS
elseif frame:fieldIsActive(id, field) then
ADFieldJobs.stopField(frame.getAdEnv(), id)
ADFieldJobs.setFieldResumed(id, false)
elseif ADFieldJobs.state.paused and ADFieldModel.isWorking(field) then
-- Stop alt-pause: kun denne mark genoptages fra sit trin (de andre venter på Start alle)
ADFieldJobs.setFieldResumed(id, true)
frame.statusOverride = string.format(frame.i18n:getText("spu_field_startRequested"), id)
frame.statusOverrideTicks = SmartPickupFrame.STATUS_OVERRIDE_TICKS
else
if ADFieldJobs.state.paused then ADFieldJobs.setFieldResumed(id, true) end
local decision = ADFieldJobs.lastDecision[id]
if decision == nil or decision.care == nil then
ADFieldJobs.forceReady(id)
@@ -430,3 +403,25 @@ function SmartPickupFrame:onClickFieldStartStop()
frame:requestRebuild()
end)
end
-- Redigeringsvinduet for en mark (gui/SmartPickupEditPanel.lua).
SmartPickupFrame.EDIT_PANEL_KINDS = SmartPickupFrame.EDIT_PANEL_KINDS or {}
SmartPickupFrame.EDIT_PANEL_KINDS.field = {
canDelete = false,
exists = function(frame, id) return ADFields.getInfo(id) ~= nil and ADFieldJobs.getField(id) ~= nil end,
title = function(frame, id) return string.format(frame.i18n:getText("spu_panel_fieldTitle"), id) end,
fill = function(frame, adEnv, id)
frame:fillFieldOverview(adEnv, ADFields.getInfo(id))
frame:addLinkRow(" " .. frame.i18n:getText("spu_guide_field_open"), function(f)
f.selectedFieldId = id
f:onClickFieldSetup()
end)
end,
status = function(frame, adEnv, id)
return {text = frame:fieldStatusText(ADFieldJobs.getField(id), ADFields.getInfo(id), true), level = frame:fieldLevel(id)}
end,
startStopText = function(frame, adEnv, id) return frame:fieldActionText(id, true) end,
startStop = function(frame, id) frame:toggleField(id) end,
canStart = function(frame, id) return frame:fieldCanStart(id) end,
delete = function() end,
}
@@ -0,0 +1,484 @@
-- Smart Pickup — fanen Flows: tavle + resumé, og guiden "Opsæt flow"
-- Tavlen øverst (én række pr. flow), det valgte flows resumé (klik på en linje = ret ét trin; Navn = omdøb)
-- og status. [Opsæt flow] / [Nyt flow] åbner guiden på den fælles trin-skærm (gui/SmartPickupGuide.lua).
-- Henter det spil-afhængige som `context` til ADFlowGuide og gemmer via ADRunsController.
-- Funktionerne lægges på SmartPickupFrame (indlæses efter SmartPickupFrame.lua).
function SmartPickupFrame:initFlowButtons(i18n)
self.flowSetupButtonInfo = {inputAction = InputAction.MENU_EXTRA_1, text = i18n:getText("spu_guide_flow_open"), callback = function() self:onClickFlowSetup() end}
self.flowNewButtonInfo = {inputAction = InputAction.MENU_EXTRA_2, text = i18n:getText("spu_button_new"), callback = function() self:onClickFlowNew() end}
-- Flows-fanen: [Start/Stop] [Opsæt flow] [Nyt flow] [Slet]; omdøb ligger på resuméets Navn-linje
-- v1.58: listen har Start/Stop pr. række; Opsæt/Slet ligger i redigeringsvinduet
self.flowButtons = {self.backButtonInfo, self.flowNewButtonInfo}
end
-- ---- traktor-tekst (også til guidens traktorliste) ----
-- "Fastrac 2135 4WS (#1) — 1.000.000 l — kan bære: Halm, TMR — OK" / "… — X ingen vogn".
function SmartPickupFrame:describeVehicle(adEnv, run, vehicle, isInRun)
local label = ADRunsController.getVehicleLabel(adEnv, vehicle)
local validation = ADRunsController.validateVehicle(adEnv, run, vehicle)
local parts = {label}
if validation.isAutoload and validation.capacity == nil then
table.insert(parts, self.i18n:getText("spu_capacity_pallets"))
elseif validation.capacity ~= nil then
table.insert(parts, string.format(self.i18n:getText("spu_capacity"), g_i18n:formatNumber(validation.capacity, 0)))
local carriable = self:describeCarriable(adEnv, run, vehicle, validation.carriable)
if carriable ~= nil then
table.insert(parts, carriable)
end
end
if validation.ok then
table.insert(parts, "OK")
else
local reason = self.i18n:getText("spu_reason_" .. tostring(validation.reason))
if validation.reason == "noMarker" and validation.missingBuilding ~= nil then
reason = string.format(reason, tostring(validation.missingBuilding))
end
table.insert(parts, "X " .. reason)
end
return table.concat(parts, " — ")
end
-- Hvilke af run'ets varer vognen kan bære (titler); nil når ingen.
function SmartPickupFrame:describeCarriable(adEnv, run, vehicle, carriable)
local canCarry = {}
for _, fillType in ipairs(carriable or {}) do
canCarry[fillType] = true
end
local titles = {}
for _, fillType in ipairs(ADRunsController.getRunIngredients(adEnv, run, vehicle)) do
if canCarry[fillType] then
local title = g_fillTypeManager.getFillTypeTitleByIndex ~= nil and g_fillTypeManager:getFillTypeTitleByIndex(fillType) or nil
table.insert(titles, tostring(title or g_fillTypeManager:getFillTypeNameByIndex(fillType)))
end
end
if #titles == 0 then
return nil
end
return string.format(self.i18n:getText("spu_canCarry"), table.concat(titles, ", "))
end
-- ---- context til ADFlowGuide ----
local function trim(text)
return (string.gsub(tostring(text or ""), "^%s+", ""))
end
local function idSet(list)
local set = {}
for _, id in ipairs(list or {}) do set[id] = true end
return set
end
function SmartPickupFrame:flowBuildingContext(adEnv, draft, farmId)
local isOut = draft.kind == "outbound"
local role = isOut and "load" or "unload"
local chosen = idSet(isOut and draft.sourceBuildingIds or draft.targetBuildingIds)
local buildings, found = {}, {}
for _, building in ipairs(ADBuildings.list(adEnv, farmId)) do
local fits = (isOut and building.canBeSource) or (not isOut and building.canBeTarget)
if fits or chosen[building.id] then
local markers = ADBuildings.getMarkers ~= nil and ADBuildings.getMarkers(building, role) or {}
found[building.id] = true
table.insert(buildings, {id = building.id, name = tostring(building.name), label = markers[1] ~= nil and tostring(markers[1].name) or nil,
hasMarker = markers[1] ~= nil, text = trim(self:describeBuilding(building, role))})
end
end
-- valgte bygninger der ikke længere findes (solgt): kan fravælges
for id in pairs(chosen) do
if not found[id] then
local gone = self.i18n:getText("spu_buildingGone")
table.insert(buildings, {id = id, name = gone, hasMarker = false, text = gone})
end
end
return buildings
end
function SmartPickupFrame:flowStoreContext(adEnv, preview, farmId)
local goods = ADRunsController.getStoreGoods(adEnv, preview)
local chosen = idSet(preview.storeBuildingIds)
local result = {}
for _, building in ipairs(ADBuildings.list(adEnv, farmId)) do
local isStoreKind = building.kind == "silo" or building.kind == "factory" or building.kind == "objectStorage"
if isStoreKind and (chosen[building.id] or ADRunsController.canStoreAt(adEnv, building, goods)) then
table.insert(result, {id = building.id, name = tostring(building.name), text = trim(self:describeBuilding(building, "unload"))})
end
end
return result
end
-- Varer (fillType-indeks) flowet kører med: udkørsel = valgte produkter, forsyning = valgte/målets varer.
-- Tom liste = ukendt endnu (ingen mål valgt) -> enhver vogn med last-enheder tæller.
local function flowFillTypes(adEnv, preview)
local names = {}
if preview.kind == "outbound" then
for _, product in ipairs(preview.products or {}) do table.insert(names, product.fillType) end
elseif preview.ingredientMode == "manual" then
names = preview.ingredients or {}
else
for _, fillType in ipairs(ADRunsController.getTargetAcceptedFillTypes(adEnv, preview)) do table.insert(names, fillType.name) end
end
local result = {}
for _, name in ipairs(names) do
local index = g_fillTypeManager:getFillTypeIndexByName(tostring(name))
if index ~= nil then table.insert(result, index) end
end
return result
end
local function carriesAny(carriable, wanted)
if #carriable == 0 then return false end
if #wanted == 0 then return true end
for _, a in ipairs(carriable) do
for _, b in ipairs(wanted) do if a == b then return true end end
end
return false
end
-- Ledige vogne til solo-traktorer: frakoblet, egen AD-parkering med "Connect"-markør, ikke holdt af et andet flow
-- og kan bære flowets varer. Bedste først (navnematch, så "Disponibel"). In-game 29/9: flowet kunne ikke oprettes
-- uden at omdøbe en vogns parkering til flowets navn.
function SmartPickupFrame:flowWagonContext(adEnv, preview)
local farmId = g_currentMission:getFarmId()
local markers = adEnv.ADGraphManager:getMapMarkers()
local wanted = flowFillTypes(adEnv, preview)
local candidates = {}
for _, any in pairs(g_currentMission.vehicleSystem.vehicles) do
local ok, entry = pcall(function()
local index = any.advd ~= nil and any.advd.parkDestination or nil
if any.spec_motorized ~= nil or any.spec_attachable == nil or type(index) ~= "number" or index < 1 then return nil end
if any.getAttacherVehicle ~= nil and any:getAttacherVehicle() ~= nil then return nil end
if any.getOwnerFarmId == nil or any:getOwnerFarmId() ~= farmId then return nil end
local id = ADRunsController.getVehicleId(any)
local park = adEnv.ADGraphManager:getMapMarkerById(index)
if id == nil or park == nil or ADPool.connectFor(park.name, markers) == nil then return nil end
local owner = ADFlowWagons.ownerOf(ADRunsController.store.runs, id)
if owner ~= nil and owner.id ~= preview.id then return nil end
if not carriesAny(ADRunsController.getCarriableFillTypes(adEnv, any), wanted) then return nil end
return {id = id, label = tostring(park.name)}
end)
if ok and entry ~= nil then table.insert(candidates, entry) end
end
return ADFlowWagons.rankWagons(preview.name, candidates)
end
function SmartPickupFrame:flowVehicleContext(adEnv, preview, wagons)
local result = {}
local hasWagons = wagons ~= nil and #wagons > 0
for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do
local ok, entry = pcall(function()
local text = self:describeVehicle(adEnv, preview, vehicle, false)
local validation = ADRunsController.validateVehicle(adEnv, preview, vehicle)
local other = ADRunsController.getRunForVehicle(vehicle)
local label = ADRunsController.getVehicleLabel(adEnv, vehicle)
-- solo-traktor (ingen vogn bagpå, kan trække): valgbar når en ledig vogn findes — den hentes ved Connect
local solo = validation.reason == "noTrailer" and ADPool.canPull(ADPool.ownRole(vehicle))
if solo and hasWagons then text = string.format(self.i18n:getText("spu_guide_flow_soloText"), tostring(label)) end
local blocked = SmartPickupFrame.VEHICLE_BLOCKING_REASONS[validation.reason] == true and not (solo and hasWagons)
return {id = ADRunsController.getVehicleId(vehicle), text = text, label = label, otherRunId = other ~= nil and other.id or nil,
otherRunName = other ~= nil and other.name or nil, blocked = blocked, solo = solo}
end)
if ok and entry.id ~= nil then
table.insert(result, entry)
elseif not ok then
Logging.warning(SmartPickupFrame.LOG_PREFIX .. "traktor '%s' kunne ikke vises i flowets traktorliste: %s",
tostring(vehicle.getName ~= nil and vehicle:getName() or "?"), tostring(entry))
end
end
return result
end
-- draft: kladden (eller selve run'et — samme feltnavne).
function SmartPickupFrame:flowGuideContext(adEnv, draft)
local farmId = g_currentMission:getFarmId()
local preview = ADFlowGuide.previewRun(draft, function(buildingId, role)
local building = ADBuildings.find(adEnv, farmId, buildingId)
local ids = {}
for _, marker in ipairs(building ~= nil and ADBuildings.getMarkers ~= nil and ADBuildings.getMarkers(building, role) or {}) do
table.insert(ids, marker.id)
end
return ids
end)
local context = {buildings = self:flowBuildingContext(adEnv, draft, farmId), fillTypes = {}, products = {}, storeBuildings = {},
waitPools = ADRunsController.getWaitPools(adEnv)}
context.wagons = self:flowWagonContext(adEnv, preview)
context.vehicles = self:flowVehicleContext(adEnv, preview, context.wagons)
if draft.kind == "outbound" then
for _, product in ipairs(ADRunsController.getRunProducts(adEnv, preview)) do
local title = tostring(product.title)
table.insert(context.products, {name = product.name, title = title,
text = string.format("%s — %s l", title, SmartPickupFrame.formatCompact(product.available or 0))})
end
context.storeBuildings = self:flowStoreContext(adEnv, preview, farmId)
else
for _, fillType in ipairs(ADRunsController.getTargetAcceptedFillTypes(adEnv, preview)) do
table.insert(context.fillTypes, {name = fillType.name, title = tostring(fillType.title)})
end
end
return context
end
-- ---- fanen: tavle + resumé ----
-- Flows: det valgte flow øverst (indstillinger som "ret"-linjer + status), derunder en kort liste til at vælge
-- et andet flow. Tavlen med alle flows står i Oversigt (in-game 27/9: de to faner så ens ud).
function SmartPickupFrame:fillFlowTabRows(adEnv)
if not self.editPanelOpen and self:hasGuideOn(SmartPickupFrame.TAB_FLOWS) and self:fillGuideRows(adEnv) then return end
self.flowListRows = {}
local i18n = self.i18n
if #ADRunsController.store.runs == 0 then
self:addTextRow(i18n:getText("spu_noRuns"))
return
end
self:addTextRow(i18n:getText("spu_flowList_hint"))
for _, group in ipairs(ADFlowView.boardGroups(ADRunsController.store.runs)) do
self:addTextRow(i18n:getText(group.kind == "outbound" and "spu_board_outbound" or "spu_board_supply"))
for _, run in ipairs(group.runs) do
local row = self:addRow(self.flowListPrefab, {kind = "flowList"})
local entry = {runId = run.id, row = row, dot = row:getDescendantByName("dot"),
cells = {row:getDescendantByName("c1"), row:getDescendantByName("c2"), row:getDescendantByName("c3"),
row:getDescendantByName("c4"), row:getDescendantByName("c5")}}
local runId = run.id
local actionCell = entry.cells[5]
self:makeRowClickable(row, function(frame)
-- klik på [Start]/[Stop]-feltet = handling (kun dette flow); ellers åbn redigeringsvinduet
if frame.lastClickX ~= nil and actionCell.absPosition ~= nil and frame.lastClickX >= actionCell.absPosition[1] then
frame:toggleRun(frame.getAdEnv(), runId)
frame:requestRebuild()
else
frame.selectedRunId = runId
frame:openEditPanel("flow", runId)
end
end)
table.insert(self.flowListRows, entry)
end
end
self:refreshFlowListRows(adEnv)
end
-- Listens celler, prik og statusfarve (hvert sekund og efter opbygning).
function SmartPickupFrame:refreshFlowListRows(adEnv)
local unpackFn = table.unpack or unpack
for _, entry in ipairs(self.flowListRows or {}) do
local run = ADRuns.getRun(ADRunsController.store, entry.runId)
if run ~= nil then
local cells = self:flowCells(adEnv, run)
local color = ADFlowView.LEVEL_COLORS[self:flowLevel(adEnv, run)]
local canStop = ADRunsController.getRunStatus(adEnv, run).canStop
entry.cells[1]:setText(tostring(run.name))
entry.cells[2]:setText(tostring(cells[2]))
entry.cells[3]:setText(tostring(cells[5]))
entry.cells[4]:setText(tostring(cells[6]))
entry.cells[5]:setText(self.i18n:getText(canStop and "spu_flowList_stop" or "spu_flowList_start"))
if entry.dot ~= nil and entry.dot.setImageColor ~= nil then entry.dot:setImageColor(nil, unpackFn(color)) end
if entry.cells[4].setTextColor ~= nil then entry.cells[4]:setTextColor(unpackFn(color)) end
end
end
end
-- Start/Stop for ét flow (rækkens felt og vinduets knap). Husker om sidste start blev helt afvist (rød).
function SmartPickupFrame:toggleRun(adEnv, runId)
local run = ADRuns.getRun(ADRunsController.store, runId)
if adEnv == nil or run == nil then return end
self.lastStartFailed = self.lastStartFailed or {}
if ADRunsController.getRunStatus(adEnv, run).canStop then
ADRunsController.stopRun(adEnv, run.id)
self.lastStartFailed[run.id] = nil
self.statusOverride = nil
else
local started, skipped, reasons = self:summarizeStartResults(ADRunsController.startRun(adEnv, run.id))
self.lastStartFailed[run.id] = started == 0 and skipped > 0
local text = string.format(self.i18n:getText("spu_status_started"), started, skipped)
self:showOverride(text, reasons)
-- statuslinjen kan ligge uden for skærmen: sprunget over vises også som besked i højre side
if skipped > 0 and ADFieldJobs ~= nil and ADFieldJobs.notify ~= nil then
ADFieldJobs.notify(string.format("%s: %s — %s", tostring(run.name), text, table.concat(reasons, "; ")), started == 0)
end
end
ADRunsController.save()
end
function SmartPickupFrame:flowLevel(adEnv, run)
local row = ADRunsController.getFlowRow(adEnv, run)
local status = ADRunsController.getRunStatus(adEnv, run)
-- en gammel afvisning gælder ikke længere når flowet kører (fx startet fra Oversigt bagefter)
if row.running > 0 and self.lastStartFailed ~= nil then self.lastStartFailed[run.id] = nil end
return ADFlowView.flowStatus({running = row.running, total = row.total, stopped = run.stopped == true,
canStop = status.canStop == true, warning = row.warning, reason = row.reason,
lastStartFailed = (self.lastStartFailed or {})[run.id] == true})
end
function SmartPickupFrame:fillFlowSummary(adEnv, run)
local i18n = self.i18n
if not self.editPanelOpen then
self:addTextRow(string.format(i18n:getText("spu_guide_flow_section"), tostring(run.name)))
end
self:addTextRow(" " .. i18n:getText("spu_guide_sum_hint"))
local guide = ADFlowGuide.open(run, self:flowGuideContext(adEnv, run))
local getText = function(key) return i18n:getText(key) end
local runId = run.id
for _, line in ipairs(ADFlowGuide.summary(guide)) do
local step = line.step
self:addLinkRow(" " .. ADGuide.lineText(line, getText) .. i18n:getText("spu_guide_editSuffix"), function(frame)
if step == "name" then frame:onClickRename() else frame:openFlowStep(runId, step) end
end)
end
local cells = self:flowCells(adEnv, run)
self:addTextRow(" " .. string.format(i18n:getText("spu_field_statusFull"), tostring(cells[6])))
self:addLinkRow(" " .. i18n:getText("spu_guide_flow_open"), function(frame)
frame.selectedRunId = runId
frame:onClickFlowSetup()
end)
end
-- Redigeringsvinduet for et flow (gui/SmartPickupEditPanel.lua).
SmartPickupFrame.EDIT_PANEL_KINDS = SmartPickupFrame.EDIT_PANEL_KINDS or {}
SmartPickupFrame.EDIT_PANEL_KINDS.flow = {
canDelete = true,
exists = function(frame, id) return ADRuns.getRun(ADRunsController.store, id) ~= nil end,
title = function(frame, id)
local run = ADRuns.getRun(ADRunsController.store, id)
return string.format(frame.i18n:getText("spu_panel_flowTitle"), tostring(run ~= nil and run.name or "?"))
end,
fill = function(frame, adEnv, id)
local run = ADRuns.getRun(ADRunsController.store, id)
if run ~= nil then frame:fillFlowSummary(adEnv, run) end
end,
status = function(frame, adEnv, id)
local run = ADRuns.getRun(ADRunsController.store, id)
return {text = tostring(frame:flowCells(adEnv, run)[6]), level = frame:flowLevel(adEnv, run)}
end,
startStopText = function(frame, adEnv, id)
local run = ADRuns.getRun(ADRunsController.store, id)
return frame.i18n:getText(ADRunsController.getRunStatus(adEnv, run).canStop and "spu_button_stop" or "spu_button_start")
end,
startStop = function(frame, id) frame:toggleRun(frame.getAdEnv(), id) end,
delete = function(frame, id)
frame.selectedRunId = id
frame:onClickDelete()
end,
}
-- Hvert sekund: tavlens celler, statuslinje og knapper.
function SmartPickupFrame:refreshFlowTab(adEnv)
if not self.editPanelOpen and self:hasGuideOn(SmartPickupFrame.TAB_FLOWS) then
self:setStatusText(self.statusOverride or "")
return
end
self:refreshFlowListRows(adEnv)
local running, total = 0, 0
for _, run in ipairs(ADRunsController.store.runs) do
total = total + 1
if ADRunsController.getRunStatus(adEnv, run).running > 0 then running = running + 1 end
end
self:setStatusText(self.statusOverride or string.format(self.i18n:getText("spu_flowList_status"), running, total))
end
-- ---- hooks til den fælles guide-skærm ----
SmartPickupFrame.GUIDE_HOOKS = SmartPickupFrame.GUIDE_HOOKS or {}
SmartPickupFrame.GUIDE_HOOKS.flow = {
model = ADFlowGuide,
tab = SmartPickupFrame.TAB_FLOWS,
heading = function(frame, guide)
return string.format(frame.i18n:getText("spu_guide_flow_title"), tostring(guide.draft.name))
end,
context = function(frame, adEnv, guide)
-- flowet er slettet mens guiden var åben
if not guide.draft.isNew and ADRuns.getRun(ADRunsController.store, guide.draft.id) == nil then return nil end
return frame:flowGuideContext(adEnv, guide.draft)
end,
choose = function(frame, id)
if id ~= "rename" then return false end
TextInputDialog.show(SmartPickupFrame.onGuideNameResult, frame, frame.guide.draft.name, frame.i18n:getText("spu_renameTitle"), nil, 30, g_i18n:getText("button_ok"))
return true
end,
startKey = function() return "spu_guide_flow_start" end,
save = function(frame, guide, start) frame:saveFlowGuide(guide, start) end,
}
function SmartPickupFrame:onGuideNameResult(value, clickOk)
self:safe(function(frame)
if not clickOk or frame.guide == nil or frame.guideKind ~= "flow" then return end
frame.guide = ADFlowGuide.choose(frame.guide, "name", value)
frame:requestRebuild()
end)
end
-- Gem: nyt flow oprettes først nu; felterne via updateRun; traktorer tildeles/fjernes; markører findes.
-- start = true: flowet startes bagefter ([Start flow]).
function SmartPickupFrame:saveFlowGuide(guide, start)
local adEnv = self.getAdEnv()
local result = ADFlowGuide.result(guide)
local runId = result.runId
local oldIds = {}
if result.isNew then
runId = ADRunsController.createRun(result.name, result.kind).id
else
local existing = ADRuns.getRun(ADRunsController.store, runId)
if existing == nil then return end
oldIds = existing.vehicleIds
end
ADRunsController.updateRun(runId, result.changes)
local added, removed = ADFlowGuide.vehicleChanges(oldIds, result.vehicleIds)
for _, id in ipairs(removed) do
local vehicle = adEnv ~= nil and ADRunsController.findVehicleById(adEnv, id) or nil
if vehicle ~= nil then
ADRunsController.unassignVehicle(vehicle)
else
ADRunsController.store = ADRuns.unassignVehicle(ADRunsController.store, id)
end
end
for _, id in ipairs(added) do
local vehicle = adEnv ~= nil and ADRunsController.findVehicleById(adEnv, id) or nil
if vehicle ~= nil then ADRunsController.assignVehicle(runId, vehicle) end
end
-- solo-traktorernes vogne fra guiden: flowet husker dem straks og henter dem ved Connect ved start
local savedRun = ADRuns.getRun(ADRunsController.store, runId)
if savedRun ~= nil then
local wagons = ADRuns.copyWagons(savedRun.wagons)
for tractorId, wagonId in pairs(result.wagons or {}) do wagons[tractorId] = wagonId end
savedRun.wagons = wagons
end
ADRunsController.resolveMarkers(adEnv, ADRuns.getRun(ADRunsController.store, runId))
self.selectedRunId = runId
local saved = string.format(self.i18n:getText("spu_guide_flow_saved"), tostring(result.name))
if start and adEnv ~= nil and not ADRunsController.getRunStatus(adEnv, ADRuns.getRun(ADRunsController.store, runId)).isRunning then
local started, skipped, reasons = self:summarizeStartResults(ADRunsController.startRun(adEnv, runId))
self:showOverride(saved .. " — " .. string.format(self.i18n:getText("spu_status_started"), started, skipped), reasons)
else
self:showOverride(saved)
end
ADRunsController.save()
end
-- ---- knapper og [Ret] ----
function SmartPickupFrame:onClickFlowSetup()
self:safe(function(frame)
local adEnv, run = frame.getAdEnv(), frame:getRun()
if adEnv == nil or run == nil then return end
frame:openGuide("flow", ADFlowGuide.open(run, frame:flowGuideContext(adEnv, run)))
end)
end
-- [Nyt flow] (også fra fanen Oversigt): guiden på Flows-fanen; intet oprettes før [Gem].
function SmartPickupFrame:onClickFlowNew()
self:safe(function(frame)
local adEnv = frame.getAdEnv()
if adEnv == nil then return end
local name = string.format(frame.i18n:getText("spu_newRunName"), #ADRunsController.store.runs + 1)
local guide = ADFlowGuide.openNew(name, {})
guide = ADGuide.withContext(guide, frame:flowGuideContext(adEnv, guide.draft))
if frame.currentTab ~= SmartPickupFrame.TAB_FLOWS then frame:switchTab(SmartPickupFrame.TAB_FLOWS) end
frame:openGuide("flow", guide)
end)
end
function SmartPickupFrame:openFlowStep(runId, step)
local adEnv = self.getAdEnv()
local run = ADRuns.getRun(ADRunsController.store, runId)
if adEnv == nil or run == nil then return end
self:openGuide("flow", ADFlowGuide.open(run, self:flowGuideContext(adEnv, run), {step = step, single = true, returnTo = "close"}))
end
+14 -115
View File
@@ -1,7 +1,7 @@
-- Smart Pickup — fanen Flows
-- Tavle øverst: én tekstrække pr. flow (Vare · Fra → Til · Regel · Traktorer · Status), det valgte
-- markeret med ">". Under tavlen: detail-blok for det valgte flow — Retning, Fra/Til som BYGNINGER
-- (markøren findes automatisk), Varer, Regler, Vent ved, Traktorer, Status — og Wait-puljerne.
-- Tavlen (én række pr. flow, det valgte markeret med ">") bruges af fanerne Oversigt og Flows; Flows-fanens
-- resumé og guiden "Opsæt flow" ligger i gui/SmartPickupFlowGuide.lua. Her også bygnings-teksten og de
-- gamle klik-callbacks for retning/bygninger.
-- Funktionerne lægges på SmartPickupFrame (indlæses efter SmartPickupFrame.lua).
-- ---- tavlen ----
@@ -49,7 +49,11 @@ function SmartPickupFrame:flowCells(adEnv, run)
end
local rule
if row.kind == "outbound" then
rule = string.format(i18n:getText("spu_flow_ruleOut"), row.sellAtPercent or 90, row.storeAbovePercent or 80)
local sells, stores = ADFlowView.policyUse(row.policies)
local parts = {}
if sells then table.insert(parts, string.format(i18n:getText("spu_flow_ruleSell"), row.sellAtPercent or 90)) end
if stores then table.insert(parts, string.format(i18n:getText("spu_flow_ruleStore"), row.storeAbovePercent or 80)) end
rule = table.concat(parts, ", ")
if row.collectRound then rule = rule .. ", " .. i18n:getText("spu_flow_round") end
else
rule = row.loops ~= nil and row.loops > 0 and string.format(i18n:getText("spu_flow_ruleInLoops"), row.loops) or i18n:getText("spu_flow_ruleIn")
@@ -80,8 +84,12 @@ function SmartPickupFrame:makeRowClickable(row, action)
row.mouseEvent = function(element, posX, posY, isDown, isUp, button, eventUsed)
local used = baseMouseEvent(element, posX, posY, isDown, isUp, button, eventUsed)
-- også inden for listens synlige udsnit: rækker scrollet ud af syne må ikke fange klik
if not used and not eventUsed and isUp and button == MOUSE_LEFT and (element.getIsVisible == nil or element:getIsVisible())
and isInside(element, posX, posY) and isInside(frame.boxLayout, posX, posY) then
-- klik tæller inde i rækkens eget layout (listen eller redigeringsvinduet); listen er død mens vinduet er åbent
local layout = element.spuLayout or frame.boxLayout
local blockedByPanel = frame.editPanelOpen and layout == frame.boxLayout
if not used and not eventUsed and not blockedByPanel and isUp and button == MOUSE_LEFT and (element.getIsVisible == nil or element:getIsVisible())
and isInside(element, posX, posY) and isInside(layout, posX, posY) then
frame.lastClickX = posX
frame.pendingClick = action
return true
end
@@ -137,115 +145,6 @@ function SmartPickupFrame:describeBuilding(building, role)
return string.format(" %s — %s", building.name, table.concat(parts, " · "))
end
-- Afkrydsning pr. bygning: kilder (udkørsel, role "load") eller mål (forsyning, role "unload").
function SmartPickupFrame:fillBuildingRows(adEnv, run, role)
if ADBuildings == nil then
self:addTextRow(self.i18n:getText("spu_status_noAd"))
return
end
local chosen = {}
local ids = role == "load" and run.sourceBuildingIds or role == "store" and run.storeBuildingIds or run.targetBuildingIds
local rowKind = role == "load" and "sourceBuilding" or role == "store" and "storeBuilding" or "targetBuilding"
for _, id in ipairs(ids or {}) do chosen[id] = true end
local count = 0
-- kandidater -> valgte først, så med markør; uden markør kun med "Vis alle bygninger"
local candidates = {}
-- "Lager til": kun bygninger der kan tage flowets lagervarer (æg-paller -> palleskure der tager æg)
local storeGoods = role == "store" and ADRunsController.getStoreGoods(adEnv, run) or nil
for _, building in ipairs(ADBuildings.list(adEnv, g_currentMission:getFarmId())) do
local isStoreKind = building.kind == "silo" or building.kind == "factory" or building.kind == "objectStorage"
local fits = (role == "load" and building.canBeSource) or (role == "unload" and building.canBeTarget)
or (role == "store" and isStoreKind and (chosen[building.id] or ADRunsController.canStoreAt(adEnv, building, storeGoods)))
if fits then
local markers = ADBuildings.getMarkers ~= nil and ADBuildings.getMarkers(building, role == "store" and "unload" or role) or {}
table.insert(candidates, {id = building.id, name = building.name, hasMarker = markers[1] ~= nil, building = building})
end
end
local shown = ADFlowView.buildingRows(candidates, chosen, self.showAllBuildings == true)
local hiddenBuildings = ADFlowView.hiddenCount(candidates, shown)
if hiddenBuildings > 0 or self.showAllBuildings then
self:addToggleRow("showAllBuildings", string.format(self.i18n:getText("spu_showAllBuildings"), hiddenBuildings), {self.i18n:getText("ui_no"), self.i18n:getText("ui_yes")}, self.showAllBuildings == true)
end
for _, entry in ipairs(shown) do
local building = entry.building
count = count + 1
local row = self:addRow(self.ingredientPrefab, {kind = rowKind, buildingId = building.id})
row:getDescendantByName("title"):setText(self:describeBuilding(building, role == "store" and "unload" or role))
row:getDescendantByName("option"):setState(chosen[building.id] and SmartPickupFrame.STATE_ON or SmartPickupFrame.STATE_OFF, false)
end
-- valgte bygninger der ikke længere findes (solgt)
for _, id in ipairs(ids or {}) do
if ADBuildings.find(adEnv, g_currentMission:getFarmId(), id) == nil then
local row = self:addRow(self.ingredientPrefab, {kind = rowKind, buildingId = id})
row:getDescendantByName("title"):setText(string.format(" %s — %s", tostring(id), self.i18n:getText("spu_buildingGone")))
row:getDescendantByName("option"):setState(SmartPickupFrame.STATE_ON, false)
end
end
if count == 0 then
self:addTextRow(self.i18n:getText("spu_noBuildings"))
end
end
function SmartPickupFrame:rebuildFlowDetail(adEnv, run)
ADRunsController.resolveMarkers(adEnv, run)
run = self:getRun()
local isOut = run.kind == "outbound"
self:addTextRow(string.format(self.i18n:getText("spu_section_flow"), run.name))
self.directionRowOption = self:addToggleRow("direction", self.i18n:getText("spu_direction"), {self.i18n:getText("spu_direction_in"), self.i18n:getText("spu_direction_out")}, isOut)
if isOut then
self:addTextRow(self.i18n:getText("spu_section_from"))
self:fillBuildingRows(adEnv, run, "load")
self:addTextRow(self.i18n:getText("spu_section_products"))
self:fillProductRows(adEnv, run)
self:addTextRow(self.i18n:getText("spu_section_rules"))
self.sellAtOption:setState(SmartPickupFrame.getStepIndex(self.sellAtSteps, run.sellAtPercent, ADRuns.DEFAULT_SELL_AT_PERCENT), false)
self.storeAboveOption:setState(SmartPickupFrame.getStepIndex(self.storeAboveSteps, run.storeAbovePercent, ADRuns.DEFAULT_STORE_ABOVE_PERCENT), false)
self.collectRoundRowOption = self:addToggleRow("collectRound", self.i18n:getText("spu_collectRound"), {self.i18n:getText("ui_no"), self.i18n:getText("ui_yes")}, run.collectRound == true)
self:addTextRow(self.i18n:getText("spu_section_store"))
self:fillBuildingRows(adEnv, run, "store")
self:fillOutboundStatusRows(adEnv, run)
else
self:addTextRow(self.i18n:getText("spu_section_to"))
self:fillBuildingRows(adEnv, run, "unload")
self:addTextRow(self.i18n:getText("spu_section_targets"))
self.ingredientModeRowOption = self:addToggleRow("ingredientMode", self.i18n:getText("spu_ingredientMode"), {self.i18n:getText("spu_mode_auto"), self.i18n:getText("spu_mode_manual")}, run.ingredientMode == "manual")
self:fillIngredientRows(adEnv, run)
self:fillNeedsRows(adEnv, run)
self:addTextRow(self.i18n:getText("spu_section_rules"))
self.loopsOption:setState(run.loops + 1, false)
self.returnBelowOption:setState(self:getReturnBelowIndex(run), false)
end
end
-- ---- callbacks ----
function SmartPickupFrame:onClickDirection(state)
self:safe(function(frame)
local run = frame:getRun()
if run == nil then return end
ADRunsController.updateRun(run.id, {kind = state == SmartPickupFrame.STATE_ON and "outbound" or "supply"})
frame:requestRebuild()
end)
end
function SmartPickupFrame:onClickBuilding(state, element)
self:safe(function(frame)
local run, data = frame:getRun(), frame.rowData[element]
if run == nil or data == nil then return end
if data.kind == "sourceBuilding" then
ADRunsController.store = ADRuns.toggleSourceBuilding(ADRunsController.store, run.id, data.buildingId)
elseif data.kind == "storeBuilding" then
ADRunsController.store = ADRuns.toggleStoreBuilding(ADRunsController.store, run.id, data.buildingId)
local updatedStore = ADRuns.getRun(ADRunsController.store, run.id)
Logging.info(SmartPickupFrame.LOG_PREFIX .. "run '%s': storeBuildings=%s", tostring(run.name), table.concat(updatedStore.storeBuildingIds or {}, " "))
frame:requestRebuild()
return
else
ADRunsController.store = ADRuns.toggleTargetBuilding(ADRunsController.store, run.id, data.buildingId)
end
local updated = ADRuns.getRun(ADRunsController.store, run.id)
Logging.info(SmartPickupFrame.LOG_PREFIX .. "run '%s': %s=%s", tostring(run.name), data.kind == "sourceBuilding" and "sourceBuildings" or "targetBuildings", table.concat(data.kind == "sourceBuilding" and updated.sourceBuildingIds or updated.targetBuildingIds, " "))
ADRunsController.resolveMarkers(frame.getAdEnv(), updated)
frame:requestRebuild()
end)
end
+213 -204
View File
@@ -11,17 +11,24 @@ SmartPickupFrame.STATUS_OVERRIDE_TICKS = 6
SmartPickupFrame.MAX_STATUS_REASONS = 3
SmartPickupFrame.LOG_PREFIX = "ADSmartPickup: "
SmartPickupFrame.STATE_OFF, SmartPickupFrame.STATE_ON = 1, 2
SmartPickupFrame.TAB_OVERVIEW, SmartPickupFrame.TAB_FLOWS, SmartPickupFrame.TAB_FIELDS = 1, 2, 3
SmartPickupFrame.TAB_OVERVIEW, SmartPickupFrame.TAB_FLOWS, SmartPickupFrame.TAB_FIELDS, SmartPickupFrame.TAB_MONTH, SmartPickupFrame.TAB_FIELDSILOS = 1, 2, 3, 4, 5
-- Måned-fanen genopbygges hvert N. sekund (ingen klikbare rækker, så fokus går ikke tabt)
SmartPickupFrame.MONTH_REBUILD_TICKS = 5
-- Marksilo-fanen har klikbare valg: genopbygges sjældnere, så fokus ikke rykker midt i et valg
SmartPickupFrame.FIELDSILO_REBUILD_TICKS = 30
-- Kæder-fanen (gui/SmartPickupChainTab.lua): klikbare forslag/pause, så genopbygning sjældent (som Marksiloer)
SmartPickupFrame.TAB_CHAINS = 10
SmartPickupFrame.CHAIN_REBUILD_TICKS = 30
-- Lagerfaner: én pr. bygningstype (indeks -> kind i ADSources.getFarmInventory)
SmartPickupFrame.TAB_NAMES = {"spu_tab_overview", "spu_tab_flows", "spu_tab_fields", "spu_tab_factories", "spu_tab_silos", "spu_tab_barns", "spu_tab_sheds"}
SmartPickupFrame.TAB_KINDS = {[4] = "factory", [5] = "silo", [6] = "husbandry", [7] = "objectStorage"}
SmartPickupFrame.TAB_NAMES = {"spu_tab_overview", "spu_tab_flows", "spu_tab_fields", "spu_tab_month", "spu_tab_fieldsilos", "spu_tab_factories", "spu_tab_silos", "spu_tab_barns", "spu_tab_sheds", "spu_tab_chains"}
SmartPickupFrame.TAB_KINDS = {[6] = "factory", [7] = "silo", [8] = "husbandry", [9] = "objectStorage"}
function SmartPickupFrame.getTabKind(tab)
return SmartPickupFrame.TAB_KINDS[tab]
end
SmartPickupFrame.POLICIES = {"off", "store", "sell", "sellElseStore"}
-- Afvisningsgrunde der handler om VOGNEN (ikke run'ets opsætning): traktoren kan ikke vælges.
SmartPickupFrame.VEHICLE_BLOCKING_REASONS = {noTrailer = true, cannotCarry = true, needsAutoload = true}
SmartPickupFrame.VEHICLE_BLOCKING_REASONS = {noTrailer = true, cannotCarry = true, needsAutoload = true, autoloadOn = true}
function SmartPickupFrame.new(i18n, getAdEnv)
local self = SmartPickupFrame:superClass().new(nil, SmartPickupFrame._mt)
@@ -32,6 +39,7 @@ function SmartPickupFrame.new(i18n, getAdEnv)
self.runIds, self.targetIds, self.poolGroups = {}, {}, {}
self.rowData = {} -- element -> {kind = "ingredient"|"vehicle", ...}
self.clonedRows = {}
self.panelRows = {}
self.refreshTimer = 0
self.statusOverride = nil
self.statusRow = nil -- Text-elementet i den klonede statusrække
@@ -49,11 +57,21 @@ function SmartPickupFrame.new(i18n, getAdEnv)
-- Oversigt: Start/Stop alle i stedet for knapperne der virker på ét valgt flow.
self.startAllButtonInfo = {inputAction = InputAction.MENU_ACTIVATE, text = i18n:getText("spu_button_startAll"), callback = function() self:onClickStartAll() end}
self.stopAllButtonInfo = {inputAction = InputAction.MENU_EXTRA_2, text = i18n:getText("spu_button_stopAll"), callback = function() self:onClickStopAll() end}
self.monthButtons = {self.backButtonInfo}
-- bygningsfanerne (Siloer/Stalde/Fabrikker/Palleskure): omdøb den valgte bygning
self.renameBuildingButtonInfo = {inputAction = InputAction.MENU_EXTRA_2, text = i18n:getText("spu_button_renameBuilding"), callback = function() self:onClickRenameBuilding() end}
self.storageButtons = {self.backButtonInfo, self.renameBuildingButtonInfo}
self.flowButtons = {self.backButtonInfo, self.startButtonInfo, self.newButtonInfo, self.renameButtonInfo, self.deleteButtonInfo}
self.overviewButtons = {self.backButtonInfo, self.startAllButtonInfo, self.stopAllButtonInfo, self.newButtonInfo}
-- Stop alt og parkér: flows + markarbejde + marksilo stoppes, køretøjer til parkering, pause til Start alle
self.stopParkButtonInfo = {inputAction = InputAction.MENU_CANCEL, text = i18n:getText("spu_button_stopPark"), callback = function() self:onClickStopAndPark() end}
self.overviewButtons = {self.backButtonInfo, self.startAllButtonInfo, self.stopAllButtonInfo, self.stopParkButtonInfo, self.newButtonInfo}
-- Marker: Start nu / Stop for den valgte mark
self.fieldStartButtonInfo = {inputAction = InputAction.MENU_ACTIVATE, text = i18n:getText("spu_field_button_start"), callback = function() self:onClickFieldStartStop() end}
self.fieldButtons = {self.backButtonInfo, self.fieldStartButtonInfo}
-- Marker: [Start nu/Stop], [Opsæt mark], [Markflåde] (gui/SmartPickupFieldGuide.lua); guidens knapper
self:initFieldButtons(i18n)
self:initGuideButtons(i18n)
-- Flows: [Start/Stop] [Opsæt flow] [Nyt flow] [Slet] (gui/SmartPickupFlowGuide.lua)
self:initFlowButtons(i18n)
self:initEditPanelButtons(i18n)
-- Egen reference til listen: fallback'en i refreshButtons skal kunne sende den igen.
self.menuButtonInfo = self.overviewButtons
self:setMenuButtonInfo(self.menuButtonInfo)
@@ -71,7 +89,7 @@ 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", "textPrefab", "productPrefab", "statPrefab", "gridPrefab", "flowPrefab", "fieldPrefab"}) do
for _, name in ipairs({"ingredientPrefab", "vehiclePrefab", "textPrefab", "productPrefab", "statPrefab", "gridPrefab", "flowPrefab", "flowListPrefab", "fieldPrefab", "fieldListPrefab", "monthPrefab", "kpiPrefab", "alertPrefab", "itemPrefab", "subPrefab"}) do
local prefab = self[name]
if prefab ~= nil then
prefab:unlinkElement()
@@ -197,6 +215,9 @@ end
function SmartPickupFrame:onFrameClose()
self.frameOpen = false
-- et åbent redigeringsvindue og en åben guide kasseres (intet er gemt før [Gem])
self:hideEditPanel()
self:closeGuide()
SmartPickupFrame:superClass().onFrameClose(self)
end
@@ -243,6 +264,11 @@ function SmartPickupFrame:requestRebuild()
end
function SmartPickupFrame:switchTab(tabIndex)
if self.editPanelOpen then
self:closeGuide()
self:hideEditPanel()
end
if self.guide ~= nil and not self:hasGuideOn(tabIndex) then self:closeGuide() end
self.currentTab = tabIndex
for index, tab in ipairs(self.subCategoryTabs) do
tab:setSelected(index == tabIndex)
@@ -263,7 +289,8 @@ end
-- De faste rækker: Flow-vælger + Wait-pulje for alle flows; Ture/kør hjem for forsyning; Sælg fra/
-- Kør til lager for udkørsel. Fanen Lagre viser ingen.
function SmartPickupFrame:setStaticRowsVisible(tab, run)
local isFlows = tab == SmartPickupFrame.TAB_FLOWS
-- Flows-fanen bruger tavle + resumé + guide (v1.32); de faste XML-rækker vises ikke længere
local isFlows = false
local hasRun = isFlows and run ~= nil
local isSupply, isOutbound = hasRun and run.kind ~= "outbound", hasRun and run.kind == "outbound"
local visibility = {
@@ -312,6 +339,7 @@ function SmartPickupFrame:clearRows()
self.productRows, self.outboundStatusRows = {}, {}
self.storageRows = {}
self.storageDetailRows = {}
self.itemView = nil
self.boardRows = {}
self.ingredientModeRowOption, self.collectRoundRowOption, self.directionRowOption = nil, nil, nil
end
@@ -328,9 +356,11 @@ function SmartPickupFrame:addToggleRow(kind, title, texts, isOn)
end
function SmartPickupFrame:addRow(prefab, data)
local row = prefab:clone(self.boxLayout)
local layout = self.rowTarget or self.boxLayout
local row = prefab:clone(layout)
row.spuLayout = layout
FocusManager:loadElementFromCustomValues(row)
table.insert(self.clonedRows, row)
table.insert(layout == self.boxLayout and self.clonedRows or self.panelRows, row)
local option = row:getDescendantByName("option")
if option ~= nil then
option:setTexts({self.i18n:getText("ui_no"), self.i18n:getText("ui_yes")})
@@ -339,23 +369,56 @@ function SmartPickupFrame:addRow(prefab, data)
return row
end
-- Rækker klonet ind i et andet layout (redigeringsvinduet) mens fn kører.
function SmartPickupFrame:withRowTarget(layout, fn)
local previous = self.rowTarget
self.rowTarget = layout
local ok, err = pcall(fn, self)
self.rowTarget = previous
if not ok then error(err, 0) end
end
function SmartPickupFrame:clearPanelRows()
local focused = SmartPickupFrame.getFocusedElement()
for _, row in ipairs(self.panelRows or {}) do
if focused ~= nil and SmartPickupFrame.isDescendantOf(focused, row) then
self.focusLost = true
end
FocusManager:removeElement(row)
row:delete()
end
self.panelRows = {}
end
-- Tekstrække uden valgmuligheder: sektionstitler, Wait-pulje-linjer og statuslinjen.
-- Tegn pr. tekstrække før teksten ombrydes (rækken afkorter med "..." ved ca. 70 tegn).
SmartPickupFrame.TEXT_ROW_CHARS = 90
-- Lange tekster fordeles på flere rækker i stedet for at blive afkortet. Returnerer første rækkes titel.
function SmartPickupFrame:addTextRow(text)
local first = nil
for _, line in ipairs(ADFlowView.wrapText(text, SmartPickupFrame.TEXT_ROW_CHARS)) do
local title = self:addRow(self.textPrefab, {kind = "text"}):getDescendantByName("title")
title:setText(text)
return title
title:setText(line)
first = first or title
end
return first
end
-- AutoDrive farver hver række i onCreate; her sker det efter hver genopbygning,
-- fordi rækkerne først findes på det tidspunkt.
function SmartPickupFrame:colorRows()
self:colorLayout(self.boxLayout)
end
function SmartPickupFrame:colorLayout(layout)
local colors = InGameMenuSettingsFrame ~= nil and InGameMenuSettingsFrame.COLOR_ALTERNATING or nil
if colors == nil or self.boxLayout == nil then
if colors == nil or layout == nil then
return
end
local unpackFn = table.unpack or unpack
local isEvenRow = false
for _, element in ipairs(self.boxLayout.elements) do
for _, element in ipairs(layout.elements) do
if element.setImageColor ~= nil and colors[isEvenRow] ~= nil then
element:setImageColor(nil, unpackFn(colors[isEvenRow]))
end
@@ -390,10 +453,45 @@ function SmartPickupFrame:rebuild()
self.statusRow = self:addTextRow("")
self:colorRows()
self.boxLayout:invalidateLayout()
if self.editPanelOpen then self:fillEditPanel(adEnv) end
self:refreshLive()
self:restoreFocusIfLost()
return
end
if self.currentTab == SmartPickupFrame.TAB_FIELDSILOS then
if adEnv ~= nil then
self:fillFieldSiloRows(adEnv)
end
self.monthRebuildTicks = SmartPickupFrame.FIELDSILO_REBUILD_TICKS
self:colorRows()
self.boxLayout:invalidateLayout()
self:refreshButtons()
self:restoreFocusIfLost()
return
end
if self.currentTab == SmartPickupFrame.TAB_CHAINS then
if adEnv ~= nil then
self:fillChainRows(adEnv)
end
self.statusRow = self:addTextRow(self.statusOverride or "")
self.monthRebuildTicks = SmartPickupFrame.CHAIN_REBUILD_TICKS
self:colorRows()
self.boxLayout:invalidateLayout()
self:refreshButtons()
self:restoreFocusIfLost()
return
end
if self.currentTab == SmartPickupFrame.TAB_MONTH then
if adEnv ~= nil then
self:fillMonthRows(adEnv)
end
self.monthRebuildTicks = SmartPickupFrame.MONTH_REBUILD_TICKS
self:colorRows()
self.boxLayout:invalidateLayout()
self:refreshButtons()
self:restoreFocusIfLost()
return
end
if self.currentTab == SmartPickupFrame.TAB_OVERVIEW then
if adEnv ~= nil then
self:fillOverviewRows(adEnv)
@@ -407,46 +505,17 @@ function SmartPickupFrame:rebuild()
self:restoreFocusIfLost()
return
end
self:fillRunOption(adEnv)
local hasRun = run ~= nil and adEnv ~= nil
for _, element in ipairs({self.waitPoolOption, self.loopsOption, self.returnBelowOption, self.sellAtOption, self.storeAboveOption}) do
if element ~= nil then
element:setDisabled(not hasRun)
end
end
if hasRun then
self:fillWaitPoolOption(adEnv, run)
self:rebuildFlowDetail(adEnv, run)
self:addTextRow(self.i18n:getText("spu_section_vehicles"))
self:fillVehicleRows(adEnv, run)
self:addTextRow(self.i18n:getText("spu_section_status"))
if adEnv ~= nil then
self:fillFlowTabRows(adEnv)
end
self.statusRow = self:addTextRow("")
self:colorRows()
self.boxLayout:invalidateLayout()
if self.editPanelOpen then self:fillEditPanel(adEnv) end
self:refreshLive()
self:restoreFocusIfLost()
end
function SmartPickupFrame:fillRunOption(adEnv)
local texts, index = {}, 1
self.runIds = {}
for position, run in ipairs(self:getRunsForTab()) 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, false)
self.runOption:setDisabled(#self.runIds == 0)
end
-- Overblik: "Cow 1 food: TMR 18.0k/27.8k · Silage 3.0k/…" (mest trængende først)
function SmartPickupFrame:describeTargetLevels(target)
if #target.levels == 0 then
@@ -455,114 +524,6 @@ function SmartPickupFrame:describeTargetLevels(target)
return string.format("%s: %s", target.name, self:formatLevels(target.levels))
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, false)
end
-- Traktorer: som standard flowets egne og de ledige der kan bruges; "Vis alle" tilføjer traktorer i andre
-- flows og låste (vognen passer ikke) — de er tit mange og gør listen uoverskuelig.
function SmartPickupFrame:fillVehicleRows(adEnv, run)
local inRun = {}
for _, id in ipairs(run.vehicleIds) do
inRun[id] = true
end
local entries = {}
for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do
local id = ADRunsController.getVehicleId(vehicle)
local otherRun = ADRunsController.getRunForVehicle(vehicle)
local state = "free"
if inRun[id] then
state = "in"
elseif otherRun ~= nil and otherRun.id ~= run.id then
state = "other"
elseif SmartPickupFrame.VEHICLE_BLOCKING_REASONS[ADRunsController.validateVehicle(adEnv, run, vehicle).reason] then
state = "blocked"
end
table.insert(entries, {id = id, label = ADRunsController.getVehicleLabel(adEnv, vehicle), state = state, vehicle = vehicle, otherRun = otherRun})
end
local rows = ADFlowView.vehicleRows(entries, self.showAllVehicles == true)
local hidden = ADFlowView.hiddenCount(entries, rows)
if hidden > 0 or self.showAllVehicles then
self.showAllVehiclesOption = self:addToggleRow("showAllVehicles", string.format(self.i18n:getText("spu_showAllVehicles"), hidden), {self.i18n:getText("ui_no"), self.i18n:getText("ui_yes")}, self.showAllVehicles == true)
end
for _, entry in ipairs(rows) do
local vehicle = entry.vehicle
local row = self:addRow(self.vehiclePrefab, {kind = "vehicle", vehicle = vehicle, row = nil})
local option, title = row:getDescendantByName("option"), row:getDescendantByName("title")
self.rowData[option].row = row
option:setState(entry.state == "in" and SmartPickupFrame.STATE_ON or SmartPickupFrame.STATE_OFF, false)
if entry.state == "other" then
-- én traktor = ét flow; rækken viser hvor den er
option:setDisabled(true)
self.rowData[option].locked = true
title:setText(string.format("%s — %s", entry.label, string.format(self.i18n:getText("spu_inRun"), tostring(entry.otherRun.name))))
else
title:setText(self:describeVehicle(adEnv, run, vehicle, entry.state == "in"))
if entry.state == "blocked" then
option:setDisabled(true)
self.rowData[option].locked = true
end
end
end
end
-- "Fastrac 2135 4WS (#1) — 1.000.000 l — ✔" / "… — ✖ ingen vogn"; reservationsnote fra Start bevares.
-- "Fastrac 2135 4WS (#1) — 1.000.000 l — kan bære: Halm, TMR — ✔" / "… — ✖ ingen vogn".
-- Vises for ALLE traktorer, så man kan se om vognen passer, før man siger Ja.
function SmartPickupFrame:describeVehicle(adEnv, run, vehicle, isInRun)
local label = ADRunsController.getVehicleLabel(adEnv, vehicle)
local validation = ADRunsController.validateVehicle(adEnv, run, vehicle)
local parts = {label}
if validation.isAutoload and validation.capacity == nil then
table.insert(parts, self.i18n:getText("spu_capacity_pallets"))
elseif validation.capacity ~= nil then
table.insert(parts, string.format(self.i18n:getText("spu_capacity"), g_i18n:formatNumber(validation.capacity, 0)))
local carriable = self:describeCarriable(adEnv, run, vehicle, validation.carriable)
if carriable ~= nil then
table.insert(parts, carriable)
end
end
if validation.ok then
table.insert(parts, "OK")
else
local reason = self.i18n:getText("spu_reason_" .. tostring(validation.reason))
if validation.reason == "noMarker" and validation.missingBuilding ~= nil then
reason = string.format(reason, tostring(validation.missingBuilding))
end
table.insert(parts, "X " .. reason)
end
return table.concat(parts, " — ")
end
-- Hvilke af run'ets varer vognen kan bære (titler); nil når ingen.
function SmartPickupFrame:describeCarriable(adEnv, run, vehicle, carriable)
local canCarry = {}
for _, fillType in ipairs(carriable or {}) do
canCarry[fillType] = true
end
local titles = {}
for _, fillType in ipairs(ADRunsController.getRunIngredients(adEnv, run, vehicle)) do
if canCarry[fillType] then
local title = g_fillTypeManager.getFillTypeTitleByIndex ~= nil and g_fillTypeManager:getFillTypeTitleByIndex(fillType) or nil
table.insert(titles, tostring(title or g_fillTypeManager:getFillTypeNameByIndex(fillType)))
end
end
if #titles == 0 then
return nil
end
return string.format(self.i18n:getText("spu_canCarry"), table.concat(titles, ", "))
end
-- Felterne følger modellen: når et klik ikke er nået ind i run'et, springer feltet tilbage,
-- og loggen ("run '…': waitPoolGroup=…") viser om klikket blev registreret.
function SmartPickupFrame:syncOptionsFromModel(adEnv, run)
@@ -657,10 +618,20 @@ end
-- sendes listen igen. Kaldes hvert sekund, så den må aldrig kaste.
function SmartPickupFrame:refreshButtons()
local wanted = self.flowButtons
if self.currentTab == SmartPickupFrame.TAB_OVERVIEW then
if self.editPanelOpen then
wanted = self.guide ~= nil and self:panelGuideButtons() or self:getPanelButtons()
elseif self:hasGuideOn(self.currentTab) then
wanted = self:getGuideButtons()
elseif self.currentTab == SmartPickupFrame.TAB_OVERVIEW then
wanted = self.overviewButtons
elseif self.currentTab == SmartPickupFrame.TAB_FIELDS then
wanted = self.fieldButtons
wanted = self:getFieldButtons()
elseif self.currentTab == SmartPickupFrame.TAB_MONTH or self.currentTab == SmartPickupFrame.TAB_FIELDSILOS
or self.currentTab == SmartPickupFrame.TAB_CHAINS then
wanted = self.monthButtons
elseif SmartPickupFrame.getTabKind(self.currentTab) ~= nil then
wanted = self.storageButtons
self.renameBuildingButtonInfo.disabled = self.storageDetailPlaceable == nil
end
if self.menuButtonInfo ~= wanted then
self.menuButtonInfo = wanted
@@ -681,10 +652,24 @@ function SmartPickupFrame:refreshLive()
self:setStatusText(self.i18n:getText("spu_status_noAd"))
return
end
if self.currentTab == SmartPickupFrame.TAB_MONTH or self.currentTab == SmartPickupFrame.TAB_FIELDSILOS
or self.currentTab == SmartPickupFrame.TAB_CHAINS then
if self.currentTab == SmartPickupFrame.TAB_CHAINS then
-- "forslaget gælder ikke længere" står STATUS_OVERRIDE_TICKS sekunder
self.statusOverrideTicks = (self.statusOverrideTicks or 0) - 1
if self.statusOverrideTicks <= 0 then self.statusOverride = nil end
self:setStatusText(self.statusOverride or "")
end
self.monthRebuildTicks = (self.monthRebuildTicks or 0) - 1
if self.monthRebuildTicks <= 0 then self:requestRebuild() end
self:refreshButtons()
return
end
local isOverview = self.currentTab == SmartPickupFrame.TAB_OVERVIEW
if isOverview then
self:refreshOverview(adEnv)
end
if self.editPanelOpen then self:refreshEditPanel(adEnv) end
if self.currentTab == SmartPickupFrame.TAB_FIELDS then
self:refreshFieldRows(adEnv)
self.statusOverrideTicks = (self.statusOverrideTicks or 0) - 1
@@ -692,6 +677,13 @@ function SmartPickupFrame:refreshLive()
self:refreshButtons()
return
end
if self.currentTab == SmartPickupFrame.TAB_FLOWS then
self:refreshFlowTab(adEnv)
self.statusOverrideTicks = (self.statusOverrideTicks or 0) - 1
if self.statusOverrideTicks <= 0 then self.statusOverride = nil end
self:refreshButtons()
return
end
self:refreshStorageRows(adEnv)
local isRunning = false
if isOverview then
@@ -701,8 +693,19 @@ function SmartPickupFrame:refreshLive()
running, total = running + status.running, total + status.total
if status.running > 0 then activeRuns = activeRuns + 1 end
end
self:setStatusText(self.statusOverride or string.format(self.i18n:getText("spu_status_overview"), running, total, activeRuns, #ADRunsController.store.runs))
local statusText = string.format(self.i18n:getText("spu_status_overview"), running, total, activeRuns, #ADRunsController.store.runs)
local queued = #(ADRunsController.startQueue or {})
if queued > 0 then
local totalQueued = ADRunsController.startQueueTotal or queued
statusText = string.format(self.i18n:getText("spu_status_starting"), totalQueued - queued, totalQueued)
elseif ADFieldJobs ~= nil and ADFieldJobs.state ~= nil and ADFieldJobs.state.paused then
statusText = self.i18n:getText("spu_status_paused")
end
self:setStatusText(self.statusOverride or statusText)
self.stopAllButtonInfo.disabled = running == 0
elseif SmartPickupFrame.getTabKind(self.currentTab) ~= nil then
-- lagerfanerne: ingen flow-status (in-game 29/9: 'Stoppet (1 traktorer)' fra det sidst valgte flow)
self:setStatusText(self.statusOverride or "")
elseif run ~= nil then
self:syncOptionsFromModel(adEnv, run)
if run.kind == "outbound" then
@@ -711,7 +714,7 @@ function SmartPickupFrame:refreshLive()
self:refreshNeedsRows(adEnv, run)
end
local status = ADRunsController.getRunStatus(adEnv, run)
isRunning = status.isRunning == true
isRunning = status.canStop == true
local text = status.running > 0 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:setStatusText(self.statusOverride or text)
@@ -747,27 +750,14 @@ function SmartPickupFrame:onClickRun(state)
end
function SmartPickupFrame:onClickIngredient(state, element)
-- mål- og kilde-rækkerne deler prefab (og dermed onClick) med vare-rækkerne
-- guiderne og markflåden deler prefab (og dermed onClick) med vare-rækkerne
local data = self.rowData[element]
if data ~= nil and data.kind == "target" then
return self:onClickTarget(state, element)
elseif data ~= nil and data.kind == "source" then
return self:onClickSource(state, element)
elseif data ~= nil and data.kind == "ingredientMode" then
return self:onClickIngredientMode(state)
elseif data ~= nil and data.kind == "collectRound" then
return self:onClickCollectRound(state)
elseif data ~= nil and data.kind == "direction" then
return self:onClickDirection(state)
elseif data ~= nil and (data.kind == "showAllVehicles" or data.kind == "showAllBuildings") then
local isOn = state == SmartPickupFrame.STATE_ON
if data.kind == "showAllVehicles" then self.showAllVehicles = isOn else self.showAllBuildings = isOn end
self:requestRebuild()
return
if data ~= nil and data.kind == "guideRow" then
return self:onClickGuideRow(state, data)
elseif data ~= nil and SmartPickupFrame.FIELDSILO_ROW_KINDS ~= nil and SmartPickupFrame.FIELDSILO_ROW_KINDS[data.kind] then
return self:onClickFieldSiloRow(state, data)
elseif data ~= nil and SmartPickupFrame.FIELD_ROW_KINDS[data.kind] then
return self:onClickFieldRow(state, data)
elseif data ~= nil and (data.kind == "sourceBuilding" or data.kind == "targetBuilding" or data.kind == "storeBuilding") then
return self:onClickBuilding(state, element)
end
self:safe(function(frame)
local run, data = frame:getRun(), frame.rowData[element]
@@ -793,12 +783,18 @@ end
function SmartPickupFrame:onClickVehicle(state, element)
local clicked = self.rowData[element]
-- markfanens maskinrækker (markflåden og markens faste maskiner) bruger samme række-type som flowets
-- traktorliste; de hører til markfanen (fieldRig blev før behandlet som flow-traktor og aldrig gemt)
if clicked ~= nil and clicked.kind == "fleetVehicle" then
return self:onClickFieldRow(state, clicked)
end
if clicked ~= nil and clicked.kind == "guideRow" then
return self:onClickGuideRow(state, clicked)
end
self:safe(function(frame)
local run, data = frame:getRun(), frame.rowData[element]
if run == nil or data == nil or data.locked then return end
-- kun flowets traktorrækker (de har et køretøj); alt andet ignoreres
if run == nil or data == nil or data.locked or data.vehicle == nil then return end
if state == SmartPickupFrame.STATE_ON then
local previousRunId = ADRunsController.assignVehicle(run.id, data.vehicle)
if previousRunId ~= nil then
@@ -819,14 +815,7 @@ 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).isRunning then
ADRunsController.stopRun(adEnv, run.id)
frame.statusOverride = nil
else
local started, skipped, reasons = frame:summarizeStartResults(ADRunsController.startRun(adEnv, run.id))
frame:showOverride(string.format(frame.i18n:getText("spu_status_started"), started, skipped), reasons)
end
ADRunsController.save()
frame:toggleRun(adEnv, run.id)
frame:requestRebuild()
end)
end
@@ -866,11 +855,28 @@ function SmartPickupFrame:onClickStartAll()
self:safe(function(frame)
local adEnv = frame.getAdEnv()
if adEnv == nil then return end
local summary = ADRunsController.startAllRuns(adEnv)
local _, skipped, reasons = frame:summarizeStartResults(summary.results)
Logging.info(SmartPickupFrame.LOG_PREFIX .. "Start alle: %d flows startet (%d traktorer), %d kørte allerede, %d sprunget over",
summary.runsStarted, summary.vehiclesStarted, summary.alreadyRunning, skipped)
frame:showOverride(string.format(frame.i18n:getText("spu_status_startedAll"), summary.runsStarted, summary.vehiclesStarted, summary.alreadyRunning, skipped), reasons)
-- pausen (Stop alt og parkér) ophæves; traktorerne startes én ad gangen med mellemrum
if ADFieldJobs ~= nil and ADFieldJobs.state ~= nil and ADFieldJobs.state.paused then ADFieldJobs.setPaused(false) end
local summary = ADRunsController.queueStartAll(adEnv)
local _, skipped, reasons = frame:summarizeStartResults(summary.skipped)
Logging.info(SmartPickupFrame.LOG_PREFIX .. "Start alle: %d traktorer i kø (én hvert %d. sekund), %d flows kørte allerede, %d sprunget over",
summary.queued, math.floor(ADRunsController.STAGGER_MS / 1000), summary.alreadyRunning, skipped)
frame:showOverride(string.format(frame.i18n:getText("spu_status_queuedAll"), summary.queued, math.floor(ADRunsController.STAGGER_MS / 1000), summary.alreadyRunning, skipped), reasons)
ADRunsController.save()
frame:requestRebuild()
end)
end
function SmartPickupFrame:onClickStopAndPark()
self:safe(function(frame)
local adEnv = frame.getAdEnv()
if adEnv == nil then return end
local summary = ADRunsController.stopAllAndPark(adEnv)
local text = string.format(frame.i18n:getText("spu_status_stopParked"), summary.stopped, summary.parked)
local notes = {}
if #summary.noPark > 0 then table.insert(notes, string.format(frame.i18n:getText("spu_status_noPark"), table.concat(summary.noPark, ", "))) end
if #summary.manual > 0 then table.insert(notes, string.format(frame.i18n:getText("spu_status_fetchManual"), table.concat(summary.manual, ", "))) end
frame:showOverride(text, notes)
ADRunsController.save()
frame:requestRebuild()
end)
@@ -897,25 +903,28 @@ function SmartPickupFrame:openFlow(runId)
self:switchTab(SmartPickupFrame.TAB_FLOWS)
end
-- Nyt run: navnet vælges FØR run'et oprettes (annulleres dialogen, oprettes intet).
-- Forslaget tæller efter antal runs, ikke efter løbenummeret, så "Run 2" kan genbruges.
-- Nyt flow: guiden "Opsæt flow" (gui/SmartPickupFlowGuide.lua) — intet oprettes før [Gem].
function SmartPickupFrame:onClickNew()
self:onClickFlowNew()
end
-- Omdøb den valgte bygning i en bygningsfane (gemmes i ADSmartPickup_names.xml; tomt = butiksnavnet igen)
function SmartPickupFrame:onClickRenameBuilding()
self:safe(function(frame)
local suggestion = string.format(frame.i18n:getText("spu_newRunName"), #ADRunsController.store.runs + 1)
TextInputDialog.show(SmartPickupFrame.onNewRunName, frame, suggestion, frame.i18n:getText("spu_renameTitle"), nil, 30, g_i18n:getText("button_ok"))
local placeable = frame.storageDetailPlaceable
if placeable == nil then return end
frame.renamingBuildingId = ADBuildings.getId(placeable)
local current = ADNames.forPlaceable(placeable) or (placeable.getName ~= nil and placeable:getName()) or ""
TextInputDialog.show(SmartPickupFrame.onRenameBuildingResult, frame, tostring(current), frame.i18n:getText("spu_renameBuildingTitle"), nil, 40, g_i18n:getText("button_ok"))
end)
end
function SmartPickupFrame:onNewRunName(value, clickOk)
function SmartPickupFrame:onRenameBuildingResult(value, clickOk)
self:safe(function(frame)
if not clickOk or value == nil or not value:match("%S") then
return
end
local run = ADRunsController.createRun(value, "supply")
frame.selectedRunId = run.id
frame.statusOverride = nil
frame.currentTab = SmartPickupFrame.TAB_FLOWS
frame:switchTab(SmartPickupFrame.TAB_FLOWS)
if not clickOk or frame.renamingBuildingId == nil then return end
ADNames.set(frame.renamingBuildingId, value)
frame.renamingBuildingId = nil
frame:requestRebuild()
end)
end
@@ -57,6 +57,14 @@
<Text profile="fs25_settingsMultiTextOptionTitle" text="$l10n_spu_storeAbove"/>
</Bitmap>
</ScrollingLayout>
<!-- redigeringsvindue (v1.58): skjult; placering/størrelse sættes i gui/SmartPickupEditPanel.lua -->
<GuiElement profile="baseReference" id="editOverlay" visible="false">
<Bitmap profile="spuOverlayBackdrop" id="editBackdrop"/>
<Bitmap profile="spuOverlayPanel" id="editPanel"/>
<Text profile="spuPanelTitle" id="editTitle"/>
<Text profile="spuCell" id="editStatus" size="700px 30px" textMaxWidth="690px"/>
<ScrollingLayout profile="fs25_settingsLayout" id="editLayout"/>
</GuiElement>
<!-- prefabs: klones pr. vare / traktor / tekstlinje og fjernes fra layoutet ved initialize -->
<Bitmap profile="fs25_multiTextOptionContainer" id="ingredientPrefab">
@@ -81,6 +89,15 @@
<Text profile="spuCell" name="c5" position="620px 0px" size="50px 40px" textMaxWidth="42px"/>
<Text profile="spuCell" name="c6" position="670px 0px" size="140px 40px" textMaxWidth="132px"/>
</Bitmap>
<!-- Flows-listen (v1.58): ● · navn · varer · kører/i alt · status · [Start]/[Stop]. ~835 px. -->
<Bitmap profile="fs25_multiTextOptionContainer" id="flowListPrefab">
<Bitmap profile="spuDot" name="dot" position="20px 0px"/>
<Text profile="spuCellBold" name="c1" position="38px 0px" size="200px 40px" textMaxWidth="192px"/>
<Text profile="spuCell" name="c2" position="238px 0px" size="170px 40px" textMaxWidth="162px"/>
<Text profile="spuCell" name="c3" position="408px 0px" size="70px 40px" textMaxWidth="62px"/>
<Text profile="spuCell" name="c4" position="478px 0px" size="252px 40px" textMaxWidth="244px"/>
<Text profile="spuCellBold" name="c5" position="730px 0px" size="100px 40px" textMaxWidth="92px"/>
</Bitmap>
<!-- Marker: én række pr. mark (mark · afgrøde · plan · auto/man. · status · maskiner). Rækken er ~815 px bred. -->
<Bitmap profile="fs25_multiTextOptionContainer" id="fieldPrefab">
<Text profile="spuCellBold" name="c1" position="20px 0px" size="100px 40px" textMaxWidth="92px"/>
@@ -90,6 +107,26 @@
<Text profile="spuCell" name="c5" position="440px 0px" size="230px 40px" textMaxWidth="222px"/>
<Text profile="spuCell" name="c6" position="670px 0px" size="140px 40px" textMaxWidth="132px"/>
</Bitmap>
<!-- Måned: mark · arbejde · afgrøde · færdig · næste · vindue (vinduet bredt nok til "OBS: sidste såmåned"). ~835 px. -->
<Bitmap profile="fs25_multiTextOptionContainer" id="monthPrefab">
<Text profile="spuCellBold" name="c1" position="20px 0px" size="100px 40px" textMaxWidth="92px"/>
<Text profile="spuCell" name="c2" position="120px 0px" size="130px 40px" textMaxWidth="122px"/>
<Text profile="spuCell" name="c3" position="250px 0px" size="110px 40px" textMaxWidth="102px"/>
<Text profile="spuCell" name="c4" position="360px 0px" size="80px 40px" textMaxWidth="72px"/>
<Text profile="spuCell" name="c5" position="440px 0px" size="150px 40px" textMaxWidth="142px"/>
<Text profile="spuCell" name="c6" position="590px 0px" size="240px 40px" textMaxWidth="232px"/>
</Bitmap>
<!-- Fields-listen (v1.59): ● · mark · afgrøde · plan · kørsel · status · maskiner · [Start]/[Stop]. ~835 px. -->
<Bitmap profile="fs25_multiTextOptionContainer" id="fieldListPrefab">
<Bitmap profile="spuDot" name="dot" position="20px 0px"/>
<Text profile="spuCellBold" name="c1" position="38px 0px" size="82px 40px" textMaxWidth="76px"/>
<Text profile="spuCell" name="c2" position="120px 0px" size="120px 40px" textMaxWidth="112px"/>
<Text profile="spuCell" name="c3" position="240px 0px" size="100px 40px" textMaxWidth="92px"/>
<Text profile="spuCell" name="c4" position="340px 0px" size="70px 40px" textMaxWidth="62px"/>
<Text profile="spuCell" name="c5" position="410px 0px" size="200px 40px" textMaxWidth="192px"/>
<Text profile="spuCell" name="c6" position="610px 0px" size="120px 40px" textMaxWidth="112px"/>
<Text profile="spuCellBold" name="c7" position="730px 0px" size="100px 40px" textMaxWidth="92px"/>
</Bitmap>
<!-- Lagre: statistik-tabel (vare · i bygningen · værdi nu · ved toppris · gården · rækker til · forbrug/prod.) -->
<Bitmap profile="fs25_multiTextOptionContainer" id="statPrefab">
<Text profile="spuCellBold" name="c1" position="20px 0px" size="140px 40px" textMaxWidth="132px"/>
@@ -106,6 +143,41 @@
<Text profile="spuCell" name="c2" position="290px 0px" size="260px 40px" textMaxWidth="252px"/>
<Text profile="spuCell" name="c3" position="550px 0px" size="260px 40px" textMaxWidth="252px"/>
</Bitmap>
<!-- Lagre, vareoversigt (v1.52). Rækken er ~815 px bred. -->
<!-- nøgletal: 4 felter (tal + label); højden sættes i Lua -->
<Bitmap profile="fs25_multiTextOptionContainer" id="kpiPrefab">
<Text profile="spuKpiValue" name="v1" position="20px 0px" size="195px 40px" textMaxWidth="190px"/>
<Text profile="spuKpiLabel" name="l1" position="20px 0px" size="195px 40px" textMaxWidth="190px"/>
<Text profile="spuKpiValue" name="v2" position="220px 0px" size="195px 40px" textMaxWidth="190px"/>
<Text profile="spuKpiLabel" name="l2" position="220px 0px" size="195px 40px" textMaxWidth="190px"/>
<Text profile="spuKpiValue" name="v3" position="420px 0px" size="195px 40px" textMaxWidth="190px"/>
<Text profile="spuKpiLabel" name="l3" position="420px 0px" size="195px 40px" textMaxWidth="190px"/>
<Text profile="spuKpiValue" name="v4" position="620px 0px" size="190px 40px" textMaxWidth="185px"/>
<Text profile="spuKpiLabel" name="l4" position="620px 0px" size="190px 40px" textMaxWidth="185px"/>
</Bitmap>
<!-- kræver handling: farvet markør + tekst -->
<Bitmap profile="fs25_multiTextOptionContainer" id="alertPrefab">
<Bitmap profile="spuDot" name="dot" position="20px 0px"/>
<Text profile="spuCell" name="c1" position="40px 0px" size="770px 40px" textMaxWidth="765px"/>
</Bitmap>
<!-- vare: +/– navn · bjælke · mængde · værdi · netto/døgn og rækkevidde (klikbar) -->
<Bitmap profile="fs25_multiTextOptionContainer" id="itemPrefab">
<Text profile="spuCellBold" name="c1" position="20px 0px" size="170px 40px" textMaxWidth="165px"/>
<Bitmap profile="spuBarBg" name="barBg" position="190px 0px" size="140px 16px"/>
<Bitmap profile="spuBar" name="bar" position="190px 0px" size="140px 16px"/>
<Text profile="spuCell" name="c2" position="340px 0px" size="160px 40px" textMaxWidth="155px"/>
<Text profile="spuCell" name="c3" position="500px 0px" size="105px 40px" textMaxWidth="100px"/>
<Text profile="spuCell" name="c4" position="605px 0px" size="225px 40px" textMaxWidth="220px"/>
</Bitmap>
<!-- bygning under en udfoldet vare: indrykket navn · tynd bjælke · mængde · værdi · procent -->
<Bitmap profile="fs25_multiTextOptionContainer" id="subPrefab">
<Text profile="spuCellDim" name="c1" position="45px 0px" size="145px 40px" textMaxWidth="140px"/>
<Bitmap profile="spuBarThinBg" name="barBg" position="190px 0px" size="140px 10px"/>
<Bitmap profile="spuBarThin" name="bar" position="190px 0px" size="140px 10px"/>
<Text profile="spuCellDim" name="c2" position="340px 0px" size="160px 40px" textMaxWidth="155px"/>
<Text profile="spuCellDim" name="c3" position="500px 0px" size="105px 40px" textMaxWidth="100px"/>
<Text profile="spuCellDim" name="c4" position="605px 0px" size="225px 40px" textMaxWidth="220px"/>
</Bitmap>
<!-- udkørsel: én række pr. vare med politik Fra/Lager/Salg/Salg, ellers lager -->
<Bitmap profile="fs25_multiTextOptionContainer" id="productPrefab">
<MultiTextOption profile="fs25_settingsMultiTextOption" onClick="onClickProduct" name="option">
+231
View File
@@ -0,0 +1,231 @@
-- Smart Pickup — fælles guide-skærm ("Opsæt mark" / "Opsæt flow")
-- Ét trin ad gangen: overskrift, "Trin 3 af 7 — …", kort forklaring, valgene som rækker og bundknapperne
-- Næste/Gem, Tilbage, Annullér (+ Gem uden at starte på oversigts-trinnet). Al logik ligger i ADGuide og
-- trin-definitionerne (ADFieldGuide/ADFlowGuide); her tegnes kun rækkerne og klik routes.
-- Hver guide registrerer sig i SmartPickupFrame.GUIDE_HOOKS[kind] = {
-- model, tab, heading(frame, guide), context(frame, adEnv, guide) -> context|nil (nil = luk guiden),
-- choose(frame, id, value) -> handled, save(frame, guide, start), startKey(guide), showSaveOnly(guide),
-- canAdvance(guide), afterRows(frame, adEnv, guide) }
-- Funktionerne lægges på SmartPickupFrame (indlæses efter SmartPickupFrame.lua).
SmartPickupFrame.GUIDE_HOOKS = SmartPickupFrame.GUIDE_HOOKS or {}
function SmartPickupFrame:initGuideButtons(i18n)
self.guideNextButtonInfo = {inputAction = InputAction.MENU_ACTIVATE, text = i18n:getText("spu_guide_next"), callback = function() self:onClickGuideNext(true) end}
self.guideSaveOnlyButtonInfo = {inputAction = InputAction.MENU_EXTRA_1, text = i18n:getText("spu_guide_saveOnly"), callback = function() self:onClickGuideNext(false) end}
self.guidePrevButtonInfo = {inputAction = InputAction.MENU_EXTRA_2, text = i18n:getText("spu_guide_back"), callback = function() self:onClickGuideBack() end}
self.guideCancelButtonInfo = {inputAction = InputAction.MENU_CANCEL, text = i18n:getText("spu_guide_cancel"), callback = function() self:onClickGuideCancel() end}
self.guideButtons = {self.backButtonInfo, self.guideNextButtonInfo, self.guidePrevButtonInfo, self.guideCancelButtonInfo}
self.guideSummaryButtons = {self.backButtonInfo, self.guideNextButtonInfo, self.guideSaveOnlyButtonInfo, self.guidePrevButtonInfo, self.guideCancelButtonInfo}
end
function SmartPickupFrame:guideHooks()
return self.guideKind ~= nil and SmartPickupFrame.GUIDE_HOOKS[self.guideKind] or nil
end
-- Er der en åben guide på fanen?
function SmartPickupFrame:hasGuideOn(tab)
local hooks = self:guideHooks()
return self.guide ~= nil and hooks ~= nil and hooks.tab == tab
end
function SmartPickupFrame:openGuide(kind, guide)
self.guideKind, self.guide = kind, guide
self.statusOverride = nil
self:requestRebuild()
end
-- Kladden kasseres (Annullér, fanebyt, lukket menu).
function SmartPickupFrame:closeGuide()
self.guideKind, self.guide = nil, nil
end
function SmartPickupFrame:guideText(label)
return ADGuide.labelText(label, function(key) return self.i18n:getText(key) end)
end
-- Klikbar tekstrække (ombrydes; alle linjer er klikbare). Handlingen køres fra update() (pendingClick).
function SmartPickupFrame:addLinkRow(text, action)
local first = nil
for _, line in ipairs(ADFlowView.wrapText(text, SmartPickupFrame.TEXT_ROW_CHARS)) do
local row = self:addRow(self.textPrefab, {kind = "text"})
local title = row:getDescendantByName("title")
title:setText(line)
self:makeRowClickable(row, action)
first = first or title
end
return first
end
local function optionIndex(options, value)
for index, option in ipairs(options) do
if option.value == value then return index end
end
return 1
end
function SmartPickupFrame:addGuideRow(row)
local data = {kind = "guideRow", row = row}
if row.type == "choice" then
local element = self:addRow(self.productPrefab, data)
local option = element:getDescendantByName("option")
local texts = {}
for _, entry in ipairs(row.options) do table.insert(texts, self:guideText(entry.label)) end
option:setTexts(texts)
option:setState(optionIndex(row.options, row.value), false)
element:getDescendantByName("title"):setText(self:guideText(row.title))
elseif row.type == "toggle" or row.type == "check" then
local element = self:addRow(self.ingredientPrefab, data)
local option = element:getDescendantByName("option")
if row.type == "toggle" then option:setTexts({self:guideText(row.off), self:guideText(row.on)}) end
option:setState(row.value and SmartPickupFrame.STATE_ON or SmartPickupFrame.STATE_OFF, false)
element:getDescendantByName("title"):setText((row.type == "check" and " " or "") .. self:guideText(row.title))
elseif row.type == "link" then
local text = row.label ~= nil and self:guideText(row.label)
or (ADGuide.lineText(row.line, function(key) return self.i18n:getText(key) end) .. self.i18n:getText("spu_guide_editSuffix"))
self:addLinkRow(" " .. text, function(frame) frame:onGuideLink(row) end)
else
self:addTextRow(self:guideText(row.label))
end
end
-- Tegner den åbne guide. false = guiden er lukket (fx marken findes ikke længere) — fanen viser oversigten.
function SmartPickupFrame:fillGuideRows(adEnv)
local hooks = self:guideHooks()
if hooks == nil or self.guide == nil then return false end
local context = hooks.context(self, adEnv, self.guide)
if context == nil then
self:closeGuide()
self.statusOverride = self.i18n:getText("spu_guide_closed")
self.statusOverrideTicks = SmartPickupFrame.STATUS_OVERRIDE_TICKS
return false
end
self.guide = ADGuide.withContext(self.guide, context)
local guide, model = self.guide, hooks.model
self:addTextRow(hooks.heading(self, guide))
local stepTitle = self.i18n:getText(model.stepKey(guide.step, guide))
local index, total = ADGuide.position(guide)
local header
if guide.single then
header = string.format(self.i18n:getText("spu_guide_editStep"), stepTitle)
elseif index ~= nil and total > 1 then
header = string.format(self.i18n:getText("spu_guide_stepOf"), index, total, stepTitle)
else
header = string.format(self.i18n:getText("spu_guide_stepOne"), index or 1, stepTitle)
end
self:addTextRow(header)
self:addTextRow(" " .. self.i18n:getText(model.hintKey(guide.step, guide)))
for _, row in ipairs(model.rows(guide)) do
self:addGuideRow(row)
end
if hooks.afterRows ~= nil then hooks.afterRows(self, adEnv, guide) end
return true
end
-- ---- klik ----
function SmartPickupFrame:applyGuideChoice(id, value)
local hooks = self:guideHooks()
if hooks == nil or self.guide == nil then return end
if hooks.choose ~= nil and hooks.choose(self, id, value) then return end
self.guide = hooks.model.choose(self.guide, id, value)
end
function SmartPickupFrame:onClickGuideRow(state, data)
self:safe(function(frame)
local row = data ~= nil and data.row or nil
if row == nil or frame.guide == nil then return end
local value
if row.type == "choice" then
local option = row.options[state]
if option == nil then return end
value = option.value
-- valg pr. vare (fx politik): modellen skal vide hvilken
if row.item ~= nil then value = {item = row.item, value = option.value} end
elseif row.type == "toggle" then
value = state == SmartPickupFrame.STATE_ON
elseif row.type == "check" then
value = {item = row.item, on = state == SmartPickupFrame.STATE_ON}
else
return
end
frame:applyGuideChoice(row.id, value)
frame:requestRebuild()
end)
end
-- Klikbare linjer: [Ret] på oversigts-trinnet åbner ét trin; andre links er valg (fx mark i trin 1).
function SmartPickupFrame:onGuideLink(row)
if self.guide == nil then return end
if row.id == "jump" then
self.guide = ADGuide.jump(self.guide, row.value, "summary")
else
self:applyGuideChoice(row.id, row.value)
end
self:requestRebuild()
end
local function finish(frame, hooks, guide, start)
hooks.save(frame, guide, start)
frame:closeGuide()
frame:requestRebuild()
end
-- Næste / Gem / Kør automatisk (start = true) og Gem uden at starte (start = false).
function SmartPickupFrame:onClickGuideNext(start)
self:safe(function(frame)
local hooks, guide = frame:guideHooks(), frame.guide
if hooks == nil or guide == nil then return end
if hooks.canAdvance ~= nil and not hooks.canAdvance(guide) then
frame:showOverride(frame.i18n:getText(hooks.cannotAdvanceKey or "spu_guide_cannotAdvance"))
return
end
if guide.single and guide.returnTo == "close" then
if start == false then return end
return finish(frame, hooks, guide, nil)
end
if guide.step == "summary" and not guide.single then
return finish(frame, hooks, guide, start)
end
if start == false then return end
local nextGuide, event = ADGuide.next(guide)
if event == "done" then return finish(frame, hooks, guide, true) end
frame.guide = nextGuide
frame:requestRebuild()
end)
end
function SmartPickupFrame:onClickGuideBack()
self:safe(function(frame)
if frame.guide == nil then return end
local previous, event = ADGuide.back(frame.guide)
if event == "cancel" then frame:closeGuide() else frame.guide = previous end
frame:requestRebuild()
end)
end
function SmartPickupFrame:onClickGuideCancel()
self:safe(function(frame)
frame:closeGuide()
frame:requestRebuild()
end)
end
-- Knapperne følger trinnet: Næste / OK (tilbage til oversigten) / Gem ([Ret] fra fanen) / Kør automatisk.
function SmartPickupFrame:getGuideButtons()
local hooks, guide = self:guideHooks(), self.guide
local i18n = self.i18n
local isSummary = guide.step == "summary" and not guide.single
local key = "spu_guide_next"
if guide.single then
key = guide.returnTo == "close" and "spu_guide_save" or "spu_guide_ok"
elseif isSummary then
key = hooks.startKey ~= nil and hooks.startKey(guide) or "spu_guide_save"
end
self.guideNextButtonInfo.text = i18n:getText(key)
self.guideNextButtonInfo.disabled = hooks.canAdvance ~= nil and not hooks.canAdvance(guide)
if isSummary and (hooks.showSaveOnly == nil or hooks.showSaveOnly(guide)) then
return self.guideSummaryButtons
end
return self.guideButtons
end
@@ -0,0 +1,107 @@
-- Smart Pickup — fanen Måned
-- Hvad skal nås før månedsskiftet? Høst og såning har korte vinduer; fanen viser markerne med arbejde i
-- gang, fremdrift, næste trin og om vinduet lukker. Kun visning — reglerne ligger i ADMonthView.
-- Periodens navn: spillets egen tekst (fx "Sen sommer") hvis den findes, ellers "Periode 6".
-- Periode 1 = tidligt forår = marts (nordlig halvkugle) -> "(aug)".
function SmartPickupFrame:monthPeriodText(period)
local month = (period + 1) % 12 + 1
local names = {}
for name in string.gmatch(self.i18n:getText("spu_month_months"), "[^,]+") do table.insert(names, name) end
local suffix = names[month] ~= nil and (" (" .. names[month] .. ")") or ""
local ok, text = pcall(function() return g_i18n:formatPeriod(period, false) end)
if ok and type(text) == "string" and text ~= "" then return text .. suffix end
return string.format(self.i18n:getText("spu_month_period"), period) .. suffix
end
-- {period, day, days, hour, minute}; nil hvis miljøet ikke kan læses.
function SmartPickupFrame.monthClock()
local ok, clock = pcall(function()
local environment = g_currentMission.environment
local dayTime = environment.dayTime or 0
return {period = environment.currentPeriod, day = environment.currentDayInPeriod, days = environment.daysPerPeriod,
hour = math.floor(dayTime / 3600000), minute = math.floor(dayTime / 60000) % 60}
end)
if ok and type(clock) == "table" and type(clock.period) == "number" then return clock end
return nil
end
-- Courseplays fremdrift for markens sæt (gennemsnit), nil hvis ingen melder noget.
function SmartPickupFrame.monthCpProgress(fieldId)
local sum, count = 0, 0
for _, assignment in ipairs(ADFieldJobs.assignmentsFor(fieldId)) do
local vehicle = assignment.vehicle
if not assignment.helper and vehicle ~= nil and vehicle.getCpFieldWorkProgress ~= nil then
local ok, progress = pcall(vehicle.getCpFieldWorkProgress, vehicle)
if ok and type(progress) == "number" then
sum, count = sum + progress, count + 1
end
end
end
return count > 0 and sum / count or nil
end
function SmartPickupFrame.monthContext(period)
return {
period = period,
plantableIn = function(name, forPeriod)
local fruit = g_fruitTypeManager ~= nil and g_fruitTypeManager:getFruitTypeByName(name) or nil
return fruit ~= nil and ADCpBridge.isPlantableIn(fruit, forPeriod)
end,
withers = function(info, nextPeriod)
local fruit = info.fruitIndex ~= nil and g_fruitTypeManager:getFruitTypeByIndex(info.fruitIndex) or nil
local ok, result = pcall(ADMonthView.withersIn, fruit, nextPeriod, info.growthState)
return ok and result == true
end,
cpProgress = SmartPickupFrame.monthCpProgress,
}
end
function SmartPickupFrame:monthStageText(stage)
if stage == nil then return "–" end
return self.i18n:getText("spu_field_stage_" .. tostring(stage))
end
function SmartPickupFrame:monthCells(row)
local crop = row.sowFruit ~= nil and self:fruitTitle(row.sowFruit) or (row.crop or "")
return {"F" .. tostring(row.id), self:monthStageText(row.stage), crop, ADMonthView.percentText(row.progress),
self:monthStageText(row.nextStage), self.i18n:getText("spu_month_window_" .. row.window)}
end
function SmartPickupFrame:fillMonthRows(adEnv)
local i18n = self.i18n
local clock = SmartPickupFrame.monthClock()
if clock == nil then
self:addTextRow(i18n:getText("spu_month_noClock"))
return
end
self:addTextRow(string.format(i18n:getText("spu_month_header"), self:monthPeriodText(clock.period),
clock.day or 1, clock.days or 1, clock.hour, clock.minute))
local infos = ADFields.list()
if #infos == 0 then
self:addTextRow(i18n:getText("spu_field_scanning"))
return
end
local view = ADMonthView.build(infos, ADFieldJobs.state.fields, SmartPickupFrame.monthContext(clock.period))
if view.critical > 0 then
self:addTextRow(string.format(i18n:getText("spu_month_verdictUrgent"), view.critical))
else
self:addTextRow(i18n:getText("spu_month_verdictCalm"))
end
if #view.rows == 0 then
self:addTextRow(i18n:getText("spu_month_noWork"))
else
self:addCellRow(self.monthPrefab, {i18n:getText("spu_col_field"), i18n:getText("spu_month_col_work"), i18n:getText("spu_col_crop"),
i18n:getText("spu_month_col_progress"), i18n:getText("spu_month_col_next"), i18n:getText("spu_month_col_window")})
for _, row in ipairs(view.rows) do
self:addCellRow(self.monthPrefab, self:monthCells(row))
end
end
if #view.upcoming > 0 then
self:addTextRow(i18n:getText("spu_month_upcoming"))
for _, entry in ipairs(view.upcoming) do
local key = entry.closes and "spu_month_upcomingLast" or "spu_month_upcomingOpen"
self:addTextRow(string.format(i18n:getText(key), entry.id, self:fruitTitle(entry.fruit)))
end
end
end
@@ -57,66 +57,9 @@ function SmartPickupFrame.getPolicyIndex(policies, policy)
return 1
end
function SmartPickupFrame:fillSourceRows(adEnv, run)
local chosen = {}
for _, id in ipairs(run.sourceWayPointIds or {}) do
chosen[id] = true
end
local entries = ADRunsController.getSourceMarkers(adEnv, g_currentMission:getFarmId())
for _, entry in ipairs(entries) do
local row = self:addRow(self.ingredientPrefab, {kind = "source", wayPointId = entry.marker.id})
local kindText = self.i18n:getText("spu_source_kind_" .. tostring(entry.kind))
row:getDescendantByName("title"):setText(string.format(" %s — %s (%s)", tostring(entry.marker.name), tostring(entry.name), kindText))
row:getDescendantByName("option"):setState(chosen[entry.marker.id] and SmartPickupFrame.STATE_ON or SmartPickupFrame.STATE_OFF, false)
end
if #entries == 0 then
self:addTextRow(self.i18n:getText("spu_noSources"))
end
end
function SmartPickupFrame:fillProductRows(adEnv, run)
self.productRows = {}
local products = ADRunsController.getRunProducts(adEnv, run)
for _, product in ipairs(products) do
local policies = SmartPickupFrame.getPoliciesFor(product)
local policyTexts = {}
for _, policy in ipairs(policies) do
table.insert(policyTexts, self.i18n:getText("spu_policy_" .. policy))
end
local row = self:addRow(self.productPrefab, {kind = "product", name = product.name, policies = policies})
local option = row:getDescendantByName("option")
option:setTexts(policyTexts)
option:setState(SmartPickupFrame.getPolicyIndex(policies, product.policy), false)
local title = row:getDescendantByName("title")
title:setText(self:describeProduct(adEnv, product))
table.insert(self.productRows, {name = product.name, title = title, option = option})
end
if #products == 0 then
self:addTextRow(#(run.sourceWayPointIds or {}) == 0 and self.i18n:getText("spu_noSources") or self.i18n:getText("spu_noProducts"))
end
end
function SmartPickupFrame:fillOutboundStatusRows(adEnv, run)
self.outboundStatusRows = {}
local status = ADRunsController.getOutboundStatus(adEnv, run)
for _, reason in ipairs(status.reasons or {}) do
table.insert(self.outboundStatusRows, self:addTextRow(string.format(self.i18n:getText("spu_outbound_waiting"), reason)))
end
if status.warning ~= nil then
table.insert(self.outboundStatusRows, self:addTextRow(string.format(self.i18n:getText("spu_outbound_warning"), status.warning)))
end
end
function SmartPickupFrame:rebuildOutboundTab(adEnv, run)
self.sellAtOption:setState(SmartPickupFrame.getStepIndex(self.sellAtSteps, run.sellAtPercent, ADRuns.DEFAULT_SELL_AT_PERCENT), false)
self.storeAboveOption:setState(SmartPickupFrame.getStepIndex(self.storeAboveSteps, run.storeAbovePercent, ADRuns.DEFAULT_STORE_ABOVE_PERCENT), false)
self.collectRoundRowOption = self:addToggleRow("collectRound", self.i18n:getText("spu_collectRound"), {self.i18n:getText("ui_no"), self.i18n:getText("ui_yes")}, run.collectRound == true)
self:addTextRow(self.i18n:getText("spu_section_sources"))
self:fillSourceRows(adEnv, run)
self:addTextRow(self.i18n:getText("spu_section_products"))
self:fillProductRows(adEnv, run)
self:fillOutboundStatusRows(adEnv, run)
end
-- Hvert sekund: produktrækkernes mængde/pris og statuslinjerne (antal linjer ændres kun ved rebuild).
function SmartPickupFrame:refreshOutboundRows(adEnv, run)
@@ -167,21 +110,21 @@ end
-- ---- callbacks ----
function SmartPickupFrame:onClickSource(state, element)
self:safe(function(frame)
local run, data = frame:getRun(), frame.rowData[element]
if run == nil or data == nil then return end
ADRunsController.store = ADRuns.toggleSource(ADRunsController.store, run.id, data.wayPointId)
Logging.info(SmartPickupFrame.LOG_PREFIX .. "run '%s': sources=%s", tostring(run.name), table.concat(ADRuns.getRun(ADRunsController.store, run.id).sourceWayPointIds, " "))
frame:requestRebuild()
end)
end
function SmartPickupFrame:onClickProduct(state, element)
local clicked = self.rowData[element]
if clicked ~= nil and clicked.kind == "guideRow" then
return self:onClickGuideRow(state, clicked)
end
if clicked ~= nil and clicked.kind == "storageView" then
return self:safe(function(frame) frame:onClickStorageView(state) end)
end
if clicked ~= nil and clicked.kind == "storageBuilding" then
return self:safe(function(frame) frame:onClickStorageBuilding(state) end)
end
if clicked ~= nil and SmartPickupFrame.FIELDSILO_ROW_KINDS ~= nil and SmartPickupFrame.FIELDSILO_ROW_KINDS[clicked.kind] then
return self:onClickFieldSiloRow(state, clicked)
end
if clicked ~= nil and SmartPickupFrame.FIELD_ROW_KINDS ~= nil and SmartPickupFrame.FIELD_ROW_KINDS[clicked.kind] then
return self:onClickFieldRow(state, clicked)
end
@@ -210,10 +153,3 @@ function SmartPickupFrame:onClickStoreAbove(state)
end)
end
function SmartPickupFrame:onClickCollectRound(state)
self:safe(function(frame)
local run = frame:getRun()
if run == nil then return end
ADRunsController.updateRun(run.id, {collectRound = state == SmartPickupFrame.STATE_ON})
end)
end
@@ -128,6 +128,7 @@ end
function SmartPickupFrame:fillStorageDetail(adEnv, inventory, fullInventory)
self.storageDetailRows = {}
self.storageDetailPlaceable = nil
if #inventory == 0 then
return
end
@@ -177,6 +178,12 @@ function SmartPickupFrame:fillStorageRows(adEnv)
self.storageRows = {}
local fullInventory = self:getInventory(adEnv)
local inventory = ADStorageStats.filterKind(fullInventory, SmartPickupFrame.getTabKind(self.currentTab))
self:addStorageViewRow()
if self:getStorageView() == SmartPickupFrame.STORAGE_VIEW_ITEMS then
self.storageDetailPlaceable = nil
self:fillItemOverview(adEnv, inventory, fullInventory)
return
end
self:fillStorageDetail(adEnv, inventory, fullInventory)
self:addTextRow(self.i18n:getText("spu_section_storage_" .. tostring(SmartPickupFrame.getTabKind(self.currentTab))))
for _, entry in ipairs(inventory) do
@@ -193,6 +200,11 @@ end
function SmartPickupFrame:refreshStorageRows(adEnv)
local kind = SmartPickupFrame.getTabKind(self.currentTab)
if kind ~= nil and self.itemView ~= nil then
local fullInventory = self:getInventory(adEnv)
self:refreshItemOverview(adEnv, ADStorageStats.filterKind(fullInventory, kind), fullInventory)
return
end
if kind == nil or #(self.storageRows or {}) == 0 then
return
end
@@ -0,0 +1,354 @@
-- Smart Pickup — fanerne Lagre, vareoversigten (v1.52)
-- Én række pr. vare med samlet beholdning, fyldningsbjælke, værdi og gårdens netto; klik folder varen ud
-- til bygningerne den ligger i. Øverst nøgletal og "kræver handling". Tallene regnes i ADStorageStats;
-- her tegnes kun. Indlæses efter SmartPickupStorageTab.lua (funktionerne lægges på SmartPickupFrame).
SmartPickupFrame.STORAGE_VIEW_ITEMS, SmartPickupFrame.STORAGE_VIEW_BUILDINGS = 1, 2
SmartPickupFrame.MAX_ATTENTION = 4
-- nøgletalsrækken er højere end de andre rækker (tal over label)
SmartPickupFrame.KPI_ROW_SCALE = 1.6
-- ændret struktur (ny vare, flere alarmer) genopbygges højst hvert N. sekund, så fokus ikke hopper
SmartPickupFrame.ITEM_REBUILD_TICKS = 10
SmartPickupFrame.LEVEL_COLORS = {
red = {0.90, 0.25, 0.20, 1},
yellow = {0.95, 0.72, 0.15, 1},
green = {0.30, 0.75, 0.35, 1},
}
SmartPickupFrame.NEUTRAL_BAR_COLOR = {0.35, 0.60, 0.85, 1}
SmartPickupFrame.TEXT_COLOR = {1, 1, 1, 1}
local unpackFn = table.unpack or unpack
-- ---- små tegne-hjælpere ----
local function setTextColor(element, color)
if element ~= nil and element.setTextColor ~= nil then element:setTextColor(unpackFn(color)) end
end
-- Fyld bjælken til brøken af baggrundens bredde; tom bjælke skjules (bredde 0 tegnes som en prik).
function SmartPickupFrame.setBar(bar, background, fraction, color)
if bar == nil or background == nil or background.size == nil then return end
if fraction <= 0 then
bar:setVisible(false)
return
end
bar:setVisible(true)
bar:setSize(background.size[1] * fraction, background.size[2])
if bar.setImageColor ~= nil then bar:setImageColor(nil, unpackFn(color)) end
end
-- Bjælkens farve: rød/gul ved problem, ellers grøn for varer i omløb og blå for rent lager.
function SmartPickupFrame.barColor(level, isActive)
if level ~= nil then return SmartPickupFrame.LEVEL_COLORS[level] end
return isActive and SmartPickupFrame.LEVEL_COLORS.green or SmartPickupFrame.NEUTRAL_BAR_COLOR
end
function SmartPickupFrame:formatDays(days)
return string.format(self.i18n:getText("spu_stat_days"), days)
end
-- "−14.4k/døgn · 3.2 døgn", "+3.6k/døgn · fuld om 2.0 døgn", "+1.2k/døgn · stiger" eller "-"
function SmartPickupFrame:itemRateText(stat)
local fc = SmartPickupFrame.formatCompact
if (stat.net or 0) == 0 then
return (stat.consumption or 0) > 0 and self.i18n:getText("spu_item_balanced") or "-"
end
local rate = string.format(self.i18n:getText("spu_item_perDay"), (stat.net < 0 and "-" or "+") .. fc(math.abs(stat.net)))
if stat.days ~= nil then
return rate .. " · " .. self:formatDays(stat.days)
elseif stat.daysToFull ~= nil then
return rate .. " · " .. string.format(self.i18n:getText("spu_item_fullIn"), self:formatDays(stat.daysToFull))
end
return rate .. " · " .. self.i18n:getText("spu_stat_rising")
end
-- "1.2M/1.5M · 80 %" eller "24 paller · 1 %"
function SmartPickupFrame:itemAmountText(entry)
local fc = SmartPickupFrame.formatCompact
local amount = entry.count ~= nil and string.format(self.i18n:getText("spu_storage_pallets"), entry.count)
or string.format("%s/%s", fc(entry.level or 0), fc(entry.capacity or 0))
if entry.percent ~= nil then amount = amount .. string.format(" · %d %%", entry.percent) end
return amount
end
-- ---- data ----
-- Fanens varer med statistik; gårdens totaler/kapacitet/rater regnes over HELE gården.
function SmartPickupFrame:computeItemStats(adEnv, inventory, fullInventory)
self.lastFullInventory = fullInventory -- til "kræver handling" -> flow (hvem bruger varen)
local farmId = g_currentMission:getFarmId()
local totals = ADStorageStats.farmTotals(fullInventory)
local capacities = ADStorageStats.farmCapacities(fullInventory)
local rates = ADSources.getFarmRates ~= nil and ADSources.getFarmRates(adEnv, farmId) or {}
local stats = {}
local titleOf = function(fillType) return self:fillTypeTitle(fillType) end
for _, group in ipairs(ADStorageStats.groupByFillType(inventory, titleOf)) do
local quote = ADSources.getMarketQuote ~= nil and ADSources.getMarketQuote(group.fillType) or nil
local farm = {level = totals[group.fillType], capacity = capacities[group.fillType]}
table.insert(stats, ADStorageStats.groupStats(group, farm, rates[group.fillType], quote))
end
return stats
end
-- Nøgle der ændrer sig når rækkerne skal være anderledes (varer, udfoldede bygninger, alarmer).
function SmartPickupFrame:itemStructureKey(stats, attention)
local parts = {}
for _, stat in ipairs(stats) do
table.insert(parts, tostring(stat.fillType))
if self:isItemExpanded(stat.fillType) then
-- sorteret: at to bygninger bytter plads i mængde må ikke udløse en genopbygning
local placeables = {}
for _, building in ipairs(stat.buildings) do table.insert(placeables, tostring(building.placeable)) end
table.sort(placeables)
table.insert(parts, table.concat(placeables, ";"))
end
end
table.insert(parts, "a" .. #attention)
return table.concat(parts, ",")
end
function SmartPickupFrame:isItemExpanded(fillType)
local expanded = self.expandedItems ~= nil and self.expandedItems[self.currentTab] or nil
return expanded ~= nil and expanded[fillType] == true
end
function SmartPickupFrame:toggleItem(fillType)
self.expandedItems = self.expandedItems or {}
self.expandedItems[self.currentTab] = self.expandedItems[self.currentTab] or {}
local expanded = self.expandedItems[self.currentTab]
expanded[fillType] = not expanded[fillType] or nil
-- listen hopper ellers til toppen ved genopbygningen
local layout = self.boxLayout
local scrollY = layout ~= nil and layout.firstVisibleY or nil
self:rebuild()
if scrollY ~= nil and layout.scrollTo ~= nil then pcall(layout.scrollTo, layout, scrollY, true) end
end
-- ---- visningsvælger (Varer | Bygninger) ----
function SmartPickupFrame:getStorageView()
local views = self.storageViewByTab or {}
return views[self.currentTab] or SmartPickupFrame.STORAGE_VIEW_ITEMS
end
function SmartPickupFrame:addStorageViewRow()
local row = self:addRow(self.productPrefab, {kind = "storageView"})
local option = row:getDescendantByName("option")
option:setTexts({self.i18n:getText("spu_view_items"), self.i18n:getText("spu_view_buildings")})
option:setState(self:getStorageView(), false)
row:getDescendantByName("title"):setText(self.i18n:getText("spu_view_title"))
local tooltip = option:getDescendantByName("ignore")
if tooltip ~= nil then tooltip:setText(self.i18n:getText("spu_view_tooltip")) end
option.toolTipText = self.i18n:getText("spu_view_tooltip")
end
function SmartPickupFrame:onClickStorageView(state)
self.storageViewByTab = self.storageViewByTab or {}
self.storageViewByTab[self.currentTab] = state
self:requestRebuild()
end
-- ---- opbygning ----
function SmartPickupFrame:kpiTexts(stats)
local kpi = ADStorageStats.kpis(stats)
local i18n = self.i18n
return {
{SmartPickupFrame.formatMoney(kpi.valueNow), i18n:getText("spu_kpi_value")},
{"+" .. SmartPickupFrame.formatMoney(kpi.gain), i18n:getText("spu_kpi_gain")},
{tostring(kpi.emptyCount), i18n:getText("spu_kpi_empty")},
{tostring(kpi.fullCount), i18n:getText("spu_kpi_full")},
}, kpi
end
function SmartPickupFrame:setKpiRow(row, stats)
local texts, kpi = self:kpiTexts(stats)
for index, pair in ipairs(texts) do
local value = row:getDescendantByName("v" .. index)
if value ~= nil then value:setText(pair[1]) end
local label = row:getDescendantByName("l" .. index)
if label ~= nil then label:setText(pair[2]) end
end
local colors = SmartPickupFrame.LEVEL_COLORS
setTextColor(row:getDescendantByName("v2"), kpi.gain > 0 and colors.green or SmartPickupFrame.TEXT_COLOR)
setTextColor(row:getDescendantByName("v3"), kpi.emptyCount > 0 and colors.red or SmartPickupFrame.TEXT_COLOR)
setTextColor(row:getDescendantByName("v4"), kpi.fullCount > 0 and colors.yellow or SmartPickupFrame.TEXT_COLOR)
end
function SmartPickupFrame:addKpiRow(stats)
local row = self:addRow(self.kpiPrefab, {kind = "text"})
if row.size ~= nil then row:setSize(row.size[1], row.size[2] * SmartPickupFrame.KPI_ROW_SCALE) end
self:setKpiRow(row, stats)
return row
end
-- Varepunkterne + kædens punkter (røde manglende led, højst 2, røde "Slukket men nødvendig", højst 2, og ét samlet
-- punkt for afgrødeforslag), samme loft MAX_ATTENTION. ADChain.last genbruges; er det ældre end CHAIN_MAX_AGE_MS,
-- evalueres der (visning, handler ikke), så en slukket nødvendig fabrik ses straks.
function SmartPickupFrame:attentionFor(stats)
local attention = ADStorageStats.attentionList(stats, SmartPickupFrame.MAX_ATTENTION)
if ADChain ~= nil and ADChain.evaluate ~= nil and self.chainLast ~= nil and self.getAdEnv ~= nil then
local okEnv, adEnv = pcall(self.getAdEnv)
if okEnv and adEnv ~= nil then pcall(self.chainLast, self, adEnv) end
end
if ADChain == nil or ADChain.last == nil then return attention end
return ADStorageStats.mergeAttention(attention, ADStorageStats.chainEntries(ADChain.last), SmartPickupFrame.MAX_ATTENTION)
end
function SmartPickupFrame:alertText(entry)
local stat, fc, i18n = entry.stat, SmartPickupFrame.formatCompact, self.i18n
if entry.kind == "chainSuggest" then
return string.format(i18n:getText("spu_attention_chainSuggests"), entry.count or 0)
end
if entry.kind == "chainProblem" then
return string.format(i18n:getText("spu_attention_chainProblems"), entry.count or 0)
end
if entry.kind == "dependencyOff" then
if (entry.more or 0) > 0 then
return string.format(i18n:getText("spu_attention_depOffMore"), tostring(entry.buildingName), entry.more)
end
return string.format(i18n:getText("spu_attention_depOff"), tostring(entry.buildingName))
end
if entry.kind == "empty" then
return string.format(i18n:getText("spu_alert_empty"), stat.title, self:formatDays(stat.days), fc(stat.farmLevel or 0), fc(math.abs(stat.net or 0)))
elseif entry.kind == "full" then
if stat.daysToFull ~= nil then
return string.format(i18n:getText("spu_alert_fullIn"), stat.title, stat.percent or 0, self:formatDays(stat.daysToFull))
end
return string.format(i18n:getText("spu_alert_full"), stat.title, stat.percent or 0)
end
return string.format(i18n:getText("spu_alert_sell"), stat.title, math.floor((stat.priceRatio or 0) * 100 + 0.5), SmartPickupFrame.formatMoney(stat.valueNow))
end
function SmartPickupFrame:setAlertRow(row, entry)
row.entry = entry
local hint = ""
if SmartPickupFrame.attentionAdvice ~= nil then
local ok, advice, info = pcall(self.attentionAdvice, self, entry)
if ok then hint = self:attentionHint(advice, info) end
end
row.c1:setText(self:alertText(entry) .. hint)
if row.dot ~= nil and row.dot.setImageColor ~= nil then
row.dot:setImageColor(nil, unpackFn(SmartPickupFrame.LEVEL_COLORS[entry.level] or SmartPickupFrame.LEVEL_COLORS.green))
end
end
function SmartPickupFrame:itemCells(stat)
local marker = self:isItemExpanded(stat.fillType) and "– " or "+ "
local value = stat.valueNow ~= nil and SmartPickupFrame.formatMoney(stat.valueNow) or self.i18n:getText("spu_stat_noSell")
return {marker .. stat.title, self:itemAmountText(stat), value, self:itemRateText(stat)}
end
function SmartPickupFrame:setItemRow(row, stat)
SmartPickupFrame.setCells(row.cells, self:itemCells(stat))
local isActive = (stat.consumption or 0) > 0 or (stat.production or 0) > 0
SmartPickupFrame.setBar(row.bar, row.barBg, ADStorageStats.fraction(stat.percent), SmartPickupFrame.barColor(stat.severity, isActive))
local rateColor = SmartPickupFrame.TEXT_COLOR
if stat.emptyLevel ~= nil then
rateColor = SmartPickupFrame.LEVEL_COLORS[stat.emptyLevel]
elseif stat.fullLevel ~= nil then
rateColor = SmartPickupFrame.LEVEL_COLORS[stat.fullLevel]
end
setTextColor(row.cells[4], rateColor)
end
-- Bygningens andel af varen: værdi efter samme pris som varen, bjælke efter bygningens egen fyldning.
function SmartPickupFrame:setSubRow(row, stat, building)
local kind = SmartPickupFrame.getTabKind(self.currentTab)
local isStableOutput = kind == "husbandry" and (stat.production or 0) > 0 and (stat.consumption or 0) == 0
local value = "-"
if stat.valueNow ~= nil and stat.level > 0 then
value = SmartPickupFrame.formatMoney(stat.valueNow * building.level / stat.level)
end
local share = stat.level > 0 and math.floor(100 * building.level / stat.level + 0.5) or 0
SmartPickupFrame.setCells(row.cells, {tostring(building.name), self:itemAmountText(building), value,
string.format(self.i18n:getText("spu_item_share"), share)})
local level = ADStorageStats.buildingLevel(building, kind, isStableOutput)
SmartPickupFrame.setBar(row.bar, row.barBg, ADStorageStats.fraction(building.percent), SmartPickupFrame.barColor(level, false))
end
-- Klon af item-/sub-prefab med referencer til cellerne og bjælken.
function SmartPickupFrame:addBarRow(prefab)
local row = self:addRow(prefab, {kind = "text"})
local cells = {}
for index = 1, 4 do cells[index] = row:getDescendantByName("c" .. index) end
return {element = row, cells = cells, bar = row:getDescendantByName("bar"), barBg = row:getDescendantByName("barBg")}
end
function SmartPickupFrame:addItemHeader()
local i18n = self.i18n
local header = self:addBarRow(self.itemPrefab)
SmartPickupFrame.setCells(header.cells, {i18n:getText("spu_col_item"), i18n:getText("spu_icol_amount"),
i18n:getText("spu_icol_value"), i18n:getText("spu_icol_rate")})
if header.bar ~= nil then header.bar:setVisible(false) end
if header.barBg ~= nil then header.barBg:setVisible(false) end
end
function SmartPickupFrame:fillItemOverview(adEnv, inventory, fullInventory)
local i18n = self.i18n
local stats = self:computeItemStats(adEnv, inventory, fullInventory)
local attention = self:attentionFor(stats)
self.itemView = {kpiRow = self:addKpiRow(stats), alertRows = {}, itemRows = {}, key = self:itemStructureKey(stats, attention)}
self.itemRebuildTicks = SmartPickupFrame.ITEM_REBUILD_TICKS
self:addTextRow(i18n:getText("spu_attention"))
if #attention == 0 then
self:addTextRow(i18n:getText("spu_attention_none"))
end
for _, entry in ipairs(attention) do
local element = self:addRow(self.alertPrefab, {kind = "text"})
local row = {c1 = element:getDescendantByName("c1"), dot = element:getDescendantByName("dot")}
self:setAlertRow(row, entry)
-- klik: åbn/opret flow til varen (gui/SmartPickupAttentionFlow.lua)
self:makeRowClickable(element, function(frame) frame:onAttentionClick(row.entry) end)
table.insert(self.itemView.alertRows, row)
end
self:addTextRow(i18n:getText("spu_section_storage_" .. tostring(SmartPickupFrame.getTabKind(self.currentTab))) .. " " .. i18n:getText("spu_items_hint"))
if #stats == 0 then
self:addTextRow(i18n:getText("spu_storage_empty"))
return
end
self:addItemHeader()
for _, stat in ipairs(stats) do
local row = self:addBarRow(self.itemPrefab)
local fillType = stat.fillType
self:makeRowClickable(row.element, function(frame) frame:toggleItem(fillType) end)
self:setItemRow(row, stat)
row.subs = {}
if self:isItemExpanded(fillType) then
for _, building in ipairs(stat.buildings) do
local sub = self:addBarRow(self.subPrefab)
sub.placeable = building.placeable
self:setSubRow(sub, stat, building)
table.insert(row.subs, sub)
end
end
table.insert(self.itemView.itemRows, row)
end
end
-- Hvert sekund: tal og bjælker opdateres på stedet; ny struktur -> genopbygning (højst hvert 10. s).
function SmartPickupFrame:refreshItemOverview(adEnv, inventory, fullInventory)
local view = self.itemView
if view == nil then return end
local stats = self:computeItemStats(adEnv, inventory, fullInventory)
local attention = self:attentionFor(stats)
if self:itemStructureKey(stats, attention) ~= view.key then
self.itemRebuildTicks = (self.itemRebuildTicks or 0) - 1
if self.itemRebuildTicks <= 0 then self:requestRebuild() end
return
end
self:setKpiRow(view.kpiRow, stats)
for index, entry in ipairs(attention) do self:setAlertRow(view.alertRows[index], entry) end
for index, stat in ipairs(stats) do
local row = view.itemRows[index]
self:setItemRow(row, stat)
-- underrækkerne beholder rækkefølgen fra opbygningen; tallene findes via bygningen
local byPlaceable = {}
for _, building in ipairs(stat.buildings) do byPlaceable[building.placeable] = building end
for _, sub in ipairs(row.subs) do
local building = byPlaceable[sub.placeable]
if building ~= nil then self:setSubRow(sub, stat, building) end
end
end
end
@@ -2,42 +2,8 @@
-- Mål, varer (Auto/Vælg), behovsrække, ture og "kør hjem"-tærskel for et forsynings-run.
-- Funktionerne lægges på SmartPickupFrame (indlæses efter SmartPickupFrame.lua).
function SmartPickupFrame:rebuildSupplyTab(adEnv, run)
self.loopsOption:setState(run.loops + 1, false)
self.returnBelowOption:setState(self:getReturnBelowIndex(run), false)
self:addTextRow(self.i18n:getText("spu_section_targets"))
self.ingredientModeRowOption = self:addToggleRow("ingredientMode", self.i18n:getText("spu_ingredientMode"), {self.i18n:getText("spu_mode_auto"), self.i18n:getText("spu_mode_manual")}, run.ingredientMode == "manual")
self:addTextRow(self.i18n:getText("spu_targets"))
self:fillTargetRows(adEnv, run)
self:fillIngredientRows(adEnv, run)
self:fillNeedsRows(adEnv, run)
end
-- Mål = afkrydsning (flere stalde/fabrikker pr. run). Ved hver tur vælges den der mangler mest.
function SmartPickupFrame:fillTargetRows(adEnv, run)
local chosen = {}
for _, id in ipairs(ADRunsController.getRunTargets(run)) do
chosen[id] = true
end
for _, marker in ipairs(ADRunsController.getTargetMarkers(adEnv, g_currentMission:getFarmId())) do
local row = self:addRow(self.ingredientPrefab, {kind = "target", wayPointId = marker.id})
row:getDescendantByName("title"):setText(" " .. tostring(marker.name))
row:getDescendantByName("option"):setState(chosen[marker.id] and SmartPickupFrame.STATE_ON or SmartPickupFrame.STATE_OFF, false)
end
end
-- Behovsrække pr. mål ("Cow 1 Food: Forage 0/33.750 l · …"), opdateres hvert sekund.
function SmartPickupFrame:fillNeedsRows(adEnv, run)
self.needsRows = {}
local targets = ADRunsController.getTargetsLevels(adEnv, run)
if #targets == 0 then
self:addTextRow(self.i18n:getText("spu_needs_noTarget"))
return
end
for _, target in ipairs(targets) do
table.insert(self.needsRows, {wayPointId = target.wayPointId, title = self:addTextRow(self:describeTargetNeeds(target))})
end
end
function SmartPickupFrame:describeTargetNeeds(target)
if #target.levels == 0 then
@@ -58,39 +24,8 @@ function SmartPickupFrame:refreshNeedsRows(adEnv, run)
end
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, {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, false)
end
end
function SmartPickupFrame:onClickTarget(state, element)
self:safe(function(frame)
local run, data = frame:getRun(), frame.rowData[element]
if run == nil or data == nil then return end
ADRunsController.store = ADRuns.toggleTarget(ADRunsController.store, run.id, data.wayPointId)
Logging.info(SmartPickupFrame.LOG_PREFIX .. "run '%s': targets=%s", tostring(run.name), table.concat(ADRunsController.getRunTargets(ADRuns.getRun(ADRunsController.store, run.id)), " "))
frame:requestRebuild()
end)
end
function SmartPickupFrame:onClickIngredientMode(state)
self:safe(function(frame)
local run = frame:getRun()
if run == nil then return end
ADRunsController.updateRun(run.id, {ingredientMode = state == SmartPickupFrame.STATE_ON and "manual" or "auto"})
frame:requestRebuild()
end)
end
function SmartPickupFrame:onClickLoops(state)
self:safe(function(frame)
+50
View File
@@ -10,6 +10,7 @@
<Profile name="spuInfoText" extends="fs25_settingsMultiTextOptionTitle">
<textBold value="false"/>
<size value="1400px 40px"/>
<textMaxWidth value="800px"/>
</Profile>
<!-- Tabel-celler i fanen Lagre: position og bredde sættes pr. celle i SmartPickupFrame.xml. -->
<Profile name="spuCell" extends="fs25_settingsMultiTextOptionTitle">
@@ -19,4 +20,53 @@
<Profile name="spuCellBold" extends="spuCell">
<textBold value="true"/>
</Profile>
<!-- Lagre (v1.52): bjælker = hvid pixel i spillets UI-atlas farvet med imageColor
(samme teknik som Precision Farming). Bredden på fyldet sættes i Lua. -->
<Profile name="spuBar" extends="baseReference" with="anchorMiddleLeft">
<imageUVs value="10px 1010px 4px 4px"/>
<positionOrigin value="middleLeft"/>
<imageColor value="0.30 0.75 0.35 1"/>
<size value="200px 16px"/>
</Profile>
<Profile name="spuBarBg" extends="spuBar">
<imageColor value="0 0 0 0.45"/>
</Profile>
<Profile name="spuBarThin" extends="spuBar">
<size value="200px 10px"/>
</Profile>
<Profile name="spuBarThinBg" extends="spuBarThin">
<imageColor value="0 0 0 0.45"/>
</Profile>
<!-- markør foran en "kræver handling"-linje -->
<Profile name="spuDot" extends="spuBar">
<size value="8px 26px"/>
</Profile>
<!-- nøgletal: stort tal over lille label -->
<Profile name="spuKpiValue" extends="spuCellBold">
<textSize value="24px"/>
<textOffset value="0px 8px"/>
</Profile>
<Profile name="spuKpiLabel" extends="spuCell">
<textSize value="14px"/>
<textOffset value="0px -18px"/>
<textColor value="0.75 0.75 0.75 1"/>
</Profile>
<Profile name="spuCellDim" extends="spuCell">
<textColor value="0.75 0.75 0.75 1"/>
</Profile>
<!-- redigeringsvindue (v1.58): mørk flade + panel; størrelser/placering sættes i Lua ud fra boxLayout -->
<Profile name="spuOverlayBackdrop" extends="baseReference" with="anchorTopLeft">
<imageUVs value="10px 1010px 4px 4px"/>
<positionOrigin value="bottomLeft"/>
<imageColor value="0 0 0 0.65"/>
<size value="100px 100px"/>
</Profile>
<!-- farver er lineære: 0.016 ≈ menuens egen mørke baggrund (0.09 blev lysegrå, in-game 29/9) -->
<Profile name="spuOverlayPanel" extends="spuOverlayBackdrop">
<imageColor value="0.016 0.016 0.016 1"/>
</Profile>
<Profile name="spuPanelTitle" extends="spuCellBold">
<textSize value="20px"/>
<size value="700px 30px"/>
</Profile>
</GUIProfiles>
+353 -14
View File
@@ -4,7 +4,7 @@
<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_noRuns" text="Ingen flows — tryk &apos;Nyt flow&apos;"/>
<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)"/>
@@ -27,7 +27,10 @@
<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_guide_flow_wagon" text=" ↳ Vogn"/>
<text name="spu_guide_flow_noFreeWagon" text=" ↳ ingen ledig vogn (frakoblet, med Connect-markør, kan bære varen)"/>
<text name="spu_guide_flow_soloText" text="%s — ingen vogn bagpå: henter en ledig vogn"/>
<text name="spu_reason_noTrailer" text="ingen vogn (kobl en på, eller giv vognens parkering flowets navn)"/>
<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"/>
@@ -40,7 +43,28 @@
<text name="spu_status_stoppedAll" text="Stoppet %d traktorer"/>
<text name="spu_overview_hint" text="Klik på et flow for at redigere det"/>
<text name="spu_tab_fields" text="Marker"/>
<text name="spu_tab_month" text="Måned"/>
<text name="spu_month_months" text="jan,feb,mar,apr,maj,jun,jul,aug,sep,okt,nov,dec"/>
<text name="spu_month_period" text="Periode %d"/>
<text name="spu_month_header" text="%s · dag %d af %d · kl. %02d:%02d"/>
<text name="spu_month_noClock" text="Kan ikke læse spillets kalender"/>
<text name="spu_month_verdictCalm" text="Intet haster — du kan roligt sove til næste måned"/>
<text name="spu_month_verdictUrgent" text="OBS: %d marker skal nå det før månedsskiftet"/>
<text name="spu_month_noWork" text="Ingen marker har arbejde i gang"/>
<text name="spu_month_col_work" text="Arbejde"/>
<text name="spu_month_col_progress" text="Færdig"/>
<text name="spu_month_col_next" text="Næste"/>
<text name="spu_month_col_window" text="Vindue"/>
<text name="spu_month_window_ok" text="ok"/>
<text name="spu_month_window_harvestCloses" text="OBS: visner snart"/>
<text name="spu_month_window_lastSowMonth" text="OBS: sidste såmåned"/>
<text name="spu_month_upcoming" text="Kan sås nu, ikke gået i gang:"/>
<text name="spu_month_upcomingOpen" text="F%d: %s — kan også sås næste måned"/>
<text name="spu_month_upcomingLast" text="F%d: %s — OBS: sidste såmåned"/>
<text name="spu_field_button_start" text="Start nu"/>
<text name="spu_field_manualTransport" text=" %s kører selv på vejen (Nej = for bred, du kører den til marken)"/>
<text name="spu_field_button_takeover" text="Start nu"/>
<text name="spu_field_takeoverRequested" text="F%d: overtager høsteren nu, hvis skærebordet passer"/>
<text name="spu_field_button_stop" text="Stop mark"/>
<text name="spu_field_noMarker" text="! mangler markør F%d"/>
<text name="spu_field_stage_idle" text="Venter"/>
@@ -51,8 +75,45 @@
<text name="spu_field_stage_collecting" text="Samles op"/>
<text name="spu_field_stage_done" text="Færdig"/>
<text name="spu_field_reason_noRig" text="ingen %s med flueben i markflåden"/>
<text name="spu_field_reasonShort_noRig" text="ingen maskine"/>
<text name="spu_field_reasonShort_allBusy" text="venter på maskine"/>
<text name="spu_field_reasonShort_tooWide" text="for bred"/>
<text name="spu_field_reasonShort_widthUnknown" text="bredde ukendt"/>
<text name="spu_field_reasonShort_tooNarrow" text="for smal"/>
<text name="spu_field_reasonShort_cannotDo" text="ingen kan"/>
<text name="spu_field_reasonShort_noSowFruit" text="vælg afgrøde"/>
<text name="spu_field_reasonShort_notEnough" text="for få ledige"/>
<text name="spu_field_reasonShort_notPlantable" text="ikke såtid nu"/>
<text name="spu_field_reasonShort_regrowing" text="venter på genvækst"/>
<text name="spu_field_reasonShort_rain" text="venter på tørvejr"/>
<text name="spu_field_reasonShort_text" text="se detaljer"/>
<text name="spu_field_reason_allBusy" text="venter på ledig %s"/>
<text name="spu_pool_section_tractors" text="Maskinpulje — traktorer (henter redskaber når ingen færdig kombination er ledig)"/>
<text name="spu_pool_section_implements" text="Maskinpulje — redskaber (parkering &quot;X&quot; + markør &quot;Connect X&quot;)"/>
<text name="spu_pool_noTractors" text="ingen traktorer uden redskab"/>
<text name="spu_pool_noImplements" text="ingen redskaber med en markrolle"/>
<text name="spu_pool_tractor_free" text="ledig%.0s"/>
<text name="spu_pool_tractor_noPark" text="mangler egen AD-parkering%.0s"/>
<text name="spu_pool_tractor_player" text="spilleren kører den%.0s"/>
<text name="spu_pool_tractor_returning" text="sætter redskab af og kører hjem%.0s"/>
<text name="spu_pool_tractor_job" text="opgave F%s"/>
<text name="spu_pool_tractor_busy" text="AutoDrive/Courseplay kører%.0s"/>
<text name="spu_pool_tractor_notInFleet" text="ikke i markflåden (flueben)%.0s"/>
<text name="spu_pool_tractor_offNetwork" text="står uden for AutoDrive-vejnettet%.0s"/>
<text name="spu_pool_tractor_hitched" text="har et redskab på (kobl det af for at bruge den i puljen)%.0s"/>
<text name="spu_pool_implement_flow" text="hører til flowet '%s'"/>
<text name="spu_pool_implement_parked" text="parkeret ved %s"/>
<text name="spu_pool_implement_noPark" text="ikke i puljen — mangler AD-parkering%.0s"/>
<text name="spu_pool_implement_noConnect" text="ikke i puljen — mangler markøren &quot;Connect %s&quot;"/>
<text name="spu_pool_implement_attached" text="på %s"/>
<text name="spu_pool_implement_quarantine" text="kunne ikke kobles på — prøves igen om %s min"/>
<text name="spu_pool_implement_reserved" text="reserveret — en traktor er på vej%.0s"/>
<text name="spu_field_reason_noPoolTractor" text="ingen ledig traktor til at hente et redskab til %s"/>
<text name="spu_field_reasonShort_noPoolTractor" text="venter på traktor"/>
<text name="spu_field_reason_fetching" text="henter '%s'"/>
<text name="spu_field_reasonShort_fetching" text="henter redskab"/>
<text name="spu_field_reason_tooWide" text="%s for bred: %.1f m &gt; %.0f m (Courseplay)"/>
<text name="spu_field_reason_widthUnknown" text="%s: arbejdsbredde %.1f m kan ikke være rigtig — sæt bredden i Courseplay (sendes ikke ud)"/>
<text name="spu_field_reason_tooNarrow" text="%s for smal: %.1f m &lt; slåbredde %.1f m — sæt Riv til eller brug en bredere"/>
<text name="spu_field_role_mower" text="slåmaskine"/>
<text name="spu_field_role_tedder" text="vender"/>
@@ -65,25 +126,33 @@
<text name="spu_field_mode_manual" text="Manuel"/>
<text name="spu_field_notMowable" text="ikke græs/kløver"/>
<text name="spu_field_scanning" text="Marker indlæses ... (én mark pr. sekund)"/>
<text name="spu_field_hint" text="Klik på en mark for at vælge den. Sæt flueben ved maskinerne i markflåden nederst."/>
<text name="spu_field_hint" text="Klik på en mark for at se og rette dens opsætning."/>
<text name="spu_field_fleetEmpty" text="! Markflåden står på &quot;kun med flueben&quot;, men ingen maskiner har flueben — intet kan sendes ud. Åbn Markflåde og vælg &quot;Brug alle ledige maskiner&quot;."/>
<text name="spu_field_showAll" text="Vis alle marker (%d skjult)"/>
<text name="spu_col_field" text="Mark"/>
<text name="spu_col_crop" text="Afgrøde"/>
<text name="spu_col_plan" text="Plan"/>
<text name="spu_col_mode" text="Kørsel"/>
<text name="spu_field_section" text="— Mark F%d: %s —"/>
<text name="spu_field_statusFull" text="Status: %s"/>
<text name="spu_field_plan" text="Plan"/>
<text name="spu_field_mode" text="Kørsel"/>
<text name="spu_field_rake" text="Riv skårene sammen før opsamling (gør smalle læssevogne brugbare)"/>
<text name="spu_field_marker" text=" Markør %s, %.0f m fra marken"/>
<text name="spu_field_mowWidth" text=" Slået med %.1f m — opsamling skal være mindst lige så bred"/>
<text name="spu_field_section_store" text="— Tving lager (ellers nærmeste silo med plads) —"/>
<text name="spu_field_section_dedicated" text="— Faste maskiner til denne mark (bruges kun her; ingen valgt = de frie) —"/>
<text name="spu_field_dedicatedElsewhere" text="fast på F%s"/>
<text name="spu_field_hiddenDedicated" text="%d maskiner er faste på andre marker (%s) og vises ikke her"/>
<text name="spu_field_fieldSilo" text="marksilo"/>
<text name="spu_field_storeHidden" text="+ %d siloer længere væk vises ikke"/>
<text name="spu_field_noStore" text="ingen silo tager markens vare"/>
<text name="spu_field_storeAuto" text="intet valgt: nærmeste silo med plads bruges"/>
<text name="spu_field_fieldSiloNotEmptied" text="! Marksilo %s tømmes ikke hjem — lav et Ud fra bygning-flow"/>
<text name="spu_field_section_fleet" text="— Markflåde (flueben = må sendes ud) —"/>
<text name="spu_field_section_fleet" text="— Markflåde, fælles for ALLE marker (flueben = må sendes ud; faste maskiner vælges ved marken) —"/>
<text name="spu_field_rig_working" text="i gang på F%s"/>
<text name="spu_field_tooWideShort" text="!! over 50 m"/>
<text name="spu_field_widthUnknownShort" text="!! bredde ukendt — sæt i Courseplay"/>
<text name="spu_field_noRigs" text="ingen køretøjer med slåmaskine, vender, river eller læssevogn"/>
<text name="spu_field_status" text="Marker i gang: %d af %d"/>
<text name="spu_field_startRequested" text="F%d startes, når en maskine er ledig"/>
@@ -94,9 +163,11 @@
<text name="spu_field_fermenter" text="gæringssilo"/>
<text name="spu_field_storeAutoSilage" text="intet valgt: nærmeste gæringssilo med plads, ellers nærmeste silo"/>
<text name="spu_field_bare" text="bar jord"/>
<text name="spu_field_stubble" text="stub (%s)"/>
<text name="spu_field_careNeed" text="trænger til %s"/>
<text name="spu_field_fertilize" text="Gød op til max (når afgrøden vokser)"/>
<text name="spu_field_lime" text="Kalk når jorden trænger"/>
<text name="spu_field_chainFree" text="Fri til kæden (moden må skifte afgrøde)"/>
<text name="spu_field_mulch" text="Stubbehakning efter høst"/>
<text name="spu_field_need_fert" text="gødning"/>
<text name="spu_field_need_lime" text="kalk"/>
@@ -116,7 +187,7 @@
<text name="spu_field_rig_seeds" text="frø ok"/>
<text name="spu_field_rigs" text="Maskiner pr. trin (Courseplay multi-tool)"/>
<text name="spu_field_role_cultivator" text="kultivator"/>
<text name="spu_field_role_fertilizer" text="gødningsspreder"/>
<text name="spu_field_role_fertilizer" text="gødskning"/>
<text name="spu_field_role_harvester" text="høster"/>
<text name="spu_field_role_limer" text="kalkspreder"/>
<text name="spu_field_role_mulcher" text="stubhakker"/>
@@ -152,6 +223,7 @@
<text name="spu_field_unloaders" text="Aflæsningsvogne under høst"/>
<text name="spu_field_weed" text="Bekæmp ukrudt"/>
<text name="spu_field_notice_done" text="er færdig"/>
<text name="spu_field_reason_regrowing" text="venter på at græsset vokser helt ud igen"/>
<text name="spu_field_reason_rain" text="venter på tørvejr"/>
<text name="spu_field_target" text="Afgrøde på marken (moden passer resten)"/>
<text name="spu_field_target_none" text="Ingen — planlæg selv"/>
@@ -169,13 +241,35 @@
<text name="spu_field_straw_chop" text="Hakkes (spredes på marken)"/>
<text name="spu_field_straw_collect" text="Samles op til silo"/>
<text name="spu_field_straw_bale" text="Presses til baller og samles"/>
<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_start" text="Start flow"/>
<text name="spu_button_stop" text="Stop flow"/>
<text name="spu_button_new" text="Nyt flow"/>
<text name="spu_button_renameBuilding" text="Omdøb bygning"/>
<text name="spu_renameBuildingTitle" text="Nyt navn på bygningen (tomt = butiksnavnet)"/>
<text name="spu_button_stopPark" text="Stop alt og parkér"/>
<text name="spu_status_queuedAll" text="Start alle: %d traktorer startes én ad gangen (hvert %d. sekund), %d flows kørte allerede, %d sprunget over"/>
<text name="spu_status_stopParked" text="Stop alt: %d stoppet, %d på vej til parkering — pause til Start alle"/>
<text name="spu_status_noPark" text="uden parkeringspunkt: %s"/>
<text name="spu_status_fetchManual" text="hent selv (for brede): %s"/>
<text name="spu_status_starting" text="Starter traktorer: %d af %d …"/>
<text name="spu_status_paused" text="Pause — tryk Start alle for at fortsætte"/>
<text name="spu_flow_pickOther" text="Vælg et andet flow (tavlen med alle flows står i Oversigt)"/>
<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_button_delete" text="Slet flow"/>
<text name="spu_panel_close" text="Luk"/>
<text name="spu_panel_gone" text="Posten findes ikke længere — vinduet er lukket"/>
<text name="spu_flowList_hint" text="Klik på et flow for at rette det. [Start]/[Stop] yderst til højre styrer det direkte. Farver: grøn kører · gul venter · rød problem · grå stoppet. &quot;Efter behov&quot; = hver tur henter det målet har mindst af."/>
<text name="spu_flowList_start" text="[Start]"/>
<text name="spu_flowList_stop" text="[Stop]"/>
<text name="spu_flowList_status" text="Flows i gang: %d af %d"/>
<text name="spu_panel_flowTitle" text="Rediger flow: %s"/>
<text name="spu_panel_fieldTitle" text="Mark F%d"/>
<text name="spu_field_pausedSuffix" text="(pause)"/>
<text name="spu_field_pausedBanner" text="! Markarbejdet står på pause siden &quot;Stop alt&quot; (gemmes med savegamet) — tryk Start alle i Oversigt, eller [Start] på en enkelt mark."/>
<text name="spu_prune_removed" text="Flow '%s': %d traktor(er) findes ikke længere og er fjernet (%d tilbage)"/>
<text name="spu_prune_empty" text="Flow '%s' har ingen traktorer længere og virker ikke — tildel en i Flows"/>
<text name="spu_newRunName" text="Flow %d"/>
<text name="spu_renameTitle" text="Navn på flow"/>
<text name="spu_deleteConfirm" text="Slet run '%s'? Dets kørende traktorer stoppes. Kan ikke fortrydes."/>
<text name="spu_capacity" text="%s l"/>
<text name="spu_capacity_pallets" text="autoload (paller)"/>
@@ -219,6 +313,8 @@
<text name="spu_sellAt" text="Sælg fra (% af årets toppris)"/>
<text name="spu_sellAt_tooltip" text="Der køres til salg når salgsstedets pris er mindst denne andel af varens toppris i årets prisforløb — uanset mængde. Stor efterspørgsel tæller altid."/>
<text name="spu_storeAbove" text="Kør til lager over"/>
<text name="spu_sellAbove" text="Sælg altid når kilden er over"/>
<text name="spu_sellAbove_off" text="Fra"/>
<text name="spu_storeAbove_tooltip" text="Varer med Lager-politik køres til silo når kildens lager er over denne fyldning (eller der er et fuldt læs)."/>
<text name="spu_collectRound" text="Saml op på én tur"/>
<text name="spu_collectRound_tooltip" text="Tipvogn: samme vare fra flere kilder til vognen er fuld. Autoload-vogn: flere pallevarer med samme mål på én tur."/>
@@ -228,6 +324,7 @@
<text name="spu_reason_noProducts" text="ingen vare valgt (sæt Lager/Salg på en vare ovenfor)"/>
<text name="spu_reason_noWaitPoint" text="intet Wait-punkt i puljen (markør i en mappe med 'Wait' i navnet)"/>
<text name="spu_reason_cargoNoTarget" text="lasten på vognen har intet mål (pris for lav / ingen silo) — tøm vognen"/>
<text name="spu_reason_autoloadOn" text="AutoLoad er slået til på vognen — AutoDrive ser den kun som palle-/ballevogn (slå AutoLoad fra på vognen)"/>
<text name="spu_reason_needsAutoload" text="pallevarer kræver Universal Autoload på vognen"/>
<text name="spu_product_row" text="%s — %s l — %s"/>
<text name="spu_price_pct" text="%d %% af top (%s)"/>
@@ -253,14 +350,16 @@
<text name="spu_flow_noSource" text="(ingen kilde)"/>
<text name="spu_flow_noTarget" text="(intet mål)"/>
<text name="spu_flow_noVehicle" text="(ingen traktor)"/>
<text name="spu_flow_ruleSell" text="pris >= %d %%"/>
<text name="spu_flow_ruleStore" text="lager > %d %%"/>
<text name="spu_flow_ruleOut" text="pris >= %d %%, lager > %d %%"/>
<text name="spu_flow_round" text="runde"/>
<text name="spu_flow_ruleIn" text="behov"/>
<text name="spu_flow_ruleInLoops" text="behov, %d ture"/>
<text name="spu_flow_ruleIn" text="efter behov — laveste først"/>
<text name="spu_flow_ruleInLoops" text="efter behov, %d ture"/>
<text name="spu_markerAt" text="%s %d m"/>
<text name="spu_noMarker" text="INGEN MARKØR inden for 40 m — sæt en AD-markør ved triggeren"/>
<text name="spu_noBuildings" text="Ingen bygninger fundet"/>
<text name="spu_buildingGone" text="bygningen findes ikke længere"/>
<text name="spu_buildingGone" text="Fjernet bygning (findes ikke mere) — sæt NEJ"/>
<text name="spu_reason_noMarker" text="'%s' mangler en AD-markør ved triggeren"/>
<text name="spu_storage_select" text="Vis bygning"/>
<text name="spu_stat_now" text="nu %s"/>
@@ -300,5 +399,245 @@
<text name="spu_col_lasts" text="Rækker til"/>
<text name="spu_col_rate" text="Forbrug/prod. døgn"/>
<text name="spu_storage_select_tooltip" text="Vælg bygningen tabellen viser."/>
<text name="spu_guide_next" text="Næste"/>
<text name="spu_guide_save" text="Gem"/>
<text name="spu_guide_ok" text="OK"/>
<text name="spu_guide_back" text="Tilbage"/>
<text name="spu_guide_cancel" text="Annullér"/>
<text name="spu_guide_saveOnly" text="Gem uden at starte"/>
<text name="spu_guide_runAuto" text="Kør automatisk"/>
<text name="spu_guide_stepOf" text="Trin %d af %d — %s"/>
<text name="spu_guide_stepOne" text="Trin %d — %s"/>
<text name="spu_guide_editStep" text="Ret: %s"/>
<text name="spu_guide_editSuffix" text=" [Ret]"/>
<text name="spu_guide_sum_hint" text="Tryk på en linje for at rette netop det."/>
<text name="spu_guide_auto" text="Automatisk (anbefalet)"/>
<text name="spu_guide_manual" text="Vælg selv"/>
<text name="spu_guide_closed" text="Guiden er lukket — marken/flowet findes ikke længere"/>
<text name="spu_guide_cannotAdvance" text="Vælg først"/>
<text name="spu_guide_pickField" text="Vælg en mark først"/>
<text name="spu_guide_saved" text="F%d gemt"/>
<text name="spu_guide_noMarker" text="! Sæt en AD-markør ved F%d — arbejdet venter på markøren"/>
<text name="spu_guide_field_open" text="Opsæt mark"/>
<text name="spu_guide_fleet_open" text="Markflåde"/>
<text name="spu_guide_fleet_close" text="Til marker"/>
<text name="spu_guide_field_title" text="— Opsæt mark F%d —"/>
<text name="spu_guide_field_titleNone" text="— Opsæt mark —"/>
<text name="spu_guide_field_step_field" text="Mark"/>
<text name="spu_guide_field_step_crop" text="Afgrøde"/>
<text name="spu_guide_field_step_cropChoice" text="Afgrødens valg"/>
<text name="spu_guide_field_step_prep" text="Klargøring"/>
<text name="spu_guide_field_step_care" text="Pleje"/>
<text name="spu_guide_field_step_machines" text="Maskiner"/>
<text name="spu_guide_field_step_store" text="Lager"/>
<text name="spu_guide_field_step_summary" text="Oversigt"/>
<text name="spu_guide_field_hint_field" text="Vælg den mark du vil sætte op."/>
<text name="spu_guide_field_hint_crop" text="Hvad skal gro på marken? 'Behold' kører videre med det der står nu."/>
<text name="spu_guide_field_hint_cropChoice" text="Hvad skal der ske med afgrøden ved slåning eller høst?"/>
<text name="spu_guide_field_hint_prep" text="Efter høst / på bar mark. Automatisk pløjer eller kultiverer og kalker efter behov."/>
<text name="spu_guide_field_hint_care" text="Mens afgrøden gror. Der gødes/sprøjtes kun når marken trænger."/>
<text name="spu_guide_field_hint_machines" text="Hvor mange ad gangen, og skal marken have sine egne faste maskiner?"/>
<text name="spu_guide_field_hint_store" text="Hvor skal det høstede/slåede hen?"/>
<text name="spu_guide_field_hint_summary" text="Tjek valgene. Tryk på en linje for at rette den."/>
<text name="spu_guide_crop" text="Afgrøde"/>
<text name="spu_guide_crop_keep" text="Behold det der gror"/>
<text name="spu_guide_crop_sowAgain" text="Så samme afgrøde igen efter høst"/>
<text name="spu_guide_prep" text="Klargøring"/>
<text name="spu_guide_prep_autoText" text=" Pløjer når jorden trænger, ellers kultiverer; kalker ved lav pH."/>
<text name="spu_guide_care" text="Pleje"/>
<text name="spu_guide_care_autoText" text=" Gøder op til max når afgrøden vokser, sprøjter/harver ukrudt væk og kalker ved lav pH."/>
<text name="spu_guide_rigs" text="Maskiner ad gangen (multi-tool)"/>
<text name="spu_guide_dedicated" text="Faste maskiner til marken?"/>
<text name="spu_guide_useFieldSilo" text="Brug marksiloen?"/>
<text name="spu_guide_store" text="Lager"/>
<text name="spu_guide_store_autoText" text=" Nærmeste silo med plads (ensilage: gæringssilo)."/>
<text name="spu_guide_sum_target" text="-&gt; %s"/>
<text name="spu_guide_sum_sowAgain" text="så igen"/>
<text name="spu_guide_sum_rake" text="river"/>
<text name="spu_guide_sum_noRake" text="uden river"/>
<text name="spu_guide_sum_straw_chop" text="halm hakkes"/>
<text name="spu_guide_sum_straw_collect" text="halm samles"/>
<text name="spu_guide_sum_straw_bale" text="halm presses"/>
<text name="spu_guide_sum_auto" text="Automatisk"/>
<text name="spu_guide_sum_lime" text="kalk"/>
<text name="spu_guide_sum_mulch" text="mulch"/>
<text name="spu_guide_sum_roll" text="tromle"/>
<text name="spu_guide_sum_fert" text="gødning"/>
<text name="spu_guide_sum_weed" text="ukrudt"/>
<text name="spu_guide_sum_noCare" text="ingen pleje"/>
<text name="spu_guide_sum_dedicated" text="%d faste"/>
<text name="spu_guide_sum_freeRigs" text="ledige maskiner"/>
<text name="spu_guide_sum_rigs" text="%d ad gangen"/>
<text name="spu_guide_sum_unloaders" text="%d aflæsningsvogne"/>
<text name="spu_guide_sum_fieldSilo" text="Marksilo"/>
<text name="spu_guide_sum_stores" text="%d valgte siloer"/>
<text name="spu_guide_flow_open" text="Opsæt flow"/>
<text name="spu_guide_flow_title" text="— Opsæt flow: %s —"/>
<text name="spu_guide_flow_section" text="— Flow: %s —"/>
<text name="spu_guide_flow_saved" text="Flow '%s' gemt"/>
<text name="spu_guide_flow_start" text="Start flow"/>
<text name="spu_guide_flow_rename" text="Skift navn …"/>
<text name="spu_guide_flow_step_name" text="Navn"/>
<text name="spu_guide_flow_step_direction" text="Retning"/>
<text name="spu_guide_flow_step_from" text="Fra"/>
<text name="spu_guide_flow_step_to" text="Til"/>
<text name="spu_guide_flow_step_goods" text="Varer"/>
<text name="spu_guide_flow_step_store" text="Lager"/>
<text name="spu_guide_flow_step_rules" text="Regler"/>
<text name="spu_guide_flow_step_wait" text="Vent ved"/>
<text name="spu_guide_flow_step_tractors" text="Traktorer"/>
<text name="spu_guide_flow_step_summary" text="Oversigt"/>
<text name="spu_guide_flow_hint_name" text="Flowets navn (traktorerne får navn efter det)."/>
<text name="spu_guide_flow_hint_direction" text="Ind til bygning: fyld stalde/fabrikker. Ud fra bygning: tøm, lagr og sælg."/>
<text name="spu_guide_flow_hint_buildings_in" text="Hvilke stalde/fabrikker skal fyldes? Markøren findes automatisk."/>
<text name="spu_guide_flow_hint_buildings_out" text="Hvor skal der hentes fra? Markøren findes automatisk."/>
<text name="spu_guide_flow_hint_goods_in" text="Automatisk henter hver tur den vare målet mangler mest."/>
<text name="spu_guide_flow_hint_goods_out" text="For hver vare: Lager, Salg, eller Salg ellers lager."/>
<text name="spu_guide_flow_hint_store" text="Hvor skal lagervarerne hen?"/>
<text name="spu_guide_flow_hint_rules_in" text="Antal ture, og hvornår traktoren kører hjem fra Wait."/>
<text name="spu_guide_flow_hint_rules_out" text="Hvornår der sælges, og hvornår der køres til lager."/>
<text name="spu_guide_flow_hint_wait" text="Hvor venter traktoren med et rest-læs?"/>
<text name="spu_guide_flow_hint_tractors" text="Hvilke traktorer kører flowet? Traktorer i andre flows vises ikke."/>
<text name="spu_guide_flow_hint_summary" text="Tjek valgene. Tryk på en linje for at rette den."/>
<text name="spu_guide_flow_goods" text="Varer"/>
<text name="spu_guide_flow_goodsAutoChoice" text="Automatisk (det bygningen mangler)"/>
<text name="spu_guide_flow_goodsAutoText" text=" Hver tur hentes den vare målet mangler mest."/>
<text name="spu_guide_flow_noGoodsTo" text=" Vælg først hvor der skal køres til (trinnet Til)"/>
<text name="spu_guide_flow_noProducts" text=" Vælg først hvor der hentes fra (trinnet Fra)"/>
<text name="spu_guide_flow_storeAutoChoice" text="Automatisk (nærmeste med plads)"/>
<text name="spu_guide_flow_storeAutoText" text=" Lagerturene kører til nærmeste lager med plads."/>
<text name="spu_guide_flow_noTractors" text=" Ingen ledige traktorer"/>
<text name="spu_guide_flow_otherFlows" text=" %d traktorer kører i andre flows og vises ikke"/>
<text name="spu_guide_flow_blockedShow" text=" %d traktorer kan ikke bruges her (vognen passer ikke) — klik for at se hvorfor"/>
<text name="spu_guide_flow_blockedHide" text=" %d traktorer kan ikke bruges her — klik for at skjule"/>
<text name="spu_guide_flow_blocked" text=" %d traktorer kan ikke bruges her (vognen passer ikke)"/>
<text name="spu_guide_flow_none" text="ingen valgt"/>
<text name="spu_guide_flow_goodsAuto" text="Automatisk"/>
<text name="spu_guide_flow_sumGoods" text="%d varer"/>
<text name="spu_guide_flow_sumSellAt" text="sælg fra %d %%"/>
<text name="spu_guide_flow_sumStoreAbove" text="lager over %d %%"/>
<text name="spu_guide_flow_sumSellAbove" text="sælg altid over %d %%"/>
<text name="spu_guide_flow_sumRound" text="samlerunde"/>
<text name="spu_guide_flow_sumLoops" text="%d ture"/>
<text name="spu_guide_flow_sumLoopsInf" text="uendelige ture"/>
<text name="spu_guide_flow_sumReturn" text="hjem fra Wait under %d %%"/>
<text name="spu_guide_flow_sumNoTractors" text="ingen"/>
<text name="spu_guide_flow_sumTractors" text="%d traktorer"/>
<text name="spu_tab_fieldsilos" text="Marksiloer"/>
<text name="spu_fs_enabled" text="Hent marksiloer hjem automatisk"/>
<text name="spu_fs_maxVehicles" text="Maks. vogne ude"/>
<text name="spu_fs_minLiters" text="Mindste mængde pr. vare"/>
<text name="spu_fs_status" text="%d/%d vogne ude · %d siloer venter"/>
<text name="spu_fs_noStores" text="Vælg mindst ét gårdlager nedenfor — ellers køres intet hjem"/>
<text name="spu_fs_silosTitle" text="Marksiloer med varer"/>
<text name="spu_fs_noVehicle" text="Ingen ledig tømmevogn: %d uden AD-parkeringspunkt, %d optaget, %d med spiller i, %d ikke i flåden (vogne i flows lånes ikke)"/>
<text name="spu_fs_nothing" text="Ingen marksiloer har varer over mindste mængde"/>
<text name="spu_fs_col_silo" text="Silo"/>
<text name="spu_fs_col_liters" text="Liter"/>
<text name="spu_fs_col_onWay" text="På vej"/>
<text name="spu_fs_col_state" text="Status"/>
<text name="spu_fs_state_ok" text="ok"/>
<text name="spu_fs_state_noMarker" text="mangler markør"/>
<text name="spu_fs_state_noFarmStore" text="ingen gård tager %s"/>
<text name="spu_fs_state_paused" text="pause (kom ikke i gang)"/>
<text name="spu_fs_storesTitle" text="Gårdlagre (hvor varerne køres hjem til)"/>
<text name="spu_fs_toProduction" text="Kør overskud til produktion (foder til staldene først)"/>
<text name="spu_fs_reserveDays" text="Foderreserve: staldenes forbrug i"/>
<text name="spu_fs_days" text="%d døgn"/>
<text name="spu_fs_toFactory" text="→ fabrik"/>
<text name="spu_fs_harvestHeld" text="%d tømmevogn(e) holdes tilbage til høstere der arbejder"/>
<text name="spu_view_title" text="Vis"/>
<text name="spu_view_items" text="Varer"/>
<text name="spu_view_buildings" text="Bygninger"/>
<text name="spu_view_tooltip" text="Varer: samlet beholdning pr. vare (klik for at se hvor den ligger). Bygninger: tabel for én bygning."/>
<text name="spu_kpi_value" text="Lagerværdi nu"/>
<text name="spu_kpi_gain" text="Ekstra ved toppris"/>
<text name="spu_kpi_empty" text="Løber tør snart"/>
<text name="spu_kpi_full" text="Næsten fuldt"/>
<text name="spu_attention" text="— Kræver handling —"/>
<text name="spu_attention_none" text="Intet kræver handling lige nu"/>
<text name="spu_attention_hintFlow" text=" → klik: opret flow"/>
<text name="spu_attention_hintOpen" text=" → klik: åbn flow '%s'"/>
<text name="spu_attention_hintProduce" text=" → producér/køb"/>
<text name="spu_attention_produce" text="Et flow hjælper ikke — gården har kun %2$s %1$s i alt. Producér eller køb mere."/>
<text name="spu_attention_flowName" text="%s til %s"/>
<text name="spu_attention_hintSellFlow" text=" → klik: opret salgsflow"/>
<text name="spu_attention_sellFlowName" text="Sælg %s"/>
<text name="spu_alert_empty" text="%s løber tør om %s — gården har %s, bruger %s/døgn"/>
<text name="spu_alert_fullIn" text="%s er %d %% fuld — fuld om %s"/>
<text name="spu_alert_full" text="%s er %d %% fuld"/>
<text name="spu_alert_sell" text="Sælg %s nu: prisen er %d %% af årets top — %s"/>
<text name="spu_items_hint" text="(klik på en vare)"/>
<text name="spu_icol_amount" text="Beholdning"/>
<text name="spu_icol_value" text="Værdi nu"/>
<text name="spu_icol_rate" text="Gården netto · rækker"/>
<text name="spu_item_perDay" text="%s/døgn"/>
<text name="spu_item_fullIn" text="fuld om %s"/>
<text name="spu_item_balanced" text="i balance"/>
<text name="spu_item_share" text="%d %% af varen"/>
<text name="spu_tab_chains" text="Kæder"/>
<text name="spu_chain_empty" text="Ingen kæder: slå et produkt til i en fabrik"/>
<text name="spu_chain_col_item" text="Vare"/>
<text name="spu_chain_col_source" text="Kilde"/>
<text name="spu_chain_col_perMonth" text="l/md"/>
<text name="spu_chain_col_status" text="Status"/>
<text name="spu_chain_col_stock" text="Lager"/>
<text name="spu_chain_approve" text="Forslag [Godkend]"/>
<text name="spu_chain_dismiss" text="[Afvis]"/>
<text name="spu_chain_auto" text="skiftet (fri mark)"/>
<text name="spu_chain_gone" text="Forslaget gælder ikke længere"/>
<text name="spu_chain_flowOk" text="flow kører"/>
<text name="spu_chain_flowMissing" text="mangler traktor/vogn"/>
<text name="spu_chain_flowNone" text="intet flow"/>
<text name="spu_chain_flowPallet" text="palle: vælg UAL-vogn"/>
<text name="spu_chain_flowStopped" text="flow stoppet"/>
<text name="spu_chain_bottleneck" text="flaskehals: -%s/md"/>
<text name="spu_chain_buy" text="skal købes: %s/md"/>
<text name="spu_chain_cycle" text="kredsløb"/>
<text name="spu_chain_fieldShort" text="mangler %s ha"/>
<text name="spu_chain_tight" text="lager &lt; 3 md"/>
<text name="spu_chain_noField" text="ingen mark"/>
<text name="spu_chain_buySource" text="køb"/>
<text name="spu_chain_fromFields" text="fra marker"/>
<text name="spu_chain_notifyMore" text="+%d manglende led — se fanen Kæder"/>
<text name="spu_chain_pause" text="[Pause]"/>
<text name="spu_chain_resume" text="[Start]"/>
<text name="spu_chain_rootOk" text="dækket"/>
<text name="spu_chain_rootError" text="mangel"/>
<text name="spu_chain_rootWaiting" text="venter"/>
<text name="spu_chain_rootPaused" text="på pause"/>
<text name="spu_chain_rootSummary" text="%s · %d forslag"/>
<text name="spu_chain_free" text="Marker fri til kæden: %s"/>
<text name="spu_chain_switches" text="Automatiske skift siden indlæsning: %d"/>
<text name="spu_chain_scanning" text="Marker scannes endnu – kæden skifter ingen marker før alle er set"/>
<text name="spu_attention_chainSuggests" text="Kæde: %d forslag"/>
<text name="spu_attention_hintChain" text=" → klik: se Kæder"/>
<text name="spu_chain_problem_buy" text="Ingen af dine fabrikker laver %s — byg en eller køb"/>
<text name="spu_chain_problem_barn" text="Dine stalde leverer ikke nok %s — køb resten eller flere dyr"/>
<text name="spu_chain_problem_bottleneck" text="%s kan kun lave %s af %s/md"/>
<text name="spu_chain_problem_off" text="%s (%s) er slukket af dig — tænd den eller sæt kæden på pause"/>
<text name="spu_chain_problem_cycle" text="Kredsløb omkring %s — kæden kan ikke beregnes"/>
<text name="spu_attention_chainProblems" text="Kæder: %d manglende led"/>
<text name="input_SPU_TOGGLE_STATUS" text="AD Smart Pickup: statusvindue"/>
<text name="spu_status_flows" text="Flows"/>
<text name="spu_status_fields" text="Marker"/>
<text name="spu_status_chains" text="Kæder"/>
<text name="spu_status_warnings" text="Advarsler"/>
<text name="spu_status_more" text="+%d mere"/>
<text name="spu_status_idle" text="Smart Pickup: intet kører lige nu"/>
<text name="spu_status_act_fetch" text="henter %s ved %s"/>
<text name="spu_status_act_driving" text="kører til %s"/>
<text name="spu_status_act_waiting" text="venter ved %s"/>
<text name="spu_status_act_holding" text="holder ved %s"/>
<text name="spu_status_act_fetchWagon" text="henter vogn"/>
<text name="spu_status_act_waitWagon" text="venter på vogn"/>
<text name="spu_status_act_emptying" text="tømmer vognen"/>
<text name="spu_status_act_homing" text="på vej hjem"/>
<text name="spu_status_stuck" text="%s står stille (%d min)"/>
<text name="spu_dep_warn" text="Advarsel: du slukkede %s (%s) — %s bruger det. Lageret rækker ca. %d dage."/>
<text name="spu_dep_warnNoDays" text="Advarsel: du slukkede %s (%s) — %s bruger det."/>
<text name="spu_attention_depOff" text="Slukket men nødvendig: %s"/>
<text name="spu_attention_depOffMore" text="Slukket men nødvendig: %s +%d"/>
<text name="spu_attention_hintFactories" text=" → klik: se Fabrikker"/>
</texts>
</l10n>
+353 -14
View File
@@ -4,7 +4,7 @@
<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_noRuns" text="No flows — press &apos;New flow&apos;"/>
<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)"/>
@@ -27,7 +27,10 @@
<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_guide_flow_wagon" text=" ↳ Trailer"/>
<text name="spu_guide_flow_noFreeWagon" text=" ↳ no free trailer (detached, with a Connect marker, able to carry the goods)"/>
<text name="spu_guide_flow_soloText" text="%s — no trailer attached: fetches a free trailer"/>
<text name="spu_reason_noTrailer" text="no trailer (attach one, or name the trailer's parking after the flow)"/>
<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"/>
@@ -40,7 +43,28 @@
<text name="spu_status_stoppedAll" text="Stopped %d tractors"/>
<text name="spu_overview_hint" text="Click a flow to edit it"/>
<text name="spu_tab_fields" text="Fields"/>
<text name="spu_tab_month" text="Month"/>
<text name="spu_month_months" text="Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec"/>
<text name="spu_month_period" text="Period %d"/>
<text name="spu_month_header" text="%s · day %d of %d · %02d:%02d"/>
<text name="spu_month_noClock" text="Cannot read the game calendar"/>
<text name="spu_month_verdictCalm" text="Nothing urgent — safe to sleep to next month"/>
<text name="spu_month_verdictUrgent" text="NOTE: %d fields must finish before the month ends"/>
<text name="spu_month_noWork" text="No fields have work in progress"/>
<text name="spu_month_col_work" text="Work"/>
<text name="spu_month_col_progress" text="Done"/>
<text name="spu_month_col_next" text="Next"/>
<text name="spu_month_col_window" text="Window"/>
<text name="spu_month_window_ok" text="ok"/>
<text name="spu_month_window_harvestCloses" text="NOTE: withers soon"/>
<text name="spu_month_window_lastSowMonth" text="NOTE: last sow month"/>
<text name="spu_month_upcoming" text="Can be sown now, not started:"/>
<text name="spu_month_upcomingOpen" text="F%d: %s — can also be sown next month"/>
<text name="spu_month_upcomingLast" text="F%d: %s — NOTE: last sowing month"/>
<text name="spu_field_button_start" text="Start now"/>
<text name="spu_field_manualTransport" text=" %s drives itself on the road (No = too wide, you drive it to the field)"/>
<text name="spu_field_button_takeover" text="Start now"/>
<text name="spu_field_takeoverRequested" text="F%d: taking over the harvester now if the header fits"/>
<text name="spu_field_button_stop" text="Stop field"/>
<text name="spu_field_noMarker" text="! missing marker F%d"/>
<text name="spu_field_stage_idle" text="Waiting"/>
@@ -51,8 +75,45 @@
<text name="spu_field_stage_collecting" text="Collecting"/>
<text name="spu_field_stage_done" text="Done"/>
<text name="spu_field_reason_noRig" text="no %s ticked in the field fleet"/>
<text name="spu_field_reasonShort_noRig" text="no machine"/>
<text name="spu_field_reasonShort_allBusy" text="waiting for machine"/>
<text name="spu_field_reasonShort_tooWide" text="too wide"/>
<text name="spu_field_reasonShort_widthUnknown" text="width unknown"/>
<text name="spu_field_reasonShort_tooNarrow" text="too narrow"/>
<text name="spu_field_reasonShort_cannotDo" text="none can do it"/>
<text name="spu_field_reasonShort_noSowFruit" text="choose crop"/>
<text name="spu_field_reasonShort_notEnough" text="too few free"/>
<text name="spu_field_reasonShort_notPlantable" text="not sowing time"/>
<text name="spu_field_reasonShort_regrowing" text="waiting for regrowth"/>
<text name="spu_field_reasonShort_rain" text="waiting for dry weather"/>
<text name="spu_field_reasonShort_text" text="see details"/>
<text name="spu_field_reason_allBusy" text="waiting for a free %s"/>
<text name="spu_pool_section_tractors" text="Machine pool — tractors (fetch implements when no ready combination is free)"/>
<text name="spu_pool_section_implements" text="Machine pool — implements (park &quot;X&quot; + marker &quot;Connect X&quot;)"/>
<text name="spu_pool_noTractors" text="no tractors without implement"/>
<text name="spu_pool_noImplements" text="no implements with a field role"/>
<text name="spu_pool_tractor_free" text="free%.0s"/>
<text name="spu_pool_tractor_noPark" text="needs its own AD park destination%.0s"/>
<text name="spu_pool_tractor_player" text="player is driving%.0s"/>
<text name="spu_pool_tractor_returning" text="dropping implement and driving home%.0s"/>
<text name="spu_pool_tractor_job" text="job F%s"/>
<text name="spu_pool_tractor_busy" text="AutoDrive/Courseplay active%.0s"/>
<text name="spu_pool_tractor_notInFleet" text="not in field fleet (tick)%.0s"/>
<text name="spu_pool_tractor_offNetwork" text="off the AutoDrive network%.0s"/>
<text name="spu_pool_tractor_hitched" text="has an implement attached (detach it to use the pool)%.0s"/>
<text name="spu_pool_implement_flow" text="belongs to flow '%s'"/>
<text name="spu_pool_implement_parked" text="parked at %s"/>
<text name="spu_pool_implement_noPark" text="not in pool — needs an AD park destination%.0s"/>
<text name="spu_pool_implement_noConnect" text="not in pool — needs marker &quot;Connect %s&quot;"/>
<text name="spu_pool_implement_attached" text="on %s"/>
<text name="spu_pool_implement_quarantine" text="could not attach — retry in %s min"/>
<text name="spu_pool_implement_reserved" text="reserved — a tractor is on its way%.0s"/>
<text name="spu_field_reason_noPoolTractor" text="no free tractor to fetch an implement for %s"/>
<text name="spu_field_reasonShort_noPoolTractor" text="waiting for tractor"/>
<text name="spu_field_reason_fetching" text="fetching '%s'"/>
<text name="spu_field_reasonShort_fetching" text="fetching implement"/>
<text name="spu_field_reason_tooWide" text="%s too wide: %.1f m &gt; %.0f m (Courseplay)"/>
<text name="spu_field_reason_widthUnknown" text="%s: work width %.1f m cannot be right — set the width in Courseplay (not sent out)"/>
<text name="spu_field_reason_tooNarrow" text="%s too narrow: %.1f m &lt; mowing width %.1f m — turn on Rake or use a wider one"/>
<text name="spu_field_role_mower" text="mower"/>
<text name="spu_field_role_tedder" text="tedder"/>
@@ -65,25 +126,33 @@
<text name="spu_field_mode_manual" text="Manual"/>
<text name="spu_field_notMowable" text="not grass/clover"/>
<text name="spu_field_scanning" text="Loading fields ... (one field per second)"/>
<text name="spu_field_hint" text="Click a field to select it. Tick the machines in the field fleet at the bottom."/>
<text name="spu_field_hint" text="Click a field to see and change its setup."/>
<text name="spu_field_fleetEmpty" text="! The field fleet is set to &quot;only ticked&quot;, but no machine is ticked — nothing can be sent out. Open Field fleet and choose &quot;Use all free machines&quot;."/>
<text name="spu_field_showAll" text="Show all fields (%d hidden)"/>
<text name="spu_col_field" text="Field"/>
<text name="spu_col_crop" text="Crop"/>
<text name="spu_col_plan" text="Plan"/>
<text name="spu_col_mode" text="Mode"/>
<text name="spu_field_section" text="— Field F%d: %s —"/>
<text name="spu_field_statusFull" text="Status: %s"/>
<text name="spu_field_plan" text="Plan"/>
<text name="spu_field_mode" text="Mode"/>
<text name="spu_field_rake" text="Rake the swaths before collecting (lets narrow loading wagons collect)"/>
<text name="spu_field_marker" text=" Marker %s, %.0f m from the field"/>
<text name="spu_field_mowWidth" text=" Mowed at %.1f m — collecting must be at least as wide"/>
<text name="spu_field_section_store" text="— Force storage (else nearest silo with room) —"/>
<text name="spu_field_section_dedicated" text="— Dedicated machines for this field (used only here; none = the free ones) —"/>
<text name="spu_field_dedicatedElsewhere" text="dedicated to F%s"/>
<text name="spu_field_hiddenDedicated" text="%d machines are dedicated to other fields (%s) and not shown here"/>
<text name="spu_field_fieldSilo" text="field silo"/>
<text name="spu_field_storeHidden" text="+ %d silos further away not shown"/>
<text name="spu_field_noStore" text="no silo takes this field's goods"/>
<text name="spu_field_storeAuto" text="none chosen: nearest silo with room is used"/>
<text name="spu_field_fieldSiloNotEmptied" text="! Field silo %s is not emptied home — create an Out of building flow"/>
<text name="spu_field_section_fleet" text="— Field fleet (ticked = may be sent out) —"/>
<text name="spu_field_section_fleet" text="— Field fleet, shared by ALL fields (ticked = may be sent out; dedicated machines are set per field) —"/>
<text name="spu_field_rig_working" text="working on F%s"/>
<text name="spu_field_tooWideShort" text="!! over 50 m"/>
<text name="spu_field_widthUnknownShort" text="!! width unknown — set in Courseplay"/>
<text name="spu_field_noRigs" text="no vehicles with mower, tedder, rake or loading wagon"/>
<text name="spu_field_status" text="Fields in progress: %d of %d"/>
<text name="spu_field_startRequested" text="F%d starts when a machine is free"/>
@@ -94,9 +163,11 @@
<text name="spu_field_fermenter" text="fermenting silo"/>
<text name="spu_field_storeAutoSilage" text="none chosen: nearest fermenting silo with room, else nearest silo"/>
<text name="spu_field_bare" text="bare soil"/>
<text name="spu_field_stubble" text="stubble (%s)"/>
<text name="spu_field_careNeed" text="needs %s"/>
<text name="spu_field_fertilize" text="Fertilize to max (while the crop grows)"/>
<text name="spu_field_lime" text="Lime when the soil needs it"/>
<text name="spu_field_chainFree" text="Free for chains (mod may change the crop)"/>
<text name="spu_field_mulch" text="Mulch stubble after harvest"/>
<text name="spu_field_need_fert" text="fertilizer"/>
<text name="spu_field_need_lime" text="lime"/>
@@ -116,7 +187,7 @@
<text name="spu_field_rig_seeds" text="seeds ok"/>
<text name="spu_field_rigs" text="Machines per step (Courseplay multi-tool)"/>
<text name="spu_field_role_cultivator" text="cultivator"/>
<text name="spu_field_role_fertilizer" text="fertilizer spreader"/>
<text name="spu_field_role_fertilizer" text="fertilizing"/>
<text name="spu_field_role_harvester" text="harvester"/>
<text name="spu_field_role_limer" text="lime spreader"/>
<text name="spu_field_role_mulcher" text="mulcher"/>
@@ -152,6 +223,7 @@
<text name="spu_field_unloaders" text="Unloading trailers during harvest"/>
<text name="spu_field_weed" text="Control weeds"/>
<text name="spu_field_notice_done" text="is done"/>
<text name="spu_field_reason_regrowing" text="waiting for the regrowth to reach full height"/>
<text name="spu_field_reason_rain" text="waiting for dry weather"/>
<text name="spu_field_target" text="Crop on the field (the mod handles the rest)"/>
<text name="spu_field_target_none" text="None — plan it yourself"/>
@@ -169,13 +241,35 @@
<text name="spu_field_straw_chop" text="Chopped (spread on the field)"/>
<text name="spu_field_straw_collect" text="Collected to a silo"/>
<text name="spu_field_straw_bale" text="Baled and collected"/>
<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_start" text="Start flow"/>
<text name="spu_button_stop" text="Stop flow"/>
<text name="spu_button_new" text="New flow"/>
<text name="spu_button_renameBuilding" text="Rename building"/>
<text name="spu_renameBuildingTitle" text="New name for the building (empty = shop name)"/>
<text name="spu_button_stopPark" text="Stop all and park"/>
<text name="spu_status_queuedAll" text="Start all: %d tractors start one at a time (every %d seconds), %d flows already running, %d skipped"/>
<text name="spu_status_stopParked" text="Stop all: %d stopped, %d driving to parking — paused until Start all"/>
<text name="spu_status_noPark" text="no park destination: %s"/>
<text name="spu_status_fetchManual" text="fetch yourself (too wide): %s"/>
<text name="spu_status_starting" text="Starting tractors: %d of %d …"/>
<text name="spu_status_paused" text="Paused — press Start all to continue"/>
<text name="spu_flow_pickOther" text="Pick another flow (the board with all flows is in Overview)"/>
<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_button_delete" text="Delete flow"/>
<text name="spu_panel_close" text="Close"/>
<text name="spu_panel_gone" text="That item no longer exists — window closed"/>
<text name="spu_flowList_hint" text="Click a flow to edit it. [Start]/[Stop] on the far right controls it directly. Colours: green runs · yellow waits · red problem · grey stopped. &quot;By need&quot; = each trip fetches what the target is lowest on."/>
<text name="spu_flowList_start" text="[Start]"/>
<text name="spu_flowList_stop" text="[Stop]"/>
<text name="spu_flowList_status" text="Flows running: %d of %d"/>
<text name="spu_panel_flowTitle" text="Edit flow: %s"/>
<text name="spu_panel_fieldTitle" text="Field F%d"/>
<text name="spu_field_pausedSuffix" text="(paused)"/>
<text name="spu_field_pausedBanner" text="! Field work is paused since &quot;Stop all&quot; (saved with the savegame) — press Start all in Overview, or [Start] on a single field."/>
<text name="spu_prune_removed" text="Flow '%s': %d tractor(s) no longer exist and were removed (%d left)"/>
<text name="spu_prune_empty" text="Flow '%s' has no tractors left and won't run — assign one in Flows"/>
<text name="spu_newRunName" text="Flow %d"/>
<text name="spu_renameTitle" text="Flow name"/>
<text name="spu_deleteConfirm" text="Delete run '%s'? Its running tractors will be stopped. This cannot be undone."/>
<text name="spu_capacity" text="%s l"/>
<text name="spu_capacity_pallets" text="autoload (pallets)"/>
@@ -219,6 +313,8 @@
<text name="spu_sellAt" text="Sell from (% of yearly top price)"/>
<text name="spu_sellAt_tooltip" text="Drive to sell when the selling point pays at least this share of the good's top price in the yearly price curve — regardless of amount. Great demand always qualifies."/>
<text name="spu_storeAbove" text="Drive to storage above"/>
<text name="spu_sellAbove" text="Always sell when source is above"/>
<text name="spu_sellAbove_off" text="Off"/>
<text name="spu_storeAbove_tooltip" text="Goods with the Store policy are driven to a silo when the source storage is above this fill level (or a full load is available)."/>
<text name="spu_collectRound" text="Collect in one trip"/>
<text name="spu_collectRound_tooltip" text="Tipper: same good from several sources until full. Autoload trailer: several pallet goods with the same destination in one trip."/>
@@ -228,6 +324,7 @@
<text name="spu_reason_noProducts" text="no good selected (set Store/Sell on a good above)"/>
<text name="spu_reason_noWaitPoint" text="no Wait point in the pool (marker in a folder with 'Wait' in its name)"/>
<text name="spu_reason_cargoNoTarget" text="the load on the trailer has no destination (price too low / no silo) — empty the trailer"/>
<text name="spu_reason_autoloadOn" text="AutoLoad is on for this trailer — AutoDrive treats it as a pallet/bale trailer only (turn AutoLoad off on the trailer)"/>
<text name="spu_reason_needsAutoload" text="pallet goods need Universal Autoload on the trailer"/>
<text name="spu_product_row" text="%s — %s l — %s"/>
<text name="spu_price_pct" text="%d %% of top (%s)"/>
@@ -253,14 +350,16 @@
<text name="spu_flow_noSource" text="(no source)"/>
<text name="spu_flow_noTarget" text="(no target)"/>
<text name="spu_flow_noVehicle" text="(no tractor)"/>
<text name="spu_flow_ruleSell" text="price >= %d %%"/>
<text name="spu_flow_ruleStore" text="storage > %d %%"/>
<text name="spu_flow_ruleOut" text="price >= %d %%, storage > %d %%"/>
<text name="spu_flow_round" text="round"/>
<text name="spu_flow_ruleIn" text="need"/>
<text name="spu_flow_ruleInLoops" text="need, %d loops"/>
<text name="spu_flow_ruleIn" text="by need — lowest first"/>
<text name="spu_flow_ruleInLoops" text="by need, %d trips"/>
<text name="spu_markerAt" text="%s %d m"/>
<text name="spu_noMarker" text="NO MARKER within 40 m — place an AD marker at the trigger"/>
<text name="spu_noBuildings" text="No buildings found"/>
<text name="spu_buildingGone" text="building no longer exists"/>
<text name="spu_buildingGone" text="Removed building (no longer exists) — set NO"/>
<text name="spu_reason_noMarker" text="'%s' has no AD marker at its trigger"/>
<text name="spu_storage_select" text="Show building"/>
<text name="spu_stat_now" text="now %s"/>
@@ -300,5 +399,245 @@
<text name="spu_col_lasts" text="Lasts"/>
<text name="spu_col_rate" text="Use/prod. per day"/>
<text name="spu_storage_select_tooltip" text="Choose the building shown in the table."/>
<text name="spu_guide_next" text="Next"/>
<text name="spu_guide_save" text="Save"/>
<text name="spu_guide_ok" text="OK"/>
<text name="spu_guide_back" text="Back"/>
<text name="spu_guide_cancel" text="Cancel"/>
<text name="spu_guide_saveOnly" text="Save without starting"/>
<text name="spu_guide_runAuto" text="Run automatically"/>
<text name="spu_guide_stepOf" text="Step %d of %d — %s"/>
<text name="spu_guide_stepOne" text="Step %d — %s"/>
<text name="spu_guide_editStep" text="Edit: %s"/>
<text name="spu_guide_editSuffix" text=" [Edit]"/>
<text name="spu_guide_sum_hint" text="Click a line to change just that."/>
<text name="spu_guide_auto" text="Automatic (recommended)"/>
<text name="spu_guide_manual" text="Choose myself"/>
<text name="spu_guide_closed" text="Guide closed — the field/flow no longer exists"/>
<text name="spu_guide_cannotAdvance" text="Choose first"/>
<text name="spu_guide_pickField" text="Pick a field first"/>
<text name="spu_guide_saved" text="F%d saved"/>
<text name="spu_guide_noMarker" text="! Place an AD marker at F%d — work waits for the marker"/>
<text name="spu_guide_field_open" text="Set up field"/>
<text name="spu_guide_fleet_open" text="Field fleet"/>
<text name="spu_guide_fleet_close" text="Back to fields"/>
<text name="spu_guide_field_title" text="— Set up field F%d —"/>
<text name="spu_guide_field_titleNone" text="— Set up field —"/>
<text name="spu_guide_field_step_field" text="Field"/>
<text name="spu_guide_field_step_crop" text="Crop"/>
<text name="spu_guide_field_step_cropChoice" text="Crop options"/>
<text name="spu_guide_field_step_prep" text="Preparation"/>
<text name="spu_guide_field_step_care" text="Crop care"/>
<text name="spu_guide_field_step_machines" text="Machines"/>
<text name="spu_guide_field_step_store" text="Storage"/>
<text name="spu_guide_field_step_summary" text="Summary"/>
<text name="spu_guide_field_hint_field" text="Pick the field you want to set up."/>
<text name="spu_guide_field_hint_crop" text="What should grow here? 'Keep' carries on with the current crop."/>
<text name="spu_guide_field_hint_cropChoice" text="What happens to the crop when mowing or harvesting?"/>
<text name="spu_guide_field_hint_prep" text="After harvest / on bare land. Automatic plows or cultivates and limes as needed."/>
<text name="spu_guide_field_hint_care" text="While the crop grows. Fertilizing/spraying only happens when needed."/>
<text name="spu_guide_field_hint_machines" text="How many at a time, and should the field have its own dedicated machines?"/>
<text name="spu_guide_field_hint_store" text="Where should the harvest go?"/>
<text name="spu_guide_field_hint_summary" text="Check your choices. Click a line to change it."/>
<text name="spu_guide_crop" text="Crop"/>
<text name="spu_guide_crop_keep" text="Keep what grows"/>
<text name="spu_guide_crop_sowAgain" text="Sow the same crop again after harvest"/>
<text name="spu_guide_prep" text="Preparation"/>
<text name="spu_guide_prep_autoText" text=" Plows when the soil needs it, otherwise cultivates; limes at low pH."/>
<text name="spu_guide_care" text="Crop care"/>
<text name="spu_guide_care_autoText" text=" Fertilizes up to max while the crop grows, removes weeds and limes at low pH."/>
<text name="spu_guide_rigs" text="Machines at a time (multi-tool)"/>
<text name="spu_guide_dedicated" text="Dedicated machines for this field?"/>
<text name="spu_guide_useFieldSilo" text="Use the field silo?"/>
<text name="spu_guide_store" text="Storage"/>
<text name="spu_guide_store_autoText" text=" Nearest silo with room (silage: fermenting silo)."/>
<text name="spu_guide_sum_target" text="-&gt; %s"/>
<text name="spu_guide_sum_sowAgain" text="sow again"/>
<text name="spu_guide_sum_rake" text="rake"/>
<text name="spu_guide_sum_noRake" text="no rake"/>
<text name="spu_guide_sum_straw_chop" text="straw chopped"/>
<text name="spu_guide_sum_straw_collect" text="straw collected"/>
<text name="spu_guide_sum_straw_bale" text="straw baled"/>
<text name="spu_guide_sum_auto" text="Automatic"/>
<text name="spu_guide_sum_lime" text="lime"/>
<text name="spu_guide_sum_mulch" text="mulch"/>
<text name="spu_guide_sum_roll" text="roll"/>
<text name="spu_guide_sum_fert" text="fertilizer"/>
<text name="spu_guide_sum_weed" text="weeds"/>
<text name="spu_guide_sum_noCare" text="no care"/>
<text name="spu_guide_sum_dedicated" text="%d dedicated"/>
<text name="spu_guide_sum_freeRigs" text="free machines"/>
<text name="spu_guide_sum_rigs" text="%d at a time"/>
<text name="spu_guide_sum_unloaders" text="%d unloaders"/>
<text name="spu_guide_sum_fieldSilo" text="Field silo"/>
<text name="spu_guide_sum_stores" text="%d chosen silos"/>
<text name="spu_guide_flow_open" text="Set up flow"/>
<text name="spu_guide_flow_title" text="— Set up flow: %s —"/>
<text name="spu_guide_flow_section" text="— Flow: %s —"/>
<text name="spu_guide_flow_saved" text="Flow '%s' saved"/>
<text name="spu_guide_flow_start" text="Start flow"/>
<text name="spu_guide_flow_rename" text="Change name …"/>
<text name="spu_guide_flow_step_name" text="Name"/>
<text name="spu_guide_flow_step_direction" text="Direction"/>
<text name="spu_guide_flow_step_from" text="From"/>
<text name="spu_guide_flow_step_to" text="To"/>
<text name="spu_guide_flow_step_goods" text="Goods"/>
<text name="spu_guide_flow_step_store" text="Storage"/>
<text name="spu_guide_flow_step_rules" text="Rules"/>
<text name="spu_guide_flow_step_wait" text="Wait at"/>
<text name="spu_guide_flow_step_tractors" text="Tractors"/>
<text name="spu_guide_flow_step_summary" text="Summary"/>
<text name="spu_guide_flow_hint_name" text="The flow's name (tractors are named after it)."/>
<text name="spu_guide_flow_hint_direction" text="Into building: keep barns/factories filled. Out of building: empty, store and sell."/>
<text name="spu_guide_flow_hint_buildings_in" text="Which barns/factories should be filled? The marker is found automatically."/>
<text name="spu_guide_flow_hint_buildings_out" text="Where should goods be picked up? The marker is found automatically."/>
<text name="spu_guide_flow_hint_goods_in" text="Automatic fetches whatever the target lacks most on each trip."/>
<text name="spu_guide_flow_hint_goods_out" text="For each good: Store, Sell, or Sell else store."/>
<text name="spu_guide_flow_hint_store" text="Where should stored goods go?"/>
<text name="spu_guide_flow_hint_rules_in" text="Number of trips, and when the tractor leaves Wait."/>
<text name="spu_guide_flow_hint_rules_out" text="When to sell, and when to drive to storage."/>
<text name="spu_guide_flow_hint_wait" text="Where does the tractor wait with a leftover load?"/>
<text name="spu_guide_flow_hint_tractors" text="Which tractors run this flow? Tractors in other flows are hidden."/>
<text name="spu_guide_flow_hint_summary" text="Check your choices. Click a line to change it."/>
<text name="spu_guide_flow_goods" text="Goods"/>
<text name="spu_guide_flow_goodsAutoChoice" text="Automatic (what the building needs)"/>
<text name="spu_guide_flow_goodsAutoText" text=" Each trip fetches the good the target lacks most."/>
<text name="spu_guide_flow_noGoodsTo" text=" First choose where to deliver (step To)"/>
<text name="spu_guide_flow_noProducts" text=" First choose where to pick up (step From)"/>
<text name="spu_guide_flow_storeAutoChoice" text="Automatic (nearest with room)"/>
<text name="spu_guide_flow_storeAutoText" text=" Storage trips go to the nearest store with room."/>
<text name="spu_guide_flow_noTractors" text=" No free tractors"/>
<text name="spu_guide_flow_otherFlows" text=" %d tractors run in other flows and are hidden"/>
<text name="spu_guide_flow_blockedShow" text=" %d tractors cannot be used here (trailer does not fit) — click to see why"/>
<text name="spu_guide_flow_blockedHide" text=" %d tractors cannot be used here — click to hide"/>
<text name="spu_guide_flow_blocked" text=" %d tractors cannot be used here (trailer does not fit)"/>
<text name="spu_guide_flow_none" text="none chosen"/>
<text name="spu_guide_flow_goodsAuto" text="Automatic"/>
<text name="spu_guide_flow_sumGoods" text="%d goods"/>
<text name="spu_guide_flow_sumSellAt" text="sell from %d %%"/>
<text name="spu_guide_flow_sumStoreAbove" text="store above %d %%"/>
<text name="spu_guide_flow_sumSellAbove" text="always sell above %d %%"/>
<text name="spu_guide_flow_sumRound" text="collection round"/>
<text name="spu_guide_flow_sumLoops" text="%d trips"/>
<text name="spu_guide_flow_sumLoopsInf" text="endless trips"/>
<text name="spu_guide_flow_sumReturn" text="leave Wait below %d %%"/>
<text name="spu_guide_flow_sumNoTractors" text="none"/>
<text name="spu_guide_flow_sumTractors" text="%d tractors"/>
<text name="spu_tab_fieldsilos" text="Field silos"/>
<text name="spu_fs_enabled" text="Bring field silos home automatically"/>
<text name="spu_fs_maxVehicles" text="Max. vehicles out"/>
<text name="spu_fs_minLiters" text="Minimum amount per product"/>
<text name="spu_fs_status" text="%d/%d vehicles out · %d silos waiting"/>
<text name="spu_fs_noStores" text="Select at least one farm storage below — otherwise nothing is brought home"/>
<text name="spu_fs_silosTitle" text="Field silos with products"/>
<text name="spu_fs_noVehicle" text="No free unloading trailer: %d without AD park destination, %d busy, %d with a player in it, %d not in the fleet (flow vehicles are never borrowed)"/>
<text name="spu_fs_nothing" text="No field silo has products above the minimum amount"/>
<text name="spu_fs_col_silo" text="Silo"/>
<text name="spu_fs_col_liters" text="Liters"/>
<text name="spu_fs_col_onWay" text="En route"/>
<text name="spu_fs_col_state" text="Status"/>
<text name="spu_fs_state_ok" text="ok"/>
<text name="spu_fs_state_noMarker" text="missing marker"/>
<text name="spu_fs_state_noFarmStore" text="no farm storage takes %s"/>
<text name="spu_fs_state_paused" text="paused (did not get going)"/>
<text name="spu_fs_storesTitle" text="Farm storages (where products are brought home to)"/>
<text name="spu_fs_toProduction" text="Send surplus to production (barn feed first)"/>
<text name="spu_fs_reserveDays" text="Feed reserve: barn consumption for"/>
<text name="spu_fs_days" text="%d days"/>
<text name="spu_fs_toFactory" text="→ factory"/>
<text name="spu_fs_harvestHeld" text="%d unloading trailer(s) held back for working harvesters"/>
<text name="spu_view_title" text="Show"/>
<text name="spu_view_items" text="Goods"/>
<text name="spu_view_buildings" text="Buildings"/>
<text name="spu_view_tooltip" text="Goods: total stock per good (click to see where it is stored). Buildings: table for one building."/>
<text name="spu_kpi_value" text="Stock value now"/>
<text name="spu_kpi_gain" text="Extra at top price"/>
<text name="spu_kpi_empty" text="Running out soon"/>
<text name="spu_kpi_full" text="Almost full"/>
<text name="spu_attention" text="— Needs attention —"/>
<text name="spu_attention_none" text="Nothing needs attention right now"/>
<text name="spu_attention_hintFlow" text=" → click: create flow"/>
<text name="spu_attention_hintOpen" text=" → click: open flow '%s'"/>
<text name="spu_attention_hintProduce" text=" → produce/buy"/>
<text name="spu_attention_produce" text="A flow won't help — the farm only has %2$s %1$s in total. Produce or buy more."/>
<text name="spu_attention_flowName" text="%s to %s"/>
<text name="spu_attention_hintSellFlow" text=" → click: create sell flow"/>
<text name="spu_attention_sellFlowName" text="Sell %s"/>
<text name="spu_alert_empty" text="%s runs out in %s — farm has %s, uses %s/day"/>
<text name="spu_alert_fullIn" text="%s is %d %% full — full in %s"/>
<text name="spu_alert_full" text="%s is %d %% full"/>
<text name="spu_alert_sell" text="Sell %s now: price is %d %% of the yearly top — %s"/>
<text name="spu_items_hint" text="(click a good)"/>
<text name="spu_icol_amount" text="Stock"/>
<text name="spu_icol_value" text="Value now"/>
<text name="spu_icol_rate" text="Farm net · lasts"/>
<text name="spu_item_perDay" text="%s/day"/>
<text name="spu_item_fullIn" text="full in %s"/>
<text name="spu_item_balanced" text="balanced"/>
<text name="spu_item_share" text="%d %% of the good"/>
<text name="spu_tab_chains" text="Chains"/>
<text name="spu_chain_empty" text="No chains: enable a product in a factory"/>
<text name="spu_chain_col_item" text="Goods"/>
<text name="spu_chain_col_source" text="Source"/>
<text name="spu_chain_col_perMonth" text="l/mo"/>
<text name="spu_chain_col_status" text="Status"/>
<text name="spu_chain_col_stock" text="Stock"/>
<text name="spu_chain_approve" text="Suggest [Approve]"/>
<text name="spu_chain_dismiss" text="[Reject]"/>
<text name="spu_chain_auto" text="switched (free field)"/>
<text name="spu_chain_gone" text="The suggestion no longer applies"/>
<text name="spu_chain_flowOk" text="flow running"/>
<text name="spu_chain_flowMissing" text="no tractor/trailer"/>
<text name="spu_chain_flowNone" text="no flow"/>
<text name="spu_chain_flowPallet" text="pallet: UAL trailer"/>
<text name="spu_chain_flowStopped" text="flow stopped"/>
<text name="spu_chain_bottleneck" text="bottleneck: -%s/mo"/>
<text name="spu_chain_buy" text="must buy: %s/mo"/>
<text name="spu_chain_cycle" text="loop"/>
<text name="spu_chain_fieldShort" text="short %s ha"/>
<text name="spu_chain_tight" text="stock &lt; 3 mo"/>
<text name="spu_chain_noField" text="no field"/>
<text name="spu_chain_buySource" text="buy"/>
<text name="spu_chain_fromFields" text="from fields"/>
<text name="spu_chain_notifyMore" text="+%d more missing links — see the Chains tab"/>
<text name="spu_chain_pause" text="[Pause]"/>
<text name="spu_chain_resume" text="[Resume]"/>
<text name="spu_chain_rootOk" text="covered"/>
<text name="spu_chain_rootError" text="short"/>
<text name="spu_chain_rootWaiting" text="waiting"/>
<text name="spu_chain_rootPaused" text="paused"/>
<text name="spu_chain_rootSummary" text="%s · %d sugg."/>
<text name="spu_chain_free" text="Fields free for the chain: %s"/>
<text name="spu_chain_switches" text="Automatic switches since load: %d"/>
<text name="spu_chain_scanning" text="Fields still being scanned – no field changes until all are seen"/>
<text name="spu_attention_chainSuggests" text="Chain: %d suggestions"/>
<text name="spu_attention_hintChain" text=" → click: see Chains"/>
<text name="spu_chain_problem_buy" text="None of your factories makes %s — build one or buy it"/>
<text name="spu_chain_problem_barn" text="Barns make too little %s — buy the rest or more animals"/>
<text name="spu_chain_problem_bottleneck" text="%s can only make %s of %s/mo"/>
<text name="spu_chain_problem_off" text="%s (%s) was turned off by you — turn it on or pause the chain"/>
<text name="spu_chain_problem_cycle" text="Loop around %s — the chain cannot be calculated"/>
<text name="spu_attention_chainProblems" text="Chains: %d missing links"/>
<text name="input_SPU_TOGGLE_STATUS" text="AD Smart Pickup: status window"/>
<text name="spu_status_flows" text="Flows"/>
<text name="spu_status_fields" text="Fields"/>
<text name="spu_status_chains" text="Chains"/>
<text name="spu_status_warnings" text="Warnings"/>
<text name="spu_status_more" text="+%d more"/>
<text name="spu_status_idle" text="Smart Pickup: nothing running right now"/>
<text name="spu_status_act_fetch" text="fetching %s at %s"/>
<text name="spu_status_act_driving" text="driving to %s"/>
<text name="spu_status_act_waiting" text="waiting at %s"/>
<text name="spu_status_act_holding" text="holding at %s"/>
<text name="spu_status_act_fetchWagon" text="fetching wagon"/>
<text name="spu_status_act_waitWagon" text="waiting for wagon"/>
<text name="spu_status_act_emptying" text="emptying the wagon"/>
<text name="spu_status_act_homing" text="heading home"/>
<text name="spu_status_stuck" text="%s standing still (%d min)"/>
<text name="spu_dep_warn" text="Warning: you switched off %s (%s) — %s uses it. Stock lasts about %d days."/>
<text name="spu_dep_warnNoDays" text="Warning: you switched off %s (%s) — %s uses it."/>
<text name="spu_attention_depOff" text="Switched off but needed: %s"/>
<text name="spu_attention_depOffMore" text="Switched off but needed: %s +%d"/>
<text name="spu_attention_hintFactories" text=" → click: see Factories"/>
</texts>
</l10n>
+41 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<modDesc descVersion="107">
<author>masterdraco</author>
<version>1.27.0.0</version>
<version>1.61.0.0</version>
<title>
<en>AD Smart Pickup</en>
</title>
@@ -12,6 +12,10 @@
<multiplayer supported="true"/>
<l10n filenamePrefix="l10n/l10n"/>
<extraSourceFiles>
<sourceFile filename="adCache.lua"/>
<sourceFile filename="adNaming.lua"/>
<sourceFile filename="adNames.lua"/>
<sourceFile filename="adStuckDiag.lua"/>
<sourceFile filename="adSupplyPlanner.lua"/>
<sourceFile filename="adRuns.lua"/>
<sourceFile filename="adRunsStorage.lua"/>
@@ -22,18 +26,54 @@
<sourceFile filename="adOutboundPlanner.lua"/>
<sourceFile filename="adStorageStats.lua"/>
<sourceFile filename="adFlowView.lua"/>
<sourceFile filename="adGuide.lua"/>
<sourceFile filename="adFlowGuide.lua"/>
<sourceFile filename="adSources.lua"/>
<sourceFile filename="adBuildings.lua"/>
<sourceFile filename="adOutbound.lua"/>
<sourceFile filename="adTipSide.lua"/>
<sourceFile filename="adUnloadWait.lua"/>
<sourceFile filename="adFieldModel.lua"/>
<sourceFile filename="adFieldFleet.lua"/>
<sourceFile filename="adFieldView.lua"/>
<sourceFile filename="adFieldGuide.lua"/>
<sourceFile filename="adFields.lua"/>
<sourceFile filename="adCpBridge.lua"/>
<sourceFile filename="adCourseCache.lua"/>
<sourceFile filename="adFieldStorage.lua"/>
<sourceFile filename="adManualTransport.lua"/>
<sourceFile filename="adFieldWork.lua"/>
<sourceFile filename="adFieldSiloPlanner.lua"/>
<sourceFile filename="adFieldSiloConfig.lua"/>
<sourceFile filename="adFieldSilos.lua"/>
<sourceFile filename="adCpResume.lua"/>
<sourceFile filename="adHitch.lua"/>
<sourceFile filename="adHitchCreep.lua"/>
<sourceFile filename="adPool.lua"/>
<sourceFile filename="adFlowWagons.lua"/>
<sourceFile filename="adFleetNames.lua"/>
<sourceFile filename="adFieldJobs.lua"/>
<sourceFile filename="adMonthView.lua"/>
<sourceFile filename="adProfiler.lua"/>
<sourceFile filename="adChainModel.lua"/>
<sourceFile filename="adCropPlan.lua"/>
<sourceFile filename="adChainActions.lua"/>
<sourceFile filename="adDependency.lua"/>
<sourceFile filename="adChainData.lua"/>
<sourceFile filename="adChain.lua"/>
<sourceFile filename="adChainRun.lua"/>
<sourceFile filename="adChainView.lua"/>
<sourceFile filename="adStatusView.lua"/>
<sourceFile filename="adStatusHud.lua"/>
<sourceFile filename="adSmartPickup.lua"/>
</extraSourceFiles>
<actions>
<action name="SPU_TOGGLE_STATUS"/>
</actions>
<inputBinding>
<actionBinding action="SPU_TOGGLE_STATUS">
<!-- numpad * (Insert bruges af FS25_UniversalAutoload) -->
<binding device="KB_MOUSE_DEFAULT" input="KEY_KP_multiply"/>
</actionBinding>
</inputBinding>
</modDesc>
+82 -241
View File
@@ -1,265 +1,106 @@
# FS25_ADSmartPickup
# AD Smart Pickup (FS25_ADSmartPickup)
**Brugermanual for spillere:** [dansk](docs/BRUGERMANUAL.md) · [English](docs/USER_MANUAL.md). Resten af denne fil er udviklernoter pr. version.
Tillæg til **AutoDrive** i Farming Simulator 25. Du fortæller, hvad der skal køres hvorhen, og moden
styrer dine AutoDrive-traktorer tur for tur. Markarbejdet kører med Courseplay på marken.
Alt sættes op i menuen **Smart Pickup** i ESC-menuen.
AutoDrive-tillæg til Farming Simulator 25 med to funktioner oven på Pickup&Deliver:
*English summary below.*
## 1. Vare-bevidst mappe-rotation (én vare valgt)
## Hvad moden gør
Når pålæsningsmål roteres i en AD-mappe, køres der direkte til den silo i mappen, der har den
valgte vare — mindst fyldte først (mælk/gylle: fyldeste først) — i stedet for at prøve markørerne én efter én.
- **Forsyning:** holder stalde og fabrikker fyldt. Hver tur henter den vare, målet mangler mest, fra
den bedste silo på kortet.
- **Udkørsel:** tømmer fabrikker, stalde, siloer og palleskure. Varerne køres til lager eller sælges, når
prisen er tæt på årets top, uden at sælge det, gården selv skal bruge.
- **Flows:** en navngivet opskrift med bygninger, varer, regler og traktorer. Flows sættes op i en guide og
startes eller stoppes samlet fra en farvet liste.
- **Flow-vogne og maskinpulje:** en traktor uden vogn kan selv hente sin vogn ved en Connect-markør og
sætte den på plads igen. Frakoblede redskaber og ledige traktorer bruges som en fælles pulje.
- **Produktionskæder:** slå et produkt til i en fabrik, og moden holder det kørende tilbage til marken. Fanen Kæder viser træet, marker markeret "Fri til kæden" skifter afgrøde automatisk, og andre marker får forslag, du godkender eller afviser. Mellemled og flows oprettes efter behov.
- **Markarbejde:** slåning, høst, klargøring, såning og pleje på alle marker. AutoDrive kører på vejen,
og Courseplay arbejder på marken.
- **Marksiloer:** varer i marksiloer køres automatisk hjem til gårdens lagre.
- **Lagerfaner:** beholdning, værdi og forbrug pr. vare og bygning. Listen "Kræver handling" opretter
det flow, der løser problemet.
- **Statusvindue:** numpad \* viser et lille vindue øverst til højre med flow-traktorernes handling og fyld,
markarbejde i gang, kæder med manglende led og advarsler.
- **Advarsel når en nødvendig fabrik slukkes:** slukker du en produktion, som et flow, en stald eller en kæde
bruger, advarer moden og viser hvor længe lageret rækker.
- **Stop alt og parkér:** stopper alt, folder redskaberne sammen og parkerer maskinerne. Pausen gemmes
med savegamet.
## 2. Behovsstyret forsyning (flere varer valgt)
## Download og installation
Én traktor holder alle input i en produktion fyldt — fx hø, græs, ensilage og sukkerroer til en
TMR-blander — i stedet for én traktor pr. vare.
1. Hent `FS25_ADSmartPickup.zip` under [Releases](https://git.powerdata.dk/masterdraco/fs25-adsmartpickup/releases).
2. Læg zip-filen (ikke udpakket) i `Documents/My Games/FarmingSimulator2025/mods/`.
3. Aktivér moden sammen med AutoDrive, når du vælger savegame. Menuen **Smart Pickup** ligger i
ESC-menuen lige over Indstillinger.
1. Aflæsningsmål = produktionens markør.
2. Multi-vælg ingredienserne i AutoDrives vare-dropdown.
3. Start Pickup&Deliver. Loop-tælleren virker som normalt (N læs → parkér, 0 = uendeligt).
## Krav
Pr. tur læses målets lager; den ingrediens der mangler mest (relativt til kapacitet) hentes fra en
silo **hvor som helst på kortet** (eget lager før nabo-lån, mindst fyldte først). Ingredienser uden
plads i målet (< 1000 l fri) eller uden kilde springes over. Mens AD kører viser HUD'en kun turens
vare (AutoDrive ville ellers selv rotere vare ved triggeren); når AD stopper, gendannes hele listen.
Slå fra ved at vælge en anden enkelt vare.
- **FS25_AutoDrive** (påkrævet) og et AutoDrive-vejnet mellem bygningerne.
- **Courseplay** anbefales. Det er nødvendigt for markarbejdet.
- **Universal Autoload** anbefales på vogne, der skal køre paller.
"Mest brug for" defineres i `ADSupplyPlanner.scoreNeed` (`adSupplyPlanner.lua`).
## Dokumentation
### Fabriksstyret (v1.6): målet er et produktionspunkt
- Brugermanual: [dansk](docs/BRUGERMANUAL.md) · [English](docs/USER_MANUAL.md)
- Ændringer pr. version: [CHANGELOG.md](CHANGELOG.md)
Er aflæsningsmålet en fabrik (produktionspunkt), **erstattes** ingredienslisten af inputs fra
fabrikkens ønskede produktioner — slår du fx TMR til i en AIO-fabrik, begynder traktoren at hente
ensilage/hø/halm/roer/mineralfoder af sig selv; slår du den fra, holder den op. Dit multi-valg er kun
tændknappen (og det gendannes ved stop). Listen genudledes hver tur, begrænset til det vognen kan bære.
## Feedback
- **Ønsket produktion** = tændt, ELLER slukket af kortet med status "mangler input" (Dakotas
`ExtendedProductionPoint` slår produktioner fra automatisk, når de løber tør — dem skal vi netop
fodre). Manuelt slukket = ikke ønsket.
- **Prioritet = tid til tom:** `lager / (Σ amount × cyclesPerMinute)` over ønskede produktioner —
den vare der løber tør først hentes først, uanset relativ fyldning. Loggen viser forbrug og minutter.
- Fabrik uden ønskede produktioner, eller inputs vognen ikke kan bære → dit multi-valg som hidtil.
Fejl og idéer: giv udvikleren besked (issues her kræver en konto på git.powerdata.dk). Vedlæg gerne `log.txt` fra
`Documents/My Games/FarmingSimulator2025/`, eller i det mindste linjerne der starter med `ADSmartPickup`.
### Kilder: en fabriks inputs stjæles aldrig (v1.6.1)
## Udvikling
En fabriks læssestation udleverer også dens **inputs** (halm i en TMR-fabrik). Sådan en vare hentes
aldrig derfra — kun fabrikkens outputs og rigtige siloer er kilder. Gælder både mappe-logik og
behovsstyring. Varer der både er input og output (kæde) må hentes.
- Mod-koden ligger i `FS25_ADSmartPickup/`.
- Byg zip: `cd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip .`
### Stalde som mål (v1.6.1)
---
Kostald m.fl. svarer selv for niveau/kapacitet (`getHusbandryFillLevel/Capacity`), så halm+TMR til en
stald rangeres korrekt, selv om staldens aflæsningsstation kun "kender" foder.
## English summary
### Wait-punkter: rest-læs ved fuld stald (v1.7)
**AD Smart Pickup** is an add-on for **AutoDrive** in Farming Simulator 25. You tell it what goes where,
and it drives your AutoDrive tractors trip by trip. Field work runs with Courseplay on the field. Everything
is set up in the **Smart Pickup** menu in the ESC menu.
AutoDrive holder ellers en traktor med rest-læs stille ved aflæsningspunktet, til den er tømt — og
spærrer for de næste i køen. Med Wait-punkter:
**Features**
1. Læg markører i en **mappe med ordet `Wait` i navnet** (fx mappen `Cow Wait`) — så mange du har
plads til; de er en fælles pulje for alle traktorer. Markørernes egne navne er ligegyldige, så
dine mark-ventepladser ("F91 Wait" i mark-mapperne) rører den ikke.
2. Kan målet ikke tage mere af vognens vare, kører traktoren til det **nærmeste ledige** Wait-punkt
(modden holder styr på reservationer) og venter dér.
3. Når målet igen har plads til **hele** resten — eller er mindst **halvt tomt** (kæmpe vogne kan aldrig
tømme hele resten i en lille stald) — kører den tilbage og læsser af. Derefter normal tur.
4. Wait-punkterne SKAL være forbundet til vejnettet både fra målets udkørsel og tilbage til målet.
Modden tjekker ruten begge veje før den omdirigerer; uden rute prøves næste punkt (AutoDrive
ville ellers vise "cannot reach" og slukke sig selv).
- **Supply:** keeps barns and factories filled. Each trip fetches the good the target lacks most, from
the best silo on the map.
- **Outbound:** empties factories, barns, silos and pallet sheds. Goods are stored or sold near the
yearly top price, and the mod never sells what the farm needs itself.
- **Flows:** a named recipe of buildings, goods, rules and tractors. You set flows up in a guide and
start or stop them from a colour-coded list.
- **Flow trailers and machine pool:** a tractor without a trailer can fetch its trailer at a Connect
marker and park it again. Detached implements and free tractors form a shared pool.
- **Production chains:** switch on a product in a factory and the mod keeps it running back to the field. The Chains tab shows the tree, fields marked "Free for chains" change crop automatically, and other fields get suggestions you approve or reject. Intermediate goods and flows are set up as needed.
- **Field work:** mowing, harvesting, preparation, sowing and crop care on every field. AutoDrive
drives the roads and Courseplay works the field.
- **Field silos:** goods in field silos are brought home to the farm's storages automatically.
- **Storage tabs:** stock, value and consumption per good and building. The "Needs attention" list
creates the flow that fixes the problem.
- **Status window:** numpad \* shows a small window in the top right with the flow tractors' action and
fill, field work in progress, chains with missing links and warnings.
- **Warning when a needed factory is switched off:** switch off a production that a flow, a barn or a chain
uses, and the mod warns you and shows how long the stock lasts.
- **Stop all and park:** stops everything, folds implements and parks the machines. The pause is saved
with the savegame.
Kun den aktuelle aflæsningsopgaves destination ændres — mål, rotation og loop-tæller røres ikke.
Ventende traktorer ser rotationen som slået fra, så AutoDrive ikke kører videre fra Wait-punktet.
Ingen ledige Wait-punkter → AutoDrive venter ved målet som i dag (én advarsel i loggen).
Seks markører må gerne hedde det samme (fx `Cow Wait`); loggen viser markørnummeret.
**Install:** download `FS25_ADSmartPickup.zip` from
[Releases](https://git.powerdata.dk/masterdraco/fs25-adsmartpickup/releases). Put the zip, not unpacked,
in `Documents/My Games/FarmingSimulator2025/mods/`. Enable the mod together with AutoDrive when you pick
the savegame. The **Smart Pickup** menu is in the ESC menu, just above Settings.
**Læs der passer, foretrækkes (v1.7):** ved valg af næste tur går varer, hvor et fuldt læs kan komme af
i målet, forud; passer intet fuldt læs, tages den mest trængende alligevel (og Wait-punktet bruges).
**Requirements:** AutoDrive is required. Courseplay is needed for field work, and Universal Autoload is
needed for pallets.
**Opsætning med flere stalde:** én traktor (eller flere) pr. stald med *Rotér mål: kun pålæsning*.
Brug IKKE "pålæsning og aflæsning": AutoDrive vælger næste pålæsning FØR den roterer aflæsningsmålet,
så behovet ville blive regnet for den forkerte stald. Pålæsningsmarkøren må ikke være fast på en
fabrik ("kun aflæsning") — så kalder AutoDrive aldrig modden, og alt valgt læsses dér.
**Docs:** [User manual](docs/USER_MANUAL.md) · [Danish manual](docs/BRUGERMANUAL.md) ·
[Changelog](CHANGELOG.md)
## v1.8 — Runs i ESC-menuen
**Feedback:** tell the developer (issues here need an account on git.powerdata.dk). Attach `log.txt`, or at least the lines starting with
`ADSmartPickup`.
Et **run** er en navngivet opskrift: mål + varer + Wait-pulje + ture, samt en liste traktorer der skal
køre den. I stedet for at sætte AutoDrive op manuelt på hver traktor, defineres run'et ét sted og
startes/stoppes samlet for alle traktorer i det.
**Oprettelse:** ESC → fanen "Smart Pickup" → "+ Nyt run". Runs vælges i Run-selectoren øverst;
"Omdøb" og "Slet run" findes som knapper nederst sammen med Start/Stop.
**Felter:**
- **Mål** — dropdown med aflæsningsmål (markører med aflæsningsstation, fx en stald eller fabrik).
- **Varer** — Auto (følger målets behov, som ved almindelig behovsstyret forsyning) eller Vælg
(afkrydsning af bestemte varer).
- **Wait-pulje** — hvilken `Wait`-mappe rest-læs skal vente i ("Alle (nærmeste ledige)" eller en
navngivet pulje som fx "Cow Wait").
- **Ture** — loop-tælleren (0–99, 0 = uendeligt), som normalt i AutoDrives Pickup&Deliver.
- **Traktorer** — rækker med Ja/Nej (er traktoren med i run'et), kapacitet og et ✔/✖-ikon. En
**✖-traktor springes over ved Start**, med en grund vist ved siden af (fx "ingen vogn", "vognen
kan ikke bære run'ets varer", "for langt fra vejnettet" eller "intet mål"). Sættes en traktor til
Nej (fjernet fra run'et), rører det IKKE dens AutoDrive-vareliste — run'ets varer forbliver valgt
i traktorens AD-vareliste (synlig/redigerbar i AD's eget HUD) indtil de ændres dér manuelt.
**Start/Stop:** "Start run" sætter AutoDrive op på hver ✔-traktor: mode Pickup&Deliver, målmarkør,
nærmeste pålæsningsmarkør som start, run'ets varer valgt, run'ets tur-tæller, mapper ("Brug mapper")
slået til, og rotation sat til "kun pålæsning" (bemærk: "Brug mapper" er en global
AutoDrive-indstilling og slås til for hele spillet, ikke kun run'ets traktorer). **Dette overskriver
traktorens hidtidige manuelle AutoDrive-indstillinger.** "Stop run" stopper AutoDrive på hver traktor
i run'et (medlemskabet i run'et bevares, så det kan startes igen). Traktorer der ikke er del af noget
run, kører som før — bortset fra at "Brug mapper" kan være blevet slået til globalt af et run.
**Wait-puljer-afsnittet** nederst på siden viser hver Wait-mappe skrivebeskyttet: antal punkter og
hvilken traktor der holder hvor.
**Gemning:** Runs gemmes i `savegameN/ADSmartPickup_runs.xml` (ved gem og ved Start/Stop). De
**genstartes ikke automatisk** ved indlæsning af et savegame — kør "Start run" igen efter load, hvis
traktorerne skal køre videre.
## v1.9 — læs-bytte fra Wait
Venter en traktor ved et Wait-punkt med en vare målet er fuldt af, mens målet **samtidig mangler
en anden vare** på traktorens liste (run eller AD's multi-valg) — kan der ellers gå i hårdknude:
alle traktorer venter med den ene vare, mens ingen henter den anden. Hvert 5. sekund tjekkes derfor:
1. Er der en anden vare i listen, målet mangler et **fuldt** læs af (og som kan hentes fra en
silo)? Ellers venter traktoren som hidtil.
2. Findes en **ren lager-silo** (ikke stald, ikke fabrik, og ikke målet selv) med en
aflæsningsstation ved en AD-markør, der tager varen og har fri plads ≥ resten, samt vejnet
både til siloen og videre fra siloen til målet? Siloer der **allerede har varen** foretrækkes,
ellers den nærmeste.
3. Findes en silo: Wait-punktet frigives, traktoren kører derhen og læsser resten af. Når vognen
er tom, henter næste tur den vare målet mangler.
4. Findes ingen egnet silo: traktoren bliver ved Wait-punktet (som før v1.9).
Loglinjer: `'<navn>' bytter læs: N l STRAW tilbage i <markør> (markør n), målet mangler FORAGE` ved
bytte, og én gang pr. venteperiode `'<navn>' venter med N l STRAW, og målet mangler FORAGE — ingen
silo med plads og vejnet til/fra tager resten, så den bliver ved Wait-punktet` når ingen silo findes.
Kræver at traktoren har **begge** varer i sin liste (run's varer eller AD's multi-valg) — kun én
vare valgt betyder ingen bytte-mulighed, og traktoren venter som hidtil. Wait-logikken gælder desuden
kun mens aflæsningsopgaven peger på AD's rigtige aflæsningsmål, så en silo-omvej (fx efter et bytte)
aldrig fejlagtigt bliver opfattet som et fuldt mål.
## v1.13 — Flow-tavle (bygningsbaseret opsætning)
ESC-fanen har nu to under-faner: **Flows** og **Lagre**. Flows viser én række pr. flow (run):
`> Mælk: Sukker · Kostald 1 + Kostald 2 → salg · pris ≥ 90 %, lager > 80 % · Virtus · kører (1/1) — SUGAR 78 % af top`
og under tavlen en detail-blok for det valgte flow: **Retning** (ind til bygning / ud fra bygning),
**Fra** (udkørsel) eller **Til** (forsyning) som afkrydsning pr. **bygning** — moden finder selv den
AD-markør der står nærmest bygningens læsse-/aflæsningstrigger (≤ 40 m; siloer via det strenge
opslag) og viser den: `Kostald 1 (stald) — markør 'cow 1 milk' 12 m` eller `INGEN MARKØR inden for
40 m`. Derefter Varer, Regler, Vent ved, Traktorer og Status som før. Forsyning har "Fra = alle
siloer" (som hidtil). Wait-puljerne står nederst.
Gamle runs oversættes automatisk: markører der er en bygnings markør bliver til bygningen; rå
markører uden bygning beholdes og vises som `markør '…'`. Start afviser med `'<bygning>' mangler en
AD-markør ved triggeren`, når en valgt bygning ingen markør har. Runs gemmes som XML version 3.
## v1.12 — Udkørsel af produkter
Et run har nu en **retning**: *Forsyning* (som hidtil) eller *Udkørsel*. ESC-fanen har tre
under-faner: **Oversigt** · **Forsyning** · **Udkørsel**.
Et udkørsels-run består af:
- **Kilder** (én eller flere AD-markører): en **fabrik** (produktionspunkt, dens outputs),
en **silo** (`spec_silo`, dens indhold) eller et **objektlager** (palle-/ballelager som HVF-
pakkens; paller tages ud automatisk). Kilden genkendes inden for 40 m af markøren.
- **Varer** med politik pr. vare: **Fra** · **Lager** · **Salg** · **Salg, ellers lager**.
Bulk-varer kræver tipvogn/tank; pallevarer kræver **Universal Autoload** på vognen.
- **Regler:** *Sælg fra* (% af årets toppris, standard 90), *Kør til lager over* (% fyldning i
kilden, standard 80), *Saml op på én tur* (opsamlingsrunde).
- Wait-pulje og traktorer som i forsynings-runs.
**Turvurdering** (før hver pålæsning, og hvert 5. sekund mens traktoren holder):
1. *Salg*: en vare med Salg-politik, hvis bedst betalende **nåelige** salgssted giver mindst
tærsklen af årets toppris — **kører uanset mængde**. Højeste pris-% først. Toppris = stationens
grundpris nu × (årets højeste periodefaktor ÷ nuværende). Stor efterspørgsel giver > 100 %.
2. *Lager*: en vare med Lager-politik, når der er et **fuldt læs** eller kildens lager er over
tærsklen — til nærmeste silo med plads til hele læsset og vejnet begge veje (aldrig en kilde,
fabrik eller stald). Mest fyldte kilde først.
3. Ingen tur → traktoren holder ved et ledigt Wait-punkt (run'ets pulje) og prøver igen.
Loggen viser hvorfor: `holder ved Wait 3: SUGAR 78 % af top (kræver 90), lager 41 %`.
**Opsamlingsrunde:** tipvogn = samme vare fra flere kilder efter tur til vognen er fuld (næste
kilde kun ved ≥ 10 % fri plads); autoload-vogn = flere pallevarer med samme mål på én tur.
**Objektlager:** markøren skal stå så vognens autoload-zone dækker lagerets spawn-område (som når
man læsser manuelt). Moden beder lageret spawne **én palle ad gangen** og venter til UAL har taget
den, så der aldrig ligger paller tilbage. Sidder lageret fast (kendt spilfejl), afsluttes
læsningen efter 30 s med det der er. Ved en autoload-kilde uden fremdrift i 15 s køres der med
det der er ("uanset mængde").
**Advarsler på skærmen:** kan intet salgssted nås (vejnet), eller findes ingen silo med plads,
vises en rød AutoDrive-notifikation (`'MT635' kan ikke nå salgssted 'Grain Elevator' for SUGAR –
tjek vejnettet`), samme tekst i AD's notifikations-historik, i log.txt (WARN) og i run'ets status.
Traktoren holder ved Wait og kører selv når vejen er koblet på. Advarslen gentages først når
traktor, station eller vare ændrer sig.
**Prisregel (v1.12.1, metode fra Distribution Redux):** pris-% = nuværende periodes faktor ÷ årets
højeste faktor i spillets faste sæsonkurve (samme data som prisgrafen) × stationens egen afvigelse
(effektiv pris ÷ stationens grundpris nu). Sværhedsgraden falder ud; flade kurver (top/bund < 1,02)
regnes som 100 %; stor efterspørgsel giver > 100 %.
**Reserve (v1.12.1):** før salg fratrækkes det gårdens egne fabrikker og stalde skal bruge det næste
døgn (forbrug × 24 t minus det de allerede har, højst deres fri plads; fabrikker = ønskede
produktioners inputs, stalde = foder/halm/vand-rate). Der sælges kun når et helt læs kan tages uden
at røre reserven. Loggen/UI'et siger fx `SUGARBEET: 196.0k reserveret til TMR-fabrik`. Lager-politik
rører ikke reserven (varen bliver på gården).
**Paller til lager (v1.12.1):** Lager-mål for pallevarer = nærmeste objektlager (palleskur) der tager
varen og har plads; AutoDrive læsser UAL-vognen af dér.
**Tætte siloer (AutoDrive-indstilling):** AutoDrive tipper ved den første aflæsningstrigger inden for sin
globale "Max trigger distance" (standard 25 m). Står siloerne tættere end det, kan nabosiloen få læsset —
sæt AD-indstillingen til **10 m**. Moden advarer i loggen første gang et lagermål har en anden
aflæsningsmarkør inden for afstanden.
**Fanen Lagre (v1.12.1):** gårdens beholdning pr. bygning (fabrikker med ▸ ved outputs, stalde,
palleskure som antal paller, siloer), opdateret hvert sekund.
**Opsætning:** Udkørsel-fanen → Nyt run → afkryds kilder → sæt politik pr. vare → Ja på traktorer
→ Start. AD-tilstand pr. traktor: PickupAndDeliver, 1./2. markør = første kilde (pladsholder,
sættes pr. tur), rotation "kun pålæsning". Runs gemmes i `ADSmartPickup_runs.xml` version 2;
ældre filer læses som forsynings-runs.
## Fælles
- Wrapper `ADMultipleTargetsManager.getNextPickup` (via den globale `FS25_AutoDrive`-tabel); rører ikke AutoDrive-zip'en.
- Siloens eget lager rangeres før nabo-lån (udvidelses-siloer deler lager inden for `storageRadius`).
- Intet fund, intet behov eller fejl i et spil-API → AutoDrives originale rotation. Valg logges som `ADSmartPickup:` i log.txt.
- Kræver i AutoDrive: "Brug mapper" (global AutoDrive-indstilling, gælder hele spillet) +
"Rotér mål: pålæsning" på køretøjet (pr. køretøj).
- **Markør ↔ silo (v1.3):** markøren er dér hvor trækkerens næse stopper; vognen holder et vogntog
bagved. Markørens silo er derfor nærmeste trigger **bag næsen** set i ankomstretningen (forrige
waypoint), højst 2 m foran. I tætte rækker (15 m mellem siloerne) er nabosiloens trigger ellers
nærmere end markørens egen. Ingen trigger bag næsen/ukendt retning → nærmeste trigger som før.
- **Kildestrategi (v1.5):** lagervarer hentes fra den **mindst fyldte** silo først (rester ryddes,
siloer frigives). Varer hvis tanke *løber fulde* — mælk, gylle, digestat — hentes fra den **fyldeste**
kilde først, så produktionen ikke går i stå. Listen er `ADSupplyPlanner.FULLEST_FIRST_FILL_TYPES`
(`adSupplyPlanner.lua`); tilføj fx `MANURE = true` hvis møddinger også skal tømmes fyldest-først.
Loggen viser den brugte strategi. En trigger foran næsen tæller aldrig som markørens egen (v1.5).
- **Spor-regel (v1.4):** triggeren skal ligge i trækkerens spor (højst 6 m til siden). En tank med
aflæsningsbane på den ene side og pålæsningsbane på den anden giver ellers to markører til samme
tank, og "Dropof"-markøren kunne vinde. Kendes retningen, men ligger intet i sporet (kurve ind til
markøren), accepteres kun en trigger inden for 12 m.
- **Kun fremme ved markøren (v1.4):** rotationen skjules først når målet er nået — undervejs ruller
vognen gennem nabosiloers triggere, og dér skal AutoDrive køre videre ved tom trigger.
- **Sikkerhedsventil (v1.4):** 30 s ved markøren uden at vognen fyldes → grebet slippes (advarsel i
log), og AutoDrive kører videre som med almindelig rotation.
- **Står og læsser (v1.2):** med rotation + mapper afslutter AutoDrive normalt pålæsningen i det øjeblik
markøren nås (læsning skal være startet på vej ind, ellers kører den tom videre). Når moden har valgt
siloen, ser AutoDrive under `LoadAtDestinationTask.update` rotationen som slået fra for netop det
køretøj, så den holder ved markøren og læsser som uden rotation. Løber kilden tør, slippes grebet og
turen fortsætter med det der er i vognen.
Begrænsninger: AD læsser fuld vogn — er der mindre plads i målet end ét læs, venter traktoren ved
aflæsning. Målet skal være en placeable med aflæsningstrigger (ikke en blandevogn).
Test: `for t in tests/test_*.lua; do luajit $t | tail -1; done`
Byg: `cd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip .`
Design/plan: `docs/superpowers/`
**Development:** the mod code is in `FS25_ADSmartPickup/`. Build the zip with
`cd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip .`
+920 -303
View File
File diff suppressed because it is too large Load Diff
-175
View File
@@ -1,175 +0,0 @@
# Testplan — Markarbejde (fanen Marker), version 1.27.0.0
Stor test-session. Kør testene i rækkefølge: de første bygger opsætningen, de senere bygger på den.
Hver test har **Gør**, **Forvent** og **Log** (linjer i `log.txt`, der beviser at det virkede). Loglinjer
begynder altid med `ADSmartPickup: F<marknr>`.
Kryds af undervejs. Skriv resultatet (OK / FEJL + hvad der skete) ud for hver test, så retter vi i
samme rækkefølge.
---
## 0. Forberedelse
- [ ] **0.1 Version.** Log: `Available mod: … (Version: 1.27.0.0) FS25_ADSmartPickup`.
- [ ] **0.2 Fanen Marker åbner.** ESC → Smart Pickup → fanen **Marker**. Forvent: "Marker indlæses …"
i få sekunder, derefter én række pr. ejet mark (også korn-, roe- og bare marker, ikke kun græs).
Afgrøde-kolonnen viser afgrøden eller "bar jord".
- [ ] **0.3 F-markører.** Marker uden `F<nr>`-markør står med `!` foran marknummeret og "! mangler
markør F…". Lav markører til de marker, der skal testes.
- [ ] **0.4 Markflåden.** Nederst: alle maskiner med slåmaskine, vender, river, læssevogn, høster,
aflæsningsvogn, kultivator, plov, såmaskine, tromle, strigle/sprøjte, gødnings-/kalkspreder,
stubhakker — med rolle og bredde. Tjek:
- [ ] Høstere viser "N afgrøder".
- [ ] Såmaskiner viser "frø ok" eller "!! tom for frø".
- [ ] Strigle viser "strigle (småt ukrudt)", ukrudtssprøjte viser "sprøjte".
- [ ] En **tom** sprøjte/spreder vises ikke (den har ingen rolle før den har fyld på).
- [ ] Maskiner i et Smart Pickup-flow kan godt ses, men sendes aldrig ud.
- [ ] **0.5 Bredder.** BiG M 450 viser **40,8 m** (Courseplays egen bredde), læssevognene 40,8 m.
## 1. Etape 1 genbekræftet (græs → ensilage)
Rest fra 25/9: slåning og opsamlingsstart virkede; afslutningen blev aldrig set.
- [ ] **1.1 Afslutning af opsamling.** Mark 22 (eller en anden lille græsmark) på Manuel + Ensilage,
BiG M + én Boss Alpin med flueben → **Start nu**.
- Forvent: slåmaskine → F22 → Courseplay slår → hjem. Læssevogn → F22 → Courseplay samler op med
**slåmaskinens kursus** → AutoDrive tømmer ved fuld → tilbage → færdig → rest til lager → parkering.
Marken står **Færdig**, og der kommer en grøn besked "F22 er færdig".
- Log: `mower … sendt til F22`, `Courseplay startet (mower)`, `mowing -> collecting`,
`læssevogn … aflæsser i <gæringssilo>`, `Courseplay startet (collector, mower-kursus kopieret)`,
`collector … færdig`, `collecting -> done`.
- [ ] **1.2 Gæringssilo.** Ensilage-læs går til nærmeste **gæringssilo** (FermentingSilo/GrassSilageSilo),
ikke en Farma-silo. Log: `aflæsser i <gæringssilo>` eller `ingen gæringssilo kan tage græsset (…)`.
- [ ] **1.3 Sidde i maskinen.** Sæt dig i slåmaskinen mens Courseplay kører, og stig ud igen.
Forvent: den slippes IKKE (ingen `frigivet`-linje). Kør selv > 3 km/t uden CP → `frigivet — spilleren kører det`.
- [ ] **1.4 Sovende køretøj.** Parkerede maskiner kører af sted med det samme ved udsendelse, uden at
du skal sætte dig ind.
## 2. Hø og river
- [ ] **2.1 Hø uden river.** Plan = **Hø**, Riv = Nej. Slå → **Vendes** (venderen i sin egen bredde) →
Samles op (læssevogn på slåkurset, ≥ 40,8 m) → hø-silo (kun almindelig silo, aldrig gæringssilo).
Log: `mowing -> tedding`, `Courseplay startet (tedder)`, `tedding -> collecting`.
- [ ] **2.2 Smal læssevogn uden river.** En læssevogn under 40,8 m → status "læssevogn for smal:
… — sæt Riv til eller brug en bredere".
- [ ] **2.3 Riv (midterrive).** Riv = Ja. Log: `river lægger skåret 0.x m fra sin kursuslinje`,
derefter `Courseplay startet (collector, rake-kursus kopieret)`. Læssevognen rammer skårene.
- [ ] **2.4 Sideriver.** Med en river der lægger skåret til siden: log viser fx `3.2 m`, og
`skår ligger 3.2 m til … -> læssevognens forskydning …`. **Tjek at vognen kører OVER skåret.**
Kører den ved siden af (forkert side), så skriv det ned — så vender jeg fortegnet.
- [ ] **2.5 Riv til ensilage.** Plan = Ensilage + Riv = Ja: Slå → Rives → Samles op.
## 3. Høst (korn, raps, majs …)
- [ ] **3.1 Høst.** En moden kornmark, Manuel, høster med passende skærebord + én aflæsningsvogn
(traktor + tipvogn/overlæssevogn) med flueben → **Start nu**.
- Forvent: høster → F-markør → Courseplay høster. Aflæsningsvognen sendes ud når høsteren
arbejder, følger den (Courseplays combine-unloader) og kører fulde læs til siloen.
- Log: `harvester … sendt`, `Courseplay startet (harvester)`, `unloader … sendt`,
`aflæsningsvogn … følger høsteren -> <silo>`.
- [ ] **3.2 Afslutning.** Høsten færdig → `harvesting -> <næste trin eller done>`,
`aflæsningsvogn … stoppes — høsten er færdig`, evt. `kører rest-læs … til lager`, parkering.
- [ ] **3.3 Forkert skærebord.** Høster der ikke kan tage afgrøden → status "ingen høster i
markflåden kan tage opgaven (afgrøde/frø/ukrudt)". Ingen udsendelse.
- [ ] **3.4 Flere aflæsningsvogne.** "Aflæsningsvogne under høst" = 2 → to vogne sendes (én ad gangen).
- [ ] **3.6 Halm samles op.** Halm efter høst = *Samles op til silo*. Mejetærskeren lægger skår
(Courseplay-indstilling halmskår = til), derefter `harvesting -> strawCollecting`, en læssevogn kører
**høsterens kursus** (`harvester-kursus kopieret`) og kører halmen til en silo der tager STRAW.
- [ ] **3.7 Halm til baller.** Halm = *Presses til baller*: `-> baling` (ballepresser på høsterens
kursus) → `-> baleCollecting` (ballesamler/Universal Autoload, `Courseplay startet (ballesamler)`),
fulde læs til ballelager (`ballesamler … kører fulde læs til …`).
- [ ] **3.8 Halm hakkes.** Halm = *Hakkes*: mejetærskeren lægger IKKE skår (Courseplay halmskår = fra).
- [ ] **3.5 Regn.** Når det regner: høst/slå/vend/riv sendes ikke ud, status "venter på tørvejr".
Kørende maskiner fortsætter.
## 4. Roer
- [ ] **4.1 Roehøst.** Roemark (sukkerroer), roeoptager (Ropa) + aflæsningsvogn → som 3.1, men med
roer. Log viser `harvester 'Ropa …'`. Roerne går til nærmeste silo/lager der tager SUGARBEET
(brug **Tving lager** hvis de skal et bestemt sted hen, fx roeknuseren).
## 5. Efter høst: klargøring og såning
Sæt på en kornmark før høst: Efter høst → Stubbehakning Nej, Jordbehandling **Auto**, **Så bagefter** Ja,
Afgrøde "Samme som høstet", Tromle Ja. Kalk Ja.
- [ ] **5.1 Kæde.** Efter høsten fortsætter marken selv: (kalk hvis jorden trænger) → pløj eller
kultivér → så → tromle → **Færdig**. Log: `harvesting -> cultivating`, `cultivating -> sowing` …
- [ ] **5.2 Auto-jordbehandling.** Står "Jorden trænger til: pløjning" under marken → plov sendes.
Ellers kultivator.
- [ ] **5.3 Såning.** Såmaskinen sættes til afgrøden (`setSeedIndex`), og den rigtige afgrøde spirer.
- [ ] **5.4 Ude af såperioden.** Afgrøde der ikke kan sås nu → status "<afgrøde> kan ikke sås i denne
periode", ingen udsendelse.
- [ ] **5.5 Tom for frø / påfyldning.** Såmaskine med lidt frø. Moden leder efter en læssestation med frø
på lager og en AutoDrive-markør ved triggeren (fx en silo/big-bag-station med SEEDS).
- Findes den: log `… fyldes op med SEEDS ved <markør> af AutoDrive når den er tom`. Når den er tom
midt på marken, kører AutoDrive derhen, fylder op, kører tilbage, og Courseplay fortsætter.
- Findes den ikke: log `ingen påfyldning af SEEDS med AutoDrive-markør fundet …`. Når den er tom:
rød besked `'…' er tom for SEEDS — fyld op, så fortsætter marken`, og marken går IKKE videre.
- [ ] **5.6 Bar mark alene.** Vælg en bar mark (stub/pløjet) → **Start nu** → kun klargøring/såning.
## 6. Pleje mens afgrøden vokser
- [ ] **6.1 Behov vises.** En voksende mark viser "Jorden trænger til: gødning, ukrudt …" og status
"— trænger til Gødes". Manuel: intet sker før **Start nu**.
- [ ] **6.2 Gødning.** Start nu (eller Auto) → gødningsspreder/-sprøjte med fyld sendes → `pleje:
fertilizing` → `fertilizing -> idle`. Højst 2 gange pr. vækstcyklus.
- [ ] **6.3 Ukrudt.** Høstafgrøde med tæt ukrudt → strigle (småt ukrudt) eller ukrudtssprøjte
(stort ukrudt, > 4). Græs ukrudtsbehandles ikke.
- [ ] **6.4 Kalk.** Jord der trænger til kalk → kalkspreder.
- [ ] **6.5 Påfyldning.** Sprøjte/spreder der løber tør → AutoDrive fylder op og kører tilbage.
## 7. Multi-maskine (Courseplay multi-tool)
- [ ] **7.1 To slåmaskiner.** "Maskiner pr. trin" = 2 på en græsmark med to BiG M med flueben.
Forvent: begge sendes samtidig, den ene laver kurset (`bane -1`), den anden kopierer det (`bane 1`)
og de kører i konvoj. Log: `… sendt … bane -1`, `… bane 1`, `Courseplay startet (mower, bane -1)`,
`Courseplay startet (mower, multi-kursus kopieret, bane 1)`.
- [ ] **7.2 For få maskiner.** 3 pr. trin men kun 2 ledige → status "multi-tool: slåmaskine — kun 2 af 3 ledige".
- [ ] **7.3 Læssevogne på multi-kursus.** Efter 7.1 sendes 2 læssevogne, én pr. bane.
## 9. Vælg og glem (afgrøde på marken)
- [ ] **9.1 Kornmark → hvede.** En mark med byg der vokser: vælg *Afgrøde på marken* = Hvede. Linjen siger
"Byg får lov at vokse færdig og høstes; derefter klargøres og tilsås med Hvede". Kørsel skjules (auto).
Når byggen er moden: høst → (halm) → klargøring → såning af **hvede** (ikke byg). Log:
`mål-afgrøde: WHEAT`, `starter (harvest: harvesting)`, `sowing`.
- [ ] **9.2 Bar mark.** Bar mark med mål = Hvede: starter selv klargøring + såning, når det er hvede-såtid.
Uden for såtid: status "venter på såtid for Hvede".
- [ ] **9.3 Græs → græs.** Græsmark med mål = Græs: slås og samles op hver gang, ingen pløjning/såning.
- [ ] **9.4 Græs → hvede.** Græsmark med mål = Hvede: slås og samles op én gang, derefter klargøring
(jordbehandling-valget vises) og såning af hvede.
- [ ] **9.5 Hele året.** Lad en mark med mål køre en hel sæson uden at røre den: vækst → pleje → høst →
klargøring → såning → ... uden at hænge og uden dobbelt-såning.
- [ ] **9.6 Alle ledige maskiner.** Markflåde: *Brug alle ledige maskiner* = Ja (standard): maskiner uden
flueben sendes også ud. = Nej: kun maskiner med flueben. Maskiner du selv kører, maskiner i flows og
maskiner AutoDrive/Courseplay allerede kører, bruges aldrig.
- [ ] **9.7 Fjern målet.** Vælg *Ingen — planlæg selv*: marken går tilbage til Manuel.
## 8. Robusthed
- [ ] **8.1 Genindlæsning midt i et trin.** Gem og genstart under fx opsamling → marken fortsætter på
samme trin, sæt sendes ud igen, slåkurset hentes fra slåmaskinen.
- [ ] **8.2 Stop mark.** **Stop mark** midt i høst → alle maskiner på marken stopper; høst står **Klar**
igen, pleje/klargøring står **Venter**.
- [ ] **8.3 Ingen vej.** Mark hvis F-markør ikke er forbundet → status "… nåede ikke F…" / "intet lager:
… AutoDrive finder ingen rute …" og nyt forsøg efter 5 min.
- [ ] **8.4 Beskeder.** Grøn besked når en mark/pleje er færdig; rød besked ved fejl (hver kun én gang i træk).
- [ ] **8.5 Ydelse.** Ingen mærkbar lag med fanen åben og mange marker (én mark scannes pr. sekund).
---
## Kendte usikkerheder (første gang i spillet)
| Område | Hvad der er gættet | Tegn på fejl |
|---|---|---|
| Vækstfase | `getFieldDataAtWorldPosition` anden returværdi = vækststadie | Høstet stub vises som voksende, eller moden mark som bar |
| Høsterens afgrøder | `spec_cutter.fruitTypes`/`fruitTypeIndices` | Høster vises med "0 afgrøder" |
| Combine-unloader | stationsvalg ud fra silovalget | `ingen aflæsningsstation Courseplay kan bruge` |
| Multi-tool | `multiTools` + `laneOffset` + `Course:setPosition` | Begge maskiner kører samme bane |
| Sideriver | fortegn på forskydningen | Læssevognen kører ved siden af skåret |
| Påfyldning | AutoDrive LOAD til den læssestation moden fandt (AutoDrives eget automatiske mål er slået fra i AutoDrive) | Sættet står ved påfyldningen og læsser ikke, eller kører ikke tilbage |
| Ukrudt | `weedSystem`-værdi ≥ 2 = tæt | Ukrudtsbehandling uden ukrudt, eller aldrig |
| Nysået som "bar" | nysået afgrøde læses som voksende | Marken står "Færdig" efter såning (ingen dobbelt-såning, men ingen næste høst-start før den er moden) |
| Ballesamler | Courseplays BALE_FINDER + AutoDrive til ballelager | Ballesamleren starter ikke, eller kører ikke fulde læs væk |
+1071 -425
View File
File diff suppressed because it is too large Load Diff
@@ -1,221 +0,0 @@
# Behovsstyret forsyning Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Én AutoDrive-traktor holder alle input i en produktion (fx TMR-blander) fyldt ved selv at vælge vare + kilde-silo pr. tur.
**Architecture:** Den eksisterende wrapper om `ADMultipleTargetsManager.getNextPickup` udvides: ved >1 valgt vare i AD læses aflæsningsmålets lager, ingredienserne rangeres efter behov (rene funktioner i ny fil), og en kilde findes blandt alle AD-markører. AD's varevalg indsnævres til én vare pr. tur og gendannes når AD stopper.
**Tech Stack:** Lua 5.1 (FS25 mod-sandbox), luajit til tests med håndskrevne mocks, AutoDrive 3.0.1.2 via global `FS25_AutoDrive`.
**Spec:** `docs/superpowers/specs/2026-09-21-behovsstyret-forsyning-design.md`
## Global Constraints
- Rør aldrig `FS25_AutoDrive.zip`; al adgang via den globale `FS25_AutoDrive`-tabel.
- Enhver fejl i spil-/AD-API → `Logging.warning` + AutoDrives originale `getNextPickup`. Aldrig crash.
- Præcis én valgt vare → opførsel uændret; de 11 eksisterende tests forbliver grønne.
- Markør↔station-tærskel: 40 m (`MAX_MARKER_TO_TRIGGER_DISTANCE`). `MIN_FREE_LITERS = 1000`.
- Rene funktioner muterer ikke input (returnér nye tabeller).
- Tests køres fra repo-roden: `luajit tests/<fil>.lua`. Commits uden Co-Authored-By.
- Verificerede AD-fakta: `selectedFillTypes` er et array-felt på stateModule og gemmes i savegame; `setFillType(ft)` bevarer valget hvis `ft` er i det; `isActive()`, `getSecondMarker()`, `raiseDirtyFlag()` findes; `ADGraphManager:getMapMarkers()` returnerer array indekseret på `markerIndex`.
---
### Task 1: `adSupplyPlanner.lua` — rene funktioner
**Files:**
- Create: `FS25_ADSmartPickup/adSupplyPlanner.lua`
- Test: `tests/test_adSupplyPlanner.lua`
**Interfaces:**
- Produces (global tabel `ADSupplyPlanner`):
- `MIN_FREE_LITERS` (number)
- `scoreNeed(level, capacity, freeSpace) -> number` (højere = mere trængende)
- `rankNeeds(needs) -> ranked` hvor `needs = {{fillType, level, capacity}, …}` og `ranked = {{fillType, level, capacity, freeSpace, score}, …}` sorteret mest trængende først, uden dem med `freeSpace < MIN_FREE_LITERS`
- `resolveIngredients(state, selected) -> ingredients|nil` hvor `state = nil | {ingredients = {ft…}, narrowedTo = ft}`
- `copyList(list) -> ny liste`
- [ ] **Step 1: Skriv fejlende test** — `tests/test_adSupplyPlanner.lua`:
```lua
-- Kør: luajit tests/test_adSupplyPlanner.lua (fra repo-roden)
dofile("FS25_ADSmartPickup/adSupplyPlanner.lua")
local HAY, GRASS, SILAGE = 20, 21, 22
local failures = 0
local function check(name, actual, expected)
if actual == expected then
print("OK " .. name)
else
failures = failures + 1
print(string.format("FAIL %s: forventede %s, fik %s", name, tostring(expected), tostring(actual)))
end
end
-- scoreNeed: tommere lager scorer højere; ugyldig kapacitet giver 0
check("tomt scorer højere end halvfuldt", ADSupplyPlanner.scoreNeed(0, 1000, 1000) > ADSupplyPlanner.scoreNeed(500, 1000, 500), true)
check("kapacitet 0 giver score 0", ADSupplyPlanner.scoreNeed(0, 0, 0), 0)
-- rankNeeds: mest trængende først, fulde springes over, input muteres ikke
local needs = {
{fillType = HAY, level = 80000, capacity = 100000},
{fillType = GRASS, level = 20000, capacity = 100000},
{fillType = SILAGE, level = 99500, capacity = 100000},
}
local ranked = ADSupplyPlanner.rankNeeds(needs)
check("fuld ingrediens springes over", #ranked, 2)
check("mest trængende først", ranked[1].fillType, GRASS)
check("dernæst næstmest trængende", ranked[2].fillType, HAY)
check("freeSpace beregnes", ranked[1].freeSpace, 80000)
check("input muteres ikke", needs[1].freeSpace, nil)
check("tom liste giver tom liste", #ADSupplyPlanner.rankNeeds({}), 0)
-- rankNeeds: deterministisk ved lige score (laveste fillType først)
local tie = ADSupplyPlanner.rankNeeds({
{fillType = SILAGE, level = 0, capacity = 5000},
{fillType = HAY, level = 0, capacity = 5000},
})
check("lige score -> laveste fillType først", tie[1].fillType, HAY)
-- resolveIngredients
local multi = {HAY, GRASS}
local resolved = ADSupplyPlanner.resolveIngredients(nil, multi)
check("multi-valg bliver ingrediensliste", #resolved, 2)
check("listen er en kopi", resolved ~= multi, true)
check("enkelt-valg uden tilstand -> nil", ADSupplyPlanner.resolveIngredients(nil, {HAY}), nil)
local state = {ingredients = {HAY, GRASS, SILAGE}, narrowedTo = GRASS}
check("indsnævret valg genbruger gemt liste", #ADSupplyPlanner.resolveIngredients(state, {GRASS}), 3)
check("bruger valgte andet -> nil", ADSupplyPlanner.resolveIngredients(state, {HAY}), nil)
check("nyt multi-valg erstatter gemt liste", #ADSupplyPlanner.resolveIngredients(state, {HAY, SILAGE}), 2)
check("ugyldigt valg -> nil", ADSupplyPlanner.resolveIngredients(state, nil), nil)
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
```
- [ ] **Step 2: Kør og se den fejle** — `luajit tests/test_adSupplyPlanner.lua` → fejler med "cannot open FS25_ADSmartPickup/adSupplyPlanner.lua".
- [ ] **Step 3: Implementér** — `FS25_ADSmartPickup/adSupplyPlanner.lua`:
```lua
-- AD Supply Planner
-- Rene funktioner (ingen spil-API) til behovsstyret forsyning: hvilken ingrediens
-- har aflæsningsmålet mest brug for, og hvilken ingrediensliste gælder for køretøjet.
ADSupplyPlanner = {}
-- Mindre fri plads end dette i målet regnes som "fuldt" (undgår ture med læs der ikke kan komme af).
ADSupplyPlanner.MIN_FREE_LITERS = 1000
function ADSupplyPlanner.copyList(list)
local copy = {}
for index, value in ipairs(list) do
copy[index] = value
end
return copy
end
-- Hvor meget trænger målet til denne ingrediens? Højere = hentes først.
-- Alle tal er liter i aflæsningsmålets lager. Standard: relativ tomhed (0..1),
-- så en lille næsten-tom tank vinder over en stor halvfuld.
function ADSupplyPlanner.scoreNeed(level, capacity, freeSpace)
if capacity <= 0 then
return 0
end
return freeSpace / capacity
end
-- needs: {{fillType, level, capacity}, ...}. Returnerer NY liste, mest trængende først;
-- ingredienser uden plads i målet er sorteret fra.
function ADSupplyPlanner.rankNeeds(needs)
local ranked = {}
for _, need in ipairs(needs) do
local freeSpace = need.capacity - need.level
if freeSpace >= ADSupplyPlanner.MIN_FREE_LITERS then
table.insert(ranked, {
fillType = need.fillType,
level = need.level,
capacity = need.capacity,
freeSpace = freeSpace,
score = ADSupplyPlanner.scoreNeed(need.level, need.capacity, freeSpace),
})
end
end
table.sort(ranked, function(a, b)
if a.score ~= b.score then
return a.score > b.score
end
return a.fillType < b.fillType
end)
return ranked
end
-- state: nil eller {ingredients, narrowedTo} fra forrige tur. selected: AD's aktuelle varevalg.
-- >1 valgt = (ny) ingrediensliste. Præcis den vare vi selv indsnævrede til = gemt liste.
-- Alt andet = brugeren har valgt noget andet -> nil (behovsstyring fra).
function ADSupplyPlanner.resolveIngredients(state, selected)
if type(selected) ~= "table" then
return nil
end
if #selected > 1 then
return ADSupplyPlanner.copyList(selected)
end
if state ~= nil and #selected == 1 and selected[1] == state.narrowedTo then
return ADSupplyPlanner.copyList(state.ingredients)
end
return nil
end
```
- [ ] **Step 4: Kør tests** — `luajit tests/test_adSupplyPlanner.lua` → `ALLE TESTS OK`.
- [ ] **Step 5: Commit** — `git add FS25_ADSmartPickup/adSupplyPlanner.lua tests/test_adSupplyPlanner.lua && git commit -m "feat: ADSupplyPlanner — behovsrangering og ingrediensliste-tilstand"`
---
### Task 2: Integration i `adSmartPickup.lua`
**Files:**
- Modify: `FS25_ADSmartPickup/adSmartPickup.lua` (hele filen omstruktureres let; se kode)
- Test: `tests/test_adSmartPickup.lua`
**Interfaces:**
- Consumes: `ADSupplyPlanner.rankNeeds`, `.resolveIngredients`, `.copyList` (Task 1).
- Produces: `ADSmartPickup.choosePickup(adEnv, driver) -> choice|nil` med `choice = {markerIndex, markerName, group, level, fillType?, need?}`; `ADSmartPickup.findLeastFilledPickup` returnerer nu samme `choice`-tabel (uden `fillType`).
- [ ] **Step 1: Udvid test-mocks og skriv fejlende tests** i `tests/test_adSmartPickup.lua`:
- `dofile("FS25_ADSmartPickup/adSupplyPlanner.lua")` før `adSmartPickup.lua`.
- `unloadStations`-liste + `getUnloadingStations` i `g_currentMission.storageSystem`; nulstilles i `resetWorld()`.
- `getMapMarkers = function() return markers end` i `ADGraphManager`-mock.
- `getSecondMarker = function() return nil end` i `makeDriver` (rigtig AD har den altid).
- Nye helpers `makeUnloadStation(node, x, z, levels, capacities, placeable, isBroken)` og `makeSupplyDriver(firstMarker, secondMarker, fillTypes)` (muterbar stateModule-mock med `setFillType`, `raiseDirtyFlag`, `isActive`).
- Tests S1–S9 (se Step 1-koden i implementeringen; dækker spec'ens testliste).
- [ ] **Step 2: Kør og se de nye fejle** — `luajit tests/test_adSmartPickup.lua` → S-tests FAIL, de 11 gamle OK.
- [ ] **Step 3: Implementér** — i `adSmartPickup.lua`:
- `supplyState = setmetatable({}, {__mode = "k"})` (køretøj → `{ingredients, narrowedTo}`).
- `getDistanceToTriggers(triggers, nodeFields, x, z)` + `getClosestStation(stations, triggersField, nodeFields, wayPoint, farmId)`; `getLoadStationAtMarker` (felt `loadTriggers`, node `triggerNode`) og `getUnloadStationAtMarker` (felt `unloadTriggers`, noder `exactFillRootNode`, `triggerNode`, `aiNode`).
- `collectMarkerStations(adEnv, candidateMarkers, farmId, excludedPlaceable)` → `{{marker, station}, …}`; `findLeastFilled(markerStations, fillTypes, farmId)` → `choice|nil` (eksisterende eget-lager-først-rangering).
- `getTargetNeeds(station, ingredients, farmId)`: pr. understøttet ingrediens summeres `storage:getFillLevel(ft)` og `storage:getFreeCapacity(ft)` over `station.targetStorages`; `capacity = level + free`. Ingen storages → `station:getFreeCapacity(ft, farmId)` som `capacity`, `level = 0`.
- `ADSmartPickup.findSupplyPickup(adEnv, driver, ingredients)`, `applySupplyChoice(driver, ingredients, choice)` (`setFillType`, `selectedFillTypes = {ft}`, `raiseDirtyFlag`, gem tilstand), `ADSmartPickup.choosePickup` (supply → ellers mappe-logik → ellers nil).
- Hook bruger `choice.group` til AD's rotations-bogføring og logger vare + behov ved supply-valg.
- `restoreStoppedVehicles()` kaldt fra `update` (pcall): når `not stateModule:isActive()` og valget stadig er `{narrowedTo}` → `selectedFillTypes = kopi af ingredients` + dirty-flag; tilstand slettes.
- [ ] **Step 4: Kør alle tests** — `luajit tests/test_adSupplyPlanner.lua && luajit tests/test_adSmartPickup.lua` → begge `ALLE TESTS OK`.
- [ ] **Step 5: Commit** — `git add -A && git commit -m "feat: behovsstyret forsyning — én traktor holder alle input fyldt"`
---
### Task 3: Pakning, docs, deploy
**Files:**
- Modify: `FS25_ADSmartPickup/modDesc.xml` (version `1.1.0.0`, `<sourceFile filename="adSupplyPlanner.lua"/>` FØR `adSmartPickup.lua`, beskrivelse), `README.md`
- [ ] **Step 1:** Opdatér modDesc + README (betjening: multi-vælg varer; begrænsninger fra spec).
- [ ] **Step 2:** Byg: `cd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip . && cd ..`; verificér med `unzip -l` at begge .lua + modDesc + icon er med.
- [ ] **Step 3:** Deploy atomisk (spillet kan køre): `cp FS25_ADSmartPickup.zip ~/FS25-data/mods/.FS25_ADSmartPickup.zip.tmp && mv ~/FS25-data/mods/.FS25_ADSmartPickup.zip.tmp ~/FS25-data/mods/FS25_ADSmartPickup.zip`.
- [ ] **Step 4:** Code-review (ecc:code-reviewer) af diff; ret CRITICAL/HIGH.
- [ ] **Step 5:** Commit + `git push gitea`. Opdatér memory + Obsidian-noten `projekter/FS25 silo-patches + AD Smart Pickup.md`.
File diff suppressed because it is too large Load Diff
@@ -1,78 +0,0 @@
# Flow-tavle — implementeringsplan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Én tavle med ét flow pr. række (Vare · Fra · Til · Regel · Traktorer · Status); Fra/Til vælges som bygninger, markører findes automatisk. Faner: Flows · Lagre.
**Architecture:** Ny spil-adapter `adBuildings.lua` (bygninger + markør-opslag + cache). Modellen får bygnings-id-lister; controlleren udleder waypoint-lister fra dem (`resolveMarkers`) så planlægger/hooks er uændrede. GUI omskrives: `SmartPickupFrame.lua` (faner, tavle, fælles), `SmartPickupFlowTab.lua` (detail-blok), `SmartPickupStorageTab.lua` (uændret).
**Tech Stack:** FS25 Lua, AutoDrive-globaler via `FS25_AutoDrive`, luajit-tests.
**Spec:** `docs/superpowers/specs/2026-09-23-flow-tavle-design.md`
## Global Constraints
- Motoren (adOutbound, adOutboundPlanner, adSupplyPlanner, adUnloadWait, adSmartPickup-hooks) røres ikke.
- Byg kun når alle tests er grønne (`for t in tests/test_*.lua; do luajit $t|tail -1; done`), derefter zip → `~/FS25-data/mods/` → commit → push gitea main.
- Version 1.13.0.0. XML-version 3; v1/v2 læses.
- Wait-markører er aldrig bygningsmarkører. Siloers læssemarkør = strengt opslag; alt andet = nærmeste ≤ 40 m.
## Review Focus
1. Run med bygning der er solgt siden sidst: rækken viser "(mangler)", Start afviser med noMarker, intet crash. (Task 3 test)
2. v2-fil med markør der ikke kan oversættes til en bygning: markøren beholdes rå og virker som før. (Task 2 test)
3. To bygninger med markører inden for 40 m af hinanden (silo-rækker): hver silo får sin egen markør (strengt opslag). (Task 1 test)
4. Forsynings-run: Fra vises som "alle siloer" og sources-listen røres ikke. (Task 3 test)
5. Fanen rebuild'es mens et run kører: Start/Stop-knappen og status følger `isRunning`. (in-game)
---
### Task 1: adBuildings
**Files:** Create `FS25_ADSmartPickup/adBuildings.lua`; Test `tests/test_adBuildings.lua`.
**Produces:**
```lua
ADBuildings.MAX_MARKER_DISTANCE = 40; ADBuildings.CACHE_MS = 1000
ADBuildings.list(adEnv, farmId) -- {building...} sorteret kind, navn (cachet)
ADBuildings.find(adEnv, farmId, id) -- building | nil
ADBuildings.byMarker(adEnv, farmId, wayPointId, role) -- role "load"|"unload": building hvis markøren er dens markør, ellers nil
ADBuildings.getId(placeable) -- uniqueId eller "pos:x:z"
ADBuildings.invalidate()
building = {id, name, kind, placeable, loadMarker={id,name,distance}|nil, unloadMarker=…|nil, canBeSource, canBeTarget, canSell}
```
Kilder til bygninger: `g_currentMission.placeableSystem.placeables` (ejet af farm eller sell) — kind: factory (spec_productionPoint), silo (spec_silo), husbandry (getHusbandryFillLevel), objectStorage (spec_objectStorage), sell (ADPrices.isSellingStation på placeable.spec_sellingPoint / unloadingStation via storageSystem). Læssetriggere: `productionPoint.loadingStation.loadTriggers`, `spec_silo.loadingStation.loadTriggers`, husbandry: alle loadingStations i storageSystem med owningPlaceable == placeable. Aflæsningstriggere: unloadingStations med owningPlaceable == placeable. Objektlager/pallespawner: rootNode/spawnPlaces[1].startNode.
Markørvalg: nærmeste markør (ikke Wait) til en trigger-node ≤ 40 m; for silo-load: kun markører hvor `ADSmartPickup.getLoadStationAtWayPointId(marker.id)` == siloens station.
- [ ] Tests B1–B7 (kind, markører, silo strengt, Wait udelukket, byMarker, id-fallback, cache-invalidering) → RED → implementér → GREEN → commit `feat: adBuildings`.
### Task 2: Model v3
**Files:** Modify `adRuns.lua`, `adRunsStorage.lua`; Test `tests/test_adRuns.lua`.
**Produces:** run-felter `sourceBuildingIds`, `targetBuildingIds` (lister af strenge); `ADRuns.toggleSourceBuilding/toggleTargetBuilding(store, runId, id)`; XML v3 `#sourceBuildings`/`#targetBuildings` (mellemrumsadskilte). `ADRuns.XML_VERSION = 3`. Migration sker i controlleren (Task 3), ikke i modellen.
- [ ] Tests R-v3 → RED → implementér → GREEN → commit.
### Task 3: Controller — resolve + migration + validering
**Files:** Modify `adRunsController.lua`; Test `tests/test_adRunsController.lua`.
**Produces:**
```lua
ADRunsController.resolveMarkers(adEnv, run) -- udfylder run.sourceWayPointIds (outbound) / targetWayPointIds (supply) fra bygninger; rå markører (uden bygning) bevares; returnerer {missing = {buildingName...}}
ADRunsController.migrateBuildings(adEnv) -- ved load: markører → bygnings-id via byMarker; logger
ADRunsController.getFlowRow(adEnv, run) -- {product, from, to, rule, vehicles, status, warning} tekster til tavlen
validateVehicle: ny grund "noMarker" når en valgt bygning mangler markør
```
Start/plan: `startRun` og `ADOutbound.buildPlanInput` kalder resolveMarkers først (buildPlanInput via ADRunsController.getRunForVehicle → resolve i getRunForVehicle? nej: i `ADSmartPickup.choosePickup`/`ADOutbound.choosePickup` kaldes `ADRunsController.resolveMarkers(adEnv, run)` én gang pr. plan).
- [ ] Tests C-flow → RED → implementér → GREEN → commit.
### Task 4: GUI — Flows-fane
**Files:** Rewrite `gui/SmartPickupFrame.lua` (faner Flows/Lagre, tavle, fælles rækker, knapper), Create `gui/SmartPickupFlowTab.lua` (detail-blok, callbacks), delete `SmartPickupSupplyTab.lua`/`SmartPickupOutboundTab.lua` (indhold flyttes), Modify `gui/SmartPickupFrame.xml` (statiske rækker: runOption "Flow", waitPool, loops, returnBelow, sellAt, storeAbove; prefabs som i dag), l10n da/en (`spu_tab_flows`, `spu_flow_*`, `spu_direction_in/out`, `spu_from_allSilos`, `spu_noMarker`, `spu_reason_noMarker`, `spu_section_*`), `adSmartPickup.lua` (source()).
- [ ] Implementér; syntaks- og definitions-tjek; in-game.
### Task 5: README, version, byg, deploy, push, memory.
@@ -1,569 +0,0 @@
# Udkørsel af produkter — implementeringsplan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Runs med retning "Udkørsel": traktorer henter fabrikkers output og lagervarer (bulk og paller, også fra objektlagre) og kører dem til silo eller salgssted efter politik pr. vare, pristærskel og lagertærskel.
**Architecture:** Ren planlægger (`adOutboundPlanner`) + rene prisregler (`adPrices`) uden spil-API; spiladaptere i `adSources`/`adPrices`; AutoDrive-integration i `adOutbound` via to hooks (næste pålæsning + læsseopgaven), samme mønster som `adUnloadWait`. Wait-puljen udskilles i `adWaitPool` og deles. GUI får tre faner (Oversigt · Forsyning · Udkørsel) med fælles byggeklodser.
**Tech Stack:** FS25 Lua (GIANTS-sandbox), AutoDrive 3.x via `FS25_AutoDrive`-globalen, Universal Autoload via AD's AL-lag, luajit-tests uden spil.
**Spec:** `docs/superpowers/specs/2026-09-23-udkoersel-design.md`
## Global Constraints
- Alle spil-API-kald i hooks i `pcall`; fejl → hold ved Wait + WARN én gang, aldrig AutoDrive-stop.
- Modellen (`adRuns`) muterer aldrig: ny store ved hver ændring.
- Log-præfiks `ADSmartPickup: ` i alle moduler; alle run-ændringer logges.
- XML-version 2; version 1-filer læses som `kind="supply"`.
- Standarder: `sellAtPercent` 90 (50–100), `storeAbovePercent` 80 (50–95), `collectRound` false.
- Politikker: `store` · `sell` · `sellElseStore` (+ "off" = varen udelades af `products`).
- Kildetyper: fabrik (produktionspunkt), silo (`spec_silo`), objektlager (`spec_objectStorage`), genkendt inden for 40 m af markøren.
- Tests køres fra repo-roden: `luajit tests/<fil>.lua`; alle eksisterende tests skal blive grønne.
- Version bump til 1.12.0.0 i `modDesc.xml`; byg `cd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip .`; deploy til `~/FS25-data/mods/`.
## Review Focus
1. Traktor med UAL-vogn står ved et objektlager hvis spawn sidder fast (`objectSpawn.isActive` for evigt): forventet: 30 s uden fremdrift → læsningen afsluttes med det der er, ingen palle efterlades ud over den ene der evt. er spawnet. (Task 6 test O5)
2. Bedst betalende salgssted er uopnåeligt, næstbedste kan nås: forventet: næstbedste vælges uden advarsel; kun når intet kan nås, vises rød advarsel én gang. (Task 4 test P6 + Task 6 test O7)
3. Vare hvis salgsstation ikke findes på kortet, politik `sell`: forventet: varen står som "intet salgssted" i UI og planlæggeren springer den over uden fejl. (Task 4 test P7)
4. Version 1-runs-fil (uden `kind`) indlæses: forventet: alle runs bliver Forsyning, intet går tabt, næste gem skriver version 2. (Task 1 test R-v2)
5. Bulk-vogn med opsamlingsrunde og to kilder hvor den første giver fuldt læs: forventet: der køres direkte til målet, kilde 2 besøges ikke. (Task 6 test O6)
---
### Task 1: Model + lagring v2
**Files:**
- Modify: `FS25_ADSmartPickup/adRuns.lua`
- Modify: `FS25_ADSmartPickup/adRunsStorage.lua`
- Test: `tests/test_adRuns.lua` (tilføj sektion R-v2)
**Interfaces:**
- Produces: `ADRuns.KINDS = {supply=true, outbound=true}`, `ADRuns.POLICIES = {store=true, sell=true, sellElseStore=true}`, `ADRuns.DEFAULT_SELL_AT_PERCENT=90`, `ADRuns.DEFAULT_STORE_ABOVE_PERCENT=80`, `ADRuns.createRun(store, name, kind)`, run-felter `kind, sourceWayPointIds, products={{fillType="SUGAR", policy="sell"}}, sellAtPercent, storeAbovePercent, collectRound`, `ADRuns.toggleSource(store, runId, wayPointId)`, `ADRuns.setProductPolicy(store, runId, fillTypeName, policy)` (policy "off" fjerner), `ADRuns.getProductPolicy(run, fillTypeName)` → policy eller "off", `ADRuns.serializeProducts/parseProducts`, `ADRuns.XML_VERSION = 2`.
- [ ] **Step 1: Skriv fejlende tests** — tilføj nederst i `tests/test_adRuns.lua` før den afsluttende opsummering:
```lua
-- R-v2: udkørsels-run — kind, kilder, varer med politik, tærskler, runde; XML v2 + v1-migration
local o1, out = ADRuns.createRun(ADRuns.newStore(), "Sugar out", "outbound")
check("Rv2 kind", out.kind, "outbound")
check("Rv2 supply som standard", ADRuns.createRun(ADRuns.newStore(), "x").kind == nil and "nil" or select(2, ADRuns.createRun(ADRuns.newStore(), "x")).kind, "supply")
check("Rv2 sellAt 90", out.sellAtPercent, 90)
check("Rv2 storeAbove 80", out.storeAbovePercent, 80)
check("Rv2 runde fra", out.collectRound, false)
local o2 = ADRuns.toggleSource(o1, out.id, 12694)
o2 = ADRuns.toggleSource(o2, out.id, 12386)
check("Rv2 to kilder", table.concat(ADRuns.getRun(o2, out.id).sourceWayPointIds, ","), "12694,12386")
check("Rv2 toggle fjerner", #ADRuns.getRun(ADRuns.toggleSource(o2, out.id, 12694), out.id).sourceWayPointIds, 1)
local o3 = ADRuns.setProductPolicy(o2, out.id, "SUGAR", "sell")
o3 = ADRuns.setProductPolicy(o3, out.id, "FORAGE", "store")
check("Rv2 politik SUGAR", ADRuns.getProductPolicy(ADRuns.getRun(o3, out.id), "SUGAR"), "sell")
check("Rv2 politik ukendt = off", ADRuns.getProductPolicy(ADRuns.getRun(o3, out.id), "STRAW"), "off")
check("Rv2 ugyldig politik ignoreres", ADRuns.getProductPolicy(ADRuns.getRun(ADRuns.setProductPolicy(o3, out.id, "SUGAR", "bogus"), out.id), "SUGAR"), "sell")
check("Rv2 off fjerner", ADRuns.getProductPolicy(ADRuns.getRun(ADRuns.setProductPolicy(o3, out.id, "SUGAR", "off"), out.id), "SUGAR"), "off")
check("Rv2 gammel store urørt", #ADRuns.getRun(o2, out.id).products, 0)
local o4 = ADRuns.updateRun(o3, out.id, {sellAtPercent = 120, storeAbovePercent = 10, collectRound = true})
check("Rv2 sellAt klippet 100", ADRuns.getRun(o4, out.id).sellAtPercent, 100)
check("Rv2 storeAbove klippet 50", ADRuns.getRun(o4, out.id).storeAbovePercent, 50)
check("Rv2 runde til", ADRuns.getRun(o4, out.id).collectRound, true)
check("Rv2 serialiser", ADRuns.serializeProducts(ADRuns.getRun(o3, out.id).products), "SUGAR:sell FORAGE:store")
local parsed = ADRuns.parseProducts("SUGAR:sell FORAGE:store BAD BAD:bogus")
check("Rv2 parse antal", #parsed, 2)
check("Rv2 parse politik", parsed[2].policy, "store")
local rt = ADRuns.fromRecords(ADRuns.toRecords(o4))
local rtRun = ADRuns.getRun(rt, out.id)
check("Rv2 roundtrip kind", rtRun.kind, "outbound")
check("Rv2 roundtrip kilder", table.concat(rtRun.sourceWayPointIds, ","), "12694,12386")
check("Rv2 roundtrip products", ADRuns.serializeProducts(rtRun.products), "SUGAR:sell FORAGE:store")
check("Rv2 roundtrip sellAt", rtRun.sellAtPercent, 100)
check("Rv2 roundtrip runde", rtRun.collectRound, true)
-- v1-post uden kind/sources/products -> supply med standarder
local v1 = ADRuns.fromRecords({{id = 7, name = "Old", target = 94, targets = "94", ingredientMode = "auto", ingredients = "", waitPool = "", loops = 0, returnBelow = 50, vehicleIds = {}}})
check("Rv2 v1 -> supply", ADRuns.getRun(v1, 7).kind, "supply")
check("Rv2 v1 tomme kilder", #ADRuns.getRun(v1, 7).sourceWayPointIds, 0)
check("Rv2 v1 sellAt standard", ADRuns.getRun(v1, 7).sellAtPercent, 90)
```
- [ ] **Step 2: Kør testen** — `luajit tests/test_adRuns.lua` → FAIL (`toggleSource` nil).
- [ ] **Step 3: Implementér i `adRuns.lua`**:
```lua
ADRuns.XML_VERSION = 2
ADRuns.KINDS = {supply = true, outbound = true}
ADRuns.POLICIES = {store = true, sell = true, sellElseStore = true}
ADRuns.DEFAULT_SELL_AT_PERCENT, ADRuns.MIN_SELL_AT_PERCENT, ADRuns.MAX_SELL_AT_PERCENT = 90, 50, 100
ADRuns.DEFAULT_STORE_ABOVE_PERCENT, ADRuns.MIN_STORE_ABOVE_PERCENT, ADRuns.MAX_STORE_ABOVE_PERCENT = 80, 50, 95
local function copyProducts(products)
local copy = {}
for index, product in ipairs(products or {}) do
copy[index] = {fillType = product.fillType, policy = product.policy}
end
return copy
end
local function clampRange(value, default, min, max)
local number = tonumber(value) or default
return math.max(min, math.min(max, math.floor(number)))
end
```
`copyRun` får `kind, sourceWayPointIds = copyList(...), products = copyProducts(...), sellAtPercent, storeAbovePercent, collectRound`. `createRun(store, name, kind)` sætter `kind = ADRuns.KINDS[kind] and kind or "supply"`, `sourceWayPointIds = {}`, `products = {}`, `sellAtPercent = 90`, `storeAbovePercent = 80`, `collectRound = false`. `updateRun` håndterer `changes.kind` (kun gyldige), `sourceWayPointIds` (copyList), `products` (copyProducts), `sellAtPercent`/`storeAbovePercent` (clampRange), `collectRound` (`== true`).
```lua
function ADRuns.toggleSource(store, runId, wayPointId)
local run = ADRuns.getRun(store, runId)
if run == nil then return copyStore(store) end
local sources, found = {}, false
for _, id in ipairs(run.sourceWayPointIds) do
if id == wayPointId then found = true else table.insert(sources, id) end
end
if not found then table.insert(sources, wayPointId) end
return ADRuns.updateRun(store, runId, {sourceWayPointIds = sources})
end
function ADRuns.getProductPolicy(run, fillTypeName)
for _, product in ipairs(run.products or {}) do
if product.fillType == fillTypeName then return product.policy end
end
return "off"
end
-- policy "off" fjerner varen; ukendt politik ignoreres (store uændret).
function ADRuns.setProductPolicy(store, runId, fillTypeName, policy)
local run = ADRuns.getRun(store, runId)
if run == nil or (policy ~= "off" and not ADRuns.POLICIES[policy]) then return copyStore(store) end
local products = {}
for _, product in ipairs(run.products) do
if product.fillType ~= fillTypeName then table.insert(products, product) end
end
if policy ~= "off" then table.insert(products, {fillType = fillTypeName, policy = policy}) end
return ADRuns.updateRun(store, runId, {products = products})
end
function ADRuns.serializeProducts(products)
local parts = {}
for _, product in ipairs(products or {}) do table.insert(parts, product.fillType .. ":" .. product.policy) end
return table.concat(parts, " ")
end
function ADRuns.parseProducts(text)
local products = {}
for token in string.gmatch(tostring(text or ""), "%S+") do
local name, policy = string.match(token, "^([^:]+):(%a+)$")
if name ~= nil and ADRuns.POLICIES[policy] then table.insert(products, {fillType = name, policy = policy}) end
end
return products
end
```
`toRecords` tilføjer `kind, sources = table.concat(run.sourceWayPointIds, " "), products = serializeProducts, sellAt, storeAbove, collectRound = run.collectRound and 1 or 0`. `fromRecords` læser dem med standarder (`kind` ugyldig/nil → "supply"; `collectRound = tonumber(record.collectRound) == 1`).
`adRunsStorage.lua`: `writeRecords` skriver `#kind` (string), `#sources`, `#products`, `#sellAt` (int), `#storeAbove` (int), `#collectRound` (int); `readRecords` læser dem (`getString`/`getInt`; manglende attribut → nil → standard i fromRecords).
- [ ] **Step 4: Kør** `luajit tests/test_adRuns.lua && luajit tests/test_adRunsController.lua` → alle OK.
- [ ] **Step 5: Commit** `feat: run-model v2 — retning, kilder, varepolitik, tærskler, runde (XML v2 + v1-migration)`
---
### Task 2: adWaitPool udskilt fra adUnloadWait
**Files:**
- Create: `FS25_ADSmartPickup/adWaitPool.lua`
- Modify: `FS25_ADSmartPickup/adUnloadWait.lua`, `adLoadSwap.lua` (excludedPlaceables), `modDesc.xml` (sourceFile før adUnloadWait)
- Test: `tests/test_adWaitPool.lua` (ny), eksisterende `tests/test_adSmartPickup.lua` skal stadig køre
**Interfaces (Produces):**
```lua
ADWaitPool.getCandidates(adEnv, poolGroup) -- {{marker, x, z}}
ADWaitPool.claim(markerId, vehicle) / ADWaitPool.releaseVehicle(vehicle) / ADWaitPool.getOwner(markerId)
ADWaitPool.pickFree(adEnv, vehicle, poolGroup, isReachable) -- candidate eller nil (nærmeste ledige fra vognens position)
ADWaitPool.hasRouteTo(adEnv, vehicle, wayPointId) / ADWaitPool.hasRouteBothWays(adEnv, vehicle, toId, homeId)
ADWaitPool.redirect(task, destinationID) -- stopper AD's trigger-aktivitet, sætter task.destinationID/state/setPathTo
ADWaitPool.getClaims() -- {{marker, vehicle}} sorteret (til GUI)
ADWaitPool.STATE_DRIVING -- sættes af adUnloadWait.install (task.STATE_DRIVING or 2)
ADLoadSwap.findDumpMarker(adEnv, vehicle, fillType, rest, homeWayPointId, isReachable, excludedPlaceables) -- 7. param: {[placeable]=true} udelukkes
```
- [ ] **Step 1: Test** `tests/test_adWaitPool.lua`: mock `FS25_AutoDrive.ADGraphManager` (markers i mapperne "Cow Wait" og "silo", waypoints, `getPathTo` der returnerer `{ {id=target} }` for nåelige), `ADSupplyPlanner` via `dofile`. Tjek: (W1) `getCandidates(adEnv, "Cow Wait")` giver kun Wait-markører i den mappe, tom pulje = alle Wait-mapper; (W2) `pickFree` returnerer nærmeste ledige, springer reserverede over, returnerer eget reserverede; (W3) `claim/releaseVehicle/getOwner`; (W4) `pickFree` med `isReachable` der afviser nærmeste → næste; (W5) `getClaims` sorteret på mappe.
- [ ] **Step 2: Kør** → FAIL (fil mangler).
- [ ] **Step 3: Implementér** `adWaitPool.lua` ved at flytte `getWaitCandidates`, `claims`, `pathReaches`, `hasRouteTo`, `hasRouteBothWays`, `redirect`, `getClaims`-sorteringen fra `adUnloadWait.lua` (samme kode, offentlige navne som ovenfor; `claims` er `setmetatable({}, {__mode="v"})`; `getClaims` itererer `claims` direkte: `{marker = ADGraphManager:getMapMarkerByWayPointId(id), vehicle}`). `adUnloadWait.lua` kalder dem (`claims[...]` → `ADWaitPool.claim/getOwner/releaseVehicle`; `pickNearestFreeWaitPoint(getWaitCandidates(...), claims, ...)` → `ADSupplyPlanner.pickNearestFreeWaitPoint(ADWaitPool.getCandidates(adEnv, pool), ADWaitPool.claims, x, z, vehicle, isReachable)`); `ADUnloadWait.getClaims/hasRouteTo/hasRouteBothWays` bliver delegater. `ADLoadSwap.findDumpMarker`: `if excludedPlaceables ~= nil and excludedPlaceables[station.owningPlaceable] then skip`.
- [ ] **Step 4: Kør** alle tests: `for t in tests/test_*.lua; do luajit $t | tail -1; done` → grønne.
- [ ] **Step 5: Commit** `refactor: Wait-puljen udskilt i adWaitPool (reservationer, rutetjek, redirect) til deling med udkørsel`
---
### Task 3: adPrices — toppris og pris-%
**Files:**
- Create: `FS25_ADSmartPickup/adPrices.lua`
- Test: `tests/test_adPrices.lua`
**Interfaces (Produces):**
```lua
ADPrices.maxFactor(factors) -- højeste værdi i {[period]=factor}; 1 uden faktorer
ADPrices.topPrice(baseNow, factorNow, maxFactor) -- baseNow * maxFactor / factorNow; baseNow når factorNow <= 0
ADPrices.percentOfTop(price, top) -- afrundet heltal; 0 når top <= 0
ADPrices.isSellingStation(station) -- SellingStation, isSellingPoint, ikke hideFromPricesMenu
ADPrices.getQuote(station, fillType) -- {price, top, percent} eller nil (spil-API i pcall)
ADPrices.bestQuote(quotes) -- højeste percent, dernæst price; nil ved tom
```
- [ ] **Step 1: Test** (P1) `maxFactor({EARLY_SPRING=0.9, LATE_AUTUMN=1.2})` = 1.2; (P2) `topPrice(500, 1.0, 1.25)` = 625; `topPrice(500, 0, 1.25)` = 500; (P3) `percentOfTop(470, 625)` = 75; `percentOfTop(700, 625)` = 112 (stor efterspørgsel > 100); `percentOfTop(1, 0)` = 0; (P4) `getQuote` med fake station `{fillTypePrices={[5]=500}, getEffectiveFillTypePrice=function(_,ft) return 470 end, isa=function() return true end, isSellingPoint=true}` og `g_fillTypeManager.getFillTypeByIndex` → `{economy={factors={A=1.0,B=1.25}}, pricePerLiter=400}`, `g_currentMission.environment.currentPeriod="A"` → percent 75; (P5) uden `fillTypePrices` bruges `pricePerLiter × factorNow × economyManager:getPriceMultiplier()` (1.0) → top 500, percent 94 (470/500); (P6) `bestQuote` vælger højeste percent.
- [ ] **Step 2: Kør** → FAIL.
- [ ] **Step 3: Implementér**:
```lua
ADPrices = {}
ADPrices.LOG_PREFIX = "ADSmartPickup: "
function ADPrices.maxFactor(factors)
local best = nil
for _, factor in pairs(factors or {}) do
if type(factor) == "number" and (best == nil or factor > best) then best = factor end
end
return best or 1
end
function ADPrices.topPrice(baseNow, factorNow, maxFactor)
if baseNow == nil or baseNow <= 0 then return 0 end
if factorNow == nil or factorNow <= 0 or maxFactor == nil or maxFactor <= 0 then return baseNow end
return baseNow * maxFactor / factorNow
end
function ADPrices.percentOfTop(price, top)
if top == nil or top <= 0 or price == nil then return 0 end
return math.floor(100 * price / top + 0.5)
end
function ADPrices.isSellingStation(station)
return station ~= nil and station.isa ~= nil and SellingStation ~= nil and station:isa(SellingStation)
and station.isSellingPoint == true and not station.hideFromPricesMenu
end
local function getCurrentPeriod()
local environment = g_currentMission ~= nil and g_currentMission.environment or nil
return environment ~= nil and environment.currentPeriod or nil
end
function ADPrices.getQuote(station, fillType)
local ok, effective = pcall(station.getEffectiveFillTypePrice, station, fillType)
if not ok or type(effective) ~= "number" then return nil end
local fillTypeDesc = g_fillTypeManager ~= nil and g_fillTypeManager.getFillTypeByIndex ~= nil and g_fillTypeManager:getFillTypeByIndex(fillType) or nil
local factors = fillTypeDesc ~= nil and fillTypeDesc.economy ~= nil and fillTypeDesc.economy.factors or nil
local period = getCurrentPeriod()
local factorNow = factors ~= nil and period ~= nil and tonumber(factors[period]) or 1
local baseNow = station.fillTypePrices ~= nil and tonumber(station.fillTypePrices[fillType]) or nil
if baseNow == nil or baseNow <= 0 then
local multiplier = 1
local economy = g_currentMission ~= nil and g_currentMission.economyManager or nil
if economy ~= nil and economy.getPriceMultiplier ~= nil then
local okMult, value = pcall(economy.getPriceMultiplier, economy)
if okMult and type(value) == "number" then multiplier = value end
end
baseNow = (fillTypeDesc ~= nil and tonumber(fillTypeDesc.pricePerLiter) or 0) * factorNow * multiplier
end
local top = ADPrices.topPrice(baseNow, factorNow, ADPrices.maxFactor(factors))
return {price = effective, top = top, percent = ADPrices.percentOfTop(effective, top)}
end
function ADPrices.bestQuote(quotes)
local best = nil
for _, quote in ipairs(quotes or {}) do
if best == nil or quote.percent > best.percent or (quote.percent == best.percent and quote.price > best.price) then best = quote end
end
return best
end
```
- [ ] **Step 4: Kør** → OK. **Step 5: Commit** `feat: adPrices — toppris fra årets prisforløb og pris-% pr. salgssted`
---
### Task 4: adOutboundPlanner (ren)
**Files:**
- Create: `FS25_ADSmartPickup/adOutboundPlanner.lua`
- Test: `tests/test_adOutboundPlanner.lua`
**Interfaces (Produces):**
```lua
-- input:
-- { capacity = liter, hasAutoload = bool, sellAtPercent, storeAbovePercent, collectRound,
-- products = { { fillType = idx, name = "SUGAR", policy = "sell"|"store"|"sellElseStore", isPallet = bool,
-- sources = { {wayPointId, available, level, capacity} ... }, -- kun kilder med available > 0
-- quote = {percent, price, wayPointId, stationName} | nil, -- bedste NÅELIGE salgssted
-- store = {wayPointId, name, free} | nil } ... } }
-- output: trip | nil, reasons
-- trip = { kind = "sell"|"store", fillTypes = {idx...}, names = {...}, sourceIds = {wayPointId...}, targetWayPointId, targetName, percent|nil }
-- reasons = { "SUGAR 78 % af top (kræver 90), lager 41 %", ... }
ADOutboundPlanner.plan(input)
ADOutboundPlanner.evaluate(product, input) -- {available, fullLoad, storageHigh, storagePercent, priceOk, percent}
ADOutboundPlanner.MIN_AVAILABLE_LITERS = 1
ADOutboundPlanner.ROUND_MIN_FREE_FRACTION = 0.1
```
- [ ] **Step 1: Tests** (`local P = ADOutboundPlanner` efter `dofile`):
- P1 salg ved pris over tærskel, uanset mængde: SUGAR sell, sources {{1, 5000, ...}}, quote 92, capacity 20000 → trip.kind "sell", targetWayPointId = quote.wayPointId, sourceIds {1}.
- P2 pris under tærskel, lager lavt → nil, reasons[1] = "SUGAR 78 % af top (kræver 90), lager 41 %".
- P3 sellElseStore, pris lav, lager 85 % → "store" til store.wayPointId.
- P4 store med fuldt læs (available ≥ capacity) og lager 20 % → store.
- P5 rangering: to sell-kandidater 95 % og 91 % → første vælges (højeste %).
- P6 quote nil (intet nåeligt salgssted), policy sell, lager højt → nil, reason "SUGAR: intet nåeligt salgssted".
- P7 store nil ved store-behov → nil, reason "FORAGE: ingen silo med plads".
- P8 runde bulk (collectRound, hasAutoload=false): SUGAR i kilde A (3000) og B (9000), capacity 10000 → sourceIds {B, A} (mest først), ét fillType.
- P9 runde UAL: STRAW_BASKET (sell 95, station S) + STRAW_CRATE (sell 93, station S) + STRAW_BROOM (sell 96, station T) → fillTypes {BROOM} kun (højest, T) — og med BROOM fjernet: {BASKET, CRATE} samme station S.
- P10 uden runde: kun første fillType og kun én kilde (den med mest).
- P11 policy off/ingen sources → nil, reasons tom for varer uden kilder ("SUGAR: intet på lager").
- [ ] **Step 2: Kør** → FAIL. **Step 3: Implementér**:
```lua
ADOutboundPlanner = {}
ADOutboundPlanner.MIN_AVAILABLE_LITERS = 1
ADOutboundPlanner.ROUND_MIN_FREE_FRACTION = 0.1
local function sumAvailable(product)
local total, high, percent = 0, false, 0
for _, source in ipairs(product.sources or {}) do
total = total + (source.available or 0)
if source.capacity ~= nil and source.capacity > 0 then
local p = 100 * (source.level or 0) / source.capacity
percent = math.max(percent, p)
end
end
return total, percent
end
function ADOutboundPlanner.evaluate(product, input)
local available, storagePercent = sumAvailable(product)
local percent = product.quote ~= nil and product.quote.percent or nil
return {
available = available,
fullLoad = input.capacity ~= nil and input.capacity > 0 and available >= input.capacity,
storagePercent = storagePercent,
storageHigh = storagePercent >= (input.storeAbovePercent or 80),
percent = percent,
priceOk = percent ~= nil and percent >= (input.sellAtPercent or 90),
}
end
local function wantsSell(policy) return policy == "sell" or policy == "sellElseStore" end
local function wantsStore(policy) return policy == "store" or policy == "sellElseStore" end
local function reasonFor(product, e, input)
if e.available < ADOutboundPlanner.MIN_AVAILABLE_LITERS then return product.name .. ": intet på lager" end
if wantsSell(product.policy) and product.quote == nil and not (wantsStore(product.policy) and product.store ~= nil) then
return product.name .. ": intet nåeligt salgssted"
end
if wantsStore(product.policy) and product.store == nil and (e.fullLoad or e.storageHigh) and not e.priceOk then
return product.name .. ": ingen silo med plads"
end
local parts = {}
if wantsSell(product.policy) and e.percent ~= nil then
table.insert(parts, string.format("%d %% af top (kræver %d)", e.percent, input.sellAtPercent or 90))
end
table.insert(parts, string.format("lager %d %%", math.floor(e.storagePercent + 0.5)))
return product.name .. " " .. table.concat(parts, ", ")
end
-- kandidater: {product, e, kind, targetWayPointId, targetName, score}
local function collectCandidates(input)
local sell, store, reasons = {}, {}, {}
for _, product in ipairs(input.products or {}) do
local e = ADOutboundPlanner.evaluate(product, input)
local added = false
if e.available >= ADOutboundPlanner.MIN_AVAILABLE_LITERS then
if wantsSell(product.policy) and e.priceOk and product.quote ~= nil then
table.insert(sell, {product = product, e = e, kind = "sell", targetWayPointId = product.quote.wayPointId, targetName = product.quote.stationName, percent = e.percent})
added = true
elseif wantsStore(product.policy) and (e.fullLoad or e.storageHigh) and product.store ~= nil then
table.insert(store, {product = product, e = e, kind = "store", targetWayPointId = product.store.wayPointId, targetName = product.store.name})
added = true
end
end
if not added then table.insert(reasons, reasonFor(product, e, input)) end
end
table.sort(sell, function(a, b)
if a.e.percent ~= b.e.percent then return a.e.percent > b.e.percent end
if a.e.available ~= b.e.available then return a.e.available > b.e.available end
return a.product.name < b.product.name
end)
table.sort(store, function(a, b)
if a.e.storagePercent ~= b.e.storagePercent then return a.e.storagePercent > b.e.storagePercent end
if a.e.available ~= b.e.available then return a.e.available > b.e.available end
return a.product.name < b.product.name
end)
return sell, store, reasons
end
local function sortedSources(product)
local sources = {}
for _, source in ipairs(product.sources or {}) do
if (source.available or 0) >= ADOutboundPlanner.MIN_AVAILABLE_LITERS then table.insert(sources, source) end
end
table.sort(sources, function(a, b) return a.available > b.available end)
return sources
end
local function buildTrip(primary, candidates, input)
local trip = {kind = primary.kind, fillTypes = {primary.product.fillType}, names = {primary.product.name}, sourceIds = {}, targetWayPointId = primary.targetWayPointId, targetName = primary.targetName, percent = primary.percent}
local members = {primary}
if input.collectRound and input.hasAutoload and primary.product.isPallet then
for _, candidate in ipairs(candidates) do
if candidate ~= primary and candidate.kind == primary.kind and candidate.targetWayPointId == primary.targetWayPointId and candidate.product.isPallet then
table.insert(members, candidate)
table.insert(trip.fillTypes, candidate.product.fillType)
table.insert(trip.names, candidate.product.name)
end
end
end
local seen = {}
for _, member in ipairs(members) do
local sources = sortedSources(member.product)
local limit = input.collectRound and #sources or math.min(1, #sources)
for index = 1, limit do
local id = sources[index].wayPointId
if not seen[id] then seen[id] = true; table.insert(trip.sourceIds, id) end
end
end
return trip
end
function ADOutboundPlanner.plan(input)
local sell, store, reasons = collectCandidates(input)
if sell[1] ~= nil then return buildTrip(sell[1], sell, input), reasons end
if store[1] ~= nil then return buildTrip(store[1], store, input), reasons end
return nil, reasons
end
```
- [ ] **Step 4: Kør** → OK. **Step 5: Commit** `feat: adOutboundPlanner — turvurdering, prisregel, politikker, opsamlingsrunde (ren)`
---
### Task 5: adSources — kilde-genkendelse og lagerstatus
**Files:**
- Create: `FS25_ADSmartPickup/adSources.lua`
- Test: `tests/test_adSources.lua`
**Interfaces (Produces):**
```lua
ADSources.MAX_DISTANCE = 40; ADSources.PALLET_RADIUS = 25
ADSources.identify(adEnv, wayPointId, farmId) -- {kind="factory"|"silo"|"objectStorage", placeable, station|nil, wayPointId, name} | nil
ADSources.getProducts(adEnv, source, farmId) -- { {fillType, name, available, level, capacity, isPallet} } (kun varer med kapacitet/indhold)
ADSources.isPalletFillType(fillType) -- fillTypeDesc.palletFilename ~= nil
ADSources.getSellMarkers(adEnv, farmId) -- { {marker, station} } salgssteder (ADPrices.isSellingStation)
ADSources.getObjectInfoIndex(source, fillType) -- objectInfo-indeks + antal for varen i et objektlager, eller nil
ADSources.spawnOne(source, objectInfoIndex) -- pcall placeable:removeAbstractObjectsFromStorage(index, 1, nil) -> ok
ADSources.countStored(source, fillType) -- antal objekter af varen i lageret
ADSources.getSourcePlaceables(sources) -- {[placeable]=true} til udelukkelse
```
Genkendelse: læssestation ved markøren (`ADSmartPickup.getLoadStationAtWayPointId`) med `owningPlaceable.spec_productionPoint` → factory; med `spec_silo` → silo; ellers nærmeste placeable med `spec_objectStorage` inden for 40 m af waypointet (`g_currentMission.placeableSystem.placeables`, `getWorldTranslation(placeable.rootNode)`, `canFarmAccess`) → objectStorage.
Fabrik-produkter: `for production in productionPoint.productions: for output in production.outputs: type` (unik); `level = productionPoint.storage:getFillLevel(ft)`, `capacity = storage:getCapacity(ft)` (fallback `storage.capacities[ft]`); paller i nærheden: `g_currentMission.vehicleSystem.vehicles` med `spec_pallet` eller `isPallet`, ejer = farm, fillUnit-varen = ft, afstand ≤ 25 m fra `productionPoint.palletSpawner.spawnPlaces[1].startNode` (fallback placeable.rootNode); `available = level + Σ palle-fyldning`.
Silo: `spec_silo.storages[*]` fillLevels > 0 → `level = getOwnStorageLevel`, `capacity = storage:getCapacity(ft)`.
Objektlager: `spec_objectStorage.objectInfos[i].objects[1]` med `palletAttributes.fillType/fillLevel` eller `baleAttributes.fillType/fillLevel`; `numObjects` → available = numObjects × fillLevel; level = `spec.numStoredObjects`, capacity = `spec.capacity`; isPallet = true.
- [ ] **Step 1: Tests** med fakes: (S1) markør ved fabrik → factory med SUGAR output level 150000, capacity 180000, ingen paller → available 150000; (S2) markør ved silo → silo med FORAGE 74500; (S3) markør uden station, objektlager 20 m væk → objectStorage, produkt STRAW_BASKET available 8×1000, isPallet true, `getObjectInfoIndex` = 1 og 8; (S4) paller ved spawner: 2 paller à 1000 STRAW_BASKET inden for 25 m + 1 uden for → available = storage + 2000; (S5) `getSellMarkers` giver kun markører hvis unload-station er SellingStation med isSellingPoint; (S6) `spawnOne` kalder `removeAbstractObjectsFromStorage(1, 1, nil)` (fake tæller); (S7) markør 60 m fra objektlager → nil.
- [ ] **Step 2–4:** implementér som beskrevet, kør, grønne. **Step 5: Commit** `feat: adSources — fabrik, silo og objektlager som kilder; paller ved spawner; salgssteder`
---
### Task 6: adOutbound — hooks, hold, objektlager-udtag, runde, advarsler
**Files:**
- Create: `FS25_ADSmartPickup/adOutbound.lua`
- Test: `tests/test_adOutbound.lua`
**Interfaces (Produces):**
```lua
ADOutbound.CHECK_INTERVAL_MS = 5000; ADOutbound.NO_PROGRESS_MS = 15000; ADOutbound.SPAWN_STALL_MS = 30000
ADOutbound.isOutboundVehicle(vehicle) -- run for vognen har kind "outbound"
ADOutbound.choosePickup(adEnv, vehicle) -- kaldes fra getNextPickup-wrap; returnerer choice {markerIndex, markerName, group, station|nil, fillTypes, fillType} eller nil
ADOutbound.buildPlanInput(adEnv, run, vehicle) -- samler kilder/priser/lager til planlæggeren (spil-API)
ADOutbound.beforeLoadUpdate(adEnv, task, dt) -- true = håndteret (spring original update over)
ADOutbound.beforeLoadFinished(adEnv, task) -- true = fortsæt runde (spring original finished over)
ADOutbound.releaseInactive()
ADOutbound.getHoldFor(vehicle) -- {markerName, reasons} | nil
ADOutbound.getRunStatus(run) -- {reasons, warning} (seneste)
ADOutbound.install(adEnv) -- wrapper LoadAtDestinationTask.update/finished
```
Adfærd (fra spec afsnit 3–4):
- `choosePickup`: `trips[vehicle]=nil`; input = buildPlanInput; `trip, reasons = ADOutboundPlanner.plan(input)`; status[run.id] = reasons. Tur → `stateModule.selectedFillTypes = trip.fillTypes; setFillType(trip.fillTypes[1]); setSecondMarkerByWayPointId(trip.targetWayPointId); raiseDirtyFlag`; `trips[vehicle] = {run, trip, queue = copy(trip.sourceIds), visited = {}, current = trip.sourceIds[1]}`; log `'MT635' udkørsel: 8×STRAW_BASKET fra Straw factory → Halm-salg (95 % af top)`; returnér choice for første kilde (station = kildens læssestation hvis bulk, ellers nil). Ingen tur → `ADWaitPool.pickFree(adEnv, vehicle, run.waitPoolGroup, isReachable=hasRouteTo)`; fundet → claim, `holds[vehicle] = {markerId, reasons}`, log én gang pr. ændret begrundelse, returnér choice for Wait-markøren (station nil); intet Wait-punkt → hold ved første kilde (warn én gang) og returnér den.
- `buildPlanInput`: for hver kilde i `run.sourceWayPointIds`: `ADSources.identify` (nil → spring over + warn én gang "kilde markør X ikke genkendt"); products = union over kilder af `ADSources.getProducts`, filtreret til `ADRuns.getProductPolicy ~= "off"` og til det vognen kan bære (`bulk: AutoDrive.getSupportedFillTypesOfAllUnitsAlphabetically`, pallet: hasAutoload = `AutoDrive:hasAL(trailer)` for en af `getAllUnits`); pr. produkt med sell-politik: quotes over `ADSources.getSellMarkers` hvor `station.acceptedFillTypes[ft]` (eller `getIsFillTypeSupported`) og `ADWaitPool.hasRouteBothWays(adEnv, vehicle, marker.id, sourceId)` → `ADPrices.getQuote`; bedste = `ADPrices.bestQuote`; ingen nåelig men mindst én unåelig → warnOnce(vehicle, "sell:"..ft, "'%s' kan ikke nå salgssted '%s' for %s – tjek vejnettet"); pr. produkt med store-politik: `ADLoadSwap.findDumpMarker(adEnv, vehicle, ft, math.min(available, capacity), nil, isReachable, ADSources.getSourcePlaceables(sources))` → store; nil → warnOnce(vehicle, "store:"..ft, "'%s' finder ingen silo med plads til %s (%s)").
- `warnOnce(vehicle, key, text)`: `warned[vehicle][key] ~= text` → Logging.warning + pcall `adEnv.AutoDriveMessageEvent.sendMessageOrNotification(vehicle, adEnv.ADMessagesManager.messageTypes.ERROR, "ADSmartPickup: " .. text, 8000)`; status[run.id].warning = text. Ryddes når nøglens tilstand ændrer sig (ny tekst) eller turen lykkes.
- `beforeLoadUpdate`: hold → hvis `task.destinationID == hold.markerId` og `drivePathModule:isTargetReached()`: `specialDrivingModule:stopVehicle(); specialDrivingModule:update(dt)`; timer 5 s → `choosePickup` igen; tur → `ADWaitPool.releaseVehicle`, `holds[vehicle]=nil`, `ADWaitPool.redirect(task, trip.sourceIds[1])`, `task.activatedUALLoading=false`; returnér true. Ikke fremme → false.
Tur aktiv og `isTargetReached()`: kilde = `state.sources[task.destinationID]`; objektlager → `updateSpawn` (nedenfor); derefter fremdriftsvagt: `level = AutoDrive.getAllFillLevels(task.trailers)`; stiger → `noProgressMs=0`; ellers `+dt`; `> NO_PROGRESS_MS` og `level > 0` og ingen spawn i gang → `tryNextSourceOrFinish(task)`; returnér false (original kører altid, den aktiverer UAL og stopper vognen).
- `updateSpawn(state, task, source, dt)`: første gang: `index = ADSources.getObjectInfoIndex(source, currentFillType)`; hvis `task.activatedUALLoading` og vognen står (`vehicle.lastSpeedReal < 0.0005`): hvis ingen udestående spawn: `ualIsFull()` på en UAL-vogn → done; `countStored == 0` → skift til næste fillType i `trip.fillTypes` (nyt index) eller done; ellers `ADSources.spawnOne` → `pending = {stored = countStored, loaded = countLoaded(trailers), ms = 0}`; udestående: `countStored < pending.stored and countLoaded > pending.loaded` → pending=nil (næste); `pending.ms > SPAWN_STALL_MS` → done + warn "objektlager spawner ikke". `countLoaded` = antal nøgler i `spec_universalAutoload.loadedObjects` summeret over trailers. done → `task:finished()` (via original finished) og returnér.
- `tryNextSourceOrFinish(task)`: næste id i `state.queue` som ikke er besøgt, og fri kapacitet ≥ 10 % (`fillFreeCapacity/capacity`) → `visited[current]=true; current=next; ADWaitPool.redirect(task, next); task.activatedUALLoading=false; activePickups opdateres via ADSmartPickup.setActivePickup(vehicle, station, fillTypes)` (ny lille funktion i adSmartPickup); ellers `task:finished()`.
- `beforeLoadFinished`: kaldes fra wrap af `finished`; hvis der er tur, kilde ikke fuld (ikke `filledToUnload`) og en ubesøgt kilde med plads → redirect som ovenfor og returnér true (spring original over); ellers `trips[vehicle]=nil` og false.
- `releaseInactive`: for inaktive køretøjer: trips/holds ryddes, `ADWaitPool.releaseVehicle`.
- [ ] **Step 1: Tests** (fake AD-miljø som i `test_adRunsController.lua` + fakes for `ADSources`, `ADPrices`, `ADWaitPool`, `ADRunsController.getRunForVehicle`): (O1) tur → selectedFillTypes/2. markør sat, choice = kilde; (O2) ingen tur → Wait-markør claimet, hold registreret, choice = Wait; (O3) hold + 5 s + nu tur → redirect til kilde, hold frigivet; (O4) objektlager: spawn kaldes én ad gangen; stored falder + loaded stiger → næste spawn; `ualIsFull` → finished kaldt; (O5) spawn uden fremdrift 30 s → finished + WARN; (O6) runde bulk: kilde A fylder vognen (`filledToUnload`) → finished kaldt, ingen redirect; kilde A giver 30 % → redirect til B; (O7) intet nåeligt salgssted → `sendMessageOrNotification` kaldt én gang med ERROR, anden gang ikke; (O8) `releaseInactive` rydder hold og claim.
- [ ] **Step 2–4:** implementér, kør, grønne. **Step 5: Commit** `feat: adOutbound — udkørsels-hooks: turvalg, hold ved Wait, objektlager-udtag, opsamlingsrunde, advarsler på skærm`
---
### Task 7: Controller — udkørsels-runs
**Files:**
- Modify: `FS25_ADSmartPickup/adRunsController.lua`
- Test: `tests/test_adRunsController.lua` (sektion C-out)
**Interfaces (Produces):**
```lua
ADRunsController.createRun(name, kind)
ADRunsController.getSourceMarkers(adEnv, farmId) -- markører der er kilder: {marker, kind}
ADRunsController.getRunProducts(adEnv, run) -- { {fillType, name, title, policy, isPallet, available, percent|nil, stationName|nil} } sorteret på titel
ADRunsController.validateVehicle(adEnv, run, vehicle) -- outbound: reasons noSource | noTrailer | noProducts | needsAutoload | cannotCarry | farFromNetwork
ADRunsController.getOutboundStatus(adEnv, run) -- {products = getRunProducts, reasons, warning} (fra ADOutbound.getRunStatus)
```
`applyRunToVehicle`: `if run.kind == "outbound"`: mode P&D, `setFirstMarkerByWayPointId(run.sourceWayPointIds[1])`, `setSecondMarkerByWayPointId(run.sourceWayPointIds[1])`, `selectedFillTypes` = produkt-fillTypes vognen kan bære, `setFillType(first)`, loopCounter 0, useFolders true, rotateTargets ONLYPICKUP; ingen dump-logik. `getVehicleActivity`: `ADOutbound.getHoldFor(vehicle)` → `{kind="waiting", place=markerName, reason=reasons[1]}`. `updateRun` logger som før. `getRunTargets` for outbound returnerer `{}` (mål vælges pr. tur).
- [ ] **Step 1: Tests** (C-out1) createRun kind; (C-out2) validate: ingen kilder → noSource; bulk-vogn og kun pallevarer → needsAutoload; ingen politik → noProducts; ok-tilfælde; (C-out3) startRun sætter 1./2. markør = kilde 1 og selectedFillTypes; (C-out4) getRunProducts slår politik op.
- [ ] **Step 2–4:** implementér, kør alle tests. **Step 5: Commit** `feat: controller — udkørsels-runs: start, validering, produkter, status`
---
### Task 8: Wiring i adSmartPickup, adUnloadWait, modDesc
**Files:**
- Modify: `adSmartPickup.lua` (source() nye gui-filer, `installHook`: outbound-gren i `getNextPickup`-wrap, `ADOutbound.install(adEnv)`, `ADSmartPickup.setActivePickup`, `update`: `ADOutbound.releaseInactive`), `adUnloadWait.lua` (`beforeUpdate`: return hvis `ADOutbound.isOutboundVehicle`), `modDesc.xml` (sourceFiles: adPrices, adOutboundPlanner, adSources, adWaitPool, adOutbound før adUnloadWait/adSmartPickup; version 1.12.0.0; beskrivelse EN udvidet med udkørsel).
`getNextPickup`-wrap, ny gren først:
```lua
if ADOutbound ~= nil and ADOutbound.isOutboundVehicle(driver) then
local ok, choice = pcall(ADOutbound.choosePickup, adEnv, driver)
if not ok then
Logging.warning(ADSmartPickup.LOG_PREFIX .. "fejl i udkørsel, bruger AutoDrives rotation: %s", tostring(choice))
activePickups[driver] = nil
return originalGetNextPickup(self, driver, forcedSkip)
end
if choice == nil then activePickups[driver] = nil; return originalGetNextPickup(self, driver, forcedSkip) end
activePickups[driver] = choice.station ~= nil and {station = choice.station, fillTypes = choice.fillTypes, markerName = choice.markerName, fillType = choice.fillType} or nil
self.pickups[choice.group] = self.pickups[choice.group] or {}
self.pickups[choice.group].lastTarget = choice.markerIndex
self.pickups[choice.group].lastVehicle = driver
return choice.markerIndex
end
```
- [ ] **Step 1:** ret filer. **Step 2:** `luajit tests/test_adSmartPickup.lua` grøn; `for t in tests/test_*.lua; do luajit $t | tail -1; done`. **Step 3: Commit** `feat: udkørsel koblet på AutoDrive-hooks; Wait-logik springer udkørsels-traktorer over`
---
### Task 9: GUI — tre faner
**Files:**
- Modify: `gui/SmartPickupFrame.lua` (faner, fælles), `gui/SmartPickupFrame.xml` (nye statiske rækker + productPrefab + sourcePrefab), `l10n/l10n_da.xml`, `l10n/l10n_en.xml`
- Create: `gui/SmartPickupSupplyTab.lua` (flyt: fillTargetRows, fillNeedsRows, refreshNeedsRows, fillIngredientRows, describeTargetNeeds, onClickTarget/IngredientMode/Ingredient/Loops/ReturnBelow, `SmartPickupFrame:rebuildSupplyTab(adEnv, run)`), `gui/SmartPickupOutboundTab.lua` (`rebuildOutboundTab(adEnv, run)`: Kilder-rækker (ingredientPrefab med kind="source"), Varer-rækker (productPrefab: MultiTextOption med 4 tilstande Fra/Lager/Salg/Salg, ellers lager + titel "Sukker — 150.0k · 78 % af top (Grain Elevator)"), Regler (sellAtOption 50..100 trin 5, storeAboveOption 50..95 trin 5, collectRoundOption), status-/advarselsrække; callbacks onClickSource/onClickPolicy/onClickSellAt/onClickStoreAbove/onClickCollectRound; `describeOutboundOverview(run)` til Oversigt).
- `SmartPickupFrame`: `TAB_OVERVIEW, TAB_SUPPLY, TAB_OUTBOUND = 1, 2, 3`, `TAB_NAMES = {"spu_tab_overview","spu_tab_supply","spu_tab_outbound"}`, `tabKind = {nil, "supply", "outbound"}`; `fillRunOption` viser kun runs med fanens kind; `onNewRunName` opretter med fanens kind (Oversigt → supply); `setStaticRowsVisible(tab)` viser forsynings-rækker (ingredientMode, waitPool, loops, returnBelow) kun på fane 2, udkørsels-rækker (waitPool, sellAt, storeAbove, collectRound) kun på fane 3 (waitPool er fælles); blok-overskrifter via `addTextRow` ("— Mål og varer —" stil: brug tekstnøglerne `spu_section_*`). `refreshLive` opdaterer også produktrækkernes titler (pris-%) hvert sekund og udkørsels-status.
- l10n (da/en): `spu_tab_supply` "Forsyning"/"Supply", `spu_tab_outbound` "Udkørsel"/"Outbound", `spu_sources` "Kilder (fabrik, silo eller objektlager)", `spu_products` "Varer", `spu_policy_off/store/sell/sellElseStore` "Fra/Lager/Salg/Salg, ellers lager", `spu_sellAt` "Sælg fra (% af årets toppris)", `spu_sellAt_tooltip`, `spu_storeAbove` "Kør til lager over", `spu_storeAbove_tooltip`, `spu_collectRound` "Saml op på én tur", `spu_collectRound_tooltip`, `spu_section_run/targets/rules/wait/vehicles/status`, `spu_reason_noSource` "ingen genkendt kilde", `spu_reason_noProducts` "ingen vare valgt", `spu_reason_needsAutoload` "pallevarer kræver Universal Autoload på vognen", `spu_product_row` "%s — %s · %s", `spu_price_pct` "%d %% af top (%s)", `spu_price_none` "intet salgssted", `spu_outbound_waiting` "venter: %s", `spu_activity_holding` "holder ved %s (%s)".
- [ ] **Step 1:** XML + l10n; **Step 2:** split + ny fane; **Step 3:** `luajit` alle tests (GUI testes in-game); **Step 4: Commit** `feat(gui): faner Oversigt/Forsyning/Udkørsel; udkørsels-run med kilder, varepolitik, regler`
---
### Task 10: README, version, byg, deploy, in-game
- [ ] README: nyt afsnit "Udkørsel (v1.12)" (kilder, politikker, prisregel, lagertærskel, runde, objektlager-krav om markørplacering, advarsler). modDesc 1.12.0.0.
- [ ] `cd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip . && cp ../FS25_ADSmartPickup.zip ~/FS25-data/mods/`
- [ ] Commit `chore: v1.12.0.0 — udkørsel af produkter` + push til Gitea (`git push gitea main` med token fra vault).
- [ ] In-game-test (bruger): savegame 3 — se spec afsnit 4.
File diff suppressed because it is too large Load Diff
@@ -1,73 +0,0 @@
# Behovsstyret forsyning ("Feed the factory") — design
Dato: 2026-09-21 · Status: godkendt af bruger
## Problem
En produktion med flere input (fx TMR-blander: hø, græs, ensilage, sukkerroer) kræver i dag
én AutoDrive-traktor pr. vare, fordi AD's Pickup&Deliver kun kører én vare fra ét sted.
Varerne ligger spredt over mange lokationer på kortet.
## Løsning
Én traktor holder alle input fyldt. For hver tur vælger moden den ingrediens aflæsningsmålet
har mest brug for, finder en silo der har den, skifter AD's vare og sender traktoren derhen.
### Betjening (ingen ny GUI, ingen config-fil)
1. Aflæsningsmål = produktionens markør.
2. Multi-vælg ingredienserne i AD's vare-dropdown (>1 vare = funktionen er aktiv).
3. "Brug mapper" + "Rotér mål: pålæsning" slået til (krævet for at AD kalder `getNextPickup`).
4. Start Pickup&Deliver. AD's loop-tæller virker uændret (N læs → parkér, 0 = uendeligt).
Med præcis én valgt vare er opførslen uændret (eksisterende mappe-rotation, mindst fyldte først).
### Logik pr. tur (i `getNextPickup`-wrapperen)
1. **Ingrediensliste pr. køretøj.** Ved >1 valgt vare gemmes listen i modens egen tilstand
(`vehicle → {ingredients, narrowedTo}`). Derefter indsnævres AD's `selectedFillTypes` til den
valgte vare, fordi AD ellers selv roterer vare ved triggeren
(`ADStateModule:selectPreferredFillTypeFromFillLevels`).
Ved næste kald: er AD's valg stadig `{narrowedTo}` → brug den gemte liste. Er det noget andet →
brugeren har ændret valget: ny multi-liste erstatter, enkelt-valg sletter tilstanden.
2. **Behov.** Find `UnloadingStation` nærmest anden markør (≤ 40 m). For hver ingrediens summeres
`level`, `capacity` over `station.targetStorages`; `free = capacity - level`.
Ingredienser stationen ikke understøtter, eller med `free < MIN_FREE_LITERS`, springes over.
3. **Rangering.** `ADSupplyPlanner.scoreNeed(level, capacity, freeSpace)` → højeste score først.
4. **Kilde.** For ingredienserne i behovs-rækkefølge: blandt *alle* AD-markører (ikke kun én mappe)
findes markører med farm-tilgængelig `LoadingStation` ≤ 40 m der har varen. Aflæsningsmålets
egen placeable udelukkes som kilde. Rangering som eksisterende: eget lager før nabo-lån,
mindst fyldte først. Første ingrediens med en kilde vinder.
5. **Effekt.** `setFillType(vare)`, `selectedFillTypes = {vare}`, dirty-flag, returnér markørens
`markerIndex`. Logges som `ADSmartPickup: '<traktor>' -> <markør> (<vare>, behov …)`.
6. **Fallback.** Intet behov / ingen kilde / ingen station / fejl (`pcall`) → AutoDrives
originale `getNextPickup` (ved fejl: `Logging.warning`). Aldrig crash.
## Filer
| Fil | Ansvar |
|---|---|
| `FS25_ADSmartPickup/adSupplyPlanner.lua` (ny) | Rene funktioner: `scoreNeed`, `rankNeeds`, `resolveIngredients` (tilstandslogik). Ingen spil-API. |
| `FS25_ADSmartPickup/adSmartPickup.lua` | Spil-/AD-adaptere (station ved markør, lagerniveauer, kilde-søgning) + hook. Kilde-søgning generaliseres til at tage en markørliste. |
| `FS25_ADSmartPickup/modDesc.xml` | Ny sourceFile før `adSmartPickup.lua`, version 1.1.0.0, beskrivelse. |
| `tests/test_adSupplyPlanner.lua` (ny) | Enhedstests af de rene funktioner. |
| `tests/test_adSmartPickup.lua` | Integration gennem hook'en med mocks; eksisterende 11 tests skal forblive grønne. |
## Tests (luajit, mocks)
- mest trængende ingrediens vælges; vare og AD-valg skiftes; rigtig markør returneres
- ingrediens uden plads i målet springes over
- ingrediens uden kilde springes over → næste i rækken
- kilde i anden mappe end første markør findes
- aflæsningsmålets egen station bruges ikke som kilde
- gemt liste genbruges når AD's valg er indsnævret; brugerændring nulstiller/erstatter
- én vare valgt → eksisterende opførsel uændret
- intet behov / ingen aflæsningsstation / API-fejl → `ORIGINAL` (+ WARN ved fejl)
## Kendte begrænsninger
- AD læsser fuld vogn; er der mindre plads end ét læs, venter traktoren ved aflæsning (AD-standard).
- Ingredienslisten lever kun i hukommelsen — efter savegame-reload multi-vælges varerne igen.
- Forudsætter at målet er en placeable med aflæsningstrigger (produktionspunkt/silo/stald),
ikke en blandevogn. Bekræftes ved første in-game test via log.txt.
- Server-side logik; MP-klienter ser vareskiftet via AD's eget dirty-flag.
@@ -1,156 +0,0 @@
# Runs-UI ("Cow Feed") — design
Dato: 2026-09-22 · Status: godkendt af bruger (sektion 1–3) · Version: v1.8
## Problem
Al opsætning sker i dag pr. traktor i AutoDrives HUD: mål, multi-valg af varer (tændknap for
behovsstyring), mapper + rotation, loop-tæller. Med fire ens "Fastrac 2135" til to stalde og flere
Wait-puljer på vej (Cow/Pig/Sheep Wait) mangler overblik og central kontrol: *hvad* kører *hvor*
med *hvilke varer*, og hvor venter de.
## Løsning
Et **run** er den centrale enhed: navn, aflæsningsmål, varer, Wait-pulje, tildelte traktorer,
loop-tæller. En ESC-menu-fane ("Smart Pickup") viser og redigerer runs og starter/stopper dem.
"Start" skriver run'et ind i hver traktors AutoDrive-tilstand og kalder AD's start — alt det
eksisterende (behovsstyring, fabriks-inputs, Wait-punkter) virker uændret ovenpå, fordi det
læser præcis den tilstand. Traktorer uden run kører som i dag.
Valgt tilgang: **run-lag oven på AutoDrives tilstand** (fravalgt: egen dispatcher der kalder AD's
lav-niveau-opgaver — genopfinder P&D og knækker ved AD-opdateringer; konfig-fil uden UI — ikke det
brugeren bad om).
## 1. Datamodel og lagring
```
Run
id løbenummer (stabilt, unikt i savegame)
name "Cow Feed"
targetWayPointId AD-waypoint-id for mål-markøren (stald/fabrik); vises som markørnavn
ingredientMode "auto" | "manual"
ingredients liste af fillType-NAVNE (kun ved manual), fx {"STRAW","FORAGE"}
waitPoolGroup AD-mappenavn (fx "Cow Wait"); tom streng = alle Wait-mapper, nærmeste ledige
loops 0 = uendeligt, N = antal ture (AD's loop-tæller)
vehicleIds liste af køretøjs-referencer (se nedenfor)
Runtime (ikke gemt):
isRunning afledt: mindst én tildelt traktor har AD aktiv og run-medlemskab
runMembership vehicle -> run.id (svagt nøglet tabel)
```
- **Køretøjs-reference:** AD's `uniqueId` (streng) når den findes på køretøjet, ellers savegame-id
(`vehicle.currentSavegameId`). Navne bruges aldrig som nøgle (fire hedder "Fastrac 2135 4WS").
- **WaitPool** er afledt, ikke gemt: alle AD-mapper med ordet "Wait" i navnet (`ADSupplyPlanner.isWaitMarker`).
Omdøbning sker i AutoDrive; vi læser kun.
- **Fil:** `savegameN/ADSmartPickup_runs.xml`, samme mappe som `AutoDrive_config.xml`. Skrives i
spillets gem-hook (som AD), læses ved `loadMap`. Ukendte køretøjs-referencer droppes med loglinje;
run'et beholdes. Runs der kørte ved gem genstartes IKKE ved load (AD gendanner selv aktive køretøjer).
- **Regler:** en traktor kan kun være i ét run (tildeles den et andet, flyttes den — UI'et siger det).
Singleplayer først; multiplayer kræver events og er uden for scope.
XML-format:
```xml
<ADSmartPickupRuns version="1">
<run id="1" name="Cow Feed" target="11066" ingredientMode="manual" ingredients="STRAW FORAGE"
waitPool="Cow Wait" loops="0">
<vehicle id="..."/>
<vehicle id="..."/>
</run>
</ADSmartPickupRuns>
```
## 2. UI — ESC-fane "Smart Pickup"
Forbillede: FarmOperationsDashboard (`TabbedMenuFrameElement`, XML-layout med `SmoothList`,
`MultiTextOption`, `Button`; registreret via `g_gui:loadGui` + indsættelse i `InGameMenu.pagingElement`).
```
┌ Smart Pickup ─────────────────────────────────────────────────────────────┐
│ [Runs] [Wait-puljer] (under-faner) │
├──────────────────┬────────────────────────────────────────────────────────┤
│ RUNS │ RUN: Cow Feed ● Kører (2/2) │
│ ▶ Cow Feed 2/2 │ Mål: [Cow 1 Food ▼] (AD-markør) │
│ Cow 2 Feed 1/2 │ Varer: (•) Auto: alt målet tager / fabrikken │
│ TMR Fabrik 1/1 │ ( ) Vælg: [x] Halm [x] TMR [ ] Vand │
│ │ Wait-pulje: [Cow Wait ▼] (6 punkter)│
│ [+ Nyt run] │ Ture (loop): [0 = uendeligt ◄►] │
│ [Slet run] │ Traktorer: │
│ │ Fastrac 2135 (#1) vogn: 1.000.000 l halm+TMR ✔ │
│ │ Fastrac 2135 (#2) vogn: — INGEN VOGN ✖ │
│ │ [+ Tilføj traktor ▼] [Fjern] │
│ │ [▶ Start run] [■ Stop run] Status: 1 advarsel │
└──────────────────┴────────────────────────────────────────────────────────┘
```
- **Runs-liste (venstre):** alle runs med "kører X af Y". Valg → detaljer til højre. `+ Nyt run`
opretter "Run N" (navn redigeres via AD-lignende tekstdialog). `Slet run` spørger ja/nej.
- **Mål:** dropdown over AD-markører der ligger ved en aflæsningsstation (`getUnloadStationAtMarker`
≠ nil) — ikke alle markører.
- **Varer:** *Auto* = det eksisterende: fabrik → ønskede produktioners inputs (v1.6); stald → alle
varer stalden tager (`getHusbandryIsFillTypeSupported`); andet mål → stationens understøttede varer.
*Vælg* = afkrydsning blandt målets mulige varer.
- **Wait-pulje:** dropdown "Alle (nærmeste ledige)" + hver mappe med "Wait" i navnet (antal punkter).
- **Ture:** 0–99.
- **Traktorer:** dropdown over farmens køretøjer med `vehicle.ad` (navn + `#løbenummer`). Pr. række:
vogn-kapacitet (AD `getAllFillLevels`), hvilke af run'ets varer vognen kan bære, og ✔/✖ med tekst:
"ingen vogn", "kan ikke bære: TMR", "N m fra vejnettet", "i run X (flyttes)".
- **Start/Stop** gælder hele run'et; ✖-traktorer springes over med besked i rækken.
- **Wait-puljer-fanen:** liste over Wait-mapper: antal punkter, hvilke traktorer holder hvor lige nu
(fra `adUnloadWait`'s reservationer). Kun visning.
- Live-opdatering hvert sekund mens siden er åben. Dansk + engelsk l10n. Ingen nye tastaturgenveje.
## 3. Start/Stop, integration, fejl og test
**Start run** (pr. tildelt traktor):
1. Validér: vogn tilkoblet og kan bære ≥1 af run'ets varer (`AutoDrive.getSupportedFillTypesOfAllUnitsAlphabetically`);
≤ 30 m fra vejnettet (`ADGraphManager:getDistanceFromNetwork`); mål-markør findes. Fejl → ✖ på rækken,
traktoren springes over, run'et fortsætter med de andre.
2. Skriv AD-tilstand: `stateModule:setMode(AutoDrive.MODE_PICKUPANDDELIVER)`;
`setSecondMarkerByWayPointId(target)`; `setFirstMarkerByWayPointId(nærmeste markør med læssestation)`
(kun en gyldig start — moden vælger reel kilde pr. tur); `selectedFillTypes` = run'ets varer
(Auto: udledt liste) + `raiseDirtyFlag`; loop-tæller; per-køretøj `vehicle.ad.settings.useFolders`
= til og `rotateTargets` = kun pålæsning (`current` og `new`).
3. `vehicle:startAutoDrive()`.
4. `runMembership[vehicle] = run.id`.
**Stop run:** `vehicle:stopAutoDrive()` pr. tildelt traktor; medlemskab bevares (konfigureret, ikke kørende).
**Integration:** de eksisterende hooks læser AD-tilstanden uændret. Run'et læses to steder:
(a) `adUnloadWait`: Wait-kandidater begrænses til run'ets mappe, hvis sat; (b) `choosePickup`:
for run-traktorer tages ingredienslisten fra run'et (Auto/manual) i stedet for "≥2 valgt i dropdown",
så tændknappen ikke længere kræves. Traktorer uden run: præcis som i dag.
**Fejlhåndtering:** al UI-kode og Start/Stop i `pcall`; fejl vises i status-linjen og logges med
`ADSmartPickup:`-præfiks; aldrig crash. Manglende AD-API → fanen viser "AutoDrive-API ikke fundet",
resten af moden upåvirket.
**Test:** rene funktioner i luajit (run-model: opret/omdøb/tildel/flyt; validering; Auto-vareliste;
XML ↔ tabel; kandidat-filtrering pr. Wait-pulje). GUI og AD-skrivning verificeres in-game efter
tjekliste: opret run → tildel 2 traktorer (én uden vogn) → Start (én ✖) → Stop → gem/load → run bevaret.
**Afgrænsning (YAGNI):** singleplayer; ingen kilde-begrænsning pr. run; ingen omdøbning af
Wait-mapper; ingen auto-genstart ved load; ingen genvejstast-dialog.
## Filer (forventet)
- `FS25_ADSmartPickup/adRuns.lua` — run-model + XML (rene funktioner hvor muligt)
- `FS25_ADSmartPickup/adRunsController.lua` — Start/Stop, validering mod AD/spil-API, medlemskab
- `FS25_ADSmartPickup/gui/SmartPickupFrame.lua` + `gui/SmartPickupFrame.xml` + `gui/guiProfiles.xml`
- `FS25_ADSmartPickup/l10n/l10n_da.xml`, `l10n_en.xml`
- `tests/test_adRuns.lua`
- Tilpasninger i `adSmartPickup.lua` (ingredienser fra run) og `adUnloadWait.lua` (pulje-filter)
## Efterfølgende (ikke i v1.8): læs-bytte fra Wait
In-game 2026-09-22 21:55: begge stalde fulde af halm, mangler TMR; alle fire traktorer venter ved
Cow Wait med vognene fulde af halm → ingen kan hente TMR (deadlock).
Design: mens en traktor venter, tjekkes målets behov. Hvis målet er fuldt for vognens vare, og en
ANDEN vare i traktorens ingrediensliste mangler så meget at et fuldt læs passer (hysterese), og
vognen kan bære den: find en silo (lager-placeable med aflæsningstrigger ved en AD-markør, fri plads
≥ rest, rute til/fra) — foretræk den moden ellers henter varen fra — og omdiriger aflæsningsopgaven
dertil. AD læsser af, vognen er tom, næste tur vælger moden den manglende vare efter behov. Log:
"bytter læs: N l STRAW tilbage i <silo>, målet mangler FORAGE". Ingen silo → bliv ved Wait.
Kræver at traktoren har begge varer i sin liste (run eller multi-valg).
@@ -1,92 +0,0 @@
# Flow-tavle — design
Dato: 2026-09-23 · Status: godkendt af bruger (grundform valgt: flow-tavle) · Version: v1.13
## Problem
Opsætningen er markør-baseret: brugeren vælger AD-markører som kilder og mål, og moden gætter
bygningen ud fra markørens geometri (trigger bag næsen, 40 m, 12 m). Fejl viser sig som
"kilde-markør 10873 er ikke …" i stedet for "Kostald 1", først når run'et starter. Forsyning og
Udkørsel er to faner med hver sin lange rulleside, Oversigt er en tredje. Brugeren: "det er noget rod".
## Løsning
Én tavle med ét flow pr. række: **Vare · Fra · Til · Regel · Traktorer · Status**. Fra og Til vælges
som **bygninger**; moden finder selv markøren ved bygningens læsse-/aflæsningstrigger og viser den.
Redigering sker i en detail-blok under tavlen for det valgte flow. Faner: **Flows** · **Lagre**.
Motoren (planlægger, hooks, XML-model) er uændret: et flow ER et run; bygninger oversættes til
markører når run'et startes og planlægges.
Fravalgt: "Bygninger først" (runs implicit → svært at se hvad der kører) og "Sætnings-run"
(mindst ændring, men fanerne forbliver adskilte).
## 1. Bygninger (`adBuildings.lua`, spil-adapter)
`ADBuildings.list(adEnv, farmId)` → liste af bygninger gården kan bruge:
```
{ id, name, kind = "factory"|"silo"|"husbandry"|"objectStorage"|"sell",
placeable, loadMarker = {id, name, distance} | nil, unloadMarker = {…} | nil,
canBeSource = bool, canBeTarget = bool, canSell = bool }
```
- Kilde: fabrik, silo, stald (læssestation), objektlager. Mål (forsyning): stald, fabrik.
Salg: SellingStation der er et rigtigt salgssted (ikke fabrikkers/staldes indleveringer).
Siloer og palleskure er automatiske lagermål (som i dag) og vælges ikke.
- `id` = `placeable.uniqueId`; mangler det, `"pos:" .. afrundet x/z`.
- Markør-opslag: for hver AD-markør (Wait-markører udelukket) afstanden til bygningens
læsse-triggere (loadTriggers.triggerNode) og aflæsnings-triggere (unloadTriggers exactFillRootNode/
triggerNode/aiNode) samt objektlagerets/pallespawnerens node; nærmeste ≤ 40 m vinder.
Siloer bruger det strenge opslag (bag næsen) for læssemarkøren, som i dag.
- Én scanning pr. sekund højst (cache), da fanen spørger ved hver opdatering.
- `ADBuildings.find(adEnv, farmId, id)`, `ADBuildings.byMarker(adEnv, farmId, wayPointId)` (omvendt
opslag til migration).
## 2. Model
Run får `sourceBuildingIds` og `targetBuildingIds` (lister af bygnings-id). De eksisterende
`sourceWayPointIds`/`targetWayPointIds` bevares som **afledte** felter: controlleren udfylder dem fra
bygningerne ved Start og ved hver plan (`ADRunsController.resolveMarkers(adEnv, run)`), så
planlægger og hooks er uændrede. XML-version 3 skriver `#sourceBuildings`/`#targetBuildings`;
version 2-filer læses, og bygninger udledes fra markørerne ved første indlæsning
(`ADBuildings.byMarker`); kan en markør ikke oversættes, beholdes den som rå markør.
Forsyning: Fra = "alle siloer" (ingen valg; sources er alle markører som i dag). Til = bygninger.
Udkørsel: Fra = bygninger. Til = politik pr. vare (Lager/Salg/Salg, ellers lager) — målet vælges
automatisk pr. tur som i dag.
## 3. Fanen Flows
**Tavle** (øverst, tekstrækker, én pr. run, den valgte markeret med "▶"):
`Mælk · Kostald 1 + Kostald 2 → Mejeri (salg) · pris ≥ 90 % · Virtus · venter: 42 % af top`
- Vare: run'ets varer (forsyning auto = "behov"), Fra/Til = bygningsnavne (+ "!" hvis en bygning
mangler markør), Regel = kort tekst, Traktorer = navne, Status = én linje (som Oversigt i dag:
kører/holder/venter/begrundelse/advarsel).
- Under tavlen: Wait-puljer (som i dag) og en linje "Vælg flow ovenfor for at rette".
**Detail-blok for valgt flow** (efter tavlen; run-vælgeren `Flow:` er en MultiTextOption):
- *Retning*: Ind til bygning / Ud fra bygning (klonet Ja/Nej-række).
- *Fra* (kun Ud): afkrydsning pr. bygning der kan være kilde: `Kostald 1 (stald) — markør 'cow 1 milk' 12 m` eller `— INGEN MARKØR inden for 40 m`.
- *Til* (kun Ind): afkrydsning pr. stald/fabrik med samme markørtekst.
- *Varer*: Ind: Auto/Vælg + afkrydsning; Ud: politik pr. vare med mængde og pris-% (som i dag).
- *Regler*: Ind: ture, kør hjem under; Ud: sælg fra, lager over, saml op.
- *Vent ved*: Wait-pulje.
- *Traktorer*: som i dag (låst når vognen ikke passer).
- Knapper: Nyt flow / Omdøb / Slet / Start-Stop (som i dag). Nyt flow spørger navn og starter som "Ind".
Fanen **Lagre** uændret. Oversigt og de to gamle faner udgår.
## 4. Validering og fejl
- Bygning uden markør: rækken siger det, tavlen viser "!" ved navnet, Start afviser med
`noMarker:<bygning>` i statuslinjen ("Kostald 2 mangler en AD-markør ved mælketanken").
- Bygning der er forsvundet (solgt): udgår af listen; run'et beholder id'et og viser "(mangler)".
- Alle eksisterende afvisningsgrunde uændrede.
## 5. Tests
- `adBuildings`: kind pr. placeable, markør-opslag (nærmeste ≤ 40 m, Wait udelukket, silo strengt),
byMarker, id-fallback.
- model: v3 roundtrip, v2-migration med byMarker-oversættelse, rå markør bevares.
- controller: resolveMarkers udfylder waypoint-lister; noMarker-validering; Start med bygninger.
- GUI in-game (savegame 3 og 2).
@@ -1,201 +0,0 @@
# Udkørsel af produkter — design
Dato: 2026-09-23 · Status: godkendt af bruger (afsnit 1–4) · Version: v1.12
## Problem
Modden styrer i dag kun forsyning *ind* til stalde og fabrikker. Fabrikkernes output (sukker,
TMR, halmvarer på paller) og varer der står på lager skal køres *ud*: til en silo som
mellemlager, eller til et salgssted — men kun når prisen er god, så man ikke sælger i bund.
Uden det løber fabrikkernes udgangslager fuldt, og produktionen stopper. Paller ligger ofte i
objektlagre (fx HVF-pakkens palle-/ballelager) hvor man normalt klikker "tag ud" manuelt.
## Løsning
Et run får en **retning**: *Forsyning* (som i dag) eller *Udkørsel*. Et udkørsels-run har en
liste af **kilder** (fabrik, silo eller objektlager), en **vareliste** med politik pr. vare
(Lager / Salg / Salg, ellers lager / Fra), en **pristærskel** (% af årets toppris), en
**lagertærskel** (% fyldning) og valget **opsamlingsrunde**. Før hver tur vurderer en ren
planlægger om en tur er berettiget, vælger vare(r) + mål, og sætter AutoDrives tilstand;
AutoDrive kører, læsser (også paller via Universal Autoload) og læsser af. Ingen tur → traktoren
holder ved et Wait-punkt og prøver igen hvert 5. sekund.
Valgt tilgang: nyt modul der wrapper AutoDrives læsseopgave (samme mønster som Wait-modulet
wrapper aflæsseopgaven). Fravalgt: AutoDrives egen "automatisk aflæsningsmål" (ingen pristærskel,
ingen lager-mulighed, ingen ventelogik) og egen kørselsstyring med DriveTo-opgaver (genopfinder
AutoDrives læsse-/pallehåndtering).
## 1. Faner og datamodel
### Faner i ESC-siden
1. **Oversigt** (som i dag): alle runs med status, traktorernes aktivitet, behovsrække. Udkørsels-
runs vises med prislinje i stedet for behov: `SUGAR 412 kr (78 % af top) → venter`.
2. **Forsyning**: de nuværende runs, samme indhold, grupperet i blokke med overskrift:
*Run* (vælger + Nyt/Omdøb/Slet) · *Mål og varer* · *Ventepunkter og ture* · *Traktorer* ·
*Status* (+ Start/Stop).
3. **Udkørsel**: *Run* · *Kilder* (liste af markører, tilføj/fjern) · *Varer* (én række pr. vare:
navn, politik, aktuel pris i % af top) · *Regler* (pristærskel 50–100, standard 90;
lagertærskel 50–95, standard 80; opsamlingsrunde Ja/Nej) · *Ventepunkter* · *Traktorer* ·
*Status* (+ Start/Stop).
Run-vælger, Wait-pulje, traktorliste, knapper og statuslinje er fælles byggeklodser; hver fane
viser kun runs med sin egen `kind`.
### Kilder
Kilde = AD-markør. Kildetypen genkendes ved markøren (nærmeste inden for 40 m, som triggere i
dag):
- **Fabrik** (produktionspunkt): tilgængeligt pr. vare = udgangslager + paller af varen der
allerede står ved pallespawneren (inden for 25 m af spawner-noden).
- **Silo** (spec_silo med læssestation): tilgængeligt = lagerniveau for varen.
- **Objektlager** (spec_objectStorage): tilgængeligt = antal × fyldning pr. palle for varen
(`objectInfos[*].objects[*].palletAttributes`). Udtag sker automatisk, se afsnit 3.
Varelisten for et run = alle varer kilderne rummer eller producerer, som mindst én tildelt vogn
kan bære. Bulk-varer kræver tipvogn/tank; pallevarer kræver Universal Autoload.
### Datamodel (adRuns.lua, rene funktioner, ny store ved hver ændring)
```
Run (fælles)
id, name, waitPoolGroup, vehicleIds, kind = "supply" | "outbound"
Run (supply, uændret)
targetWayPointId, targetWayPointIds, ingredientMode, ingredients, loops, returnBelowPercent
Run (outbound)
sourceWayPointIds liste af AD-waypoint-id'er (1..n)
products liste af {fillType = NAVN, policy = "store"|"sell"|"sellElseStore"|"off"}
sellAtPercent 50..100, standard 90
storeAbovePercent 50..95, standard 80
collectRound bool, standard false
```
XML-version 2 i samme `ADSmartPickup_runs.xml`. Version 1-filer læses og får `kind="supply"`.
Ukendte varer i `products` (mod fjernet) ignoreres ved indlæsning med én log-linje.
## 2. Turvurdering og prisregel
`adOutboundPlanner.lua` er rent Lua (ingen spil-API). Input: run, vognens kapacitet (liter) og
om den har UAL, kildestatus pr. vare `{available, level, capacity}` pr. kilde, prisstatus pr.
vare `{percent, stationId, reachable}`. Output: en tur eller `nil` + begrundelse.
Pr. vare:
- **Tilgængeligt**: sum over kilderne.
- **Fuldt læs**: tilgængeligt ≥ vognens kapacitet.
- **Lager højt**: mindst én kilde har `level/capacity ≥ storeAbovePercent/100` for varen.
- **Pris-%**: bedst betalende *nåelige* salgssted der tager varen (adPrices). Toppris =
stationens aktuelle grundpris for varen × (årets højeste periodefaktor ÷ nuværende periodes
faktor). Pris-% = aktuel effektiv pris ÷ toppris. Stor efterspørgsel giver > 100 % og opfylder
altid reglen. Mangler stationens grundpris, bruges `fillType.pricePerLiter × højeste faktor ×
sværhedsgradens multiplikator` som toppris.
Beslutning, i rækkefølge:
1. **Salgskandidater**: politik ∈ {sell, sellElseStore}, pris-% ≥ sellAtPercent, tilgængeligt > 0.
Kører **uanset mængde**. Rangering: højeste pris-% først, derefter størst tilgængeligt.
2. **Lagerkandidater**: politik ∈ {store, sellElseStore} og (fuldt læs ELLER lager højt).
Rangering: højeste fyldningsgrad først, derefter størst tilgængeligt.
3. Ingen kandidat → hold ved Wait. Log én gang pr. ændret begrundelse:
`venter: SUGAR 78 % af top (kræver 90), lager 41 %`.
**Opsamlingsrunde** (`collectRound`):
- Bulk-vogn: én vare, men fra flere kilder efter tur indtil vognen er fuld (afsnit 3).
- UAL-vogn: alle kandidater med samme mål på én tur; vognens vareliste sættes til dem alle, og
AutoDrive skifter selv vare når der ikke kommer flere af den første.
**Mål**:
- Salg: det salgssted der betaler bedst for hovedvaren (første i rangeringen), tager alle rundens
varer, og kan nås begge veje.
- Lager: nærmeste silo-markør med læsse+aflæsningsstation der tager varen, fri plads ≥ hele læsset,
ikke selv kilde, fabrik eller stald, rute begge veje (samme regler som læs-byttet).
Prisen tjekkes kun ved turens start.
## 3. Kørsel, hold og pallelæsning
`adOutbound.lua` med to hooks:
**Start (controller).** Som forsyning: PickupAndDeliver, rotation "kun pålæsning", mapper til.
1. markør = første kilde, 2. markør = første kilde (pladsholder, sættes pr. tur). Turen
registreres i `activePickups`, så "stå og læs"-grebet og 30 s-sikkerhedsventilen gælder.
**Hook 1 — næste pålæsning** (det eksisterende `getNextPickup`-wrap): udkørsels-traktor →
planlæggeren spørges. Tur → vognens `selectedFillTypes` = turens varer, 2. markør = målet,
kilden returneres. Ingen tur → ledigt Wait-punkt fra run'ets pulje returneres (reservation +
rutetjek via adWaitPool), traktoren markeres "holder".
**Hook 2 — læsseopgaven** (prepend på `LoadAtDestinationTask.update`):
- *Holder ved Wait:* traktoren stoppes ved punktet; planlæggeren spørges hvert 5. sekund. Tur →
opgaven omdirigeres til kilden (`redirect`), varer + 2. markør sættes, "holder" slippes.
- *Ved objektlager:* når markøren er nået, traktoren står stille og AutoDrive har aktiveret UAL:
kald `placeable:removeAbstractObjectsFromStorage(objectInfoIndex, 1, nil)` for turens vare;
vent til lagerets antal falder OG UAL's antal læssede stiger; gentag. Stop når
`ualIsFull()`, lageret er tomt for varen, eller intet er sket i 30 s → `task:finished()`.
Ved runde med flere pallevarer tages varerne efter tur. `connection = nil` så
AutomaticBaleStorage-moddens kø ikke aktiveres.
- *Kør uanset mængde:* stiger vognens fyldning ikke i 15 s og vognen ikke er tom → `task:finished()`.
- *Runde, bulk:* når læsning stopper ved kilde A med fri plads ≥ 10 % og en anden kilde i listen
har samme vare, omdirigeres opgaven til kilde B i stedet for at afslutte. Hver kilde højst én
gang pr. tur.
**Aflæsning** er AutoDrives egen (tip i silo; tip eller UAL-aflæsning ved salgssted).
`adUnloadWait` springer udkørsels-traktorer over (run'ets `kind`). Efter aflæsning går AutoDrive
selv til næste pålæsning → hook 1 igen. Stop som i dag.
`adWaitPool.lua` udskilles fra `adUnloadWait.lua`: reservationer (markør-id → køretøj),
`hasRouteTo`/`hasRouteBothWays`, `redirect`, kandidatliste for en pulje. Begge moduler bruger den.
## 4. Fejl, advarsler og tests
**Uopnåeligt salgssted / ingen silo.** Kan det bedst betalende salgssted ikke nås, tages det
næstbedste nåelige. Kan intet nås (eller ingen silo med plads ved Lager), vises en rød
AutoDrive-notifikation på skærmen
(`AutoDriveMessageEvent.sendMessageOrNotification(vehicle, ERROR, …)`):
`ADSmartPickup: 'MT635' kan ikke nå salgssted 'Grain Elevator' for SUGAR – tjek vejnettet`.
Samme tekst i AutoDrives notifikations-historik, log.txt (WARN) og run'ets statuslinje.
Traktoren holder ved Wait og prøver igen hvert 5. sekund. Gentages først når traktor, station
eller vare ændrer sig.
**Fejlhåndtering:** spil-API-kald i pcall; fejl → hold ved Wait + WARN én gang, aldrig
AutoDrive-stop. Validering ved Start (nye grunde): `noSource` (markør uden genkendelig kilde),
`needsAutoload` (pallevare uden UAL), `noProducts` (ingen vare med politik ≠ Fra). Objektlager
der ikke spawner → 30 s-vagt afslutter læsningen med det der er.
**Tests (luajit, uden spil):**
- planlægger: prisregel, tærskler, alle fire politikker, rangering, runde bulk/UAL, uanset mængde
- priser: toppris, pris-%, stor efterspørgsel > 100 %, fallback uden stationspris
- model + XML v2 + migration fra v1
- objektlager-udtag som tilstandsmaskine mod fake-lager (fuld, tom, stall)
- kilde-genkendelse mod fake-markører (fabrik/silo/objektlager, paller ved spawner)
- controller-validering (noSource, needsAutoload, noProducts)
**In-game (savegame 3):** sukker → salgssted ved høj pris; TMR → Farma-silo; halmkurve fra
fabrik og fra objektlager → halm-salgsstedet (UAL); uopnåeligt salgssted → advarsel på skærm.
## Filer (forventet)
```
FS25_ADSmartPickup/
adRuns.lua kind, sourceWayPointIds, products, tærskler, collectRound; XML v2
adRunsStorage.lua v2 læs/skriv, v1-migration
adOutboundPlanner.lua NY, ren
adPrices.lua NY, ren del + spiladapter
adSources.lua NY, kilde-genkendelse + lagerstatus
adOutbound.lua NY, hooks, objektlager-udtag, hold, runde
adWaitPool.lua NY, udskilt fra adUnloadWait.lua
adUnloadWait.lua bruger adWaitPool; springer outbound over
adRunsController.lua start/validering for outbound
adSmartPickup.lua source() af nye filer; getNextPickup-wrap kalder adOutbound
gui/SmartPickupFrame.lua faner + fælles blokke
gui/SupplyTab.lua NY
gui/OutboundTab.lua NY
l10n/l10n_da.xml, l10n_en.xml, README.md
tests/test_adOutboundPlanner.lua, test_adPrices.lua, test_adSources.lua,
tests/test_adOutbound.lua, test_adRuns.lua (v2), test_adRunsController.lua (outbound)
```
@@ -1,151 +0,0 @@
# Markarbejde, etape 1: slåafgrøder (græs, kløver, lucerne)
Dato: 2026-09-25 · Mod: FS25_ADSmartPickup · Branch: `feature/markarbejde`
## Mål
Når en græs-, kløver- eller lucernemark er klar, sender Smart Pickup selv maskinerne ud. Slåmaskinen slår
marken via Courseplay. Ved **ensilage** samler en læssevogn skårene op og kører dem til den rigtige silo.
Ved **hø** vender en vender først græsset (og en river river det evt. sammen), og derefter samles høet op.
Alt kørsel på vej sker med AutoDrive. Alt arbejde på marken sker med Courseplay.
Succes: en mark sat til *Auto* går fra "klar" til "færdig" med græsset i en silo, uden at spilleren rører
noget, og menuen viser hele tiden, hvad der sker og hvorfor.
Ikke med i etape 1: mejetærskerafgrøder (etape 2), roer (etape 3), jordbehandling og såning, baller.
## Arbejdsdeling
| Del | Ansvar |
|---|---|
| **Smart Pickup (vores)** | Hvilken mark, hvilket trin, hvilken maskine, hvilken silo. Holder status og viser den |
| **AutoDrive** | Kørsel på vej til markens `F`-markør og til silo; overtager ved "vogn fuld" og genstarter Courseplay |
| **Courseplay** | Markgrænse, kursus, selve arbejdet på marken, "fuld"/"færdig"-signaler |
## Beslutninger (med begrundelse)
1. **Faste sæt.** Redskabet sidder fast på traktoren (eller er selvkørende, fx Krone BiG M 450). Mod'en
kobler aldrig redskaber på. *Begrundelse:* auto-påkobling var det, der aldrig blev pålideligt i AI-Bonden.
2. **Markflåde med forslag og flueben.** Mod'en foreslår sæt ud fra redskabets spec (`spec_mower`,
`spec_tedder`, `spec_windrower`, `spec_forageWagon`); kun sæt med flueben bliver sendt ud.
*Begrundelse:* mod'en må aldrig tage en maskine, spilleren selv bruger. Et sæt, spilleren sidder i
(`getIsControlled`/`getIsEntered`), springes altid over.
3. **Staging-markør pr. mark = AutoDrive-markør med navnet `F<marknummer>`** (fx `F109`), stort/småt
ligegyldigt, evt. med mellemrum (`F 109`). Mangler den, vises marken med "!" og sendes ikke ud.
4. **Auto/Manuel pr. mark.** Auto starter selv ved "klar"; Manuel venter på *Start nu*. Standard: Manuel
(intet sker uventet efter opdatering).
5. **Plan pr. mark: Ensilage eller Hø.** Standard: Ensilage. **Riv** er et valg pr. mark, standard *fra*:
læssevognene i Unrealistic Pack kan køre 40 m bredt og samler spredt hø op.
6. **Bredde.** Maskiner kører med deres *aktuelle* bredde (Unrealistic Packs `variableWorkWidth`,
`aiKeepCurrentWidth`). Courseplay kan højst 50 m → et sæt over 50 m sendes ikke ud (advarsel).
Marken husker **slåbredden**; opsamling/vending/rivning kræver bredde ≥ slåbredden.
**Revideret efter in-game test 25/9:** et nyt Courseplay-kursus til læssevognen lå forskudt i forhold til
skårene (anden start/retning/forager). Derfor genbruger vender, river og læssevogn **slåmaskinens
kursus** (`vehicle:cpCopyCourse`). Kurset gemmes pr. mark; efter genindlæsning hentes det fra
slåmaskinen (id gemt som `mowVehicle`), som Courseplay selv gemmer kurset på. Uden kendt slåkursus
laves et nyt som reserve (logges).
**Revideret igen 25/9 (smalle standardmaskiner):** vender og river kører hele marken i egen bredde med
eget kursus (intet breddekrav). Læssevognen følger skårene: uden riv slåmaskinens kursus og bredde ≥
slåbredde; med riv rivens kursus (skåret ligger midt i banen) og intet breddekrav. Riv kan vælges til
både hø og ensilage.
**Sideriver (25/9):** mens riven arbejder, måles nedlægningsområdets midte (runtime-felt
`dropWindrowWorkAreaIndex`, ellers riverens XML) i traktorens ramme minus rivens CP-forskydning =
skårets sideforskydning (gemt pr. mark). Læssevognen på rivens kursus får `toolOffsetX = egen −
skårets` (±10 m) og får sin egen værdi tilbage bagefter.
7. **Silovalg** genbruger Smart Pickups motor (`ADLoadSwap`). *Tving lager* pr. mark (som "Lager til" i
flows). **Revideret 25/9:** ensilage går først til egne **gæringssiloer** (produktion skår -> SILAGE,
fx FermentingSiloPack/GrassSilageSilo), nærmest marken, ellers nærmeste silo med plads; hø kun til
almindelige siloer (gæringssiloer laver også hø om til ensilage).
8. **Marksilo.** En silo, hvis afstand til markens polygon er ≤ 150 m, er et mellemlager. Den må gerne
vælges (korte ture). Oversigten viser "!" ved marken, hvis intet Smart Pickup-udkørselsflow har
siloen som kilde — hjemkørslen klares af de eksisterende flows, ikke af markdelen.
9. **"Klar"** = ≥ 90 % af markens prøvepunkter står i høstvinduet for markens slåafgrøde
(`FSDensityMapUtil.getFruitArea`, samme metode som Arbejdsseddel, bevist i spillet). `field.fieldState`
bruges ikke (frossen NPC-plan for spillerejede marker).
10. **Slåafgrøde** afgøres ud fra spillets konvertere: `MOWER` giver et skår, og `TEDDER` gør skåret til en
`DRY…`-type (græs, lucerne, kløver, kløver2). Rug/bønner (→ halm) er udelukket. Ensilage-varen er
skåret (`GRASS_WINDROW` …), hø-varen er tørskåret (`DRYGRASS_WINDROW` …).
11. **Én maskine, én mark.** Flere marker kan køre samtidig. Højst 2 læssevogne pr. mark.
12. **Rækkefølge.** Næste trin starter først, når det forrige er *færdigt* på hele marken (ingen vender
bag slåmaskinen). Enklere og uden kollisioner mellem Courseplay-kurser.
13. **Fejl stopper ikke alt.** Kan Courseplay ikke lave kursus, prøves igen efter 5 min; imens står marken
med grunden. Andre marker kører videre.
14. **Genindlæsning.** Markernes trin gemmes i `ADSmartPickup_fields.xml` i savegame-mappen. Efter
indlæsning genoptages trinnet: sæt, der ikke længere kører, bliver sendt ud igen.
## Opbygning
Rene moduler (ingen spil-API, testes med `luajit`):
| Fil | Ansvar |
|---|---|
| `adFieldModel.lua` | Mark-plan og -status som data: plan, tilstand, overgange (`nextStage`), bredde-regler, gem/indlæs-records |
| `adFieldFleet.lua` | Rolle ud fra redskabs-specs, forslag, valg af ledigt sæt (nærmest markøren) |
| `adFieldView.lua` | Rækker og tekster til fanen Marker (sortering, filtre, statusord) |
Spil-moduler (tynde, pcall'ede):
| Fil | Ansvar |
|---|---|
| `adFields.lua` | Ejede marker, polygon/prøvepunkter, afgrøde, klar-andel, `F`-markør, marksiloer |
| `adCpBridge.lua` | Start Courseplay på en mark (grænse → kursus → start, async med annullering), læs aktiv/færdig |
| `adFieldJobs.lua` | Tick hvert sekund: opdag klar, send sæt ud, følg ankomst → CP → færdig, næste trin, gem |
| `gui/SmartPickupFieldTab.lua` | Fanen **Marker**: tabel, detalje, markflåde |
### Forløb pr. mark
```
Ensilage: idle → ready → mowing → collecting → done
Hø: idle → ready → mowing → tedding → [raking] → collecting → done
done → idle, når marken ikke længere er slået (vokser igen)
```
Hvert arbejdstrin har undertilstande pr. sæt: `driving` (AutoDrive til `F`) → `starting` (CP laver
kursus) → `working` (CP kører) → `returning` (AutoDrive til parkering). Trinnet er færdigt, når Courseplay
meldte færdig for mindst ét sæt, og intet sæt på marken stadig arbejder.
**Slå/vende/rive:** AutoDrive `DRIVETO` til `F`-markøren. Ved stop inden for 30 m starter
`adCpBridge` Courseplay med `fieldPosition` = et prøvepunkt inde i marken. Når CP er færdig: AutoDrive
`DRIVETO` til sættets parkering (sættets AD-parkeringsmarkør, ellers bliver det stående ved `F`).
**Opsamling:** Før CP startes sættes AutoDrive til `PICKUPANDDELIVER`, første markør = `F`, anden =
silo, `setStartHelper(true)` med hjælper Courseplay. Så klarer AutoDrive og Courseplay selv
fuld → aflæs → tilbage → `restartCP`. Ved CP færdig: AutoDrive tømmer resten (`enableParkAtJobFinished`)
og parkerer.
## Fanen Marker
- Tabel: **Mark · Afgrøde · Plan · Auto/Man. · Status · Maskiner**. Kun slåafgrøde-marker som standard
(*Vis alle marker* viser resten). Klik på en række → detalje.
- Detalje: Plan (Ensilage/Hø), Auto/Manuel, Riv, Tving lager (liste som "Lager til"), **Start nu**/**Stop**,
status med grund, markør og slåbredde.
- Markflåde: foreslåede sæt med flueben, rolle og aktuel bredde; advarsel over 50 m.
- Knapper på fanen: Start nu / Stop på valgt mark (samme placering som flows).
- Statusord: *Venter · Klar · Slås · Vendes · Rives · Samles op · Færdig* + grund ("venter på
slåmaskine", "! mangler markør F109", "læssevogn for smal: 20 m < 40,8 m", "Courseplay: intet kursus").
## Fejlhåndtering
| Situation | Hvad sker |
|---|---|
| Ingen `F`-markør | "!" i tabellen, intet sendes |
| Intet ledigt sæt med rollen | Status "venter på …", prøver hvert 5 s |
| Sæt for bredt (> 50 m) eller for smalt (< slåbredde) | Sættet springes over med grund |
| AutoDrive kan ikke finde vej | Sættet frigives, status med grund, andet sæt prøves |
| CP-grænse/kursus fejler | 5 min pause for marken, grund vises |
| Spilleren sætter sig i et sæt | Sættet frigives fra marken (arbejdet fortsætter ikke automatisk) |
| Genindlæsning | Trin genoptages fra gemt fil |
## Test
- `tests/test_adFieldModel.lua`, `tests/test_adFieldFleet.lua`, `tests/test_adFieldView.lua`: alle
overgange, bredde-regler, forslag, sortering/filtre, gem/indlæs.
- In-game testprotokol (manualen + denne spec): én mark Manuel → Start nu → slå → saml op → silo, derefter
Hø, derefter Auto. Loglinjer: `ADSmartPickup: F109 …`.
## Senere udvidelser (noter)
- **Flere maskiner om samme mark, maks 5** (Courseplays multi-tool-grænse, 2026-09-25): slå/vende/rive med
op til 5 sæt på én mark samtidig via Courseplays konvoj (multi-tool), og op til 5 læssevogne. Kræver at
`ADFieldModel.maxRigs` bliver en indstilling pr. mark (1–5) og at sættene får samme CP-kursus med
hver sin position i konvojen.
@@ -1,52 +0,0 @@
# Markarbejde, etape 2–3 + multi-maskine: høst, roer, jordbehandling, såning
Dato: 2026-09-26 (nat) · Bygger på `2026-09-25-markarbejde-etape1-design.md` · Branch `feature/markarbejde-2`
Brugeren gav fuldt mandat ("byg resten, ALLE PUNKTER, du træffer selv gode valg") og tester i spillet dagen
efter. Beslutningerne herunder er truffet uden dialog og skal læses igennem.
## Mål
Alle gårdens marker kan køres af Smart Pickup: slåafgrøder (etape 1) og nu **høstafgrøder** (korn, raps,
majs, roer, kartofler … alt der høstes med mejetærsker/optager), inkl. **aflæsningsvogne**, **jordbehandling**
efter høst og **såning**. Hvert trin kan køres med **1–5 maskiner** på samme mark (Courseplays multi-tool).
## Beslutninger
1. **Høst er én pipeline for alle høstafgrøder.** Roer og korn adskiller sig kun ved hvilken høster der
kan tage afgrøden. Høsteren matches på afgrøden: skærebordets/optagerens frugttyper (`spec_cutter`)
skal omfatte markens afgrøde. Finsnittere (cutter med `fruitTypeConverters`) bruges ikke til høst.
2. **Aflæsning under høst = Courseplays combine-unloader** (`COMBINE_UNLOADER_CP`, bevist i AI-Bonden):
vognen følger høsteren, tømmer den og kører til en **station**. Stationen vælges af Smart Pickups
silovalg (nærmeste silo med plads der tager afgrøden, eller *Tving lager*); findes den ikke i
Courseplays stationsliste, bruges nærmeste station. Antal vogne pr. mark: 1–3 (plan).
3. **Høsteren sendes hjem** (AD-parkering) når Courseplay er færdig og tanken er tømt. Aflæsningsvogne
stoppes når høsten er færdig; har de rest, kører AutoDrive den til lageret (samme reserve som
læssevognen), ellers parkering.
4. **Efter høst** (valg pr. mark): *Ingenting*, *Kultivér* eller *Pløj*, og derefter evt. **Så**.
Såmaskinen sættes til planens afgrøde (standard: samme afgrøde som blev høstet). Kan afgrøden ikke
sås i den nuværende periode, venter marken med grund. Tom for frø -> sættet springes over med grund
(moden fylder ikke frø på; det gør brugeren/AutoDrive-refill).
5. **Multi-maskine (1–5 pr. trin, plan pr. mark).** Trin med eget kursus (slå, vend, riv, høst,
kultivér, pløj, så) laver ét Courseplay-kursus med `multiTools = n` på første sæt; de øvrige kopierer det,
og hver får sin bane (`laneOffset`: 2 -> −1,1 · 3 -> −1,0,1 · 4 -> −2,−1,1,2 · 5 -> −2…2). Et multi-trin
startes kun når **alle n** sæt er ledige (ellers kører én bane ikke). Læssevognen på et multi-kursus
kræver også n vogne, én pr. bane.
6. **Rolle-rækkefølge** når et køretøj har flere redskaber: høster > læssevogn > river > vender >
slåmaskine > såmaskine > kultivator > plov > aflæsningsvogn (traktor+vogn som Courseplay kan bruge som
combine-unloader).
7. **Afgrødetype pr. mark:** slåafgrøde (etape 1) eller høstafgrøde (alt andet med en høstbar frugt). Plan
*Høst* er standard for høstafgrøder. Marker uden afgrøde (pløjet/stub) kan få *Start nu* for
jordbehandling/såning alene.
8. **Stadig ikke med:** gødning/kalk/sprøjtning, baller, halm efter mejetærsker, auto-påfyldning af frø.
## Opbygning (ændringer)
| Fil | Ændring |
|---|---|
| `adFieldModel.lua` | Planer `harvest`, jordbehandling, såning, `rigs`/`unloaders`; trin `harvesting/cultivating/plowing/sowing`; hjælperoller pr. trin; `lanePositions(n)` |
| `adFieldFleet.lua` | Nye roller; `pick` tager afgrøde (høster skal kunne tage den); `pickMany` til multi-trin |
| `adCpBridge.lua` | `startUnloader`, `canHarvest`, frø-valg (`setSeedFruit`, `isPlantable`, `hasSeeds`), multi-tool kursus + lane |
| `adFields.lua` | Afgrødetype (slå/høst), sidst høstede afgrøde |
| `adFieldWork.lua` (ny) | Udsendelse og opfølgning af sæt (AD/CP) — flyttet ud af `adFieldJobs.lua` der blev for stor |
| `gui/SmartPickupFieldTab.lua` | Plan-rækker pr. afgrødetype, maskiner pr. trin, aflæsningsvogne, efter høst, så + afgrøde |
-238
View File
@@ -1,238 +0,0 @@
-- Kør: luajit tests/test_adBuildings.lua (fra repo-roden)
-- Bygninger gården kan bruge + automatisk markør ved læsse-/aflæsningstrigger (≤ 40 m, Wait udelukket,
-- siloer strengt), omvendt opslag markør -> bygning, id-fallback, cache.
local SUGAR, FORAGE, MILK, BASKET = 5, 6, 12, 7
local DESC = {[SUGAR] = {name = "SUGAR", title = "Sukker", isPalletType = true}, [FORAGE] = {name = "FORAGE", title = "TMR"}, [MILK] = {name = "MILK", title = "Mælk"}, [BASKET] = {name = "STRAW_BASKET", title = "Kurve", palletFilename = "x"}}
g_fillTypeManager = {getFillTypeByIndex = function(_, i) return DESC[i] end, getFillTypeNameByIndex = function(_, i) return DESC[i] and DESC[i].name end, getFillTypeTitleByIndex = function(_, i) return DESC[i] and DESC[i].title end}
Logging = {info = function() end, warning = function() end}
getWorldTranslation = function(node) return node.x, 0, node.z end
SellingStation = {}
dofile("FS25_ADSmartPickup/adSupplyPlanner.lua")
dofile("FS25_ADSmartPickup/adPrices.lua")
dofile("FS25_ADSmartPickup/adBuildings.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
local markers, wayPoints = {}, {}
local function addMarker(id, name, group, x, z)
wayPoints[id] = {id = id, x = x, z = z}
table.insert(markers, {id = id, markerIndex = #markers + 1, name = name, group = group})
end
local loadStations, unloadStations = {}, {}
local adEnv = {ADGraphManager = {
getWayPointById = function(_, id) return wayPoints[id] end,
getMapMarkers = function() return markers end,
getMapMarkerByWayPointId = function(_, id) for _, m in ipairs(markers) do if m.id == id then return m end end end,
}}
local strictLoadAt = {}
ADSmartPickup = {getLoadStationAtWayPointId = function(_, id) return strictLoadAt[id] end}
g_currentMission = {
placeableSystem = {placeables = {}},
storageSystem = {getLoadingStations = function() return loadStations end, getUnloadingStations = function() return unloadStations end},
accessHandler = {canFarmAccess = function() return true end},
getFarmId = function() return 1 end,
}
local function placeable(uid, name, x, z, extra)
local p = {uniqueId = uid, rootNode = {x = x, z = z}, ownerFarmId = 1, getOwnerFarmId = function() return 1 end, getName = function() return name end}
for k, v in pairs(extra or {}) do p[k] = v end
table.insert(g_currentMission.placeableSystem.placeables, p)
return p
end
-- verden: kostald med mælketank (load) og foder (unload), sukkerfabrik (unload + pallespawner), to siloer i række, objektlager, salgssted
local barn = placeable("barn1", "Kostald 1", 0, 0, {getHusbandryFillLevel = function() return 0 end})
-- som FS25_LargerAnimalBarns' cowBarnBig: ÉN læssestation med TO læssepunkter (gylle + mælk)
local milkStation = {owningPlaceable = barn, loadTriggers = {{triggerNode = {x = 20, z = 0}}, {triggerNode = {x = 0, z = 30}}}}
local barnUnload = {owningPlaceable = barn, unloadTriggers = {{exactFillRootNode = {x = -20, z = 0}}}}
table.insert(loadStations, milkStation); table.insert(unloadStations, barnUnload)
local mill = placeable("mill", "Sukkerfabrik", 500, 0, {spec_productionPoint = {productionPoint = {productions = {}, storage = {}, palletSpawner = {spawnPlaces = {{startNode = {x = 520, z = 0}}}}, unloadingStation = nil}}})
local millUnload = {owningPlaceable = mill, unloadTriggers = {{exactFillRootNode = {x = 480, z = 0}}}, isa = function() return true end, isSellingPoint = true}
table.insert(unloadStations, millUnload)
local silo1 = placeable("silo1", "Silo 1", 1000, 0, {spec_silo = {}})
local silo2 = placeable("silo2", "Silo 2", 1015, 0, {spec_silo = {}})
local silo1Station = {owningPlaceable = silo1, loadTriggers = {{triggerNode = {x = 1000, z = 5}}}}
local silo2Station = {owningPlaceable = silo2, loadTriggers = {{triggerNode = {x = 1015, z = 5}}}}
table.insert(loadStations, silo1Station); table.insert(loadStations, silo2Station)
table.insert(unloadStations, {owningPlaceable = silo1, unloadTriggers = {{exactFillRootNode = {x = 1000, z = 5}}}})
table.insert(unloadStations, {owningPlaceable = silo2, unloadTriggers = {{exactFillRootNode = {x = 1015, z = 5}}}})
local shed = placeable("shed", "Palleskur", 2000, 0, {spec_objectStorage = {objectInfos = {}, capacity = 100}})
local sellPoint = placeable("sell", "Mejeri", 3000, 0, {})
local sellStation = {owningPlaceable = sellPoint, unloadTriggers = {{exactFillRootNode = {x = 3000, z = 10}}}, isa = function() return true end, isSellingPoint = true}
table.insert(unloadStations, sellStation)
local other = placeable("otherfarm", "Naboens", 4000, 0, {spec_silo = {}, ownerFarmId = 2, getOwnerFarmId = function() return 2 end})
addMarker(1, "cow 1 milk", "Milk", 25, 0) -- 5 m fra mælketriggeren
addMarker(2, "Cow 1 food", "Cow food", -30, 0) -- 10 m fra fodertriggeren
addMarker(3, "cow wait 1", "cow wait", 22, 0) -- Wait: må ALDRIG vælges (tættere end mælkemarkøren)
addMarker(11, "Cow 1 Slurry", "Slurry", 0, 33) -- 3 m fra gylletriggeren
addMarker(4, "Suger Load", "Pickup", 525, 0) -- 5 m fra pallespawneren
addMarker(5, "sugar offload", "production", 470, 0)
addMarker(6, "silo 1", "silo", 1000, 12) -- strengt: silo1
addMarker(7, "silo 2", "silo", 1015, 12) -- strengt: silo2 (7 m fra silo1's trigger-node på x-aksen? nej: 15 m)
strictLoadAt[6] = silo1Station; strictLoadAt[7] = silo2Station
addMarker(8, "Storage pickup", "Pickup", 2010, 0)
addMarker(9, "Mejeri", "selling", 3000, 20)
addMarker(10, "Langt væk", "All", 9000, 9000)
-- B1: liste, kind og markører
local list = ADBuildings.list(adEnv, 1)
local byId = {}
for _, b in ipairs(list) do byId[b.id] = b end
check("B1 antal (naboens silo udeladt)", #list, 6)
check("B1 stald", byId.barn1.kind, "husbandry")
check("B1 stald læssemarkør = mælk (ikke Wait, som er tættere)", byId.barn1.loadMarker.id, 1)
-- én markør pr. læssestation (mælketank OG gyllebeholder), sorteret på markørnavn
check("B1 stald har to læssemarkører", #byId.barn1.loadMarkers, 2)
check("B1 første = cow 1 milk (navnesorteret, uanset store/små)", byId.barn1.loadMarkers[1].id, 1)
check("B1 anden = Cow 1 Slurry", byId.barn1.loadMarkers[2].name, "Cow 1 Slurry")
check("B1 gyllemarkør -> stald via byMarker", ADBuildings.byMarker(adEnv, 1, 11, "load").id, "barn1")
check("B1 stald aflæsningsmarkør = foder", byId.barn1.unloadMarker.id, 2)
check("B1 stald afstand", byId.barn1.loadMarker.distance, 5)
check("B1 fabrik læssemarkør via pallespawner", byId.mill.loadMarker.id, 4)
check("B1 fabrik aflæsningsmarkør", byId.mill.unloadMarker.id, 5)
check("B1 fabrik er ikke salgssted", byId.mill.canSell, false)
check("B1 objektlager markør via rootNode", byId.shed.loadMarker.id, 8)
check("B1 salgssted", byId.sell.kind, "sell")
check("B1 salgssted markør", byId.sell.unloadMarker.id, 9)
check("B1 salgssted canSell", byId.sell.canSell, true)
check("B1 stald kan være kilde og mål", byId.barn1.canBeSource and byId.barn1.canBeTarget, true)
check("B1 silo kilde, ikke mål", byId.silo1.canBeSource and not byId.silo1.canBeTarget, true)
-- B2: siloer i række: strengt opslag giver hver sin markør
check("B2 silo 1 markør", byId.silo1.loadMarker.id, 6)
check("B2 silo 2 markør", byId.silo2.loadMarker.id, 7)
strictLoadAt[7] = nil
ADBuildings.invalidate()
check("B2 silo uden strengt match: ingen læssemarkør", ADBuildings.find(adEnv, 1, "silo2").loadMarker, nil)
strictLoadAt[7] = silo2Station
ADBuildings.invalidate()
-- B3: byMarker (omvendt opslag)
check("B3 mælkemarkør -> stald (load)", ADBuildings.byMarker(adEnv, 1, 1, "load").id, "barn1")
check("B3 fodermarkør -> stald (unload)", ADBuildings.byMarker(adEnv, 1, 2, "unload").id, "barn1")
check("B3 fodermarkør er ikke load", ADBuildings.byMarker(adEnv, 1, 2, "load"), nil)
check("B3 fjern markør -> nil", ADBuildings.byMarker(adEnv, 1, 10, "load"), nil)
-- B4: id-fallback uden uniqueId
check("B4 id fra position", ADBuildings.getId({rootNode = {x = 12.6, z = -3.2}}), "pos:13:-3")
check("B4 id fra uniqueId", ADBuildings.getId(barn), "barn1")
-- B5: cache: samme tabel inden for CACHE_MS, ny efter invalidate
local first = ADBuildings.list(adEnv, 1)
check("B5 cachet", ADBuildings.list(adEnv, 1) == first, true)
ADBuildings.invalidate()
check("B5 ny efter invalidate", ADBuildings.list(adEnv, 1) ~= first, true)
-- B6: sortering efter NAVN (naturlig talrækkefølge), dernæst første markørnavn — ikke efter slags
local order = {}
for _, b in ipairs(ADBuildings.list(adEnv, 1)) do table.insert(order, b.name) end
check("B6 rækkefølge", table.concat(order, ","), "Kostald 1,Mejeri,Palleskur,Silo 1,Silo 2,Sukkerfabrik")
check("B6 naturlig sortering", ADBuildings.naturalLess("Silo 9", "Silo 10"), true)
check("B6 naturlig sortering, tal før tekst-lighed", ADBuildings.naturalLess("25 Silo Storage", "26 Silo Storage"), true)
check("B6 naturlig sortering ens navn -> markørnavn", ADBuildings.compare({name = "Farma", loadMarkers = {{name = "30 Silo"}}}, {name = "Farma", loadMarkers = {{name = "4 Silo"}}}), false)
-- B-DOB: silo der kun kan FYLDES (drive-on-bunker ved biogas: aflæsning, ingen læssestation) = mål, ikke kilde
do
local dob = placeable("dob1", "DOB 115", 5000, 0, {spec_silo = {}})
table.insert(unloadStations, {owningPlaceable = dob, unloadTriggers = {{exactFillRootNode = {x = 5000, z = 5}}}})
addMarker(21, "Biogas Drop 1", "Biogas", 5000, 12)
ADBuildings.invalidate()
local byIdDob = {}
for _, b in ipairs(ADBuildings.list(adEnv, 1)) do byIdDob[b.id] = b end
check("B-DOB kan være mål", byIdDob.dob1.canBeTarget, true)
check("B-DOB ikke kilde (intet at hente)", byIdDob.dob1.canBeSource, false)
check("B-DOB aflæsningsmarkør", byIdDob.dob1.unloadMarker.id, 21)
check("B-DOB almindelig silo stadig ikke mål", byIdDob.silo1.canBeTarget, false)
end
-- B-SEP: bygning med læssestation men uden silo/fabrik/stald-spec (Pumps&Hoses separatorhal: digestat-udlevering)
do
local sep = placeable("sep1", "Separator Hall", 7000, 0, {spec_manureSeparator = {}})
table.insert(loadStations, {owningPlaceable = sep, loadTriggers = {{triggerNode = {x = 7000, z = 5}}}})
addMarker(41, "Digestate pickup", "Biogas", 7000, 10)
ADBuildings.invalidate()
local bySep = {}
for _, b in ipairs(ADBuildings.list(adEnv, 1)) do bySep[b.id] = b end
check("B-SEP findes som lager", bySep.sep1 and bySep.sep1.kind, "storage")
check("B-SEP kan være kilde", bySep.sep1 and bySep.sep1.canBeSource, true)
check("B-SEP læssemarkør", bySep.sep1 and bySep.sep1.loadMarker and bySep.sep1.loadMarker.id, 41)
end
-- B-DROP: fabrik med pickup-markør TÆT på aflæsningstriggeren og en separat drop-markør længere væk
-- (Sugarbeet Crusher): aflæsningsmarkøren skal være drop, ikke bygningens egen læssemarkør
do
local crusher = placeable("crusher1", "Sugarbeet Crusher", 8000, 0, {spec_productionPoint = {productionPoint = {productions = {}, storage = {}}}})
table.insert(loadStations, {owningPlaceable = crusher, loadTriggers = {{triggerNode = {x = 8000, z = 5}}}})
table.insert(unloadStations, {owningPlaceable = crusher, unloadTriggers = {{exactFillRootNode = {x = 8003, z = 2}}}})
addMarker(51, "Sugar Beet Cut Pickup", "Fabrication", 8000, 8) -- 3 m fra læs, ~6 m fra aflæsning
addMarker(52, "Sugar Beet Cut Beet Drop", "Fabrication", 8012, 2) -- 9 m fra aflæsning
ADBuildings.invalidate()
local byC = {}
for _, b in ipairs(ADBuildings.list(adEnv, 1)) do byC[b.id] = b end
check("B-DROP læssemarkør = pickup", byC.crusher1.loadMarker.id, 51)
check("B-DROP aflæsningsmarkør = drop (ikke pickup)", byC.crusher1.unloadMarker.id, 52)
-- kun én fælles markør (AIO Load / Unload): bruges til begge
local aio = placeable("aio1", "AIO", 9000, 0, {spec_productionPoint = {productionPoint = {productions = {}, storage = {}}}})
table.insert(loadStations, {owningPlaceable = aio, loadTriggers = {{triggerNode = {x = 9000, z = 5}}}})
table.insert(unloadStations, {owningPlaceable = aio, unloadTriggers = {{exactFillRootNode = {x = 9002, z = 5}}}})
addMarker(53, "AIO Load / Unload", "Fabrication", 9000, 9)
ADBuildings.invalidate()
for _, b in ipairs(ADBuildings.list(adEnv, 1)) do byC[b.id] = b end
check("B-DROP fælles markør bruges til aflæsning", byC.aio1.unloadMarker.id, 53)
check("B-DROP silo beholder sin egen markør (ikke nabosiloens)", byC.silo1.unloadMarker.id, 6)
end
-- B-COOP: hønsehus — foder aflæsses i den ene ende, æg-paller spawnes i den anden (ingen læssestation)
do
local coop = placeable("coop1", "Chicken Coop", 10000, 0, {getHusbandryFillLevel = function() return 0 end,
spec_husbandryFood = {triggers = {{someTrigger = {exactFillRootNode = {x = 9970, z = 0}}}}}, -- ukendt feltsti: søges
spec_husbandryPallets = {fillTypeIndexToPalletSpawner = {[99] = {spawnPlaces = {{startNode = {x = 10030, z = 0}}}}}}})
table.insert(unloadStations, {owningPlaceable = coop, unloadTriggers = {}}) -- som chickenBarnBig: tom station
addMarker(61, "Chicken Food", "Chicken", 9965, 0)
addMarker(62, "Egg Pickup", "Chicken", 10035, 0)
ADBuildings.invalidate()
local byCoop = {}
for _, b in ipairs(ADBuildings.list(adEnv, 1)) do byCoop[b.id] = b end
check("B-COOP læssemarkør ved æggene", byCoop.coop1.loadMarker and byCoop.coop1.loadMarker.id, 62)
check("B-COOP aflæsningsmarkør ved foderet", byCoop.coop1.unloadMarker and byCoop.coop1.unloadMarker.id, 61)
end
-- B-FUEL: brændstoftank (læssestation kun med DIESEL) er ikke et lager/en kilde
do
local fmBefore = g_fillTypeManager
g_fillTypeManager = {getFillTypeNameByIndex = function(_, index) return index == 900 and "DIESEL" or "X" end}
local tank = placeable("fuel1", "Small Petrol Tank", 11000, 0, {})
table.insert(loadStations, {owningPlaceable = tank, supportedFillTypes = {[900] = true}, loadTriggers = {{triggerNode = {x = 11000, z = 3}}}})
ADBuildings.invalidate()
local byFuel = {}
for _, b in ipairs(ADBuildings.list(adEnv, 1)) do byFuel[b.id] = b end
check("B-FUEL brændstoftank udeladt", byFuel.fuel1, nil)
g_fillTypeManager = fmBefore
end
-- B-SCAN: søgning efter trigger-noder i indlejrede tabeller (dybde ≤ 3, ingen løkker)
do
local loop = {}
loop.self = loop
local nodes = ADBuildings.findTriggerNodes({a = {b = {exactFillRootNode = "n1"}}, c = {triggerNode = "n2"}, d = loop, e = {f = {g = {h = {exactFillRootNode = "for-dybt"}}}}})
table.sort(nodes)
check("B-SCAN finder n1 og n2, ikke for dybt", table.concat(nodes, ","), "n1,n2")
end
-- B-SHED: palleskur læsser og aflæsser samme sted — aflæsningsmarkøren er den nærmeste (ikke "den anden")
do
local shedX = placeable("shedX", "Bale & Pallet Storage", 12000, 0, {spec_objectStorage = {objectInfos = {}, capacity = 4000}})
addMarker(71, "Chicken Storage Unload", "Chicken", 12010, 0) -- 10 m
addMarker(72, "Chicken Storage Pickup", "Chicken", 12018, 0) -- 18 m
ADBuildings.invalidate()
local byShed = {}
for _, b in ipairs(ADBuildings.list(adEnv, 1)) do byShed[b.id] = b end
check("B-SHED aflæsning = nærmeste (Unload)", byShed.shedX.unloadMarker and byShed.shedX.unloadMarker.id, 71)
end
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
-90
View File
@@ -1,90 +0,0 @@
-- Kør: luajit tests/test_adCpBridge.lua (fra repo-roden)
-- Kun getWorkWidth: variabel bredde (Unrealistic Pack) vinder over AI-markørernes grundbredde.
dofile("FS25_ADSmartPickup/adCpBridge.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
local function withMarkers(width)
return {getAIMarkers = function() return 1, 2, 3, 4, width end}
end
-- W1: læssevogn med variabel bredde 20,4 + 20,4 = 40,8 m, AI-markører siger 2,4 m
local wagon = withMarkers(2.4)
wagon.getVariableWorkWidth = function(_, isLeft) return isLeft and 20.4 or -20.4, nil, true end
local tractor = {getAttachedImplements = function() return {{object = wagon}} end}
check("W1 variabel bredde", ADCpBridge.getWorkWidth(tractor), 40.8)
-- W2: ugyldig variabel bredde -> AI-markører
local plain = withMarkers(9.5)
plain.getVariableWorkWidth = function() return 0, nil, false end
check("W2 AI-markører", ADCpBridge.getWorkWidth(plain), 9.5)
-- W3: selvkørende slåmaskine uden redskaber, kun variabel bredde
local mower = {getVariableWorkWidth = function(_, isLeft) return isLeft and 20.4 or -20.4, nil, true end}
check("W3 selvkørende", ADCpBridge.getWorkWidth(mower), 40.8)
-- W4: Courseplays egen bredde (HUD'en) vinder — det er den marken køres med
local cpVehicle = {getVariableWorkWidth = function(_, isLeft) return isLeft and 20.4 or -0.4, nil, true end,
getCourseGeneratorSettings = function() return {workWidth = {getValue = function() return 40.8 end}} end}
check("W4 Courseplay-bredde", ADCpBridge.getWorkWidth(cpVehicle), 40.8)
-- W5: Courseplay-indstilling 0/ugyldig -> egen beregning
local zeroCp = {getVariableWorkWidth = function(_, isLeft) return isLeft and 5 or -5, nil, true end,
getCourseGeneratorSettings = function() return {workWidth = {getValue = function() return 0 end}} end}
check("W5 CP 0 -> egen beregning", ADCpBridge.getWorkWidth(zeroCp), 10)
-- WO: skårets sideforskydning på en river, målt fra nedlægningsområdet (venstre positiv), minus rivens
-- egen CP-forskydning. Noder er tal; localToLocal-mock giver nodens x i traktorens ramme.
local nodeX = {[11] = 2.0, [12] = 4.0, [21] = -1.0, [22] = 1.0}
function localToLocal(node) return nodeX[node] or 0, 0, 0 end
local function rake(dropIndex, runtime)
local areas = {
{start = 1, width = 2, height = 3, dropWindrowWorkAreaIndex = runtime and dropIndex or nil},
{start = 10, width = 11, height = 12},
{start = 20, width = 21, height = 22},
}
return {spec_windrower = {}, spec_workArea = {workAreas = areas}, configFileName = "rake.xml"}
end
local sideRake = rake(2, true)
local tractorWithRake = {rootNode = 100, getAttachedImplements = function() return {{object = sideRake}} end,
getCpSettings = function() return {toolOffsetX = {getValue = function() return 0.5 end}} end}
check("WO1 sideriver (runtime-felt)", ADCpBridge.getWindrowOffset(tractorWithRake), 2.5)
local centerRake = rake(3, true)
local tractorCenter = {rootNode = 100, getAttachedImplements = function() return {{object = centerRake}} end}
check("WO2 midterrive", ADCpBridge.getWindrowOffset(tractorCenter), 0)
-- WO3: runtime-felt mangler -> riverens XML (workArea(0) windrower#dropWindrowWorkAreaIndex = 2)
ADCpBridge.readDropIndicesFromXml = function(fileName) return fileName == "rake.xml" and {[1] = 2} or {} end
local xmlRake = rake(2, false)
local tractorXml = {rootNode = 100, getAttachedImplements = function() return {{object = xmlRake}} end}
check("WO3 fra XML", ADCpBridge.getWindrowOffset(tractorXml), 3)
check("WO4 ingen river -> nil", ADCpBridge.getWindrowOffset({rootNode = 100}), nil)
-- SR: sprøjte/spreder -> rolle ud fra fyldet
check("SR gødning", ADCpBridge.sprayRoleForFillType("FERTILIZER"), "fertilizer")
check("SR flydende", ADCpBridge.sprayRoleForFillType("LIQUIDFERTILIZER"), "fertilizer")
check("SR gylle", ADCpBridge.sprayRoleForFillType("LIQUIDMANURE"), "fertilizer")
check("SR møg", ADCpBridge.sprayRoleForFillType("MANURE"), "fertilizer")
check("SR digestat", ADCpBridge.sprayRoleForFillType("DIGESTATE"), "fertilizer")
check("SR kalk", ADCpBridge.sprayRoleForFillType("LIME"), "limer")
check("SR ukrudt", ADCpBridge.sprayRoleForFillType("HERBICIDE"), "weeder")
check("SR vand/ukendt", ADCpBridge.sprayRoleForFillType("WATER"), nil)
-- HF: høsterens afgrøder fra skærebordet (liste eller opslag); finsnitter tager ingen
local list = ADCpBridge.harvestFruitSet({fruitTypes = {3, 5}})
check("HF liste", list[3] and list[5], true)
local lookup = ADCpBridge.harvestFruitSet({fruitTypes = {[3] = true}})
check("HF opslag", lookup[3], true)
local forage = ADCpBridge.harvestFruitSet({fruitTypes = {3}, fruitTypeConverters = {[3] = {}}})
check("HF finsnitter tager intet", next(forage), nil)
check("HF fruitTypeIndices", ADCpBridge.harvestFruitSet({fruitTypeIndices = {7}})[7], true)
-- PL: plantbar i perioden
local fruitPlant = {growthDataSeasonal = {periods = {[3] = {plantingAllowed = true}, [4] = {plantingAllowed = false}}}}
check("PL tilladt", ADCpBridge.isPlantableIn(fruitPlant, 3), true)
check("PL ikke nu", ADCpBridge.isPlantableIn(fruitPlant, 4), false)
check("PL ukendt data = tilladt", ADCpBridge.isPlantableIn({}, 4), true)
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
-98
View File
@@ -1,98 +0,0 @@
-- Kør: luajit tests/test_adFieldFleet.lua (fra repo-roden)
dofile("FS25_ADSmartPickup/adFieldModel.lua")
dofile("FS25_ADSmartPickup/adFieldFleet.lua")
local F = ADFieldFleet
local failures = 0
local function check(name, actual, expected)
if actual == expected then print("OK " .. name) else
failures = failures + 1
print(string.format("FAIL %s: forventede %s, fik %s", name, tostring(expected), tostring(actual)))
end
end
-- FL1: rolle ud fra redskabets spec (læssevogn vinder over andet på samme køretøj)
check("FL1 slåmaskine", F.roleFromSpecs({spec_mower = true}), "mower")
check("FL1 vender", F.roleFromSpecs({spec_tedder = true}), "tedder")
check("FL1 river", F.roleFromSpecs({spec_windrower = true}), "rake")
check("FL1 læssevogn", F.roleFromSpecs({spec_forageWagon = true, spec_mower = true}), "collector")
check("FL1 intet", F.roleFromSpecs({spec_trailer = true}), nil)
local function rig(id, role, x, extra)
local r = {id = id, role = role, enabled = true, busy = false, controlled = false, width = 40.8, x = x, z = 0, label = id}
for k, v in pairs(extra or {}) do r[k] = v end
return r
end
-- FL2: nærmeste ledige med flueben og rigtig rolle
local picked = F.pick({rig("far", "mower", 500), rig("near", "mower", 50), rig("wagon", "collector", 10)}, "mower", 0, 0, nil)
check("FL2 nærmeste slåmaskine", picked.id, "near")
-- FL3: uden flueben / optaget / spiller i sædet springes over
local _, why = F.pick({rig("a", "mower", 1, {enabled = false})}, "mower", 0, 0)
check("FL3 intet med flueben", why, "noRig")
local _, whyBusy = F.pick({rig("a", "mower", 1, {busy = true}), rig("b", "mower", 2, {controlled = true})}, "mower", 0, 0)
check("FL3 alle optaget", whyBusy, "allBusy")
check("FL3 ingen med rollen", select(2, F.pick({rig("a", "tedder", 1)}, "mower", 0, 0)), "noRig")
-- FL4: læssevogn smallere end slåbredden springes over, bredere vælges
local wide = F.pick({rig("smal", "collector", 1, {width = 20}), rig("bred", "collector", 900, {width = 40.8})}, "collector", 0, 0, 40.8)
check("FL4 bred valgt selv om længere væk", wide.id, "bred")
-- FL5: kun for smalle -> grund tooNarrow med tal
local none, whyNarrow, args = F.pick({rig("smal", "collector", 1, {width = 20})}, "collector", 0, 0, 40.8)
check("FL5 ingen", none, nil); check("FL5 grund", whyNarrow, "tooNarrow"); check("FL5 bredde", args[1], 20)
-- FL6: over 50 m -> tooWide
check("FL6 for bred", select(2, F.pick({rig("x", "mower", 1, {width = 60})}, "mower", 0, 0)), "tooWide")
-- FL7: toggle giver ny tabel
local enabled = {a = true}
local toggled = F.toggle(enabled, "b")
check("FL7 b til", toggled.b, true); check("FL7 original urørt", enabled.b, nil)
check("FL7 a fra", F.toggle(toggled, "a").a, nil)
-- FL8: nye roller i prioriteret rækkefølge
check("FL8 høster", F.roleFromSpecs({spec_combine = true, spec_cutter = true}), "harvester")
check("FL8 såmaskine før kultivator (combi)", F.roleFromSpecs({spec_sowingMachine = true, spec_cultivator = true, sprayRole = "fertilizer"}), "seeder")
check("FL8 kultivator", F.roleFromSpecs({spec_cultivator = true}), "cultivator")
check("FL8 plov", F.roleFromSpecs({spec_plow = true}), "plow")
check("FL8 tromle", F.roleFromSpecs({spec_roller = true}), "roller")
check("FL8 strigle", F.roleFromSpecs({spec_weeder = true}), "weeder")
check("FL8 stubhakker", F.roleFromSpecs({spec_mulcher = true}), "mulcher")
check("FL8 gødningssprøjte", F.roleFromSpecs({sprayRole = "fertilizer"}), "fertilizer")
check("FL8 kalkspreder", F.roleFromSpecs({sprayRole = "limer"}), "limer")
check("FL8 ukrudtssprøjte", F.roleFromSpecs({sprayRole = "weeder"}), "weeder")
check("FL8 aflæsningsvogn", F.roleFromSpecs({canUnload = true}), "unloader")
check("FL8 traktor med plov er ikke aflæsningsvogn", F.roleFromSpecs({spec_plow = true, canUnload = true}), "plow")
-- FL9: accept-filter (fx høster der kan tage afgrøden, strigle kun til småt ukrudt)
local combineWheat = rig("jd", "harvester", 10, {fruits = {[3] = true}})
local combineBeet = rig("ropa", "harvester", 1, {fruits = {[9] = true}})
local forWheat = F.pick({combineBeet, combineWheat}, "harvester", 0, 0, nil, function(c) return c.fruits[3] == true end)
check("FL9 høster der kan tage hvede", forWheat.id, "jd")
local _, whyFruit = F.pick({combineBeet}, "harvester", 0, 0, nil, function(c) return c.fruits[3] == true end)
check("FL9 ingen kan tage afgrøden", whyFruit, "cannotDo")
-- FL10: flere sæt til multi-tool — alle eller ingen, nærmeste først
local m1, m2, m3 = rig("m1", "mower", 30), rig("m2", "mower", 10), rig("m3", "mower", 20, {busy = true})
local two = F.pickMany({m1, m2, m3}, "mower", 2, 0, 0)
check("FL10 to valgt", #two, 2)
check("FL10 nærmeste først", two[1].id, "m2")
local none, whyMany, args = F.pickMany({m1, m2, m3}, "mower", 3, 0, 0)
check("FL10 for få -> ingen", none, nil)
check("FL10 grund", whyMany, "notEnough")
check("FL10 ledige/krævet", args[1] .. "/" .. args[2], "2/3")
check("FL10 ingen med rollen", select(2, F.pickMany({m1}, "tedder", 1, 0, 0)), "noRig")
-- FL11: markflådens tilstand: alle ledige maskiner (standard) eller kun med flueben
check("FL11 alle: uden flueben med", F.isEnabled("all", {}, "v1"), true)
check("FL11 flueben: uden flueben ude", F.isEnabled("ticked", {}, "v1"), false)
check("FL11 flueben: med flueben", F.isEnabled("ticked", {v1 = true}, "v1"), true)
check("FL11 ukendt tilstand = alle", F.isEnabled(nil, {}, "v1"), true)
-- FL12: ballepresser og ballesamler (ballevogn/Universal Autoload som Courseplays ballesamler kan bruge)
check("FL12 ballepresser", F.roleFromSpecs({spec_baler = true}), "baler")
check("FL12 ballesamler", F.roleFromSpecs({canBaleCollect = true}), "baleCollector")
check("FL12 ballesamler før aflæsningsvogn", F.roleFromSpecs({canBaleCollect = true, canUnload = true}), "baleCollector")
check("FL12 høster før presser", F.roleFromSpecs({spec_combine = true, spec_baler = true}), "harvester")
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
-102
View File
@@ -1,102 +0,0 @@
-- Kør: luajit tests/test_adFieldJobs.lua (fra repo-roden)
dofile("FS25_ADSmartPickup/adFieldModel.lua")
dofile("FS25_ADSmartPickup/adFieldJobs.lua")
local J = ADFieldJobs
local M = ADFieldModel
local failures = 0
local function check(name, actual, expected)
if actual == expected then print("OK " .. name) else
failures = failures + 1
print(string.format("FAIL %s: forventede %s, fik %s", name, tostring(expected), tostring(actual)))
end
end
local info = {id = 109, markerId = 5, mowable = true, readyRatio = 1}
-- J1: klar auto-mark med markør -> start; uden markør -> vent
local auto = M.onReadiness(M.newField(109), 1); auto.plan.mode = "auto"
check("J1 start", J.decide(auto, info, {}, 0).action, "start")
check("J1 uden markør", J.decide(auto, {id = 109, mowable = true}, {}, 0).action, "wait")
-- J2: arbejdende trin uden sæt -> send rolle; alle sæt færdige -> næste trin
local mowing = M.start(auto)
local wanted = J.decide(mowing, info, {}, 0)
check("J2 send slåmaskine", wanted.action, "dispatch"); check("J2 rolle", wanted.role, "mower")
check("J2 sæt i gang -> ingenting", J.decide(mowing, info, {{phase = "working"}}, 0).action, "none")
local mowedInfo = {id = 109, markerId = 5, mowable = true, readyRatio = 0}
check("J2 færdig -> videre", J.decide(mowing, mowedInfo, {{phase = "finished"}}, 0).action, "advance")
-- J3: sæt frigivet (spilleren tog det) tæller ikke som færdigt
check("J3 frigivet -> send nyt", J.decide(mowing, info, {{phase = "released"}}, 0).action, "dispatch")
-- J4: slåning færdig men marken stadig klar -> ikke videre (afbrudt)
check("J4 afbrudt slåning", J.decide(mowing, {id = 109, markerId = 5, mowable = true, readyRatio = 0.8}, {{phase = "finished"}}, 0).action, "dispatch")
-- J5: opsamling efter genindlæsning uden sæt -> send læssevogn
local collecting = M.advance(mowing)
check("J5 genoptag opsamling", J.decide(collecting, info, {}, 0).role, "collector")
check("J5 to vogne maks", J.decide(collecting, info, {{phase = "working"}, {phase = "driving"}}, 0).action, "none")
-- J6: Courseplay efterlader et hjørne -> efter 2 færdige slå-ture går marken videre alligevel
local standing = {id = 109, markerId = 5, mowable = true, readyRatio = 0.8}
check("J6 én tur -> slå igen", J.decide(mowing, standing, {{phase = "finished"}}, 0).action, "dispatch")
check("J6 to ture -> videre", J.decide(mowing, standing, {{phase = "finished"}, {phase = "finished"}}, 0).action, "advance")
-- J7: slået mark uden sæt (fx sluppet undervejs) -> videre til opsamling, ikke en ny slå-tur
check("J7 slået uden sæt -> videre", J.decide(mowing, {id = 109, markerId = 5, mowable = true, readyRatio = 0.1}, {}, 0).action, "advance")
check("J7 stående uden sæt -> slå", J.decide(mowing, info, {}, 0).action, "dispatch")
check("J7 slået men sæt i gang -> vent", J.decide(mowing, {id = 109, markerId = 5, mowable = true, readyRatio = 0.1}, {{phase = "working"}}, 0).action, "none")
-- J8: høst — høster først (hovedrolle), aflæsningsvogne kun mens høsteren arbejder
local harvestInfo = {id = 50, markerId = 7, kind = "harvest", readyRatio = 1, fruitIndex = 3}
local harvesting = M.start(M.onReadiness(M.newField(50), 1), "harvest", {})
local d = J.decide(harvesting, harvestInfo, {}, 0)
check("J8 send høster", d.role, "harvester"); check("J8 ikke hjælper", d.helper, false)
local withHarvester = {{phase = "working", stage = "harvesting", helper = false}}
local dHelper = J.decide(harvesting, harvestInfo, withHarvester, 0)
check("J8 så aflæsningsvogn", dHelper.role, "unloader"); check("J8 hjælper", dHelper.helper, true)
check("J8 vogn på plads -> intet", J.decide(harvesting, harvestInfo, {withHarvester[1], {phase = "working", stage = "harvesting", helper = true}}, 0).action, "none")
-- høsteren færdig, afgrøden væk (marken er nu bar) -> videre, selv om vognen stadig kører
local afterInfo = {id = 50, markerId = 7, kind = "bare", readyRatio = 0}
check("J8 høst færdig -> videre", J.decide(harvesting, afterInfo, {{phase = "finished", stage = "harvesting"}, {phase = "working", stage = "harvesting", helper = true}}, 0).action, "advance")
-- sæt fra et tidligere trin (vogn på vej hjem) tæller ikke på det nye trin
local cultivating = M.advance(harvesting, {})
check("J8 gammel vogn tæller ikke", J.decide(cultivating, afterInfo, {{phase = "working", stage = "harvesting", helper = true}}, 0).role, "cultivator")
-- J9: multi-tool — hele holdet sendes samlet; mens nogen kører, sendes ingen nye hovedsæt
local multiMow = M.with(mowing, {plan = {rigs = 3}})
local dMulti = J.decide(multiMow, info, {}, 0)
check("J9 tre slåmaskiner", dMulti.count, 3)
check("J9 én kører stadig -> ingen nye", J.decide(multiMow, info, {{phase = "working"}, {phase = "finished"}}, 0).action, "none")
-- J10: pleje under vækst — Auto starter, Manuel viser behovet
local growingInfo = {id = 60, markerId = 8, kind = "harvest", growing = true, fert = 0, fertMax = 2, lime = 2, weed = 0, readyRatio = 0.2}
local autoField = M.with(M.newField(60), {plan = {mode = "auto"}})
local care = J.decide(autoField, growingInfo, {}, 0)
check("J10 auto -> pleje", care.action, "care"); check("J10 gødning", care.stage, "fertilizing")
local manualCare = J.decide(M.newField(60), growingInfo, {}, 0)
check("J10 manuel -> intet, men behov vist", manualCare.action, "none"); check("J10 behov", manualCare.care, "fertilizing")
check("J10 Start nu -> pleje", J.decide(M.with(M.newField(60), {startRequested = true}), growingInfo, {}, 0).action, "care")
check("J10 uden markør -> vent", J.decide(autoField, {id = 60, kind = "harvest", growing = true, fert = 0, fertMax = 2}, {}, 0).action, "wait")
-- J11: regn — vejrfølsomme trin sendes ikke ud (kørende sæt fortsætter); klargøring/pleje kører videre
local rain = {raining = true}
local dRain = J.decide(mowing, info, {}, 0, rain)
check("J11 slåning venter i regn", dRain.action, "wait"); check("J11 grund", dRain.reason, "rain")
check("J11 kørende slåmaskine fortsætter", J.decide(mowing, info, {{phase = "working"}}, 0, rain).action, "none")
check("J11 høst venter", J.decide(harvesting, harvestInfo, {}, 0, rain).reason, "rain")
check("J11 kultivering kører i regn", J.decide(cultivating, afterInfo, {}, 0, rain).role, "cultivator")
check("J11 start af høst venter", J.decide(M.with(M.onReadiness(M.newField(51), 1), {startRequested = true, kind = "harvest"}), harvestInfo, {}, 0, rain).reason, "rain")
local silageCollect = M.advance(mowing)
check("J11 ensilage-opsamling kører i regn", J.decide(silageCollect, info, {}, 0, rain).action, "dispatch")
local hayCollect = M.with(silageCollect, {plan = {product = "hay"}})
check("J11 hø-opsamling venter", J.decide(hayCollect, info, {}, 0, rain).reason, "rain")
check("J11 tørvejr", J.decide(mowing, info, {}, 0, {raining = false}).action, "dispatch")
-- J12: bar mark med mål-afgrøde starter selv når målet kan sås; ellers venter den
local bareInfo = {id = 90, markerId = 9, kind = "bare", readyRatio = 0, needs = {}}
local bareTarget = M.with(M.newField(90), {plan = {targetFruit = "WHEAT", mode = "auto"}})
local canSow = {plantable = function(name) return name == "WHEAT" end}
check("J12 bar + såtid -> start", J.decide(bareTarget, bareInfo, {}, 0, canSow).action, "start")
check("J12 bar + ikke såtid -> intet", J.decide(bareTarget, bareInfo, {}, 0, {plantable = function() return false end}).action, "none")
check("J12 bar uden markør -> vent", J.decide(bareTarget, {id = 90, kind = "bare"}, {}, 0, canSow).action, "wait")
check("J12 voksende mark med mål -> lad den vokse", J.decide(bareTarget, {id = 90, markerId = 9, kind = "harvest", growing = true, readyRatio = 0.3}, {}, 0, canSow).action, "none")
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
-293
View File
@@ -1,293 +0,0 @@
-- Kør: luajit tests/test_adFieldModel.lua (fra repo-roden)
dofile("FS25_ADSmartPickup/adFieldModel.lua")
local M = ADFieldModel
local failures = 0
local function check(name, actual, expected)
if actual == expected then print("OK " .. name) else
failures = failures + 1
print(string.format("FAIL %s: forventede %s, fik %s", name, tostring(expected), tostring(actual)))
end
end
-- M1: ny mark = venter, manuel ensilage uden riv
local f = M.newField(109)
check("M1 stage", f.stage, "idle")
check("M1 mode", f.plan.mode, "manual")
check("M1 product auto", f.plan.product, "auto")
check("M1 auto = ensilage på slåafgrøde", M.product(f.plan, "mowable"), "silage")
check("M1 auto = høst ellers", M.product(f.plan, "harvest"), "harvest")
check("M1 rake", f.plan.rake, false)
-- M2: klar ved >= 90 %
check("M2 89 % ikke klar", M.onReadiness(f, 0.89).stage, "idle")
local ready = M.onReadiness(f, 0.9)
check("M2 90 % klar", ready.stage, "ready")
check("M2 immutabel", f.stage, "idle")
-- M3: klar mark slået af spilleren -> tilbage til venter
check("M3 slået manuelt", M.onReadiness(ready, 0.3).stage, "idle")
check("M3 lidt under 90 % forbliver klar", M.onReadiness(ready, 0.8).stage, "ready")
-- M4: trin pr. plan
check("M4 ensilage", table.concat(M.workStages({product = "silage"}), ","), "mowing,collecting")
check("M4 ensilage m. riv", table.concat(M.workStages({product = "silage", rake = true}), ","), "mowing,raking,collecting")
check("M4 hø", table.concat(M.workStages({product = "hay", rake = false}), ","), "mowing,tedding,collecting")
check("M4 hø m. riv", table.concat(M.workStages({product = "hay", rake = true}), ","), "mowing,tedding,raking,collecting")
-- M5: start: auto starter selv, manuel kun på forespørgsel
check("M5 manuel starter ikke", M.shouldStart(ready), false)
local auto = M.onReadiness(M.newField(5), 1)
auto.plan.mode = "auto"
check("M5 auto starter", M.shouldStart(auto), true)
local requested = M.newField(6); requested.stage = "ready"; requested.startRequested = true
check("M5 Start nu", M.shouldStart(requested), true)
local started = M.start(ready)
check("M5 start -> slås", started.stage, "mowing")
check("M5 start rydder forespørgsel", started.startRequested, false)
local oldMow = M.with(ready, {mowVehicleId = "old"})
check("M5 start glemmer gammel slåmaskine", M.start(oldMow).mowVehicleId, nil)
-- M6: videre gennem trinene; færdig mark bliver klar igen når den er vokset
local hay = M.start(M.onReadiness(M.newField(7), 1)); hay.plan.product = "hay"
hay = M.advance(hay); check("M6 hø -> vendes", hay.stage, "tedding")
hay = M.advance(hay); check("M6 -> samles op", hay.stage, "collecting")
hay = M.advance(hay); check("M6 -> færdig", hay.stage, "done")
check("M6 færdig forbliver ved lav andel", M.onReadiness(hay, 0.1).stage, "done")
check("M6 færdig -> klar når vokset", M.onReadiness(hay, 0.95).stage, "ready")
check("M6 arbejdende mark ignorerer andel", M.onReadiness(started, 0).stage, "mowing")
check("M6 isWorking", M.isWorking(started), true)
check("M6 stop -> klar", M.stop(started).stage, "ready")
-- M7: roller, maks sæt, bredde
check("M7 rolle slå", M.roleFor("mowing"), "mower")
check("M7 rolle saml", M.roleFor("collecting"), "collector")
check("M7 rolle klar = ingen", M.roleFor("ready"), nil)
check("M7 maks læssevogne = planens sæt", M.maxRigs("collecting", {rigs = 2}), 2)
check("M7 maks slå", M.maxRigs("mowing"), 1)
local okWide, whyWide = M.checkWidth("mower", 60, nil)
check("M7 over 50 m", okWide, false); check("M7 grund", whyWide, "tooWide")
check("M7 40,8 m slå ok", (M.checkWidth("mower", 40.8, nil)), true)
local okNarrow, whyNarrow, args = M.checkWidth("collector", 20, 40.8)
check("M7 for smal", okNarrow, false); check("M7 grund smal", whyNarrow, "tooNarrow"); check("M7 args", args[2], 40.8)
check("M7 lige bred ok (tolerance)", (M.checkWidth("collector", 40.76, 40.8)), true)
check("M7 ukendt slåbredde ok", (M.checkWidth("tedder", 10, nil)), true)
-- wantsRig: kun arbejdende trin, under maks, efter pause
check("M7 wantsRig slå", M.wantsRig(started, 0, 0).role, "mower")
check("M7 wantsRig fuld", M.wantsRig(started, 1, 0), nil)
local paused = M.start(M.onReadiness(M.newField(8), 1)); paused.retryAt = 1000
check("M7 wantsRig i pause", M.wantsRig(paused, 0, 500), nil)
check("M7 wantsRig efter pause", M.wantsRig(paused, 0, 1000).role, "mower")
check("M7 wantsRig klar = nil", M.wantsRig(ready, 0, 0), nil)
-- M8: gem/indlæs bevarer trin, plan og slåbredde
local saved = M.advance(M.start(M.onReadiness(M.newField(109), 1))); saved.mowWidth = 40.8; saved.mowVehicleId = "vehicleBigM"
saved.plan = {mode = "auto", product = "hay", rake = true, storeBuildingIds = {"b12", "b7"}}
local back = M.fromRecord(M.toRecord(saved))
check("M8 id", back.id, 109)
check("M8 stage", back.stage, saved.stage)
check("M8 mode", back.plan.mode, "auto")
check("M8 product", back.plan.product, "hay")
check("M8 rake", back.plan.rake, true)
check("M8 lager", table.concat(back.plan.storeBuildingIds, " "), "b12 b7")
check("M8 slåbredde", back.mowWidth, 40.8)
check("M8 slåmaskine", back.mowVehicleId, "vehicleBigM")
check("M8 ingen slåmaskine", M.fromRecord({id = 4, stage = "idle"}).mowVehicleId, nil)
check("M8 ukendt trin -> venter", M.fromRecord({id = 3, stage = "bogus"}).stage, "idle")
-- M9: with() kopierer og fletter planen
local base = M.start(M.onReadiness(M.newField(1), 1))
local noted = M.with(base, {reason = {text = "x"}, retryAt = 5, plan = {product = "hay"}})
check("M9 grund", noted.reason.text, "x"); check("M9 pause", noted.retryAt, 5)
check("M9 plan flettet", noted.plan.product, "hay"); check("M9 mode bevaret", noted.plan.mode, "manual")
check("M9 trin bevaret", noted.stage, "mowing"); check("M9 original urørt", base.plan.product, "auto")
-- M10: Start nu på en mark der ikke er helt klar -> klar; arbejdende mark røres ikke
check("M10 venter -> klar", M.forceReady(M.newField(2)).stage, "ready")
check("M10 arbejdende uændret", M.forceReady(base).stage, "mowing")
-- M11: krævet bredde og kursus-kilde pr. rolle (smalle standardmaskiner, 25/9)
local cut = M.with(M.newField(3), {mowWidth = 40.8})
check("M11 vender kræver ingen bredde", M.requiredWidth(cut, "tedder"), nil)
check("M11 river kræver ingen bredde", M.requiredWidth(cut, "rake"), nil)
check("M11 læssevogn uden riv = slåbredde", M.requiredWidth(cut, "collector"), 40.8)
local raked = M.with(cut, {plan = {rake = true}})
check("M11 læssevogn efter riv = ingen krav", M.requiredWidth(raked, "collector"), nil)
check("M11 vender eget kursus", M.courseSource(cut, "tedder"), nil)
check("M11 river eget kursus", M.courseSource(raked, "rake"), nil)
check("M11 læssevogn uden riv -> slåkursus", M.courseSource(cut, "collector"), "mower")
check("M11 læssevogn efter riv -> rivekursus", M.courseSource(raked, "collector"), "rake")
check("M11 slåmaskine eget kursus", M.courseSource(cut, "mower"), nil)
-- gem/indlæs rivemaskine
local withRake = M.with(raked, {rakeVehicleId = "vehicleRake"})
check("M11 rivemaskine gemt", M.fromRecord(M.toRecord(withRake)).rakeVehicleId, "vehicleRake")
check("M11 start glemmer rivemaskine", M.start(M.with(M.onReadiness(M.newField(9), 1), {rakeVehicleId = "x"})).rakeVehicleId, nil)
-- M12: sideriver — læssevognens CP-forskydning = egen forskydning − skårets forskydning (venstre positiv),
-- holdt inden for Courseplays ±10 m; skårets forskydning gemmes pr. mark
check("M12 midterrive", M.collectorOffset(0, 0), 0)
check("M12 skår 3 m til venstre", M.collectorOffset(0, 3), -3)
check("M12 vogn med egen forskydning", M.collectorOffset(0.5, -2), 2.5)
check("M12 holdes inden for 10 m", M.collectorOffset(0, 14), -10)
check("M12 ukendt skår -> egen forskydning", M.collectorOffset(0.4, nil), 0.4)
local side = M.with(M.newField(11), {windrowOffset = 3.2})
check("M12 skår gemt", M.fromRecord(M.toRecord(side)).windrowOffset, 3.2)
check("M12 intet skår gemt", M.fromRecord(M.toRecord(M.newField(12))).windrowOffset, nil)
check("M12 start glemmer skår", M.start(M.with(M.onReadiness(M.newField(13), 1), {windrowOffset = 1})).windrowOffset, nil)
-- M13: gæringssilo = produktion der laver markens skår om til ensilage (FermentingSiloPack m.fl.)
local GRASS_W, DRY_W, SILAGE, CHAFF, BREAD = 10, 11, 20, 30, 40
local fermenter = {{inputs = {{type = CHAFF}}, outputs = {{type = SILAGE}}}, {inputs = {{type = GRASS_W}}, outputs = {{type = SILAGE}}}}
check("M13 græs -> ensilage", M.isFermenter(fermenter, GRASS_W, SILAGE), true)
check("M13 hø ikke i denne", M.isFermenter(fermenter, DRY_W, SILAGE), false)
check("M13 bageri ikke", M.isFermenter({{inputs = {{type = GRASS_W}}, outputs = {{type = BREAD}}}}, GRASS_W, SILAGE), false)
check("M13 ingen produktioner", M.isFermenter(nil, GRASS_W, SILAGE), false)
check("M13 ukendt ensilage-type", M.isFermenter(fermenter, GRASS_W, nil), false)
-- M14: høstkæde + klargøring efter behov
local plan = {mode = "manual", product = "auto", rake = false, rigs = 1, unloaders = 1, mulch = false, lime = true,
soil = "auto", sow = true, sowFruit = "", roll = true, fertilize = true, weed = true, storeBuildingIds = {}}
check("M14 høst + kultivér + så + tromle", table.concat(M.workStages(plan, "harvest", {}), ","), "harvesting,cultivating,sowing,rolling")
check("M14 kalk + pløj når jorden trænger", table.concat(M.workStages(plan, "harvest", {lime = true, plow = true}), ","), "harvesting,liming,plowing,sowing,rolling")
local noSoil = {soil = "none", sow = false, lime = false, mulch = true}
check("M14 kun stubbehakning", table.concat(M.workStages(noSoil, "harvest", {}), ","), "harvesting,mulching")
check("M14 bar mark = kun klargøring", table.concat(M.workStages(plan, "bare", {plow = true}), ","), "plowing,sowing,rolling")
check("M14 tvunget pløj", table.concat(M.prepStages({soil = "plow"}, {}), ","), "plowing")
local harvestField = M.start(M.with(M.newField(20), {plan = {sow = true, soil = "cultivate"}}), "harvest", {})
check("M14 start = høst", harvestField.stage, "harvesting")
check("M14 husker art", harvestField.kind, "harvest")
local afterHarvest = M.advance(harvestField, {})
check("M14 høst -> kultivér", afterHarvest.stage, "cultivating")
check("M14 -> så", M.advance(afterHarvest, {}).stage, "sowing")
check("M14 så -> færdig", M.advance(M.advance(afterHarvest, {}), {}).stage, "done")
check("M14 kalk springes over når jorden er kalket", M.advance(M.with(harvestField, {plan = {lime = true}}), {lime = false}).stage, "cultivating")
check("M14 tom kæde -> færdig", M.start(M.with(M.newField(21), {plan = {soil = "none", sow = false, lime = false}}), "bare", {}).stage, "done")
-- M15: hjælpere under høst og multi-tool-baner
check("M15 aflæsningsvogn efter høsteren", M.wantsRig(harvestField, {primary = 1, helpers = 0}, 0).role, "unloader")
check("M15 hjælper-flag", M.wantsRig(harvestField, {primary = 1, helpers = 0}, 0).helper, true)
check("M15 alle vogne på plads -> intet", M.wantsRig(harvestField, {primary = 1, helpers = 1}, 0), nil)
local multi = M.with(harvestField, {plan = {rigs = 3, unloaders = 2}})
check("M15 multi: 3 høstere samlet", M.wantsRig(multi, {primary = 0, helpers = 0}, 0).count, 3)
check("M15 multi: 2 vogne", M.maxHelpers("harvesting", multi.plan), 2)
check("M15 ingen hjælper ved slåning", M.maxHelpers("mowing", multi.plan), 0)
check("M15 baner 2", table.concat(M.lanePositions(2), ","), "-1,1")
check("M15 baner 3", table.concat(M.lanePositions(3), ","), "-1,0,1")
check("M15 baner 4", table.concat(M.lanePositions(4), ","), "-2,-1,1,2")
check("M15 baner 5", table.concat(M.lanePositions(5), ","), "-2,-1,0,1,2")
check("M15 over 5 -> 5", #M.lanePositions(9), 5)
check("M15 maxRigs holdes 1-5", M.maxRigs("mowing", {rigs = 7}), 5)
-- M16: pleje under vækst
local growing = {kind = "harvest", growing = true, fert = 1, fertMax = 2, lime = 2, weed = 0}
local idleField = M.newField(30)
check("M16 gødning", M.careNeed(idleField, growing), "fertilizing")
check("M16 ukrudt før gødning", M.careNeed(idleField, {kind = "harvest", growing = true, fert = 1, fertMax = 2, lime = 2, weed = 3}), "weeding")
check("M16 græs ukrudtsbehandles ikke", M.careNeed(idleField, {kind = "mowable", growing = true, fert = 2, fertMax = 2, lime = 2, weed = 3}), nil)
check("M16 kalk", M.careNeed(idleField, {kind = "harvest", growing = true, fert = 2, fertMax = 2, lime = 0, weed = 0}), "careLiming")
check("M16 intet behov", M.careNeed(idleField, {kind = "harvest", growing = true, fert = 2, fertMax = 2, lime = 1, weed = 1}), nil)
check("M16 ikke voksende", M.careNeed(idleField, {kind = "harvest", growing = false, fert = 0, fertMax = 2}), nil)
check("M16 gødning slået fra", M.careNeed(M.with(idleField, {plan = {fertilize = false}}), growing), nil)
check("M16 arbejdende mark", M.careNeed(harvestField, growing), nil)
local cared = M.startCare(idleField, "fertilizing")
check("M16 pleje-trin", cared.stage, "fertilizing")
check("M16 rolle", M.roleFor(cared.stage), "fertilizer")
check("M16 kalk-pleje rolle", M.roleFor("careLiming"), "limer")
check("M16 pleje -> idle", M.advance(cared, {}).stage, "idle")
local twice = M.startCare(M.advance(cared, {}), "fertilizing")
check("M16 højst 2 gange", M.careNeed(M.advance(twice, {}), growing), nil)
check("M16 ny cyklus nulstiller", M.careNeed(M.advance(M.start(M.advance(twice, {}), "harvest", {}), {}), growing) ~= "x", true)
check("M16 start nulstiller pleje-tæller", next(M.start(twice, "harvest", {}).careRuns), nil)
check("M16 stop af pleje -> idle", M.stop(cared).stage, "idle")
check("M16 stop af høst -> klar", M.stop(harvestField).stage, "ready")
-- M17: såafgrøde og gem/indlæs af den udvidede plan
check("M17 så samme afgrøde", M.sowFruit(M.with(M.newField(40), {lastFruit = "WHEAT"})), "WHEAT")
check("M17 planens afgrøde vinder", M.sowFruit(M.with(M.newField(40), {lastFruit = "WHEAT", plan = {sowFruit = "CANOLA"}})), "CANOLA")
local full = M.with(M.startCare(M.newField(41), "weeding"), {kind = "harvest", lastFruit = "BARLEY",
plan = {rigs = 3, unloaders = 2, mulch = true, lime = false, soil = "plow", sow = true, sowFruit = "WHEAT", roll = true, fertilize = false, weed = false, product = "harvest"}})
local round = M.fromRecord(M.toRecord(full))
check("M17 trin", round.stage, "weeding")
check("M17 sæt", round.plan.rigs, 3)
check("M17 vogne", round.plan.unloaders, 2)
check("M17 stub", round.plan.mulch, true)
check("M17 kalk fra", round.plan.lime, false)
check("M17 jord", round.plan.soil, "plow")
check("M17 så", round.plan.sow, true)
check("M17 såafgrøde", round.plan.sowFruit, "WHEAT")
check("M17 tromle", round.plan.roll, true)
check("M17 gødning fra", round.plan.fertilize, false)
check("M17 ukrudt fra", round.plan.weed, false)
check("M17 art", round.kind, "harvest")
check("M17 sidste afgrøde", round.lastFruit, "BARLEY")
check("M17 pleje-tæller", round.careRuns.weeding, 1)
check("M17 produkt", round.plan.product, "harvest")
local legacy = M.fromRecord({id = 5, stage = "collecting", mode = "auto", product = "hay", rake = 1})
check("M17 etape 1-post: hø", legacy.plan.product, "hay")
check("M17 etape 1-post: standard kalk til", legacy.plan.lime, true)
check("M17 etape 1-post: 1 sæt", legacy.plan.rigs, 1)
-- M18: Start nu på en bar mark (klar-andel 0) må ikke nulstilles af vækst-reglen før den starter
local bare = M.with(M.forceReady(M.newField(70)), {startRequested = true})
check("M18 bar mark forbliver klar", M.onReadiness(bare, 0).stage, "ready")
check("M18 uden forespørgsel -> venter", M.onReadiness(M.forceReady(M.newField(71)), 0).stage, "idle")
check("M18 vejrfølsom: høst", M.isWeatherSensitive("harvesting", {}, "harvest"), true)
check("M18 vejrfølsom: kultivering ikke", M.isWeatherSensitive("cultivating", {}, "harvest"), false)
-- M19: mål-afgrøde — "vælg mark + afgrøde, glem den"
local target = M.with(M.newField(80), {plan = {targetFruit = "WHEAT"}})
local wheatNow = M.applyTarget(target.plan, "harvest", "BARLEY")
check("M19 mål -> auto", wheatNow.mode, "auto")
check("M19 høst byg -> så hvede", wheatNow.sow, true)
check("M19 såafgrøde = mål", wheatNow.sowFruit, "WHEAT")
check("M19 kæde: høst -> kultivér -> så", table.concat(M.workStages(wheatNow, "harvest", {}), ","), "harvesting,cultivating,sowing")
local grassToWheat = M.applyTarget(target.plan, "mowable", "GRASS")
check("M19 græs -> hvede: slå, saml op, så klargør og så", table.concat(M.workStages(grassToWheat, "mowable", {}), ","), "mowing,collecting,cultivating,sowing")
local grassTarget = M.applyTarget(M.with(M.newField(81), {plan = {targetFruit = "GRASS"}}).plan, "mowable", "GRASS")
check("M19 græs -> græs: kun slå og saml op", table.concat(M.workStages(grassTarget, "mowable", {}), ","), "mowing,collecting")
check("M19 græs -> græs: ingen såning", grassTarget.sow, false)
local bareTarget = M.applyTarget(target.plan, "bare", nil)
check("M19 bar mark -> klargør og så", table.concat(M.workStages(bareTarget, "bare", {plow = true}), ","), "plowing,sowing")
check("M19 uden mål: plan urørt", M.applyTarget(M.newField(82).plan, "harvest", "BARLEY").mode, "manual")
local started = M.start(target, "harvest", {}, "BARLEY")
check("M19 start anvender målet", started.plan.sowFruit, "WHEAT")
check("M19 start: høst først", started.stage, "harvesting")
check("M19 målet gemmes", M.fromRecord(M.toRecord(started)).plan.targetFruit, "WHEAT")
check("M19 klargøring efter slåning gemmes", M.fromRecord(M.toRecord(M.start(target, "mowable", {}, "GRASS"))).plan.prepAfterMow, true)
-- M20: skal en bar mark med mål starte? (kun når målafgrøden kan sås nu)
check("M20 bar + sås nu -> start", M.shouldStartBare(target, "bare", true), true)
check("M20 bar + ikke såtid -> vent", M.shouldStartBare(target, "bare", false), false)
check("M20 uden mål -> ingen auto-start", M.shouldStartBare(M.newField(83), "bare", true), false)
check("M20 voksende mark -> nej", M.shouldStartBare(target, "harvest", true), false)
check("M20 arbejdende mark -> nej", M.shouldStartBare(started, "bare", true), false)
check("M20 færdig (lige sået) -> nej, ingen dobbelt-såning", M.shouldStartBare(M.with(target, {stage = "done"}), "bare", true), false)
-- M21: vælg mål-afgrøde fra menuen: auto, og en færdig mark nulstilles så den kan starte
local chosen = M.setTarget(M.with(M.newField(84), {stage = "done"}), "CANOLA")
check("M21 mål sat", chosen.plan.targetFruit, "CANOLA"); check("M21 auto", chosen.plan.mode, "auto")
check("M21 færdig -> venter", chosen.stage, "idle")
check("M21 arbejdende mark beholder trin", M.setTarget(started, "CANOLA").stage, "harvesting")
local cleared = M.setTarget(chosen, "")
check("M21 mål fjernet", cleared.plan.targetFruit, ""); check("M21 tilbage til manuel", cleared.plan.mode, "manual")
-- M22: halm efter høst — hakkes (standard), samles op, eller presses til baller og samles
local strawBase = {soil = "none", sow = false, lime = false}
check("M22 hakkes: kun høst", table.concat(M.workStages(strawBase, "harvest", {}), ","), "harvesting")
check("M22 samles op", table.concat(M.workStages(M.with(M.newField(1), {plan = {soil = "none", lime = false, straw = "collect"}}).plan, "harvest", {}), ","), "harvesting,strawCollecting")
check("M22 baller", table.concat(M.workStages(M.with(M.newField(1), {plan = {soil = "none", lime = false, straw = "bale"}}).plan, "harvest", {}), ","), "harvesting,baling,baleCollecting")
local baleChain = M.start(M.with(M.newField(2), {plan = {straw = "bale", soil = "cultivate", lime = false}}), "harvest", {})
check("M22 høst -> presses", M.advance(baleChain, {}).stage, "baling")
check("M22 presses -> baller samles", M.advance(M.advance(baleChain, {}), {}).stage, "baleCollecting")
check("M22 -> kultivér", M.advance(M.advance(M.advance(baleChain, {}), {}), {}).stage, "cultivating")
check("M22 roller", M.roleFor("baling") .. "," .. M.roleFor("baleCollecting") .. "," .. M.roleFor("strawCollecting"), "baler,baleCollector,collector")
check("M22 halm-læssevogn følger høsterens kursus", M.courseSource(M.with(M.newField(3), {stage = "strawCollecting"}), "collector"), "harvester")
check("M22 presser følger høsterens kursus", M.courseSource(M.with(M.newField(3), {stage = "baling"}), "baler"), "harvester")
check("M22 græs-læssevogn følger stadig slåkurset", M.courseSource(M.with(M.newField(3), {stage = "collecting"}), "collector"), "mower")
check("M22 halm-læssevogn uden breddekrav", M.requiredWidth(M.with(M.newField(3), {stage = "strawCollecting", mowWidth = 40}), "collector"), nil)
check("M22 presning vejrfølsom", M.isWeatherSensitive("baling", {}, "harvest"), true)
check("M22 ballesamling ikke vejrfølsom", M.isWeatherSensitive("baleCollecting", {}, "harvest"), false)
check("M22 gemmes", M.fromRecord(M.toRecord(M.with(M.newField(4), {plan = {straw = "bale"}}))).plan.straw, "bale")
check("M22 ukendt -> hakkes", M.fromRecord({id = 5, straw = "x"}).plan.straw, "chop")
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
-42
View File
@@ -1,42 +0,0 @@
-- Kør: luajit tests/test_adFieldStorage.lua (fra repo-roden)
-- Lagringen skal dække HVERT felt modellen gemmer (FIELD_TYPES), og en mark skal overleve en tur gennem XML.
dofile("FS25_ADSmartPickup/adFieldModel.lua")
dofile("FS25_ADSmartPickup/adFieldStorage.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
-- falsk XMLFile: gemmer værdier pr. nøgle
local function fakeXml()
local values = {}
local xml = {}
for _, kind in ipairs({"Int", "String", "Float"}) do
xml["set" .. kind] = function(_, key, value) values[key] = value end
xml["get" .. kind] = function(_, key) return values[key] end
end
return xml
end
local field = ADFieldModel.with(ADFieldModel.startCare(ADFieldModel.newField(77), "weeding"), {
kind = "harvest", lastFruit = "WHEAT", mowWidth = 40.8, mowVehicleId = "v1", rakeVehicleId = "v2", windrowOffset = -2.5,
plan = {mode = "auto", product = "harvest", rake = true, storeBuildingIds = {"b1"}, rigs = 3, unloaders = 2,
mulch = true, lime = false, soil = "plow", sow = true, sowFruit = "CANOLA", roll = true, fertilize = false, weed = false},
})
local record = ADFieldModel.toRecord(field)
for key in pairs(record) do
check("ST1 felt med i lagringen: " .. key, ADFieldStorage.FIELD_TYPES[key] ~= nil, true)
end
local xml = fakeXml()
ADFieldStorage.writeRecord(xml, "k", record)
local back = ADFieldModel.fromRecord(ADFieldStorage.readRecord(xml, "k"))
local again = ADFieldModel.toRecord(back)
for key, value in pairs(record) do
check("ST2 rundtur: " .. key, tostring(again[key]), tostring(value))
end
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
-42
View File
@@ -1,42 +0,0 @@
-- Kør: luajit tests/test_adFieldView.lua (fra repo-roden)
dofile("FS25_ADSmartPickup/adFieldModel.lua")
dofile("FS25_ADSmartPickup/adFieldView.lua")
local V = ADFieldView
local failures = 0
local function check(name, actual, expected)
if actual == expected then print("OK " .. name) else
failures = failures + 1
print(string.format("FAIL %s: forventede %s, fik %s", name, tostring(expected), tostring(actual)))
end
end
local infos = {
{id = 109, mowable = true, markerId = 5},
{id = 12, mowable = true, markerId = nil},
{id = 90, mowable = false, markerId = 7},
}
local fields = {[109] = ADFieldModel.newField(109), [12] = ADFieldModel.newField(12)}
-- V1: alle marker (etape 2: også høst og bare), sorteret efter nummer
local rows = V.rows(infos, fields)
check("V1 tre rækker", #rows, 3)
check("V1 sorteret", rows[1].info.id, 12)
check("V1 model med", rows[3].field.id, 109)
-- V2: mangler model -> ny standardmark
check("V2 standardmodel", rows[2].field.stage, "idle")
-- V3: status — manglende markør vinder over trin
check("V3 mangler markør", V.statusKey(fields[12], infos[2]), "spu_field_noMarker")
check("V3 trin", V.statusKey(fields[109], infos[1]), "spu_field_stage_idle")
-- V4: planens tekstnøgler pr. markart
check("V4 ensilage", V.planKey({product = "auto"}, "mowable"), "spu_field_plan_silage")
check("V4 hø", V.planKey({product = "hay"}, "mowable"), "spu_field_plan_hay")
check("V4 høst", V.planKey({product = "auto"}, "harvest"), "spu_field_plan_harvest")
check("V4 bar mark", V.planKey({product = "auto"}, "bare"), "spu_field_plan_prep")
-- V5: jordtilstand som korte tekstdele
local parts = V.soilParts({lime = 0.2, fert = 1, fertMax = 2, weed = 3, needs = {lime = true, plow = true}})
check("V5 dele", table.concat(parts, ","), "lime,plow,fert,weed")
check("V5 intet", #V.soilParts({lime = 2, fert = 2, fertMax = 2, weed = 0, needs = {}}), 0)
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
-65
View File
@@ -1,65 +0,0 @@
-- Kør: luajit tests/test_adFields.lua (fra repo-roden)
dofile("FS25_ADSmartPickup/adFields.lua")
local F = ADFields
local failures = 0
local function check(name, actual, expected)
if actual == expected then print("OK " .. name) else
failures = failures + 1
print(string.format("FAIL %s: forventede %s, fik %s", name, tostring(expected), tostring(actual)))
end
end
-- F1/F2: markørnavne
check("F1 F109", F.parseMarkerName("F109"), 109)
check("F1 småt", F.parseMarkerName("f7"), 7)
check("F1 mellemrum", F.parseMarkerName(" F 12 "), 12)
check("F2 andet navn", F.parseMarkerName("Field 109"), nil)
check("F2 tekst efter", F.parseMarkerName("F109 wait"), nil)
check("F2 nil", F.parseMarkerName(nil), nil)
-- F3: slåafgrøde = MOWER giver et skår som TEDDER gør til DRY…; halm udelukket
local names = {[10] = "GRASS_WINDROW", [11] = "DRYGRASS_WINDROW", [20] = "STRAW", [30] = "CLOVER_WINDROW", [31] = "DRYCLOVER_WINDROW", [40] = "BEANSTRAW"}
local mower = {[1] = {fillTypeIndex = 10}, [2] = {fillTypeIndex = 20}, [3] = {fillTypeIndex = 30}, [4] = {fillTypeIndex = 40}}
local tedder = {[10] = {targetFillTypeIndex = 11}, [30] = {targetFillTypeIndex = 31}, [40] = {targetFillTypeIndex = 20}}
local mowable = F.buildMowable(mower, tedder, function(index) return names[index] end)
check("F3 græs", mowable[1] ~= nil, true)
check("F3 græs skår", mowable[1].windrow, 10)
check("F3 græs hø", mowable[1].hay, 11)
check("F3 rug (halm) ikke", mowable[2], nil)
check("F3 kløver", mowable[3].hay, 31)
check("F3 bønnehalm -> halm ikke", mowable[4], nil)
-- F4: markens art og om den vokser
local kind, growing = F.classify(3, false, 0.4)
check("F4 høstafgrøde", kind, "harvest"); check("F4 vokser", growing, true)
check("F4 klar høst vokser ikke", select(2, F.classify(3, false, 0.95)), false)
check("F4 slåafgrøde", (F.classify(1, true, 0.2)), "mowable")
check("F4 ingen afgrøde = bar", (F.classify(nil, false, 0)), "bare")
check("F4 bar vokser ikke", select(2, F.classify(nil, false, 0)), false)
-- F5: jordens behov ud fra gennemsnit og spillets indstillinger
local needs = F.soilNeeds({lime = 0.2, plowLevel = 0}, {limeRequired = true, plowingRequired = true})
check("F5 kalk", needs.lime, true); check("F5 pløj", needs.plow, true)
local off = F.soilNeeds({lime = 0, plowLevel = 0}, {limeRequired = false, plowingRequired = false})
check("F5 kalk slået fra i spillet", off.lime, false); check("F5 pløjning slået fra", off.plow, false)
check("F5 kalket jord", F.soilNeeds({lime = 2, plowLevel = 1}, {limeRequired = true, plowingRequired = true}).lime, false)
check("F5 ukendt niveau = intet behov", F.soilNeeds({}, {limeRequired = true, plowingRequired = true}).plow, false)
-- F6: vækststadie -> fase ud fra afgrødens egne grænser
local wheat = {minHarvestingGrowthState = 6, maxHarvestingGrowthState = 7, cutState = 9, witheredState = 8}
check("F6 intet", F.growthPhase(0, wheat), "none")
check("F6 spirer/vokser", F.growthPhase(3, wheat), "growing")
check("F6 klar", F.growthPhase(6, wheat), "ready")
check("F6 visnet", F.growthPhase(8, wheat), "withered")
check("F6 høstet stub", F.growthPhase(9, wheat), "cut")
check("F6 ukendt stadie", F.growthPhase(nil, wheat), nil)
check("F6 FS25-navne (minHarvestGrowthState)", F.growthPhase(6, {minHarvestGrowthState = 5, maxHarvestGrowthState = 6, cutState = 8}), "ready")
-- F7: vokser = fase growing; ukendt fase -> voksende pixels og ikke klar
check("F7 fase vinder", F.isGrowing("cut", 100, 0.1), false)
check("F7 fase vokser", F.isGrowing("growing", 0, 0), true)
check("F7 reserve: voksende pixels", F.isGrowing(nil, 50, 0.2), true)
check("F7 reserve: ingen pixels (stub)", F.isGrowing(nil, 0, 0), false)
check("F7 reserve: klar", F.isGrowing(nil, 50, 0.95), false)
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
-69
View File
@@ -1,69 +0,0 @@
-- Kør: luajit tests/test_adFlowView.lua (fra repo-roden)
-- Ren oprydning af Flows-fanen: tavlens rækkefølge, hvilke traktorer og bygninger der vises.
dofile("FS25_ADSmartPickup/adFlowView.lua")
local V = ADFlowView
local failures = 0
local function check(name, actual, expected)
if actual == expected then
print("OK " .. name)
else
failures = failures + 1
print(string.format("FAIL %s: forventede %s, fik %s", name, tostring(expected), tostring(actual)))
end
end
local function ids(list, field)
local out = {}
for _, item in ipairs(list) do table.insert(out, tostring(item[field or "id"])) end
return table.concat(out, ",")
end
-- FV1: tavlen — forsyning (Ind) først, så udkørsel (Ud); hver gruppe sorteret efter navn (naturligt)
local runs = {
{id = 1, name = "Pig Food", kind = "supply"},
{id = 2, name = "Milk Sale", kind = "outbound"},
{id = 3, name = "Cow Food", kind = "supply"},
{id = 4, name = "Biogas 10", kind = "supply"},
{id = 5, name = "Biogas 9", kind = "supply"},
{id = 6, name = "Digestate", kind = "outbound"},
}
local groups = V.boardGroups(runs)
check("FV1 to grupper", #groups, 2)
check("FV1 Ind først", groups[1].kind, "supply")
check("FV1 Ind sorteret (Biogas 9 før 10)", ids(groups[1].runs), "5,4,3,1")
check("FV1 Ud sorteret", ids(groups[2].runs), "6,2")
check("FV1 samlet rækkefølge til vælgeren", ids(V.orderedRuns(runs)), "5,4,3,1,6,2")
check("FV1 tom gruppe udelades", #V.boardGroups({{id = 1, name = "A", kind = "supply"}}), 1)
-- FV2: traktorer — egne først, så ledige brugbare; andre flows og låste kun med "vis alle"
local vehicles = {
{id = "a", label = "Zetor", state = "free"},
{id = "b", label = "Fendt", state = "in"},
{id = "c", label = "Case", state = "other"},
{id = "d", label = "Claas", state = "blocked"},
{id = "e", label = "Deutz", state = "free"},
}
check("FV2 standard: egne + ledige, sorteret", ids(V.vehicleRows(vehicles, false)), "b,e,a")
check("FV2 vis alle: + andre flows + låste til sidst", ids(V.vehicleRows(vehicles, true)), "b,e,a,c,d")
check("FV2 antal skjulte", V.hiddenCount(vehicles, V.vehicleRows(vehicles, false)), 2)
-- FV3: bygninger — valgte først; uden markør skjult medmindre valgt eller "vis alle"
local buildings = {
{id = "s2", name = "Silo 2", hasMarker = true},
{id = "s10", name = "Silo 10", hasMarker = true},
{id = "x", name = "Gammel lade", hasMarker = false},
{id = "y", name = "Anden uden", hasMarker = false},
{id = "a", name = "Anlæg", hasMarker = true},
}
local chosen = {s10 = true, y = true}
check("FV3 standard: valgte først, så med markør", ids(V.buildingRows(buildings, chosen, false)), "y,s10,a,s2")
check("FV3 vis alle: uden markør til sidst", ids(V.buildingRows(buildings, chosen, true)), "y,s10,a,s2,x")
-- FV4: ens navne slås sammen med antal ("Chicken Coop x5"), rækkefølge efter første forekomst
check("FV4 sammenlæg", V.joinCounted({"Chicken Coop", "Chicken Coop", "Cow Barn", "Chicken Coop"}, " + ", "-"), "Chicken Coop x3 + Cow Barn")
check("FV4 ingen dubletter", V.joinCounted({"A", "B"}, " + ", "-"), "A + B")
check("FV4 tom -> tekst", V.joinCounted({}, " + ", "(ingen)"), "(ingen)")
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
-73
View File
@@ -1,73 +0,0 @@
-- Kør: luajit tests/test_adLoadSwap.lua (fra repo-roden)
-- Ren rangering af aflæsnings-siloer (lagermål for udkørsel og læs-bytte).
dofile("FS25_ADSmartPickup/adPrices.lua")
dofile("FS25_ADSmartPickup/adLoadSwap.lua")
local L = ADLoadSwap
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
-- LS1: markører et andet forløb HENTER fra (fx "Milk Storage Load") vælges sidst; ellers har-varen, så afstand
local candidates = {
{marker = {id = 1, name = "Milk Storage Load"}, hasFillType = true, distance = 100},
{marker = {id = 2, name = "Milk Storage unload"}, hasFillType = true, distance = 400},
{marker = {id = 3, name = "Silo tom"}, hasFillType = false, distance = 10},
}
local ranked = L.rankDumpCandidates(candidates, {[1] = true})
check("LS1 afhentningsmarkør undgås", ranked[1].marker.id, 2)
check("LS1 derefter tom silo før afhentning", ranked[2].marker.id, 3)
check("LS1 afhentning sidst (stadig mulig)", ranked[3].marker.id, 1)
local plain = L.rankDumpCandidates(candidates, nil)
check("LS1 uden undgå-liste: har varen + nærmest", plain[1].marker.id, 1)
-- LS2: lagermål — rene siloer altid; egne fabrikker der bruger varen som input kun når tilladt (udkørsel)
local SLURRY, MILK = 20, 21
g_currentMission = {getFarmId = function() return 1 end}
local biogas = {spec_productionPoint = {productionPoint = {productions = {{inputs = {{type = SLURRY, amount = 1}}}}}}, getOwnerFarmId = function() return 1 end}
local foreignBiogas = {spec_productionPoint = {productionPoint = {productions = {{inputs = {{type = SLURRY, amount = 1}}}}}}, getOwnerFarmId = function() return 0 end}
local silo = {spec_silo = {}}
local barn = {spec_silo = {}, getHusbandryFillLevel = function() return 0 end}
check("LS2 silo", L.isStoreTarget({owningPlaceable = silo}, SLURRY, nil, false), true)
check("LS2 stald aldrig", L.isStoreTarget({owningPlaceable = barn}, SLURRY, nil, true), false)
check("LS2 biogas kun når fabrikker tilladt", L.isStoreTarget({owningPlaceable = biogas}, SLURRY, nil, false), false)
check("LS2 biogas tager gylle", L.isStoreTarget({owningPlaceable = biogas}, SLURRY, nil, true), true)
check("LS2 biogas tager ikke mælk", L.isStoreTarget({owningPlaceable = biogas}, MILK, nil, true), false)
check("LS2 fremmed anlæg aldrig", L.isStoreTarget({owningPlaceable = foreignBiogas}, SLURRY, nil, true), false)
check("LS2 hjemmet selv aldrig", L.isStoreTarget({owningPlaceable = biogas}, SLURRY, {owningPlaceable = biogas}, true), false)
g_currentMission = nil
-- LS3: tvunget lagermål — kun de valgte markører, i rækkefølge; første der tager varen, har plads og kan nås
do
local stations = {
[101] = {accepts = {[SLURRY] = true}, free = 0}, -- BGA fuld
[102] = {accepts = {[SLURRY] = true}, free = 900000}, -- gylletank
[103] = {accepts = {[MILK] = true}, free = 900000}, -- tager ikke gylle
}
for _, st in pairs(stations) do
st.getIsFillTypeSupported = function(self, ft) return self.accepts[ft] == true end
st.getFreeCapacity = function(self) return self.free end
end
ADSmartPickup = {getFarmId = function() return 1 end, getUnloadStationAtWayPointId = function(_, id) return stations[id] end}
local adEnv = {ADGraphManager = {getMapMarkerByWayPointId = function(_, id) return {id = id, name = "m" .. id} end}}
local vehicle = {}
local all = function() return true end
check("LS3 fuld BGA springes over -> tank", L.findForcedDumpMarker(adEnv, vehicle, SLURRY, 20000, {101, 102}, all).id, 102)
stations[101].free = 500000
check("LS3 BGA med plads vælges først", L.findForcedDumpMarker(adEnv, vehicle, SLURRY, 20000, {101, 102}, all).id, 101)
local none, why = L.findForcedDumpMarker(adEnv, vehicle, SLURRY, 20000, {103}, all)
check("LS3 intet valgt mål tager varen -> nil", none, nil)
check("LS3 forklaring", why ~= nil and why:find("valgt", 1, true) ~= nil, true)
check("LS3 ikke nåelig -> næste", L.findForcedDumpMarker(adEnv, vehicle, SLURRY, 20000, {101, 102}, function(id) return id ~= 101 end).id, 102)
ADSmartPickup = nil
end
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)

Some files were not shown because too many files have changed in this diff Show More