198 Commits

Author SHA1 Message Date
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
masterdraco 7a073ae047 refactor(marker): review-fund — død kodegren i klik-routing, eksplicit kursus-kilde-opslag 2026-09-26 01:32:55 +02:00
masterdraco 322f7b1c30 docs(marker): vælg og glem (mål-afgrøde), halm, alle ledige maskiner — manual + testplan; version 1.27.0.0 2026-09-26 01:27:19 +02:00
masterdraco 293d66156f feat(marker): halm efter høst — hakkes, samles op til silo eller presses til baller + ballesamler (CP BALE_FINDER); græsvalg også ved græs-mål 2026-09-26 01:26:35 +02:00
masterdraco af71548b0b feat(marker): mål-afgrøde pr. mark ('vælg mark + afgrøde, glem den') + brug alle ledige maskiner (standard) 2026-09-26 01:24:28 +02:00
99 changed files with 13953 additions and 13393 deletions
+4
View File
@@ -1 +1,5 @@
*.zip
docs/superpowers/
bench/
.superpowers/
tests/
+441
View File
@@ -0,0 +1,441 @@
# Æ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.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
+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
+294 -17
View File
@@ -5,7 +5,7 @@
ADCpBridge = {}
ADCpBridge.SPECS = {"spec_mower", "spec_tedder", "spec_windrower", "spec_forageWagon", "spec_combine",
ADCpBridge.SPECS = {"spec_mower", "spec_tedder", "spec_windrower", "spec_forageWagon", "spec_combine", "spec_baler",
"spec_sowingMachine", "spec_cultivator", "spec_plow", "spec_roller", "spec_weeder", "spec_mulcher"}
-- Sprøjtens/sprederens fyld -> rolle (tom sprøjte har ingen rolle; den kan ikke sendes ud).
@@ -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,21 +156,56 @@ 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)
specs.canUnload = ok and can == true or nil
end
if vehicle.getCanStartCpBaleFinder ~= nil then
local ok, can = pcall(vehicle.getCanStartCpBaleFinder, vehicle)
specs.canBaleCollect = ok and can == true or nil
end
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
@@ -160,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")
@@ -169,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
@@ -189,6 +310,58 @@ function ADCpBridge.getRefillLevel(vehicle, fillType)
return total
end
-- Halmskår bag mejetærskeren (Courseplays køretøjsindstilling strawSwath: 1 = fra/hakkes, 2 = til).
function ADCpBridge.setStrawSwath(vehicle, on)
return (pcall(function() vehicle:getCpSettings().strawSwath:setValue(on and 2 or 1) end))
end
-- Ballesamler: Courseplays BALE_FINDER_CP (ballevogn eller Universal Autoload) samler markens baller.
function ADCpBridge.startBaleFinder(vehicle, fieldX, fieldZ, onDone)
if AIJobType == nil or AIJobType.BALE_FINDER_CP == nil then
return nil, "Courseplay (BALE_FINDER_CP) findes ikke"
end
local farmId = vehicle:getOwnerFarmId()
local job = g_currentMission.aiJobTypeManager:createJob(AIJobType.BALE_FINDER_CP)
if job == nil then return nil, "kunne ikke oprette ballesamler-job" end
pcall(function() job:applyCurrentState(vehicle, g_currentMission, farmId, false, false) end)
local params = job.cpJobParameters
if params ~= nil and params.fieldPosition ~= nil then pcall(function() params.fieldPosition:setPosition(fieldX, fieldZ) end) end
if params ~= nil and params.startPosition ~= nil then
local vx, _, vz = getWorldTranslation(vehicle.rootNode)
pcall(function() params.startPosition:setPosition(vx, vz) end)
end
pcall(function() job:setValues() end)
local handle = {cancelled = false, fired = false, finished = false}
local function finish(success, reason)
if handle.finished then return end
handle.finished = true
if not handle.cancelled then onDone(success, reason) end
end
local function startJob()
if handle.cancelled then return end
local valid, err = job:validate(farmId)
if not valid then return finish(false, "Courseplay: " .. tostring(err)) end
g_currentMission.aiSystem:startJob(job, farmId)
finish(true)
end
handle.onBoundary = function(_, success, message)
if handle.fired then return end
handle.fired = true
if not success then return finish(false, "Courseplay fandt ikke markgrænsen: " .. tostring(message)) end
startJob()
end
if job.registerFieldBoundaryDetectionCallback ~= nil then
job:registerFieldBoundaryDetectionCallback(handle, handle.onBoundary)
end
local isValid, isRunning, message = job:detectFieldBoundary()
if isValid and not isRunning then
startJob()
elseif not isRunning then
return nil, "Courseplay: " .. tostring(message)
end
return handle
end
-- Stop køretøjets AI-job (Courseplay/hjælper).
function ADCpBridge.stop(vehicle)
pcall(function()
@@ -218,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)
@@ -232,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
@@ -309,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)
@@ -328,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
@@ -342,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)
@@ -355,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
@@ -368,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
@@ -406,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
+93 -4
View File
@@ -8,6 +8,7 @@ ADFieldFleet = {}
-- plov, ikke aflæsningsvogn). sprayRole kommer fra sprøjtens/sprederens fyld (gødning/kalk/ukrudt).
ADFieldFleet.ROLE_SPECS = {
{role = "harvester", spec = "spec_combine"},
{role = "baler", spec = "spec_baler"},
{role = "collector", spec = "spec_forageWagon"},
{role = "rake", spec = "spec_windrower"},
{role = "tedder", spec = "spec_tedder"},
@@ -17,8 +18,10 @@ 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"},
}
@@ -33,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
@@ -46,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
@@ -54,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"
@@ -71,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
@@ -89,9 +148,39 @@ function ADFieldFleet.pickMany(candidates, role, n, x, z, requiredWidth, accept)
return nil, reason, args
end
-- Må sættet sendes ud? "all" (standard): alle ledige maskiner; "ticked": kun med flueben.
function ADFieldFleet.isEnabled(mode, enabledIds, id)
if mode == "ticked" then return (enabledIds or {})[id] == true end
return true
end
function ADFieldFleet.toggle(enabledIds, id)
local copy = {}
for key, value in pairs(enabledIds or {}) do copy[key] = value end
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
+488
View File
@@ -0,0 +1,488 @@
-- 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, 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
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)
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
+294 -19
View File
@@ -7,28 +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
ADFieldJobs.state = {fields = {}, fleet = {}, assignments = {}}
-- fleetMode: "all" = alle ledige maskiner (standard, "vælg og glem"), "ticked" = kun med flueben
-- 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.
@@ -46,6 +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 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
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
@@ -53,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]
@@ -61,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
@@ -69,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
@@ -82,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"}
@@ -124,6 +262,10 @@ function ADFieldJobs.load()
local ok, loaded = pcall(ADFieldStorage.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
@@ -135,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
@@ -143,6 +309,30 @@ 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))
ADFieldJobs.log(id, "mål-afgrøde: %s", fruitName ~= "" and fruitName or "ingen")
ADFieldJobs.save()
end
function ADFieldJobs.setFleetMode(mode)
ADFieldJobs.state.fleetMode = 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()
@@ -192,32 +382,56 @@ function ADFieldJobs.environment()
local weather = g_currentMission.environment.weather
return weather ~= nil and weather.getIsRaining ~= nil and weather:getIsRaining() or false
end)
return {raining = ok and raining == true}
return {
raining = ok and raining == true,
-- kan afgrøden (navn) sås i den aktuelle periode?
plantable = function(name)
local fruit = g_fruitTypeManager ~= nil and g_fruitTypeManager:getFruitTypeByName(name) or nil
return fruit ~= nil and ADCpBridge.isPlantableNow(fruit.index)
end,
}
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)
local started = ADFieldModel.start(field, info.kind, info.needs, info.fruitName)
started.lastFruit = info.kind == "harvest" and info.fruitName or field.lastFruit
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)
@@ -225,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
@@ -249,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
+100 -10
View File
@@ -28,7 +28,7 @@ ADFieldModel.ROLES = {
mowing = "mower", tedding = "tedder", raking = "rake", collecting = "collector",
harvesting = "harvester", mulching = "mulcher", liming = "limer", plowing = "plow",
cultivating = "cultivator", sowing = "seeder", rolling = "roller", weeding = "weeder",
fertilizing = "fertilizer",
fertilizing = "fertilizer", strawCollecting = "collector", baling = "baler", baleCollecting = "baleCollector",
}
ADFieldModel.HELPERS = {harvesting = "unloader"}
ADFieldModel.CARE_STAGES = {fertilizing = true, weeding = true, careLiming = true}
@@ -42,8 +42,11 @@ KNOWN_STAGES.careLiming = true
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,
fertilize = true, weed = true, targetFruit = "", prepAfterMow = false, straw = "chop",
}
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)
local STRAW_STAGES = {collect = {"strawCollecting"}, bale = {"baling", "baleCollecting"}}
local SOIL_VALUES = {auto = true, cultivate = true, plow = true, none = true}
local PRODUCTS = {auto = true, silage = true, hay = true, harvest = true}
@@ -58,6 +61,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
@@ -84,6 +89,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
@@ -116,6 +123,7 @@ function ADFieldModel.workStages(plan, kind, needs)
local product = ADFieldModel.product(plan, kind)
if product == "harvest" then
local stages = {"harvesting"}
for _, stage in ipairs(STRAW_STAGES[plan.straw or "chop"] or {}) do table.insert(stages, stage) end
for _, stage in ipairs(ADFieldModel.prepStages(plan, needs)) do table.insert(stages, stage) end
return stages
end
@@ -123,12 +131,58 @@ function ADFieldModel.workStages(plan, kind, needs)
if product == "hay" then table.insert(stages, "tedding") end
if plan.rake then table.insert(stages, "raking") end
table.insert(stages, "collecting")
-- mål-afgrøde på en slåmark med en anden afgrøde: efter sidste slæt klargøres og sås målet
if plan.prepAfterMow then
for _, stage in ipairs(ADFieldModel.prepStages(plan, needs)) do table.insert(stages, stage) end
end
return stages
end
-- Krævet bredde for et sæt: kun læssevognen uden riv skal dække slåmaskinens baner.
-- Mål-afgrøde ("vælg mark + afgrøde, glem den"): planen udfyldes ud fra hvad der står på marken nu.
-- Står målet der allerede som slåafgrøde (græs -> græs): bare slå igen og igen. Ellers: høst/slå det der
-- står, klargør og så målet. Kører altid automatisk. Uden mål returneres planen uændret.
function ADFieldModel.applyTarget(plan, kind, currentFruit)
local copy = copyPlan(plan)
if copy.targetFruit == nil or copy.targetFruit == "" then return copy end
copy.mode = "auto"
copy.sowFruit = copy.targetFruit
if kind == "mowable" then
copy.prepAfterMow = currentFruit ~= copy.targetFruit
copy.sow = copy.prepAfterMow
else
copy.prepAfterMow = false
copy.sow = true
end
return copy
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)
local copy = copyField(field)
copy.plan.targetFruit = fruitName 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"
end
return copy
end
-- 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 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
-- høsterens spor — der er breddekravet ligegyldigt).
function ADFieldModel.requiredWidth(field, role)
if role == "collector" and not field.plan.rake then
if role == "collector" and not field.plan.rake and field.stage ~= "strawCollecting" then
return field.mowWidth
end
return nil
@@ -136,6 +190,8 @@ end
-- Hvilket tidligere kursus sættet skal køre: læssevognen følger skårene; de andre laver deres eget.
function ADFieldModel.courseSource(field, role)
-- halm: læssevogn og presser kører høsterens kursus (skåret ligger i mejetærskerens spor)
if field.stage == "strawCollecting" or role == "baler" then return "harvester" end
if role ~= "collector" then return nil end
return field.plan.rake and "rake" or "mower"
end
@@ -174,17 +230,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)
@@ -205,14 +272,18 @@ local function freshCycle(copy)
copy.mowWidth = nil
copy.mowVehicleId = nil
copy.rakeVehicleId = nil
copy.harvestVehicleId = nil
copy.windrowOffset = nil
copy.careRuns = {}
end
-- Første trin i kæden. En tom kæde (bar mark uden jordbehandling eller såning) -> færdig.
function ADFieldModel.start(field, kind, needs)
-- 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)
local stages = ADFieldModel.workStages(field.plan, kind, needs)
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"
copy.kind = kind or "mowable"
freshCycle(copy)
@@ -232,7 +303,7 @@ function ADFieldModel.advance(field, needs)
local stages = ADFieldModel.workStages(field.plan, field.kind, needs)
copy.stage = "done"
local passed = false
local order = {"mowing", "tedding", "raking", "collecting", "harvesting", "mulching", "liming", "plowing", "cultivating", "sowing", "rolling"}
local order = {"mowing", "tedding", "raking", "collecting", "harvesting", "strawCollecting", "baling", "baleCollecting", "mulching", "liming", "plowing", "cultivating", "sowing", "rolling"}
local rank = {}
for index, stage in ipairs(order) do rank[stage] = index end
for _, stage in ipairs(stages) do
@@ -254,12 +325,14 @@ 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
-- Vejrfølsomme trin: afgrøde og skår skal være tørre (høst, slåning, vending, rivning, hø-opsamling).
-- Jordbehandling, såning, pleje og ensilage-opsamling kan køre i regn.
local WEATHER_STAGES = {harvesting = true, mowing = true, tedding = true, raking = true}
local WEATHER_STAGES = {harvesting = true, mowing = true, tedding = true, raking = true, strawCollecting = true, baling = true}
function ADFieldModel.isWeatherSensitive(stage, plan, kind)
if WEATHER_STAGES[stage] then return true end
return stage == "collecting" and ADFieldModel.product(plan, kind or "mowable") == "hay"
@@ -297,7 +370,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
@@ -376,13 +458,16 @@ 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",
kind = field.kind or "", lastFruit = field.lastFruit or "", care = table.concat(care, " "),
mowWidth = field.mowWidth or 0,
mowVehicle = field.mowVehicleId or "",
rakeVehicle = field.rakeVehicleId or "",
harvestVehicle = field.harvestVehicleId or "",
windrowOffset = field.windrowOffset ~= nil and string.format("%.2f", field.windrowOffset) or "",
}
end
@@ -405,6 +490,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)
@@ -415,12 +501,16 @@ function ADFieldModel.fromRecord(record)
plan.roll = readFlag(record.roll, false)
plan.fertilize = readFlag(record.fertilize, true)
plan.weed = readFlag(record.weed, true)
plan.targetFruit = record.targetFruit or ""
plan.prepAfterMow = readFlag(record.prepAfterMow, false)
plan.straw = STRAW_VALUES[record.straw] and record.straw or "chop"
field.kind = readText(record.kind)
field.lastFruit = readText(record.lastFruit)
for stage, runs in string.gmatch(record.care or "", "(%w+)=(%d+)") do field.careRuns[stage] = tonumber(runs) end
field.mowWidth = (record.mowWidth or 0) > 0 and record.mowWidth or nil
field.mowVehicleId = readText(record.mowVehicle)
field.rakeVehicleId = readText(record.rakeVehicle)
field.harvestVehicleId = readText(record.harvestVehicle)
field.windrowOffset = tonumber(record.windrowOffset)
return field
end
+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
+46 -3
View File
@@ -11,8 +11,14 @@ ADFieldStorage.FIELD_TYPES = {
id = "Int", stage = "String", mode = "String", product = "String", rake = "Int", store = "String",
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",
mowWidth = "Float", mowVehicle = "String", rakeVehicle = "String", windrowOffset = "String",
kind = "String", lastFruit = "String", care = "String", targetFruit = "String", prepAfterMow = "Int", straw = "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 +42,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
@@ -46,22 +67,36 @@ function ADFieldStorage.save(state)
ADFieldStorage.writeRecord(xmlFile, string.format("%s.field(%d)", ADFieldStorage.ROOT, index), ADFieldModel.toRecord(field))
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 = {}}
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
@@ -70,6 +105,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
File diff suppressed because it is too large Load Diff
+416 -121
View File
@@ -11,6 +11,11 @@ ADFields.cache = {}
ADFields.order = {}
ADFields.scanIndex = 0
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 +50,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 +60,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 +102,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 +165,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 +175,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 +189,141 @@ 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
-- 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 +334,302 @@ 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
else
Logging.warning("ADSmartPickup: F%s kunne ikke scannes: %s", tostring(entry.id), tostring(info))
end
ADFields.order = scan.result
ADFields.listCache = nil
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
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))
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)) 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",
}
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
+752
View File
@@ -0,0 +1,752 @@
-- 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
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
+121
View File
@@ -0,0 +1,121 @@
-- 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"},
}
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
+78
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,8 @@ local function copyRun(run)
loops = run.loops,
returnBelowPercent = run.returnBelowPercent,
vehicleIds = copyList(run.vehicleIds),
stopped = run.stopped == true,
wagons = ADRuns.copyWagons(run.wagons),
}
end
@@ -98,6 +135,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 = {},
@@ -164,6 +202,7 @@ 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
return new
end
@@ -308,10 +347,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 +436,10 @@ 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),
}
end
return records
@@ -407,6 +482,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 +492,8 @@ 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),
})
store.nextId = math.max(store.nextId, id + 1)
end
+245 -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,12 @@ function ADRunsController.save()
if ADFieldJobs ~= nil and ADFieldJobs.loaded then
pcall(ADFieldJobs.save)
end
if ADFieldSilos ~= nil then pcall(ADFieldSilos.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 +1492,10 @@ 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 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 +1506,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
+7
View File
@@ -46,7 +46,11 @@ 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 "")
for vehicleIndex, vehicleId in ipairs(record.vehicleIds) do
xmlFile:setString(string.format("%s.vehicle(%d)#id", key, vehicleIndex - 1), vehicleId)
end
@@ -74,7 +78,10 @@ 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"),
vehicleIds = {},
}
xmlFile:iterate(key .. ".vehicle", function(_, vehicleKey)
+116 -8
View File
@@ -10,14 +10,29 @@
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/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 +231,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 +265,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 +374,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 +422,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 +482,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 +669,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 +702,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 +729,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 +797,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 +1148,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 +1202,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 +1263,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 +1291,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,6 +1303,26 @@ 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 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))
+118 -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
@@ -999,6 +1090,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
+212
View File
@@ -108,3 +108,215 @@ 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
table.sort(list, function(a, b)
if a.score ~= b.score then return a.score > b.score end
return tostring(a.stat.title) < tostring(b.stat.title)
end)
local result = {}
for index = 1, math.min(#list, max or #list) do result[index] = list[index] end
return result
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
+79
View File
@@ -0,0 +1,79 @@
-- 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
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,130 @@
-- 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).
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)
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 == "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)
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,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
+239 -189
View File
@@ -1,30 +1,20 @@
-- 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,
}
-- 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",
mulcher = "spu_field_role_mulcher", limer = "spu_field_role_limer", plow = "spu_field_role_plow",
cultivator = "spu_field_role_cultivator", seeder = "spu_field_role_seeder", roller = "spu_field_role_roller",
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"}
-- ---- tekster ----
@@ -50,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
@@ -62,12 +73,24 @@ function SmartPickupFrame:fieldStatusText(field, info)
if decision ~= nil and decision.care ~= nil and not ADFieldModel.isWorking(field) then
text = text .. " — " .. string.format(self.i18n:getText("spu_field_careNeed"), self.i18n:getText("spu_field_stage_" .. decision.care))
end
local target = field.plan.targetFruit
if target ~= nil and target ~= "" and info.kind == "bare" and field.stage == "idle" and not ADFieldJobs.environment().plantable(target) then
text = text .. " — " .. string.format(self.i18n:getText("spu_field_waitSowing"), self:fruitTitle(target))
end
local reason = self:fieldReasonText(field.reason)
if reason ~= nil then text = text .. ": " .. reason end
return text
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)
end
function SmartPickupFrame:fieldPlanText(field, info)
if field.plan.targetFruit ~= nil and field.plan.targetFruit ~= "" then
return "-> " .. self:fruitTitle(field.plan.targetFruit)
end
local text = self.i18n:getText(ADFieldView.planKey(field.plan, info.kind))
if field.plan.rake and info.kind == "mowable" then text = text .. " + " .. self.i18n:getText("spu_field_plan_rake") end
if field.plan.sow and info.kind ~= "mowable" then text = text .. " + " .. self.i18n:getText("spu_field_plan_sow") end
@@ -81,187 +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).
function SmartPickupFrame:sowFruitChoices()
local texts, values = {self.i18n:getText("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))
self:addToggleRow("fieldMode", i18n:getText("spu_field_mode"), {i18n:getText("spu_field_mode_manual"), i18n:getText("spu_field_mode_auto")}, plan.mode == "auto")
local rigTexts, rigValues = numberTexts(1, ADFieldModel.MAX_RIGS)
self:addChoiceRow("fieldRigs", i18n:getText("spu_field_rigs"), rigTexts, rigValues, plan.rigs or 1)
if info.kind == "mowable" 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)
else
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)
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")
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 "")
self:addToggleRow("fieldRoll", i18n:getText("spu_field_roll"), yesNo, plan.roll == true)
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
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).
@@ -277,16 +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 rigs = ADFieldJobs.getRigs(adEnv)
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 = {}
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)
@@ -296,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
@@ -313,43 +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 id == nil then
return
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 == "fleetMode" then
ADFieldJobs.setFleetMode(isOn and "all" or "ticked")
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)
@@ -357,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)
@@ -375,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
+190 -204
View File
@@ -11,17 +11,21 @@ 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
-- 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"}
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 +36,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 +54,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 +86,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 +212,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 +261,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 +286,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 +336,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 +353,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 +366,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 +450,33 @@ 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_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 +490,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 +509,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 +603,19 @@ 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 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 +636,17 @@ 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 then
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 +654,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 +670,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 +691,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 +727,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 +760,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 +792,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 +832,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 +880,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,329 @@
-- 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
function SmartPickupFrame:alertText(entry)
local stat, fc, i18n = entry.stat, SmartPickupFrame.formatCompact, self.i18n
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 = ADStorageStats.attentionList(stats, SmartPickupFrame.MAX_ATTENTION)
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 = ADStorageStats.attentionList(stats, SmartPickupFrame.MAX_ATTENTION)
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>
+304 -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,6 +163,7 @@
<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"/>
@@ -116,7 +186,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,14 +222,53 @@
<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_button_start" text="Start run"/>
<text name="spu_button_stop" text="Stop run"/>
<text name="spu_button_new" text="Nyt run"/>
<text name="spu_field_target" text="Afgrøde på marken (moden passer resten)"/>
<text name="spu_field_target_none" text="Ingen — planlæg selv"/>
<text name="spu_field_target_bare" text="Klargøres og tilsås med %s, så snart det er såtid"/>
<text name="spu_field_target_keep" text="%s står allerede: slås og samles op, hver gang den er klar"/>
<text name="spu_field_target_switch" text="%s får lov at vokse færdig og høstes/slås; derefter klargøres og tilsås med %s"/>
<text name="spu_field_waitSowing" text="venter på såtid for %s"/>
<text name="spu_field_fleetAll" text="Brug alle ledige maskiner (Nej = kun med flueben)"/>
<text name="spu_field_role_baler" text="ballepresser"/>
<text name="spu_field_role_baleCollector" text="ballesamler"/>
<text name="spu_field_stage_strawCollecting" text="Halm samles"/>
<text name="spu_field_stage_baling" text="Presses"/>
<text name="spu_field_stage_baleCollecting" text="Baller samles"/>
<text name="spu_field_straw" text="Halm efter høst"/>
<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 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)"/>
@@ -203,6 +312,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."/>
@@ -212,6 +323,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)"/>
@@ -237,14 +349,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"/>
@@ -284,5 +398,181 @@
<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"/>
</texts>
</l10n>
+304 -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,6 +163,7 @@
<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"/>
@@ -116,7 +186,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,14 +222,53 @@
<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_button_start" text="Start run"/>
<text name="spu_button_stop" text="Stop run"/>
<text name="spu_button_new" text="New run"/>
<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"/>
<text name="spu_field_target_bare" text="Prepared and sown with %s as soon as it is sowing time"/>
<text name="spu_field_target_keep" text="%s is already there: mowed and collected every time it is ready"/>
<text name="spu_field_target_switch" text="%s is left to finish growing and is harvested/mowed; then the field is prepared and sown with %s"/>
<text name="spu_field_waitSowing" text="waiting for sowing time for %s"/>
<text name="spu_field_fleetAll" text="Use all free machines (No = only ticked ones)"/>
<text name="spu_field_role_baler" text="baler"/>
<text name="spu_field_role_baleCollector" text="bale collector"/>
<text name="spu_field_stage_strawCollecting" text="Collecting straw"/>
<text name="spu_field_stage_baling" text="Baling"/>
<text name="spu_field_stage_baleCollecting" text="Collecting bales"/>
<text name="spu_field_straw" text="Straw after harvest"/>
<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 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)"/>
@@ -203,6 +312,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."/>
@@ -212,6 +323,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)"/>
@@ -237,14 +349,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"/>
@@ -284,5 +398,181 @@
<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"/>
</texts>
</l10n>
+22 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<modDesc descVersion="107">
<author>masterdraco</author>
<version>1.26.0.0</version>
<version>1.60.21.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,35 @@
<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="adSmartPickup.lua"/>
</extraSourceFiles>
</modDesc>
+72 -241
View File
@@ -1,265 +1,96 @@
# 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.
- **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.
- **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.
- **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.
- **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 .`
+753 -290
View File
File diff suppressed because it is too large Load Diff
-148
View File
@@ -1,148 +0,0 @@
# Testplan — Markarbejde (fanen Marker), version 1.26.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.26.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.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.
## 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 |
+892 -408
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)
-86
View File
@@ -1,86 +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")
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
-93
View File
@@ -1,93 +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")
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
-238
View File
@@ -1,238 +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)
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)
-617
View File
@@ -1,617 +0,0 @@
-- Kør: luajit tests/test_adOutbound.lua (fra repo-roden)
-- Udkørsels-hooks: turvalg + AD-tilstand, hold ved Wait, objektlager-udtag, opsamlingsrunde,
-- advarsler på skærmen. Fake AD-miljø; adSources/adPrices/adLoadSwap er fakes, adWaitPool og
-- adOutboundPlanner er de rigtige.
local SUGAR, FORAGE, BASKET, CRATE = 5, 6, 7, 8
local NAMES = {[SUGAR] = "SUGAR", [FORAGE] = "FORAGE", [BASKET] = "STRAW_BASKET", [CRATE] = "STRAW_CRATE"}
g_fillTypeManager = {
getFillTypeNameByIndex = function(_, i) return NAMES[i] end,
getFillTypeByIndex = function(_, i) return {name = NAMES[i], palletFilename = (i == BASKET or i == CRATE) and "p.xml" or nil} end,
}
local logLines = {}
Logging = {
info = function(fmt, ...) table.insert(logLines, string.format(fmt, ...)) end,
warning = function(fmt, ...) table.insert(logLines, "WARN " .. string.format(fmt, ...)) end,
}
getWorldTranslation = function(node) return node.x, 0, node.z end
g_currentMission = {getFarmId = function() return 1 end}
dofile("FS25_ADSmartPickup/adSupplyPlanner.lua")
dofile("FS25_ADSmartPickup/adRuns.lua")
dofile("FS25_ADSmartPickup/adWaitPool.lua")
dofile("FS25_ADSmartPickup/adOutboundPlanner.lua")
dofile("FS25_ADSmartPickup/adOutbound.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 lastLog(pattern)
for i = #logLines, 1, -1 do
if string.find(logLines[i], pattern, 1, true) then return logLines[i] end
end
return nil
end
local function countLog(pattern)
local n = 0
for _, line in ipairs(logLines) do if string.find(line, pattern, 1, true) then n = n + 1 end end
return n
end
-- ---- AD-miljø ----
local markers, wayPoints, unreachable = {}, {}, {}
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})
return markers[#markers]
end
local notifications = {}
local RT_NONE, RT_ONLYPICKUP = 1, 2
local adEnv = {
AutoDrive = {
RT_NONE = RT_NONE, RT_ONLYPICKUP = RT_ONLYPICKUP,
getAllUnits = function(vehicle) return vehicle.trailers end,
getAllFillLevels = function(trailers) return trailers.level, trailers.capacity, trailers.level >= trailers.capacity * 0.99, trailers.capacity - trailers.level end,
getSupportedFillTypesOfAllUnitsAlphabetically = function(vehicle) return vehicle.carriable end,
hasAL = function(_, trailer) return trailer.ual ~= nil end,
activateALTrailers = function(vehicle, trailers) trailers.activated = (trailers.activated or 0) + 1 end,
},
ADGraphManager = {
getMapMarkers = function() return markers end,
getMapMarkerByWayPointId = function(_, id) for _, m in ipairs(markers) do if m.id == id then return m end end end,
getWayPointById = function(_, id) return wayPoints[id] end,
getPathTo = function(_, vehicle, id) if unreachable[id] then return {} end return {{id = id}} end,
pathFromTo = function(_, from, to) if unreachable[to] then return {} end return {{id = to}} end,
},
AutoDriveMessageEvent = {sendMessageOrNotification = function(vehicle, kind, text, duration, ...) table.insert(notifications, {vehicle = vehicle, kind = kind, text = string.format(text, ...)}) end},
ADMessagesManager = {messageTypes = {INFO = 1, WARN = 2, ERROR = 3}},
LoadAtDestinationTask = {STATE_DRIVING = 2, update = function() end, finished = function(self) self.finishedCalls = (self.finishedCalls or 0) + 1 end},
UnloadAtDestinationTask = {STATE_DRIVING = 2, update = function() end},
}
local function newVehicle(name, x, z, capacity, ual)
local v = {name = name, rootNode = {x = x, z = z}, lastSpeedReal = 0, carriable = {SUGAR, FORAGE}, farmId = 1}
v.getName = function() return name end
v.trailers = {level = 0, capacity = capacity, ual = ual and {spec = {loadedObjects = {}}} or nil}
v.trailers[1] = {spec_universalAutoload = ual and v.trailers.ual.spec or nil, ualIsFull = ual and function() return v.trailers.full == true end or nil, ual = ual and true or nil}
v.ad = {
stateModule = {isActive = function() return v.active ~= false end, selectedFillTypes = {}, fillType = nil,
setFillType = function(self, ft) self.fillType = ft end, getFillType = function(self) return self.fillType end,
setSecondMarkerByWayPointId = function(self, id) self.secondMarkerId = id end,
getSecondMarker = function(self) return self.secondMarkerId and {id = self.secondMarkerId} or nil end,
raiseDirtyFlag = function() end},
drivePathModule = {isTargetReached = function() return v.atTarget == true end, setPathTo = function(_, id) v.pathTo = id end},
specialDrivingModule = {stopVehicle = function() v.stopped = true end, update = function() end},
trailerModule = {isActiveAtTrigger = function() return false end},
}
return v
end
local function newTask(vehicle, destinationID)
return {vehicle = vehicle, destinationID = destinationID, trailers = vehicle.trailers, state = 2, activatedUALLoading = false,
finished = function(self) return adEnv.LoadAtDestinationTask.finished(self) end}
end
-- kilder, priser, siloer som fakes
local sources, products, quotes, dumpMarker = {}, {}, {}, nil
ADSources = {
identify = function(_, id) return sources[id] end,
getProducts = function(_, source) return products[source.wayPointId] or {} end,
getSellMarkers = function() local r = {} for _, m in ipairs(markers) do if m.group == "selling" then table.insert(r, {marker = m, station = {name = m.name}}) end end return r end,
stationAccepts = function(station, ft) return quotes[station.name] ~= nil and quotes[station.name][ft] ~= nil end,
getSourcePlaceables = function() return {} end,
getObjectInfoIndex = function(source, ft) local s = source.stored or {} return s[ft] ~= nil and s[ft] > 0 and 1 or nil, s[ft] end,
countStored = function(source, ft) return (source.stored or {})[ft] or 0 end,
spawnOne = function(source, index) source.spawnCalls = (source.spawnCalls or 0) + 1 return source.spawnFails ~= true end,
}
ADPrices = {
getQuote = function(station, ft) return quotes[station.name] and quotes[station.name][ft] end,
bestQuote = function(list) local best for _, q in ipairs(list) do if best == nil or q.percent > best.percent then best = q end end return best end,
}
ADLoadSwap = {findDumpMarker = function() return dumpMarker, "ingen silo" end}
local runs = {}
ADRunsController = {getRunForVehicle = function(vehicle) return runs[vehicle] end}
local activePickupCalls = {}
ADSmartPickup = {getFarmId = function(v) return v.farmId end, setActivePickup = function(vehicle, choice) table.insert(activePickupCalls, choice) end,
getUnloadStationAtWayPointId = function(_, id) for _, m in ipairs(markers) do if m.id == id and (m.group == "silo" or m.group == "selling") then return {name = m.name} end end end}
local mSugar = addMarker(1, "Suger Load", "Pickup points", 0, 0)
local mSilo = addMarker(2, "silo 4", "silo", 50, 0)
local mObj = addMarker(3, "Objektlager", "Pickup points", 100, 0)
local mSell = addMarker(10, "selling station", "selling", 200, 0)
local mSell2 = addMarker(11, "Far station", "selling", 300, 0)
local mWait = addMarker(20, "Wait 3", "General Wait", 20, 0)
local mStore = addMarker(30, "silo 6", "silo", 60, 0)
sources[1] = {kind = "factory", wayPointId = 1, station = {name = "factoryStation"}, name = "Sugar mill"}
sources[2] = {kind = "silo", wayPointId = 2, station = {name = "siloStation"}, name = "Farma"}
sources[3] = {kind = "objectStorage", wayPointId = 3, station = nil, name = "Objektlager", stored = {[BASKET] = 3, [CRATE] = 1}}
products[1] = {{fillType = SUGAR, name = "SUGAR", available = 5000, level = 5000, capacity = 180000, isPallet = false}}
products[2] = {{fillType = SUGAR, name = "SUGAR", available = 9000, level = 9000, capacity = 12000000, isPallet = false}}
products[3] = {{fillType = BASKET, name = "STRAW_BASKET", available = 3000, level = 4, capacity = 4000, isPallet = true}, {fillType = CRATE, name = "STRAW_CRATE", available = 1000, level = 4, capacity = 4000, isPallet = true}}
quotes["selling station"] = {[SUGAR] = {percent = 92, price = 480}, [BASKET] = {percent = 95, price = 1}, [CRATE] = {percent = 95, price = 1}}
quotes["Far station"] = {[SUGAR] = {percent = 99, price = 600}}
local function outboundRun(id, sourceIds, productsList, overrides)
local run = {id = id, name = "Out " .. id, kind = "outbound", waitPoolGroup = "", sourceWayPointIds = sourceIds, products = productsList, sellAtPercent = 90, storeAbovePercent = 80, collectRound = false}
for k, v in pairs(overrides or {}) do run[k] = v end
return run
end
-- O1: tur -> AD's varevalg og 2. markør sat, choice = kilden; næstbedste salgssted når bedste er uopnåeligt
unreachable[11] = true
local v1 = newVehicle("MT635", 0, 0, 20000)
runs[v1] = outboundRun(1, {1}, {{fillType = "SUGAR", policy = "sell"}})
check("O1 er udkørsel", ADOutbound.isOutboundVehicle(v1), true)
local choice = ADOutbound.choosePickup(adEnv, v1)
check("O1 kilde-markør", choice.markerIndex, mSugar.markerIndex)
check("O1 station med (bulk-greb)", choice.station.name, "factoryStation")
check("O1 vare", v1.ad.stateModule.fillType, SUGAR)
check("O1 varevalg", #v1.ad.stateModule.selectedFillTypes, 1)
check("O1 2. markør = nåeligt salgssted", v1.ad.stateModule.secondMarkerId, 10)
check("O1 ingen advarsel (næstbedste kunne nås)", #notifications, 0)
check("O1 log", lastLog("udkørsel") ~= nil, true)
local trip = ADOutbound.getTripFor(v1)
check("O1 trip kind", trip.kind, "sell")
unreachable[11] = nil
-- O2: ingen tur -> Wait-markør reserveret, hold registreret
local v2 = newVehicle("ARION", 25, 0, 20000)
runs[v2] = outboundRun(2, {1}, {{fillType = "SUGAR", policy = "sell"}})
quotes["selling station"][SUGAR].percent = 70
quotes["Far station"][SUGAR].percent = 60
local choice2 = ADOutbound.choosePickup(adEnv, v2)
check("O2 Wait-markør", choice2.markerIndex, mWait.markerIndex)
check("O2 uden station", choice2.station, nil)
check("O2 reserveret", ADWaitPool.getOwner(20), v2)
local hold = ADOutbound.getHoldFor(v2)
check("O2 hold", hold.markerName, "Wait 3")
check("O2 begrundelse", hold.reasons[1], "SUGAR 70 % af top (kræver 90), lager 3 %")
check("O2 run-status", ADOutbound.getRunStatus(2).reasons[1], hold.reasons[1])
check("O2 hold-log én gang", countLog("holder ved Wait 3"), 1)
ADOutbound.choosePickup(adEnv, v2)
check("O2 samme begrundelse logges ikke igen", countLog("holder ved Wait 3"), 1)
-- O3: holder ved Wait: vognen stoppes; efter 5 s med tur -> omdirigeret til kilden, hold sluppet
local task2 = newTask(v2, 20)
v2.atTarget = true
check("O3 håndteret", ADOutbound.beforeLoadUpdate(adEnv, task2, 1000), true)
check("O3 stoppet", v2.stopped, true)
quotes["selling station"][SUGAR].percent = 92
ADOutbound.beforeLoadUpdate(adEnv, task2, 4500)
check("O3 omdirigeret til kilden", task2.destinationID, 1)
check("O3 sti sat", v2.pathTo, 1)
check("O3 hold væk", ADOutbound.getHoldFor(v2), nil)
check("O3 reservation frigivet", ADWaitPool.getOwner(20), nil)
check("O3 activePickup opdateret", activePickupCalls[#activePickupCalls].station.name, "factoryStation")
check("O3 UAL-flag nulstillet", task2.activatedUALLoading, false)
v2.atTarget = false
check("O3 undervejs ikke håndteret", ADOutbound.beforeLoadUpdate(adEnv, task2, 100), false)
-- O4: objektlager: én palle ad gangen; stored falder + loaded stiger -> næste; ualIsFull -> finished
local v3 = newVehicle("Fendt", 100, 0, 8000, true)
runs[v3] = outboundRun(3, {3}, {{fillType = "STRAW_BASKET", policy = "sell"}, {fillType = "STRAW_CRATE", policy = "sell"}}, {collectRound = true})
local choice3 = ADOutbound.choosePickup(adEnv, v3)
check("O4 kilde = objektlager", choice3.markerIndex, mObj.markerIndex)
check("O4 kun én vare valgt ad gangen ved objektlager", #v3.ad.stateModule.selectedFillTypes, 1)
check("O4 turen har begge varer", #ADOutbound.getTripFor(v3).fillTypes, 2)
local task3 = newTask(v3, 3)
v3.atTarget = true
ADOutbound.beforeLoadUpdate(adEnv, task3, 100)
check("O4 intet spawn før UAL er aktiveret", sources[3].spawnCalls, nil)
task3.activatedUALLoading = true
ADOutbound.beforeLoadUpdate(adEnv, task3, 100)
check("O4 første spawn", sources[3].spawnCalls, 1)
ADOutbound.beforeLoadUpdate(adEnv, task3, 100)
check("O4 venter på pallen", sources[3].spawnCalls, 1)
sources[3].stored[BASKET] = 2
v3.trailers.ual.spec.loadedObjects[{}] = 1
ADOutbound.beforeLoadUpdate(adEnv, task3, 100)
ADOutbound.beforeLoadUpdate(adEnv, task3, 100)
check("O4 andet spawn efter læsning", sources[3].spawnCalls, 2)
sources[3].stored[BASKET] = 0
v3.trailers.ual.spec.loadedObjects[{}] = 1
ADOutbound.beforeLoadUpdate(adEnv, task3, 100)
ADOutbound.beforeLoadUpdate(adEnv, task3, 100)
check("O4 skifter til kasser når kurvene er væk", v3.ad.stateModule.fillType, CRATE)
check("O4 UAL genaktiveret ved vareskift", v3.trailers.activated, 1)
ADOutbound.beforeLoadUpdate(adEnv, task3, 100)
check("O4 tredje spawn (kasse)", sources[3].spawnCalls, 3)
v3.trailers.full = true
sources[3].stored[CRATE] = 0
v3.trailers.ual.spec.loadedObjects[{}] = 1
ADOutbound.beforeLoadUpdate(adEnv, task3, 100)
ADOutbound.beforeLoadUpdate(adEnv, task3, 100)
check("O4 fuld -> finished", task3.finishedCalls, 1)
-- O5: spawn uden fremdrift i 30 s -> finished + WARN
local v4 = newVehicle("Valtra", 100, 0, 8000, true)
runs[v4] = outboundRun(4, {3}, {{fillType = "STRAW_BASKET", policy = "sell"}})
sources[3].stored[BASKET] = 5
sources[3].spawnCalls = 0
ADOutbound.choosePickup(adEnv, v4)
local task4 = newTask(v4, 3)
v4.atTarget, task4.activatedUALLoading = true, true
ADOutbound.beforeLoadUpdate(adEnv, task4, 100)
check("O5 spawn kaldt", sources[3].spawnCalls, 1)
ADOutbound.beforeLoadUpdate(adEnv, task4, 31000)
ADOutbound.beforeLoadUpdate(adEnv, task4, 100)
check("O5 finished efter stall", task4.finishedCalls, 1)
check("O5 WARN", lastLog("spawner ikke") ~= nil, true)
-- O6: opsamlingsrunde, bulk: kilde A fylder vognen -> finished uden redirect; kilde A giver 30 % -> videre til B
local v5 = newVehicle("Claas", 0, 0, 10000)
runs[v5] = outboundRun(5, {1, 2}, {{fillType = "SUGAR", policy = "sell"}}, {collectRound = true})
local choice5 = ADOutbound.choosePickup(adEnv, v5)
check("O6 mest først = silo 4", choice5.markerIndex, mSilo.markerIndex)
local task5 = newTask(v5, 2)
v5.trailers.level = 10000
check("O6 fuld: ingen runde", ADOutbound.beforeLoadFinished(adEnv, task5), false)
local v6 = newVehicle("Claas2", 0, 0, 10000)
runs[v6] = outboundRun(6, {1, 2}, {{fillType = "SUGAR", policy = "sell"}}, {collectRound = true})
ADOutbound.choosePickup(adEnv, v6)
local task6 = newTask(v6, 2)
v6.trailers.level = 3000
check("O6 plads tilbage: videre til næste kilde", ADOutbound.beforeLoadFinished(adEnv, task6), true)
check("O6 destination = Suger Load", task6.destinationID, 1)
check("O6 activePickup = fabrik", activePickupCalls[#activePickupCalls].station.name, "factoryStation")
check("O6 alle besøgt: afslut", ADOutbound.beforeLoadFinished(adEnv, task6), false)
-- O7: intet nåeligt salgssted -> rød notifikation én gang
notifications = {}
unreachable[10], unreachable[11] = true, true
local v7 = newVehicle("Deutz", 0, 0, 20000)
runs[v7] = outboundRun(7, {1}, {{fillType = "SUGAR", policy = "sell"}})
local choice7 = ADOutbound.choosePickup(adEnv, v7)
check("O7 holder", ADOutbound.getHoldFor(v7) ~= nil, true)
check("O7 én notifikation", #notifications, 1)
check("O7 ERROR", notifications[1].kind, 3)
check("O7 tekst", string.find(notifications[1].text, "kan ikke nå salgssted", 1, true) ~= nil, true)
check("O7 status-advarsel", ADOutbound.getRunStatus(7).warning ~= nil, true)
ADOutbound.choosePickup(adEnv, v7)
check("O7 ikke gentaget", #notifications, 1)
unreachable[10] = nil
ADOutbound.choosePickup(adEnv, v7)
check("O7 tur når vejen er der", ADOutbound.getTripFor(v7) ~= nil, true)
check("O7 advarsel ryddet", ADOutbound.getRunStatus(7).warning, nil)
unreachable[11] = nil
-- O8: ingen silo med plads ved lagerbehov -> notifikation; med silo -> store-tur
notifications = {}
local v8 = newVehicle("JD", 0, 0, 20000)
runs[v8] = outboundRun(8, {2}, {{fillType = "SUGAR", policy = "store"}})
products[2][1].level, products[2][1].capacity = 11000000, 12000000
ADOutbound.choosePickup(adEnv, v8)
check("O8 advarsel ingen silo", #notifications, 1)
dumpMarker = mStore
ADOutbound.choosePickup(adEnv, v8)
check("O8 store-tur", ADOutbound.getTripFor(v8).kind, "store")
check("O8 mål = silo 6", v8.ad.stateModule.secondMarkerId, 30)
-- O9: releaseInactive rydder hold + reservation
local v9 = newVehicle("Case", 25, 0, 20000)
runs[v9] = outboundRun(9, {1}, {{fillType = "FORAGE", policy = "sell"}})
ADOutbound.choosePickup(adEnv, v9)
check("O9 reserveret", ADWaitPool.getOwner(20), v9)
v9.active = false
ADOutbound.releaseInactive()
check("O9 frigivet", ADWaitPool.getOwner(20), nil)
check("O9 hold væk", ADOutbound.getHoldFor(v9), nil)
-- O11 (I2): hold-markøren skifter (Wait-punkt bliver ledigt) -> opgaven omdirigeres
ADWaitPool.releaseVehicle(v9)
local v11 = newVehicle("Hold", 25, 0, 20000)
runs[v11] = outboundRun(11, {1}, {{fillType = "FORAGE", policy = "sell"}}) -- FORAGE findes ikke ved kilden -> aldrig tur
local blocker = newVehicle("Blocker", 0, 0, 1)
ADWaitPool.claim(20, blocker)
local choice11 = ADOutbound.choosePickup(adEnv, v11)
check("O11 uden ledigt Wait: holder ved kilden", choice11.markerIndex, mSugar.markerIndex)
local task11 = newTask(v11, 1)
v11.atTarget = true
ADOutbound.beforeLoadUpdate(adEnv, task11, 100)
ADWaitPool.releaseVehicle(blocker)
ADOutbound.beforeLoadUpdate(adEnv, task11, 5000)
check("O11 Wait-punkt ledigt -> omdirigeret", task11.destinationID, 20)
check("O11 hold peger på Wait", ADOutbound.getHoldFor(v11).markerName, "Wait 3")
ADWaitPool.releaseVehicle(v11)
-- O12 (I3): mål for last der allerede er på vognen ved Start
local v12 = newVehicle("Loaded", 0, 0, 20000)
runs[v12] = outboundRun(12, {1}, {{fillType = "SUGAR", policy = "sell"}})
quotes["selling station"][SUGAR].percent = 92
check("O12 salgsmål for sukker-last", ADOutbound.planCargoTarget(adEnv, runs[v12], v12, SUGAR), 10)
quotes["selling station"][SUGAR].percent = 50
check("O12 pris lav, ingen lager-politik -> nil", ADOutbound.planCargoTarget(adEnv, runs[v12], v12, SUGAR), nil)
runs[v12].products = {{fillType = "SUGAR", policy = "sellElseStore"}}
check("O12 pris lav -> silo", ADOutbound.planCargoTarget(adEnv, runs[v12], v12, SUGAR), 30)
check("O12 last der ikke er i run'et -> nil", ADOutbound.planCargoTarget(adEnv, runs[v12], v12, FORAGE), nil)
quotes["selling station"][SUGAR].percent = 92
-- O13 (I4): tom vogn 30 s ved kilden -> kilden udelukkes for varen og turen afsluttes
local v13 = newVehicle("Empty", 0, 0, 20000)
runs[v13] = outboundRun(13, {1, 2}, {{fillType = "SUGAR", policy = "sell"}}, {collectRound = false})
local choice13 = ADOutbound.choosePickup(adEnv, v13)
check("O13 første kilde = silo 4 (mest)", choice13.markerIndex, mSilo.markerIndex)
local task13 = newTask(v13, 2)
v13.atTarget = true
ADOutbound.beforeLoadUpdate(adEnv, task13, 100)
ADOutbound.beforeLoadUpdate(adEnv, task13, 31000)
check("O13 finished efter 30 s uden last", task13.finishedCalls, 1)
check("O13 WARN", lastLog("intet at læsse") ~= nil, true)
local choice13b = ADOutbound.choosePickup(adEnv, v13)
check("O13 næste plan udelukker den tomme kilde", choice13b.markerIndex, mSugar.markerIndex)
-- O14 (I6): rutetjek kun for salgssteder over tærsklen, bedste først, stop ved første nåelige
local routeChecks = 0
local originalGetPathTo = adEnv.ADGraphManager.getPathTo
adEnv.ADGraphManager.getPathTo = function(_, vehicle, id) routeChecks = routeChecks + 1 return originalGetPathTo(_, vehicle, id) end
local v14 = newVehicle("Routes", 0, 0, 20000)
runs[v14] = outboundRun(14, {1}, {{fillType = "SUGAR", policy = "sell"}})
quotes["Far station"][SUGAR].percent = 99
quotes["selling station"][SUGAR].percent = 92
ADOutbound.choosePickup(adEnv, v14)
check("O14 bedste nåelig først: én rute tjekket", routeChecks, 1)
check("O14 mål = bedst betalende", v14.ad.stateModule.secondMarkerId, 11)
routeChecks = 0
quotes["Far station"][SUGAR].percent = 60
ADOutbound.choosePickup(adEnv, v14)
check("O14 under tærsklen tjekkes ikke", routeChecks, 1)
adEnv.ADGraphManager.getPathTo = originalGetPathTo
-- O15 (M1): advarsel for vare A gentages ikke efter en tur med vare B
notifications = {}
local v15 = newVehicle("Two", 0, 0, 20000)
runs[v15] = outboundRun(15, {1, 2}, {{fillType = "SUGAR", policy = "sell"}, {fillType = "FORAGE", policy = "sell"}})
products[1] = {{fillType = SUGAR, name = "SUGAR", available = 5000, level = 5000, capacity = 180000, isPallet = false}, {fillType = FORAGE, name = "FORAGE", available = 5000, level = 5000, capacity = 180000, isPallet = false}}
quotes["selling station"][FORAGE] = {percent = 95, price = 1}
quotes["Far station"][SUGAR].percent = 99 -- (O14 satte den til 60)
unreachable[10] = true; unreachable[11] = true
ADOutbound.choosePickup(adEnv, v15) -- SUGAR uopnåelig -> advarsel, FORAGE uopnåelig -> advarsel
local firstCount = #notifications
unreachable[11] = nil -- Far station kan nås for SUGAR (FORAGE sælges kun på selling station)
ADOutbound.choosePickup(adEnv, v15) -- tur med SUGAR
check("O15 tur", ADOutbound.getTripFor(v15).kind, "sell")
ADOutbound.choosePickup(adEnv, v15) -- igen: FORAGE stadig uopnåelig, men allerede advaret
check("O15 FORAGE-advarsel gentages ikke", #notifications, firstCount)
unreachable[10] = nil
products[1] = {{fillType = SUGAR, name = "SUGAR", available = 5000, level = 5000, capacity = 180000, isPallet = false}}
-- O16 (M6): pallens ankomst ses også på fyldniveau når UAL's interne liste mangler
local v16 = newVehicle("NoList", 100, 0, 8000, true)
v16.trailers[1].spec_universalAutoload = {}
runs[v16] = outboundRun(16, {3}, {{fillType = "STRAW_BASKET", policy = "sell"}})
sources[3].stored[BASKET] = 3; sources[3].spawnCalls = 0
ADOutbound.choosePickup(adEnv, v16)
local task16 = newTask(v16, 3)
v16.atTarget, task16.activatedUALLoading = true, true
ADOutbound.beforeLoadUpdate(adEnv, task16, 100)
check("O16 første spawn", sources[3].spawnCalls, 1)
sources[3].stored[BASKET] = 2
v16.trailers.level = 1000
ADOutbound.beforeLoadUpdate(adEnv, task16, 100)
ADOutbound.beforeLoadUpdate(adEnv, task16, 100)
check("O16 næste spawn via fyldniveau", sources[3].spawnCalls, 2)
-- O17 (reserve + paller til lager): buildPlanInput henter reserve og palle-lager fra ADSources
local demandCalls = {}
ADSources.getFarmDemand = function(_, ft, farmId, excluded) table.insert(demandCalls, ft) return {liters = ft == SUGAR and 4000 or 0, consumers = {"TMR-fabrik"}} end
local palletStoreMarker = nil
ADSources.findPalletStore = function(_, vehicle, ft, excluded, isReachable, farmId) return palletStoreMarker end
local v17 = newVehicle("Reserve", 0, 0, 20000)
runs[v17] = outboundRun(17, {1}, {{fillType = "SUGAR", policy = "sell"}})
local input17 = ADOutbound.buildPlanInput(adEnv, runs[v17], v17)
check("O17 reserve på salgsvare", input17.products[1].reserve, 4000)
check("O17 reserveret til", input17.products[1].reservedFor[1], "TMR-fabrik")
local v18 = newVehicle("PalletStore", 100, 0, 8000, true)
runs[v18] = outboundRun(18, {3}, {{fillType = "STRAW_BASKET", policy = "store"}})
palletStoreMarker = {wayPointId = 40, name = "Palleskur", free = 100}
sources[3].stored[BASKET] = 4
products[3][1].available, products[3][1].level, products[3][1].capacity = 4000, 3900, 4000
local input18 = ADOutbound.buildPlanInput(adEnv, runs[v18], v18)
check("O18 palle-lager som store-mål", input18.products[1].store ~= nil and input18.products[1].store.wayPointId, 40)
check("O18 reserve spørges ikke for lager-politik", demandCalls[#demandCalls] ~= BASKET, true)
-- O19: pallebil (kun UAL) ved en fabrik der både har bulk-læssestation og sukkerpaller -> paller
local v19 = newVehicle("Dragon", 0, 0, 6000, true)
v19.carriable = {}
runs[v19] = outboundRun(19, {1}, {{fillType = "SUGAR", policy = "sell"}})
products[1] = {{fillType = SUGAR, name = "SUGAR", available = 150000, level = 150000, capacity = 180000, isPallet = false, canPallet = true, palletLiters = 3000, palletCount = 3}}
quotes["selling station"][SUGAR].percent = 92
local input19 = ADOutbound.buildPlanInput(adEnv, runs[v19], v19)
check("O19 varen tages som paller", input19.products[1] ~= nil and input19.products[1].isPallet, true)
check("O19 mængde = paller ved spawner", input19.products[1].sources[1].available, 3000)
check("O19 palle-antal følger med", input19.products[1].sources[1].palletCount, 3)
local v19b = newVehicle("Tipvogn", 0, 0, 20000)
runs[v19b] = outboundRun(20, {1}, {{fillType = "SUGAR", policy = "sell"}})
local input19b = ADOutbound.buildPlanInput(adEnv, runs[v19b], v19b)
check("O19 tipvogn tager bulk", input19b.products[1].isPallet, false)
check("O19 bulk-mængde", input19b.products[1].sources[1].available, 150000)
products[1] = {{fillType = SUGAR, name = "SUGAR", available = 5000, level = 5000, capacity = 180000, isPallet = false}}
-- O20: UAL-bil: AD's "kapacitet" er antal paller (læssede + 1) -> liter-kapacitet ukendt i planen
local v20 = newVehicle("Dragon2", 100, 0, 1, true)
v20.carriable = {}
runs[v20] = outboundRun(21, {3}, {{fillType = "STRAW_BASKET", policy = "sell"}})
local input20 = ADOutbound.buildPlanInput(adEnv, runs[v20], v20)
check("O20 kapacitet ukendt for pallebil", input20.capacity, nil)
check("O20 har autoload", input20.hasAutoload, true)
check("O20 turen planlægges alligevel", ADOutboundPlanner.plan(input20) ~= nil, true)
-- O21: autoload-bil på vej til aflæsning: stropperne løsnes UNSTRAP_BEFORE_M før målet (ladet passerer
-- triggeren før markøren), kun én gang pr. tur, og aldrig for tipvogne
local v21 = newVehicle("Dragon3", 0, 0, 1, true)
v21.carriable = {}
v21.trailers[1].setAllTensionBeltsActive = function(self, active) self.beltsActive = active end
runs[v21] = outboundRun(22, {3}, {{fillType = "STRAW_BASKET", policy = "sell"}})
sources[3].stored[BASKET] = 3
quotes["selling station"][BASKET] = {percent = 95, price = 1}
ADOutbound.choosePickup(adEnv, v21)
check("O21 tur mod salgssted", v21.ad.stateModule.secondMarkerId, 10)
local unloadTask21 = {vehicle = v21, destinationID = 10, trailers = v21.trailers}
v21.rootNode = {x = 200 - 40, z = 0} -- 40 m fra markør 10 (x=200)
ADOutbound.beforeUnloadUpdate(adEnv, unloadTask21, 16)
check("O21 40 m: stropper stadig på", v21.trailers[1].beltsActive, nil)
v21.rootNode = {x = 200 - 20, z = 0}
ADOutbound.beforeUnloadUpdate(adEnv, unloadTask21, 16)
check("O21 20 m: stropper løsnet", v21.trailers[1].beltsActive, false)
v21.trailers[1].beltsActive = nil
ADOutbound.beforeUnloadUpdate(adEnv, unloadTask21, 16)
check("O21 kun én gang", v21.trailers[1].beltsActive, nil)
local v21b = newVehicle("Tip", 0, 0, 20000)
v21b.trailers[1].setAllTensionBeltsActive = function(self, active) self.beltsActive = active end
runs[v21b] = outboundRun(23, {1}, {{fillType = "SUGAR", policy = "sell"}})
quotes["selling station"][SUGAR].percent = 92
ADOutbound.choosePickup(adEnv, v21b)
v21b.rootNode = {x = 195, z = 0}
ADOutbound.beforeUnloadUpdate(adEnv, {vehicle = v21b, destinationID = 10, trailers = v21b.trailers}, 16)
check("O21 tipvogn: ingen stropper", v21b.trailers[1].beltsActive, nil)
-- O22: lagermål med en anden silo-markør inden for AD's globale trigger-afstand -> advarsel én gang
-- (AD tipper ved første trigger inden for afstanden; 10 m løser det når siloer står tæt)
local triggerDistance = 25
adEnv.AutoDrive.getSetting = function(name) if name == "maxTriggerDistance" then return triggerDistance end end
local v22 = newVehicle("Tip2", 0, 0, 20000)
runs[v22] = outboundRun(24, {1}, {{fillType = "SUGAR", policy = "store"}})
products[1] = {{fillType = SUGAR, name = "SUGAR", available = 170000, level = 170000, capacity = 180000, isPallet = false}}
dumpMarker = mStore -- silo 6 (x=60); silo 4 (x=50) står 10 m derfra
logLines = {}
ADOutbound.choosePickup(adEnv, v22)
check("O22 lager-tur", ADOutbound.getTripFor(v22) ~= nil and ADOutbound.getTripFor(v22).kind, "store")
check("O22 advarsel om trigger-afstand", lastLog("Max trigger distance") ~= nil, true)
logLines = {}
ADOutbound.choosePickup(adEnv, v22)
check("O22 kun én gang", lastLog("Max trigger distance"), nil)
triggerDistance = 10
local v22b = newVehicle("Tip3", 0, 0, 20000)
runs[v22b] = outboundRun(25, {1}, {{fillType = "SUGAR", policy = "store"}})
logLines = {}
ADOutbound.choosePickup(adEnv, v22b)
check("O22 ved 10 m ingen advarsel", lastLog("Max trigger distance"), nil)
adEnv.AutoDrive.getSetting = nil
products[1] = {{fillType = SUGAR, name = "SUGAR", available = 5000, level = 5000, capacity = 180000, isPallet = false}}
-- O23: intet salgssted tager varen -> log én gang med de fundne salgsmarkører og hvad de tager
ADSources.describeAccepted = function(station) return "SUGAR, FORAGE" end
local v23 = newVehicle("Dragon4", 100, 0, 1, true)
v23.carriable = {}
runs[v23] = outboundRun(26, {3}, {{fillType = "STRAW_CRATE", policy = "sell"}})
quotes["selling station"][CRATE] = nil; quotes["Far station"][CRATE] = nil
sources[3].stored[CRATE] = 2
logLines = {}
ADOutbound.choosePickup(adEnv, v23)
local noSell = lastLog("intet salgssted tager STRAW_CRATE")
check("O23 log om manglende salgssted", noSell ~= nil, true)
check("O23 lister salgsmarkører", noSell ~= nil and noSell:find("selling station (SUGAR, FORAGE)", 1, true) ~= nil, true)
logLines = {}
ADOutbound.choosePickup(adEnv, v23)
check("O23 kun én gang", lastLog("intet salgssted tager STRAW_CRATE"), nil)
-- O10: install wrapper: fejl i hook stopper ikke AD
local calls = {}
adEnv.LoadAtDestinationTask.update = function(self, dt) calls.update = (calls.update or 0) + 1 end
adEnv.LoadAtDestinationTask.finished = function(self) calls.finished = (calls.finished or 0) + 1 end
ADOutbound.install(adEnv)
local vX = newVehicle("X", 0, 0, 1000)
adEnv.LoadAtDestinationTask.update(newTask(vX, 1), 16)
check("O10 original update kaldt", calls.update, 1)
adEnv.LoadAtDestinationTask.finished(newTask(vX, 1))
check("O10 original finished kaldt", calls.finished, 1)
adEnv.UnloadAtDestinationTask.update = function(self, dt) calls.unload = (calls.unload or 0) + 1 end
ADOutbound.install(adEnv)
check("O10 unload-wrap ikke installeret dobbelt", adEnv.UnloadAtDestinationTask.update ~= nil, true)
-- O24: stropperne spændes igen når pålæsningen er færdig (de blev løsnet 25 m før forrige salg;
-- in-game 23/9: Dragon tabte paller på vejen to gange)
local v24 = newVehicle("Dragon5", 100, 0, 1, true)
v24.carriable = {}
v24.trailers[1].setAllTensionBeltsActive = function(self, active) self.beltsActive = active end
runs[v24] = outboundRun(27, {3}, {{fillType = "STRAW_BASKET", policy = "sell"}})
sources[3].stored[BASKET] = 3
quotes["selling station"][BASKET] = {percent = 95, price = 1}
ADOutbound.choosePickup(adEnv, v24)
local task24 = newTask(v24, 3)
v24.trailers.level = 3
adEnv.LoadAtDestinationTask.finished(task24)
check("O24 stropper spændt efter pålæsning", v24.trailers[1].beltsActive, true)
check("O24 tur planlagt", ADOutbound.getTripFor(v24) ~= nil, true)
check("O24 original finished kaldt", calls.finished, 2)
local v24b = newVehicle("Tip4", 0, 0, 20000)
v24b.trailers[1].setAllTensionBeltsActive = function(self, active) self.beltsActive = active end
adEnv.LoadAtDestinationTask.finished(newTask(v24b, 1))
check("O24 tipvogn uden tur: rører ikke stropper", v24b.trailers[1].beltsActive, nil)
-- O-pool: to kilder hvis stationer deler lager (mælketanke som udvidelser) = ét stop i runden
do
local pool = {}
local st1, st2 = sources[1].station, sources[2].station
sources[1].station = {name = "tankA", sourceStorages = {[pool] = pool}}
sources[2].station = {name = "tankB", sourceStorages = {[pool] = pool}}
local k1, k2 = sources[1].kind, sources[2].kind
sources[1].kind, sources[2].kind = "silo", "silo"
local vPool = newVehicle("Tank", 0, 0, 10000)
runs[vPool] = outboundRun(77, {1, 2}, {{fillType = "SUGAR", policy = "sell"}}, {collectRound = true})
local inputPool = ADOutbound.buildPlanInput(adEnv, runs[vPool], vPool)
local ids = {}
for _, product in ipairs(inputPool.products) do
for _, source in ipairs(product.sources) do table.insert(ids, tostring(source.wayPointId)) end
end
check("O-pool kun første kilde", table.concat(ids, ","), "1")
check("O-pool logget", lastLog("deler lager") ~= nil, true)
sources[1].station, sources[2].station = st1, st2
sources[1].kind, sources[2].kind = k1, k2
end
-- O-pool-stald: mælke- og gyllemarkør ved samme stald deler lager, men er forskellige læssepunkter
do
local pool = {}
sources[31] = {kind = "husbandry", wayPointId = 31, station = {name = "barnSlurry", sourceStorages = {[pool] = pool}}, name = "Stald"}
sources[32] = {kind = "husbandry", wayPointId = 32, station = {name = "barnMilk", sourceStorages = {[pool] = pool}}, name = "Stald"}
products[31] = {{fillType = SUGAR_FT or 5, name = "SUGAR", title = "Sukker", available = 20000, level = 20000, capacity = 50000, isPallet = false}}
products[32] = {{fillType = SUGAR_FT or 5, name = "SUGAR", title = "Sukker", available = 20000, level = 20000, capacity = 50000, isPallet = false}}
local vBarn = newVehicle("Tank2", 0, 0, 10000)
runs[vBarn] = outboundRun(78, {31, 32}, {{fillType = "SUGAR", policy = "sell"}}, {collectRound = true})
local inputBarn = ADOutbound.buildPlanInput(adEnv, runs[vBarn], vBarn)
local ids = {}
for _, product in ipairs(inputBarn.products) do
for _, source in ipairs(product.sources) do table.insert(ids, tostring(source.wayPointId)) end
end
check("O-pool-stald: stalde lægges ikke sammen", table.concat(ids, ","), "31,32")
sources[31], sources[32], products[31], products[32] = nil, nil, nil, nil
end
-- O-forced: "Lager til" valgt -> lagerturen går til det valgte mål, ikke moden egen silo-valg
do
sources[41] = {kind = "silo", wayPointId = 41, station = {name = "gylleSilo"}, name = "Gyllesilo"}
products[41] = {{fillType = SUGAR, name = "SUGAR", title = "Sukker", available = 11000000, level = 11000000, capacity = 12000000, isPallet = false}}
local vF = newVehicle("Gyllevogn", 0, 0, 20000)
runs[vF] = outboundRun(79, {41}, {{fillType = "SUGAR", policy = "store"}})
runs[vF].forcedMarkers = {31}
ADRunsController.getStoreMarkers = function(_, run) return run.forcedMarkers or {} end
local forcedCalls = 0
ADLoadSwap.findForcedDumpMarker = function(_, _, _, _, ids) forcedCalls = forcedCalls + 1 return {id = ids[1], name = "BGA", markerIndex = 99} end
dumpMarker = mStore
ADOutbound.choosePickup(adEnv, vF)
check("O-forced valgt mål brugt", forcedCalls > 0, true)
check("O-forced tur-mål = BGA", ADOutbound.getTripFor(vF) and ADOutbound.getTripFor(vF).targetWayPointId, 31)
ADRunsController.getStoreMarkers, ADLoadSwap.findForcedDumpMarker = nil, nil
end
-- O-empty: ingen vare ved kilden (æggene er ikke spawnet endnu) -> intet lagermål søges, ingen advarsel
do
sources[42] = {kind = "husbandry", wayPointId = 42, station = nil, name = "Chicken Coop"}
products[42] = {{fillType = SUGAR, name = "SUGAR", title = "Æg", available = 0, level = 0, capacity = 0, isPallet = false}}
local vE = newVehicle("Æg", 0, 0, 20000)
runs[vE] = outboundRun(80, {42}, {{fillType = "SUGAR", policy = "store"}})
local storeLookups = 0
local dumpBefore = ADLoadSwap.findDumpMarker
ADLoadSwap.findDumpMarker = function(...) storeLookups = storeLookups + 1 return nil, "ingen" end
ADOutbound.buildPlanInput(adEnv, runs[vE], vE)
check("O-empty intet lagermål søgt uden vare", storeLookups, 0)
ADLoadSwap.findDumpMarker = dumpBefore
end
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
-177
View File
@@ -1,177 +0,0 @@
-- Kør: luajit tests/test_adOutboundPlanner.lua (fra repo-roden)
-- Ren turvurdering for udkørsel: prisregel, tærskler, politikker, rangering, opsamlingsrunde.
dofile("FS25_ADSmartPickup/adOutboundPlanner.lua")
local P = ADOutboundPlanner
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 join(list) return table.concat(list or {}, ",") end
local SUGAR, FORAGE, BASKET, CRATE, BROOM = 5, 6, 7, 8, 9
local function input(products, overrides)
local result = {capacity = 20000, hasAutoload = false, sellAtPercent = 90, storeAbovePercent = 80, collectRound = false, products = products}
for key, value in pairs(overrides or {}) do result[key] = value end
return result
end
local function product(name, fillType, policy, sources, quote, store, isPallet)
return {fillType = fillType, name = name, policy = policy, isPallet = isPallet == true, sources = sources, quote = quote, store = store}
end
local quoteS = {percent = 92, price = 480, wayPointId = 900, stationName = "Grain Elevator"}
local storeSilo = {wayPointId = 400, name = "silo 1", free = 500000}
-- P1: salg ved pris over tærskel, uanset mængde
local trip = P.plan(input({product("SUGAR", SUGAR, "sell", {{wayPointId = 1, available = 5000, level = 5000, capacity = 180000}}, quoteS, nil)}))
check("P1 salg", trip.kind, "sell")
check("P1 mål", trip.targetWayPointId, 900)
check("P1 målnavn", trip.targetName, "Grain Elevator")
check("P1 kilde", join(trip.sourceIds), "1")
check("P1 vare", join(trip.fillTypes), "5")
check("P1 procent", trip.percent, 92)
-- P2: pris under tærskel, lager lavt -> ingen tur med begrundelse
local low = {percent = 78, price = 400, wayPointId = 900, stationName = "Grain Elevator"}
local trip2, reasons2 = P.plan(input({product("SUGAR", SUGAR, "sell", {{wayPointId = 1, available = 74000, level = 74000, capacity = 180000}}, low, nil)}))
check("P2 ingen tur", trip2, nil)
check("P2 begrundelse", reasons2[1], "SUGAR 78 % af top (kræver 90), lager 41 %")
-- P3: sellElseStore, pris lav, lager 85 % -> lager
local trip3 = P.plan(input({product("SUGAR", SUGAR, "sellElseStore", {{wayPointId = 1, available = 153000, level = 153000, capacity = 180000}}, low, storeSilo)}))
check("P3 lager", trip3.kind, "store")
check("P3 silo", trip3.targetWayPointId, 400)
-- P4: store med fuldt læs, lager 20 %
local trip4 = P.plan(input({product("FORAGE", FORAGE, "store", {{wayPointId = 2, available = 700000, level = 700000, capacity = 3500000}}, nil, storeSilo)}))
check("P4 fuldt læs -> lager", trip4.kind, "store")
local trip4b = P.plan(input({product("FORAGE", FORAGE, "store", {{wayPointId = 2, available = 15000, level = 15000, capacity = 3500000}}, nil, storeSilo)}))
check("P4 for lidt og lager lavt -> nil", trip4b, nil)
-- P5: rangering: højeste pris-% først
local trip5 = P.plan(input({
product("SUGAR", SUGAR, "sell", {{wayPointId = 1, available = 1000, level = 1000, capacity = 180000}}, {percent = 91, price = 1, wayPointId = 900, stationName = "A"}, nil),
product("FORAGE", FORAGE, "sell", {{wayPointId = 2, available = 1000, level = 1000, capacity = 3500000}}, {percent = 95, price = 1, wayPointId = 901, stationName = "B"}, nil),
}))
check("P5 højeste procent vinder", join(trip5.fillTypes), "6")
check("P5 uden runde kun én vare", #trip5.fillTypes, 1)
-- P6: intet nåeligt salgssted
local trip6, reasons6 = P.plan(input({product("SUGAR", SUGAR, "sell", {{wayPointId = 1, available = 170000, level = 170000, capacity = 180000}}, nil, nil)}))
check("P6 ingen tur", trip6, nil)
check("P6 begrundelse", reasons6[1], "SUGAR: intet nåeligt salgssted")
-- P7: ingen silo med plads ved lagerbehov
local trip7, reasons7 = P.plan(input({product("FORAGE", FORAGE, "store", {{wayPointId = 2, available = 3000000, level = 3000000, capacity = 3500000}}, nil, nil)}))
check("P7 ingen tur", trip7, nil)
check("P7 begrundelse", reasons7[1], "FORAGE: intet lager med plads (silo eller egen fabrik der bruger varen)")
-- P8: runde, bulk: samme vare fra flere kilder, mest først
local trip8 = P.plan(input({product("SUGAR", SUGAR, "sell", {{wayPointId = 1, available = 3000, level = 3000, capacity = 180000}, {wayPointId = 3, available = 9000, level = 9000, capacity = 180000}}, quoteS, nil)}, {collectRound = true, capacity = 10000}))
check("P8 kilder mest først", join(trip8.sourceIds), "3,1")
check("P8 én vare", #trip8.fillTypes, 1)
-- P9: runde, UAL: alle pallevarer med samme mål
local sellS = function(pct) return {percent = pct, price = 1, wayPointId = 950, stationName = "Halm-salg"} end
local sellT = {percent = 96, price = 1, wayPointId = 951, stationName = "Andet"}
local pallets = {
product("STRAW_BASKET", BASKET, "sell", {{wayPointId = 5, available = 8000, level = 8, capacity = 4000}}, sellS(95), nil, true),
product("STRAW_CRATE", CRATE, "sell", {{wayPointId = 5, available = 2000, level = 2, capacity = 4000}}, sellS(93), nil, true),
product("STRAW_BROOM", BROOM, "sell", {{wayPointId = 6, available = 1000, level = 1, capacity = 4000}}, sellT, nil, true),
}
local trip9 = P.plan(input(pallets, {collectRound = true, hasAutoload = true}))
check("P9 højeste procent = kost alene (andet mål)", join(trip9.fillTypes), "9")
local trip9b = P.plan(input({pallets[1], pallets[2]}, {collectRound = true, hasAutoload = true}))
check("P9 kurve+kasser samme mål", join(trip9b.fillTypes), "7,8")
check("P9 navne", join(trip9b.names), "STRAW_BASKET,STRAW_CRATE")
check("P9 kilder unikke", join(trip9b.sourceIds), "5")
local trip9c = P.plan(input({pallets[1], pallets[2]}, {collectRound = true, hasAutoload = false}))
check("P9 uden UAL kun én pallevare", #trip9c.fillTypes, 1)
-- P10: uden runde kun én kilde (den med mest)
local trip10 = P.plan(input({product("SUGAR", SUGAR, "sell", {{wayPointId = 1, available = 3000, level = 3000, capacity = 180000}, {wayPointId = 3, available = 9000, level = 9000, capacity = 180000}}, quoteS, nil)}))
check("P10 én kilde, den fyldeste", join(trip10.sourceIds), "3")
-- P11: intet på lager
local trip11, reasons11 = P.plan(input({product("SUGAR", SUGAR, "sell", {}, quoteS, nil)}))
check("P11 ingen tur", trip11, nil)
check("P11 begrundelse", reasons11[1], "SUGAR: intet på lager")
-- P12: evaluate
local e = P.evaluate(product("SUGAR", SUGAR, "sell", {{wayPointId = 1, available = 150000, level = 150000, capacity = 180000}}, quoteS, nil), input({}))
check("P12 storagePercent", math.floor(e.storagePercent + 0.5), 83)
check("P12 storageHigh", e.storageHigh, true)
check("P12 fullLoad", e.fullLoad, true)
check("P12 priceOk", e.priceOk, true)
-- P13 (reserve): det gårdens fabrikker/stalde skal bruge, sælges ikke
local BEET = 10
local withReserve = product("SUGARBEET", BEET, "sell", {{wayPointId = 3, available = 268000, level = 268000, capacity = 12000000}}, quoteS, nil)
withReserve.reserve, withReserve.reservedFor = 196000, {"TMR-fabrik"}
local trip13 = P.plan(input({withReserve}))
check("P13 fuldt læs uden om reserven -> salg", trip13 ~= nil and trip13.kind, "sell")
withReserve.reserve = 260000 -- kun 8.000 til overs: mindre end et læs
local trip13b, reasons13b = P.plan(input({withReserve}))
check("P13 reserven ville blive rørt -> ingen tur", trip13b, nil)
check("P13 begrundelse", reasons13b[1], "SUGARBEET: 260.0k reserveret til TMR-fabrik")
withReserve.reserve = 300000 -- alt reserveret
check("P13 alt reserveret -> ingen tur", P.plan(input({withReserve})), nil)
withReserve.reserve = 0
check("P13 reserve 0 -> salg uanset mængde", P.plan(input({withReserve})).kind, "sell")
-- lager-politik ignorerer reserven (varen bliver på gården)
local storeReserve = product("SUGARBEET", BEET, "store", {{wayPointId = 3, available = 268000, level = 11000000, capacity = 12000000}}, nil, storeSilo)
storeReserve.reserve = 300000
check("P13 lager rører ikke reserven", P.plan(input({storeReserve})).kind, "store")
local e13 = P.evaluate(withReserve, input({}, {capacity = 20000}))
check("P13 evaluate sellable", e13.sellable, 268000)
-- P14 (paller): kapacitet ukendt for en autoload-bil -> "fuldt læs" = mindst PALLET_FULL_LOAD_COUNT paller klar
local function palletInput(products)
local inp = input(products, {hasAutoload = true})
inp.capacity = nil
return inp
end
local palletProduct = product("STRAW_BASKET", BASKET, "store", {{wayPointId = 5, available = 3000, level = 1600, capacity = 8000, palletCount = 3}}, nil, storeSilo, true)
check("P14 3 paller: ikke fuldt læs", P.plan(palletInput({palletProduct})), nil)
palletProduct.sources[1].palletCount = 4
check("P14 4 paller: lager-tur", P.plan(palletInput({palletProduct})).kind, "store")
local twoSources = product("STRAW_BASKET", BASKET, "store", {{wayPointId = 5, available = 2000, level = 1600, capacity = 8000, palletCount = 2}, {wayPointId = 3, available = 2000, level = 2, capacity = 4000, palletCount = 2}}, nil, storeSilo, true)
check("P14 paller summeres over kilder", P.plan(palletInput({twoSources})).kind, "store")
check("P14 evaluate palletCount", P.evaluate(twoSources, palletInput({})).palletCount, 4)
-- P15: kilder der deler lager (mælketanke = udvidelser inden for storageRadius) regnes som ét stop;
-- den første i forløbets rækkefølge beholdes, rækkefølgen bevares
local storA, storB, storC = {}, {}, {}
local pools = {[1] = {storA, storB}, [2] = {storB}, [3] = {storC}, [4] = {storA}, [5] = nil}
local srcs = {{wayPointId = 1}, {wayPointId = 2}, {wayPointId = 3}, {wayPointId = 4}, {wayPointId = 5}}
local kept, merged = P.dedupeSharedSources(srcs, function(source) return pools[source.wayPointId] end)
check("P15 behold 1, 3, 5", join((function() local ids = {} for _, s in ipairs(kept) do table.insert(ids, s.wayPointId) end return ids end)()), "1,3,5")
check("P15 slået sammen: 2 og 4 ind i 1", join(merged[1]), "2,4")
check("P15 kilde uden lagre beholdes", kept[3].wayPointId, 5)
local none = P.dedupeSharedSources({}, function() return nil end)
check("P15 tom liste", #none, 0)
-- P16: fodring ("Lager til" valgt = feedMode): kør så snart kilden har FEED_MIN_LITERS — ikke fuldt læs / 80 %
local feedStore = {wayPointId = 50, name = "Slurry Drop 1", free = 100000}
local feedProduct = product("LIQUIDMANURE", FORAGE, "store", {{wayPointId = 2, available = 20000, level = 20000, capacity = 500000}}, nil, feedStore)
local tripNoFeed = P.plan(input({feedProduct}, {capacity = 100000}))
check("P16 uden fodring: 4 % og intet fuldt læs -> ingen tur", tripNoFeed, nil)
local tripFeed = P.plan(input({feedProduct}, {capacity = 100000, feedMode = true}))
check("P16 fodring: 20k >= minimum -> lagertur", tripFeed and tripFeed.kind, "store")
check("P16 fodring: mål = valgt drop", tripFeed and tripFeed.targetWayPointId, 50)
local tinyProduct = product("LIQUIDMANURE", FORAGE, "store", {{wayPointId = 2, available = 3000, level = 3000, capacity = 500000}}, nil, feedStore)
check("P16 fodring: under minimum -> ingen tur", P.plan(input({tinyProduct}, {capacity = 100000, feedMode = true})), nil)
-- P17: valgt lager ("Lager til") kan ikke bruges -> begrundelsen er den præcise grund, ikke "intet lager med plads"
local blocked = product("EGG", FORAGE, "store", {{wayPointId = 2, available = 3000000, level = 3000000, capacity = 3500000}}, nil, nil)
blocked.storeReason = "Bale & Pallet Storage: ingen vej til 'Chicken Storage Unload'"
local _, reasons17 = P.plan(input({blocked}))
check("P17 præcis grund i status", reasons17[1], "EGG: Bale & Pallet Storage: ingen vej til 'Chicken Storage Unload'")
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
-106
View File
@@ -1,106 +0,0 @@
-- Kør: luajit tests/test_adPrices.lua (fra repo-roden)
-- Prisregel: toppris fra årets prisforløb, pris-% pr. salgssted, bedste tilbud.
dofile("FS25_ADSmartPickup/adPrices.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
-- P1: højeste faktor
check("P1 maxFactor", ADPrices.maxFactor({EARLY_SPRING = 0.9, LATE_AUTUMN = 1.2, MID_SUMMER = 1.0}), 1.2)
check("P1 uden faktorer = 1", ADPrices.maxFactor(nil), 1)
-- P2: toppris = grundpris nu × (maks ÷ nu)
check("P2 topPrice", ADPrices.topPrice(500, 1.0, 1.25), 625)
check("P2 factorNow 0 -> grundpris", ADPrices.topPrice(500, 0, 1.25), 500)
check("P2 grundpris 0 -> 0", ADPrices.topPrice(0, 1, 1.25), 0)
-- P3: procent, afrundet; stor efterspørgsel > 100
check("P3 75 %", ADPrices.percentOfTop(470, 625), 75)
check("P3 112 %", ADPrices.percentOfTop(700, 625), 112)
check("P3 top 0 -> 0", ADPrices.percentOfTop(1, 0), 0)
-- P4: spil-adapter med stationens grundpris
SellingStation = {}
local SUGAR = 5
g_fillTypeManager = {getFillTypeByIndex = function(_, index)
if index == SUGAR then return {economy = {factors = {A = 1.0, B = 1.25}}, pricePerLiter = 400} end
end}
g_currentMission = {environment = {currentPeriod = "A"}, economyManager = {getPriceMultiplier = function() return 1.0 end}}
local station = {
fillTypePrices = {[SUGAR] = 500},
getEffectiveFillTypePrice = function(_, fillType) return 470 end,
isa = function(_, class) return class == SellingStation end,
isSellingPoint = true,
}
local quote = ADPrices.getQuote(station, SUGAR)
check("P4 pris", quote.price, 470)
check("P4 top (pris ved 100 %)", math.floor(quote.top + 0.5), 588)
check("P4 procent = kurven alene (stationens grundpris bruges ikke: den mangler sværhedsgrad m.m.)", quote.percent, 80)
check("P4 er salgssted", ADPrices.isSellingStation(station), true)
check("P4 skjult = ikke salgssted", ADPrices.isSellingStation({isa = function() return true end, isSellingPoint = true, hideFromPricesMenu = true}), false)
check("P4 fabrik-station = ikke salgssted", ADPrices.isSellingStation({isa = function() return true end, isSellingPoint = false}), false)
-- in-game (23/9): TMR-fabrikkens indlevering er en SellingStation med isSellingPoint=true -> blev regnet som marked
check("P4 fabrikkens egen station = ikke salgssted", ADPrices.isSellingStation({isa = function() return true end, isSellingPoint = true, owningPlaceable = {spec_productionPoint = {}}}), false)
check("P4 staldens egen station = ikke salgssted", ADPrices.isSellingStation({isa = function() return true end, isSellingPoint = true, owningPlaceable = {getHusbandryFillLevel = function() end}}), false)
-- P4b: fremmed produktion (kortets mejeri, ejer 0) køber varen = salgssted; gårdens egen fabrik er ikke
check("P4b fremmed mejeri = salgssted", ADPrices.isSellingStation({isa = function() return true end, isSellingPoint = true, owningPlaceable = {spec_productionPoint = {}, getOwnerFarmId = function() return 0 end}}), true)
check("P4b egen fabrik (ejer 1) = ikke salgssted", ADPrices.isSellingStation({isa = function() return true end, isSellingPoint = true, owningPlaceable = {spec_productionPoint = {}, getOwnerFarmId = function() return 1 end}}), false)
check("P4b fremmed stald-felt ownerFarmId", ADPrices.isSellingStation({isa = function() return true end, isSellingPoint = true, owningPlaceable = {getHusbandryFillLevel = function() end, ownerFarmId = 0}}), true)
check("P4 rigtigt salgssted med placeable", ADPrices.isSellingStation({isa = function() return true end, isSellingPoint = true, owningPlaceable = {}}), true)
-- P5: uden stationens grundpris: kun kurven tæller (faktor nu ÷ faktor max)
local bare = {getEffectiveFillTypePrice = function() return 470 end}
local quote2 = ADPrices.getQuote(bare, SUGAR)
check("P5 procent = kurven alene", quote2.percent, 80)
check("P5 top = effektiv ÷ kurve", math.floor(quote2.top + 0.5), 588)
-- P7: kurven er grundlaget (Distribution Redux-metoden): faktor nu ÷ årets max, uafhængigt af sværhedsgrad
check("P7 curvePercent", ADPrices.curvePercent({1.0, 1.25, 0.9}, 1), 80)
check("P7 curvePercent ved top", ADPrices.curvePercent({1.0, 1.25, 0.9}, 2), 100)
check("P7 flad kurve = 100", ADPrices.curvePercent({1.0, 1.01, 1.0}, 3), 100)
check("P7 ingen kurve = 100", ADPrices.curvePercent(nil, 1), 100)
check("P7 ukendt periode = 100", ADPrices.curvePercent({1.0, 1.25}, nil), 100)
-- P8: in-game (23/9): effektiv 1602 mod fillTypePrices 505 gav 317 % — stationens grundpris indeholder
-- hverken sværhedsgrad eller prisskala, så forholdet er ubrugeligt. Procenten er kurven alene.
local demandStation = {fillTypePrices = {[SUGAR] = 505}, getEffectiveFillTypePrice = function() return 1602 end}
check("P8 procent uafhængig af stationens grundpris", ADPrices.getQuote(demandStation, SUGAR).percent, 80)
check("P8 top = pris ved 100 % af kurven", math.floor(ADPrices.getQuote(demandStation, SUGAR).top + 0.5), 2003)
-- P9: sværhedsgraden påvirker ikke procenten
local hardStation = {getEffectiveFillTypePrice = function() return 235 end}
check("P9 uafhængig af sværhedsgrad", ADPrices.getQuote(hardStation, SUGAR).percent, 80)
check("P5 fejlende station -> nil", ADPrices.getQuote({getEffectiveFillTypePrice = function() error("x") end}, SUGAR), nil)
-- P6: bedste tilbud = højeste procent, dernæst pris
local best = ADPrices.bestQuote({{percent = 80, price = 1}, {percent = 92, price = 2}, {percent = 92, price = 3}})
check("P6 højeste procent", best.percent, 92)
check("P6 ved lighed højeste pris", best.price, 3)
check("P6 tom -> nil", ADPrices.bestQuote({}), nil)
-- P-land: kortanlæg der følger jorden (boughtWithFarmland) er gårdens, når jorden under dem er gårdens —
-- selv om placeable-ejeren står som 15 (Dakotas BGA500kw på jordlod 69)
getWorldTranslation = getWorldTranslation or function(node) return node.x, 0, node.z end
g_currentMission = g_currentMission or {}
g_currentMission.getFarmId = function() return 1 end
g_farmlandManager = {
getFarmlandIdAtWorldPosition = function(_, x, z) return x < 0 and 69 or 70 end,
getFarmlandOwner = function(_, id) return id == 69 and 1 or 0 end,
}
local bga = {rootNode = {x = -1600, z = -89}, boughtWithFarmland = true, spec_productionPoint = {}, getOwnerFarmId = function() return 15 end}
local bgaOther = {rootNode = {x = 1600, z = -89}, boughtWithFarmland = true, spec_productionPoint = {}, getOwnerFarmId = function() return 15 end}
local dairy = {rootNode = {x = -3173, z = -4028}, spec_productionPoint = {}, getOwnerFarmId = function() return 0 end}
check("P-land BGA på egen jord = egen", ADPrices.isOwnedBy(bga, 1), true)
check("P-land BGA på fremmed jord = ikke egen", ADPrices.isOwnedBy(bgaOther, 1), false)
check("P-land mejeri (ejer 0, ikke jord-bundet) = ikke egen", ADPrices.isOwnedBy(dairy, 1), false)
check("P-land egen ejer", ADPrices.isOwnedBy({getOwnerFarmId = function() return 1 end}, 1), true)
check("P-land BGA er ikke salgssted", ADPrices.isSellingStation({isa = function() return true end, isSellingPoint = true, owningPlaceable = bga}), false)
g_farmlandManager = nil
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
-326
View File
@@ -1,326 +0,0 @@
-- Kør: luajit tests/test_adRuns.lua (fra repo-roden)
-- Tester run-modellen (rene funktioner) og XML-laget mod en XMLFile-mock.
dofile("FS25_ADSmartPickup/adRuns.lua")
local failures = 0
local function check(name, actual, expected)
if actual == expected then
print("OK " .. name)
else
failures = failures + 1
print(string.format("FAIL %s: forventede %s, fik %s", name, tostring(expected), tostring(actual)))
end
end
-- R1: opret run -> løbenummer, standardværdier; input-store røres ikke
local empty = ADRuns.newStore()
local s1, cow = ADRuns.createRun(empty, "Cow Feed")
check("R1 id 1", cow.id, 1)
check("R1 navn", cow.name, "Cow Feed")
check("R1 auto-varer som standard", cow.ingredientMode, "auto")
check("R1 loops 0", cow.loops, 0)
check("R1 tom Wait-pulje", cow.waitPoolGroup, "")
check("R1 input-store urørt", #empty.runs, 0)
check("R1 nextId tæller op", s1.nextId, 2)
local s2, pig = ADRuns.createRun(s1, "Pig Feed")
check("R1 andet run får id 2", pig.id, 2)
check("R1 to runs", #s2.runs, 2)
-- R2: opdatér felter; ukendt mode ignoreres; loops klippes
local s3 = ADRuns.updateRun(s2, 1, {name = "Cow 1", targetWayPointId = 94, ingredientMode = "manual", ingredients = {"STRAW", "FORAGE"}, waitPoolGroup = "Cow Wait", loops = 150})
local run1 = ADRuns.getRun(s3, 1)
check("R2 navn", run1.name, "Cow 1")
check("R2 mål", run1.targetWayPointId, 94)
check("R2 manual", run1.ingredientMode, "manual")
check("R2 varer", table.concat(run1.ingredients, ","), "STRAW,FORAGE")
check("R2 pulje", run1.waitPoolGroup, "Cow Wait")
check("R2 loops klippet til 99", run1.loops, 99)
check("R2 gammel store urørt", ADRuns.getRun(s2, 1).name, "Cow Feed")
local s3b = ADRuns.updateRun(s3, 1, {ingredientMode = "bogus", loops = -5})
check("R2 ukendt mode ignoreres", ADRuns.getRun(s3b, 1).ingredientMode, "manual")
check("R2 loops under 0 -> 0", ADRuns.getRun(s3b, 1).loops, 0)
check("R2 ukendt run -> uændret", #ADRuns.updateRun(s3, 99, {name = "x"}).runs, 2)
check("R2 targetWayPointId=false rydder målet", ADRuns.getRun(ADRuns.updateRun(s3, 1, {targetWayPointId = false}), 1).targetWayPointId, nil)
check("R2 nil rører ikke målet", ADRuns.getRun(ADRuns.updateRun(s3, 1, {name = "y"}), 1).targetWayPointId, 94)
-- R3: tildel traktor; samme traktor flyttes mellem runs
local s4, prev = ADRuns.assignVehicle(s3, 1, "vehicleA")
check("R3 tildelt", ADRuns.getRun(s4, 1).vehicleIds[1], "vehicleA")
check("R3 ingen tidligere", prev, nil)
check("R3 opslag", ADRuns.findRunByVehicle(s4, "vehicleA").id, 1)
local s5, prev2 = ADRuns.assignVehicle(s4, 2, "vehicleA")
check("R3 flyttet fra run 1", prev2, 1)
check("R3 væk fra run 1", #ADRuns.getRun(s5, 1).vehicleIds, 0)
check("R3 i run 2", ADRuns.getRun(s5, 2).vehicleIds[1], "vehicleA")
local s5b, prev3 = ADRuns.assignVehicle(s5, 2, "vehicleA")
check("R3 gen-tildeling samme run -> ingen dublet", #ADRuns.getRun(s5b, 2).vehicleIds, 1)
check("R3 gen-tildeling -> ingen 'flyttet fra'", prev3, nil)
local s6 = ADRuns.unassignVehicle(s5, "vehicleA")
check("R3 fjernet", ADRuns.findRunByVehicle(s6, "vehicleA"), nil)
check("R3 ukendt run ved tildeling -> uændret", ADRuns.findRunByVehicle(ADRuns.assignVehicle(s6, 42, "vehicleB"), "vehicleB"), nil)
-- R4: slet run
local s7 = ADRuns.deleteRun(s5, 1)
check("R4 ét run tilbage", #s7.runs, 1)
check("R4 det rigtige", s7.runs[1].id, 2)
check("R4 nextId bevares", s7.nextId, 3)
-- R5: records frem og tilbage
local records = ADRuns.toRecords(s5)
check("R5 target som tal", records[1].target, 94)
check("R5 varer som streng", records[1].ingredients, "STRAW FORAGE")
check("R5 køretøjer", records[2].vehicleIds[1], "vehicleA")
local back = ADRuns.fromRecords(records)
check("R5 to runs tilbage", #back.runs, 2)
check("R5 nextId = højeste + 1", back.nextId, 3)
check("R5 varer parset", ADRuns.getRun(back, 1).ingredients[2], "FORAGE")
check("R5 pulje", ADRuns.getRun(back, 1).waitPoolGroup, "Cow Wait")
check("R5 target 0 -> nil", ADRuns.getRun(back, 2).targetWayPointId, nil)
check("R5 køretøj", ADRuns.findRunByVehicle(back, "vehicleA").id, 2)
-- R6: defekte poster springes over; dubletter af id ignoreres
local dirty = ADRuns.fromRecords({
{id = "x", name = "Broken"},
{id = 5, name = "", },
{id = 7, name = "Ok", target = "12", ingredientMode = "weird", ingredients = "", waitPool = nil, loops = "3", vehicleIds = {}},
{id = 7, name = "Dublet"},
})
check("R6 kun den gyldige", #dirty.runs, 1)
check("R6 target parses fra streng", dirty.runs[1].targetWayPointId, 12)
check("R6 ukendt mode -> auto", dirty.runs[1].ingredientMode, "auto")
check("R6 tom varestreng -> tom liste", #dirty.runs[1].ingredients, 0)
check("R6 manglende pulje -> tom streng", dirty.runs[1].waitPoolGroup, "")
check("R6 loops fra streng", dirty.runs[1].loops, 3)
check("R6 nextId", dirty.nextId, 8)
check("R6 tom liste", #ADRuns.fromRecords(nil).runs, 0)
check("R6 parseIngredients trimmer", #ADRuns.parseIngredients(" STRAW FORAGE "), 2)
-- R7: "kør hjem under X %" — standard 50, klippes til 10..90, overlever records
local r7, r7run = ADRuns.createRun(ADRuns.newStore(), "Tærskel")
check("R7 standard 50 %", r7run.returnBelowPercent, 50)
check("R7 sættes", ADRuns.getRun(ADRuns.updateRun(r7, r7run.id, {returnBelowPercent = 30}), r7run.id).returnBelowPercent, 30)
check("R7 klippes til 90", ADRuns.getRun(ADRuns.updateRun(r7, r7run.id, {returnBelowPercent = 150}), r7run.id).returnBelowPercent, 90)
check("R7 klippes til 10", ADRuns.getRun(ADRuns.updateRun(r7, r7run.id, {returnBelowPercent = 0}), r7run.id).returnBelowPercent, 10)
local r7rec = ADRuns.toRecords(ADRuns.updateRun(r7, r7run.id, {returnBelowPercent = 25}))
check("R7 i records", r7rec[1].returnBelow, 25)
check("R7 tilbage fra records", ADRuns.fromRecords(r7rec).runs[1].returnBelowPercent, 25)
check("R7 manglende i gammel fil -> 50", ADRuns.fromRecords({{id = 1, name = "Gammel"}}).runs[1].returnBelowPercent, 50)
-- R8: flere mål pr. run — liste + første som "primært" mål (bagudkompatibelt); toggle; records
local r8, r8run = ADRuns.createRun(ADRuns.newStore(), "Kvæg")
check("R8 ingen mål", #r8run.targetWayPointIds, 0)
r8 = ADRuns.updateRun(r8, r8run.id, {targetWayPointIds = {94, 99}})
check("R8 to mål", table.concat(ADRuns.getRun(r8, r8run.id).targetWayPointIds, ","), "94,99")
check("R8 primært mål = første", ADRuns.getRun(r8, r8run.id).targetWayPointId, 94)
r8 = ADRuns.toggleTarget(r8, r8run.id, 94)
check("R8 toggle fjerner", table.concat(ADRuns.getRun(r8, r8run.id).targetWayPointIds, ","), "99")
check("R8 primært følger med", ADRuns.getRun(r8, r8run.id).targetWayPointId, 99)
r8 = ADRuns.toggleTarget(r8, r8run.id, 120)
check("R8 toggle tilføjer", table.concat(ADRuns.getRun(r8, r8run.id).targetWayPointIds, ","), "99,120")
check("R8 gammel enkelt-sætning = liste med ét", table.concat(ADRuns.getRun(ADRuns.updateRun(r8, r8run.id, {targetWayPointId = 7}), r8run.id).targetWayPointIds, ","), "7")
check("R8 false rydder listen", #ADRuns.getRun(ADRuns.updateRun(r8, r8run.id, {targetWayPointId = false}), r8run.id).targetWayPointIds, 0)
local r8rec = ADRuns.toRecords(r8)
check("R8 records: targets-streng", r8rec[1].targets, "99 120")
check("R8 records: target = første (gammelt felt)", r8rec[1].target, 99)
check("R8 tilbage fra records", table.concat(ADRuns.fromRecords(r8rec).runs[1].targetWayPointIds, ","), "99,120")
check("R8 gammel fil med kun target", table.concat(ADRuns.fromRecords({{id = 1, name = "Gl", target = 55}}).runs[1].targetWayPointIds, ","), "55")
-- ==== XML-laget mod en flad XMLFile-mock (nøgle -> værdi) ====
dofile("FS25_ADSmartPickup/adRunsStorage.lua")
local function makeXmlMock(values)
local xml = {values = values or {}, saved = false, deleted = false}
xml.setInt = function(self, key, value) self.values[key] = value end
xml.setString = function(self, key, value) self.values[key] = value end
xml.getInt = function(self, key) return tonumber(self.values[key]) end
xml.getString = function(self, key) local v = self.values[key]; return v ~= nil and tostring(v) or nil end
-- iterate: kalder callback for "prefix(0)", "prefix(1)", ... så længe en nøgle med den stamme findes
xml.iterate = function(self, prefix, callback)
local index = 0
while true do
local key = string.format("%s(%d)", prefix, index)
local found = false
for k in pairs(self.values) do
if k:sub(1, #key) == key then found = true break end
end
if not found then return end
callback(index + 1, key)
index = index + 1
end
end
xml.save = function(self) self.saved = true end
xml.delete = function(self) self.deleted = true end
return xml
end
-- X1: records skrives som attributter + vehicle-underelementer
local out = makeXmlMock()
ADRunsStorage.writeRecords(out, ADRuns.toRecords(s5))
check("X1 version", out.values["ADSmartPickupRuns#version"], 3)
check("X1 run 0 navn", out.values["ADSmartPickupRuns.run(0)#name"], "Cow 1")
check("X1 run 0 target", out.values["ADSmartPickupRuns.run(0)#target"], 94)
check("X1 run 0 varer", out.values["ADSmartPickupRuns.run(0)#ingredients"], "STRAW FORAGE")
check("X1 run 0 pulje", out.values["ADSmartPickupRuns.run(0)#waitPool"], "Cow Wait")
check("X1 run 1 køretøj", out.values["ADSmartPickupRuns.run(1).vehicle(0)#id"], "vehicleA")
-- X2: samme data læses tilbage
local records2 = ADRunsStorage.readRecords(makeXmlMock(out.values))
check("X2 to poster", #records2, 2)
check("X2 id", records2[1].id, 1)
check("X2 køretøj", records2[2].vehicleIds[1], "vehicleA")
check("X2 loops", records2[1].loops, 99)
check("X2 round-trip", ADRuns.findRunByVehicle(ADRuns.fromRecords(records2), "vehicleA").id, 2)
-- X3: save/load via XMLFile-globalen; ingen savegame-mappe -> tom store, intet skrives
Logging = Logging or {warning = function() end, info = function() end}
local created, loaded = nil, nil
XMLFile = {
create = function(_, path, root) created = makeXmlMock(); created.path = path; created.root = root; return created end,
load = function(_, path) loaded = makeXmlMock(created and created.values or {}); loaded.path = path; return loaded end,
}
fileExists = function(path) return created ~= nil and created.path == path end
g_currentMission = {missionInfo = {savegameDirectory = "/tmp/sg1"}}
check("X3 gemt", ADRunsStorage.save(s5), true)
check("X3 sti", created.path, "/tmp/sg1/ADSmartPickup_runs.xml")
check("X3 rodelement", created.root, "ADSmartPickupRuns")
check("X3 save+delete kaldt", created.saved and created.deleted, true)
check("X3 indlæst", #ADRunsStorage.load().runs, 2)
check("X3 læse-handle lukket", loaded.deleted, true)
g_currentMission = {missionInfo = {}}
check("X3 uden savegame -> false", ADRunsStorage.save(s5), false)
check("X3 uden savegame -> tom store", #ADRunsStorage.load().runs, 0)
g_currentMission = {missionInfo = {savegameDirectory = "/tmp/sg-ny"}}
check("X3 fil mangler -> tom store", #ADRunsStorage.load().runs, 0)
-- 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")
local _, plain = ADRuns.createRun(ADRuns.newStore(), "x")
check("Rv2 supply som standard", plain.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)
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)
-- XML-laget skriver/læser v2-felterne
do
local written = {}
local xml = {
setInt = function(_, k, v) written[k] = v end, setString = function(_, k, v) written[k] = v end,
getInt = function(_, k) return written[k] end, getString = function(_, k) return written[k] end,
iterate = function(_, prefix, fn)
local i = 0
while written[string.format("%s(%d)#id", prefix, i)] ~= nil do fn(i, string.format("%s(%d)", prefix, i)); i = i + 1 end
end,
}
ADRunsStorage.writeRecords(xml, ADRuns.toRecords(o4))
check("Rv2 xml version 2", written["ADSmartPickupRuns#version"], 3)
check("Rv2 xml kind", written["ADSmartPickupRuns.run(0)#kind"], "outbound")
check("Rv2 xml sources", written["ADSmartPickupRuns.run(0)#sources"], "12694 12386")
check("Rv2 xml products", written["ADSmartPickupRuns.run(0)#products"], "SUGAR:sell FORAGE:store")
check("Rv2 xml collectRound", written["ADSmartPickupRuns.run(0)#collectRound"], 1)
local back = ADRuns.fromRecords(ADRunsStorage.readRecords(xml))
check("Rv2 xml læs sellAt", ADRuns.getRun(back, out.id).sellAtPercent, 100)
check("Rv2 xml læs runde", ADRuns.getRun(back, out.id).collectRound, true)
end
-- R-v3: bygninger som kilder/mål (id-lister); XML v3; v2-fil uden bygninger giver tomme lister
local b1, flow = ADRuns.createRun(ADRuns.newStore(), "Milk", "outbound")
check("Rv3 tomme kilde-bygninger", #flow.sourceBuildingIds, 0)
check("Rv3 tomme mål-bygninger", #flow.targetBuildingIds, 0)
local b2 = ADRuns.toggleSourceBuilding(b1, flow.id, "barn1")
b2 = ADRuns.toggleSourceBuilding(b2, flow.id, "barn2")
check("Rv3 to kilde-bygninger", table.concat(ADRuns.getRun(b2, flow.id).sourceBuildingIds, ","), "barn1,barn2")
check("Rv3 toggle fjerner", #ADRuns.getRun(ADRuns.toggleSourceBuilding(b2, flow.id, "barn1"), flow.id).sourceBuildingIds, 1)
local b3 = ADRuns.toggleTargetBuilding(b2, flow.id, "mill")
check("Rv3 mål-bygning", ADRuns.getRun(b3, flow.id).targetBuildingIds[1], "mill")
check("Rv3 gammel store urørt", #ADRuns.getRun(b2, flow.id).targetBuildingIds, 0)
local b4 = ADRuns.updateRun(b3, flow.id, {sourceBuildingIds = {"shed"}, targetBuildingIds = {}})
check("Rv3 updateRun sætter lister", ADRuns.getRun(b4, flow.id).sourceBuildingIds[1], "shed")
check("Rv3 updateRun tømmer", #ADRuns.getRun(b4, flow.id).targetBuildingIds, 0)
local rt3 = ADRuns.fromRecords(ADRuns.toRecords(b3))
check("Rv3 roundtrip kilder", table.concat(ADRuns.getRun(rt3, flow.id).sourceBuildingIds, ","), "barn1,barn2")
check("Rv3 roundtrip mål", ADRuns.getRun(rt3, flow.id).targetBuildingIds[1], "mill")
check("Rv3 XML_VERSION", ADRuns.XML_VERSION, 3)
local v2 = ADRuns.fromRecords({{id = 3, name = "V2", kind = "outbound", sources = "12694", products = "SUGAR:sell", vehicleIds = {}}})
check("Rv3 v2 rå markør bevares", ADRuns.getRun(v2, 3).sourceWayPointIds[1], 12694)
check("Rv3 v2 ingen bygninger", #ADRuns.getRun(v2, 3).sourceBuildingIds, 0)
do
local written = {}
local xml = {
setInt = function(_, k, v) written[k] = v end, setString = function(_, k, v) written[k] = v end,
getInt = function(_, k) return written[k] end, getString = function(_, k) return written[k] end,
iterate = function(_, prefix, fn)
local i = 0
while written[string.format("%s(%d)#id", prefix, i)] ~= nil do fn(i, string.format("%s(%d)", prefix, i)); i = i + 1 end
end,
}
ADRunsStorage.writeRecords(xml, ADRuns.toRecords(b3))
check("Rv3 xml version 3", written["ADSmartPickupRuns#version"], 3)
check("Rv3 xml sourceBuildings", written["ADSmartPickupRuns.run(0)#sourceBuildings"], "barn1 barn2")
check("Rv3 xml targetBuildings", written["ADSmartPickupRuns.run(0)#targetBuildings"], "mill")
local back = ADRuns.fromRecords(ADRunsStorage.readRecords(xml))
check("Rv3 xml læs kilder", table.concat(ADRuns.getRun(back, flow.id).sourceBuildingIds, ","), "barn1,barn2")
end
-- RN: køretøjsnavne efter forløbet — "Navn 1", "Navn 2" … i medlemsrækkefølge; ét køretøj = bare navnet
do
local store, runA = ADRuns.createRun(ADRuns.newStore(), "Milk Collect", "outbound")
store = ADRuns.assignVehicle(store, runA.id, "v1")
store = ADRuns.assignVehicle(store, runA.id, "v2")
local runB
store, runB = ADRuns.createRun(store, "Milk Salg", "outbound")
store = ADRuns.assignVehicle(store, runB.id, "v3")
local names = ADRuns.vehicleNames(store)
check("RN to i samme forløb: 1", names.v1, "Milk Collect 1")
check("RN to i samme forløb: 2", names.v2, "Milk Collect 2")
check("RN alene i forløb = navnet", names.v3, "Milk Salg")
check("RN ukendt køretøj", names.v9, nil)
end
-- RS: tvunget lagermål — storeBuildingIds (toggle, kopi, records roundtrip)
do
local store, run = ADRuns.createRun(ADRuns.newStore(), "Slurry", "outbound")
check("RS tom som standard", #run.storeBuildingIds, 0)
store = ADRuns.toggleStoreBuilding(store, run.id, "BGA500kw")
store = ADRuns.toggleStoreBuilding(store, run.id, "tank2")
check("RS to valgt", table.concat(ADRuns.getRun(store, run.id).storeBuildingIds, ","), "BGA500kw,tank2")
store = ADRuns.toggleStoreBuilding(store, run.id, "tank2")
check("RS fravalgt igen", table.concat(ADRuns.getRun(store, run.id).storeBuildingIds, ","), "BGA500kw")
local records = ADRuns.toRecords(store)
check("RS record", records[1].storeBuildings, "BGA500kw")
local back = ADRuns.fromRecords(records)
check("RS roundtrip", table.concat(ADRuns.getRun(back, run.id).storeBuildingIds, ","), "BGA500kw")
end
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
-804
View File
@@ -1,804 +0,0 @@
-- Kør: luajit tests/test_adRunsController.lua (fra repo-roden)
-- Controlleren oversætter et run til AutoDrive-tilstand pr. traktor. Mock'er AD + de
-- få ADSmartPickup-opslag den bruger.
local STRAW, TMR, WATER, MILK = 31, 116, 8, 30
local FILL_TYPE_NAMES = {[STRAW] = "STRAW", [TMR] = "FORAGE", [WATER] = "WATER", [MILK] = "MILK"}
local FILL_TYPE_INDEX = {}
for index, name in pairs(FILL_TYPE_NAMES) do FILL_TYPE_INDEX[name] = index end
g_fillTypeManager = {
getFillTypeNameByIndex = function(_, index) return FILL_TYPE_NAMES[index] end,
getFillTypeIndexByName = function(_, name) return FILL_TYPE_INDEX[name] end,
getFillTypes = function()
return {
{index = STRAW, name = "STRAW", title = "Halm"},
{index = TMR, name = "FORAGE", title = "TMR"},
{index = WATER, name = "WATER", title = "Vand"},
{index = MILK, name = "MILK", title = "Mælk"},
}
end,
}
local logLines = {}
Logging = {
info = function(fmt, ...) table.insert(logLines, string.format(fmt, ...)) end,
warning = function(fmt, ...) table.insert(logLines, "WARN " .. string.format(fmt, ...)) end,
}
Utils = {prependedFunction = function(original, extra) return function(...) extra(...); return original(...) end end}
ItemSystem = {save = function() end}
g_currentMission = {getFarmId = function() return 1 end, missionInfo = {}}
-- ---- AutoDrive-mock ----
local RT_NONE, RT_ONLYPICKUP = 1, 2
local MODE_PICKUPANDDELIVER = 2
local vehicles, markers, wayPoints = {}, {}, {}
FS25_AutoDrive = {
AutoDrive = {
RT_NONE = RT_NONE, RT_ONLYPICKUP = RT_ONLYPICKUP, MODE_PICKUPANDDELIVER = MODE_PICKUPANDDELIVER,
settings = {
useFolders = {values = {false, true}, current = 1, isVehicleSpecific = false},
rotateTargets = {values = {1, 2, 3, 4}, current = 1, isVehicleSpecific = true},
ALUnload = {values = {0, 1, 2, 3, 4}, current = 1, isVehicleSpecific = true},
},
getAllVehicles = function() return vehicles end,
getAllUnits = function(vehicle) return vehicle.trailers end,
getAllFillLevels = function(trailers) return trailers.level or 0, trailers.capacity or 0, false, 0 end,
getSupportedFillTypesOfAllUnitsAlphabetically = function(vehicle) return vehicle.carriable or {} end,
},
ADGraphManager = {
getMapMarkers = function() return markers end,
getMapMarkerByWayPointId = function(_, id) for _, m in ipairs(markers) do if m.id == id then return m end end end,
getWayPointById = function(_, id) return wayPoints[id] end,
getDistanceFromNetwork = function(_, vehicle) return vehicle.networkDistance or 0 end,
},
}
local function addMarker(id, name, group, x, z)
wayPoints[id] = {x = x, z = z, incoming = {}}
table.insert(markers, {id = id, markerIndex = #markers + 1, name = name, group = group})
end
-- ADSmartPickup-opslag: hvilke waypoints har hvilken slags station, og hvad målet tager
local unloadAt, loadAt = {}, {}
ADSmartPickup = {
getFarmId = function(vehicle) return vehicle.farmId end,
getUnloadStationAtWayPointId = function(_, id) return unloadAt[id] end,
getLoadStationAtWayPointId = function(_, id) return loadAt[id] end,
getStationLevelAndCapacity = function(station, fillType)
if station.accepts[fillType] == nil then return nil end
return (station.levels or {})[fillType] or 0, station.accepts[fillType]
end,
-- forenklet rangering: mest fri plads (relativt) først; fulde sidst
getTargetNeedsRanked = function(station, fillTypes)
local ranked = {}
for _, fillType in ipairs(fillTypes) do
local capacity = station.accepts[fillType]
if capacity ~= nil then
local level = (station.levels or {})[fillType] or 0
table.insert(ranked, {fillType = fillType, level = level, capacity = capacity, freeSpace = capacity - level, score = (capacity - level) / capacity})
end
end
table.sort(ranked, function(a, b) return a.score > b.score end)
return ranked
end,
}
ADSupplyPlanner = {isWaitMarker = function(marker) return string.find(string.lower(marker.group or ""), "%f[%a]wait%f[%A]") ~= nil end}
local function makeVehicle(uniqueId, name, carriable, capacity, x, z)
local stateModule = {selectedFillTypes = {}, fillType = 0, active = false, loopCounter = 5, loopsDone = 3, dirtyCount = 0}
stateModule.setMode = function(self, mode) self.mode = mode end
stateModule.setFirstMarkerByWayPointId = function(self, id) self.firstMarkerId = id end
stateModule.setSecondMarkerByWayPointId = function(self, id) self.secondMarkerId = id end
stateModule.setFillType = function(self, fillType) self.fillType = fillType end
stateModule.raiseDirtyFlag = function(self) self.dirtyCount = self.dirtyCount + 1 end
stateModule.setLoopsDone = function(self, n) self.loopsDone = n end
stateModule.isActive = function(self) return self.active end
-- Bruges af C13: getTargetFillTypes skal følge AD's LIVE mål (mock'er AD's eget getSecondMarker).
stateModule.getSecondMarker = function(self) return self.secondMarkerId ~= nil and {id = self.secondMarkerId} or nil end
local vehicle = {
uniqueId = uniqueId, farmId = 1, rootNode = uniqueId,
carriable = carriable, trailers = {level = 0, capacity = capacity},
getName = function() return name end,
getOwnerFarmId = function() return 1 end,
startAutoDrive = function(self) self.ad.stateModule.active = true; self.started = (self.started or 0) + 1 end,
getClosestWayPoint = function() return nil, 0 end,
stopAutoDrive = function(self) self.ad.stateModule.active = false; self.stopped = (self.stopped or 0) + 1 end,
ad = {stateModule = stateModule, settings = {rotateTargets = {values = {1, 2, 3, 4}, current = 1, new = 1, isVehicleSpecific = true}, ALUnload = {values = {0, 1, 2, 3, 4}, current = 1, new = 1, isVehicleSpecific = true}}},
}
nodePositions = nodePositions or {}
nodePositions[uniqueId] = {x = x or 0, z = z or 0}
table.insert(vehicles, vehicle)
return vehicle
end
function getWorldTranslation(node) local p = nodePositions[node]; return p.x, 0, p.z end
dofile("FS25_ADSmartPickup/adRuns.lua")
dofile("FS25_ADSmartPickup/adRunsStorage.lua")
dofile("FS25_ADSmartPickup/adWaitPool.lua")
dofile("FS25_ADSmartPickup/adRunsController.lua")
local adEnv = FS25_AutoDrive
local failures = 0
local function check(name, actual, expected)
if actual == expected then
print("OK " .. name)
else
failures = failures + 1
print(string.format("FAIL %s: forventede %s, fik %s", name, tostring(expected), tostring(actual)))
end
end
local function buildWorld()
vehicles, markers, wayPoints, unloadAt, loadAt, logLines = {}, {}, {}, {}, {}, {}
ADRunsController.store = ADRuns.newStore()
addMarker(94, "Cow 1 Food", "Cow Food", 5000, 0); unloadAt[94] = {accepts = {[STRAW] = 300000, [TMR] = 300000, [WATER] = 50000}}
addMarker(44, "10 Silo Storage", "Siloer", 0, 0); loadAt[44] = {}
addMarker(75, "TMR Load", "Fabrication", 900, 0); loadAt[75] = {}
addMarker(100, "Cow Wait", "Cow Wait", 5100, 0)
addMarker(101, "Cow Wait", "Cow Wait", 5200, 0)
addMarker(200, "F91 Wait", "F91", 8000, 0)
addMarker(300, "Pig Wait", "Pig Wait", 9000, 0)
end
-- C1: køretøjs-id og farmens AD-køretøjer
buildWorld()
local fastracA = makeVehicle("vehicleA", "Fastrac A", {STRAW, TMR}, 1000000, 100, 0)
local fastracB = makeVehicle("vehicleB", "Fastrac B", {STRAW}, 700000, 4000, 0)
local noAd = {uniqueId = "vehicleC", getOwnerFarmId = function() return 1 end}
table.insert(vehicles, noAd)
local otherFarm = makeVehicle("vehicleD", "Nabo", {STRAW}, 1000, 0, 0); otherFarm.getOwnerFarmId = function() return 2 end
check("C1 uniqueId", ADRunsController.getVehicleId(fastracA), "vehicleA")
check("C1 fallback savegame-id", ADRunsController.getVehicleId({currentSavegameId = 7}), "7")
check("C1 intet id -> nil", ADRunsController.getVehicleId({}), nil)
check("C1 farmens AD-køretøjer", #ADRunsController.getFarmVehicles(adEnv), 2)
check("C1 opslag på id", ADRunsController.findVehicleById(adEnv, "vehicleB"), fastracB)
check("C1 ukendt id -> nil", ADRunsController.findVehicleById(adEnv, "nope"), nil)
-- C2: opret run, tildel; opslag run for køretøj; flyt mellem runs
local cow = ADRunsController.createRun("Cow Feed")
check("C2 run oprettet", ADRuns.getRun(ADRunsController.store, cow.id).name, "Cow Feed")
check("C2 ingen tidligere", ADRunsController.assignVehicle(cow.id, fastracA), nil)
check("C2 run for køretøj", ADRunsController.getRunForVehicle(fastracA).id, cow.id)
check("C2 ikke tildelt -> nil", ADRunsController.getRunForVehicle(fastracB), nil)
local pig = ADRunsController.createRun("Pig Feed")
check("C2 flyttet fra Cow", ADRunsController.assignVehicle(pig.id, fastracA), cow.id)
check("C2 nu i Pig", ADRunsController.getRunForVehicle(fastracA).id, pig.id)
ADRunsController.unassignVehicle(fastracA)
check("C2 fjernet", ADRunsController.getRunForVehicle(fastracA), nil)
ADRunsController.deleteRun(pig.id)
check("C2 slettet", ADRuns.getRun(ADRunsController.store, pig.id), nil)
-- C3: Auto-vareliste = vognens varer ∩ målets varer; manual = navne -> indeks (ukendte droppes)
ADRunsController.updateRun(cow.id, {targetWayPointId = 94})
local cowRun = ADRuns.getRun(ADRunsController.store, cow.id)
check("C3 auto: halm+TMR (vand kan vognen ikke bære)", table.concat(ADRunsController.getRunIngredients(adEnv, cowRun, fastracA), ","), STRAW .. "," .. TMR)
check("C3 auto: kun halm for halm-vogn", table.concat(ADRunsController.getRunIngredients(adEnv, cowRun, fastracB), ","), tostring(STRAW))
ADRunsController.updateRun(cow.id, {ingredientMode = "manual", ingredients = {"FORAGE", "UNKNOWN_THING"}})
cowRun = ADRuns.getRun(ADRunsController.store, cow.id)
check("C3 manual: FORAGE -> indeks, ukendt droppes", table.concat(ADRunsController.getRunIngredients(adEnv, cowRun, fastracA), ","), tostring(TMR))
ADRunsController.updateRun(cow.id, {ingredientMode = "auto"})
local noTarget = ADRunsController.createRun("Uden mål")
check("C3 uden mål -> tom liste", #ADRunsController.getRunIngredients(adEnv, ADRuns.getRun(ADRunsController.store, noTarget.id), fastracA), 0)
-- C4: validering
cowRun = ADRuns.getRun(ADRunsController.store, cow.id)
check("C4 ok", ADRunsController.validateVehicle(adEnv, cowRun, fastracA).ok, true)
check("C4 kapacitet", ADRunsController.validateVehicle(adEnv, cowRun, fastracA).capacity, 1000000)
local noTrailer = makeVehicle("vehicleE", "Uden vogn", {}, 0)
check("C4 ingen vogn", ADRunsController.validateVehicle(adEnv, cowRun, noTrailer).reason, "noTrailer")
local milkTruck = makeVehicle("vehicleF", "Mælkebil", {MILK}, 20000)
check("C4 kan ikke bære", ADRunsController.validateVehicle(adEnv, cowRun, milkTruck).reason, "cannotCarry")
fastracB.networkDistance = 31
check("C4 for langt fra vejnettet", ADRunsController.validateVehicle(adEnv, cowRun, fastracB).reason, "farFromNetwork")
fastracB.networkDistance = 0
check("C4 intet mål", ADRunsController.validateVehicle(adEnv, ADRuns.getRun(ADRunsController.store, noTarget.id), fastracA).reason, "noTarget")
ADRunsController.updateRun(noTarget.id, {targetWayPointId = 4242})
check("C4 mål-markør findes ikke", ADRunsController.validateVehicle(adEnv, ADRuns.getRun(ADRunsController.store, noTarget.id), fastracA).reason, "noTarget")
-- C5: Start skriver AD-tilstand og starter; ✖-traktorer springes over
ADRunsController.updateRun(cow.id, {loops = 4, waitPoolGroup = "Cow Wait"})
ADRunsController.assignVehicle(cow.id, fastracA)
ADRunsController.assignVehicle(cow.id, noTrailer)
local results = ADRunsController.startRun(adEnv, cow.id)
check("C5 to resultater", #results, 2)
check("C5 A startet", fastracA.started, 1)
check("C5 A ok", results[1].ok, true)
check("C5 uden vogn sprunget over", noTrailer.started, nil)
check("C5 uden vogn grund", results[2].reason, "noTrailer")
local state = fastracA.ad.stateModule
check("C5 mode P&D", state.mode, MODE_PICKUPANDDELIVER)
check("C5 mål = run'ets mål", state.secondMarkerId, 94)
check("C5 start-pickup = nærmeste læssestation", state.firstMarkerId, 44)
check("C5 varer = auto-listen", table.concat(state.selectedFillTypes, ","), STRAW .. "," .. TMR)
check("C5 fillType = første", state.fillType, STRAW)
check("C5 loops", state.loopCounter, 4)
check("C5 loopsDone nulstillet", state.loopsDone, 0)
check("C5 rotateTargets = kun pålæsning (current)", fastracA.ad.settings.rotateTargets.current, 2)
check("C5 rotateTargets = kun pålæsning (new)", fastracA.ad.settings.rotateTargets.new, 2)
check("C5 useFolders globalt til", adEnv.AutoDrive.settings.useFolders.current, 2)
check("C5 dirty flag", state.dirtyCount > 0, true)
check("C5 status", ADRunsController.getRunStatus(adEnv, ADRuns.getRun(ADRunsController.store, cow.id)).running, 1)
check("C5 total", ADRunsController.getRunStatus(adEnv, ADRuns.getRun(ADRunsController.store, cow.id)).total, 2)
check("C5 ukendt run -> tom", #ADRunsController.startRun(adEnv, 999), 0)
-- C6: Stop
check("C6 stoppede", ADRunsController.stopRun(adEnv, cow.id), 1)
check("C6 A stoppet", fastracA.stopped, 1)
check("C6 status 0", ADRunsController.getRunStatus(adEnv, ADRuns.getRun(ADRunsController.store, cow.id)).running, 0)
check("C6 medlemskab bevaret", ADRunsController.getRunForVehicle(fastracA).id, cow.id)
-- C7: Start på traktor der allerede kører AD (fx startet manuelt) -> stoppes og startes på run'et
fastracA.ad.stateModule.active = true
fastracA.stopped = 0
ADRunsController.startRun(adEnv, cow.id)
check("C7 kørende traktor stoppes først", fastracA.stopped, 1)
check("C7 og startes på run'et", fastracA.started, 2)
check("C7 run markeret som kørende", ADRunsController.getRunStatus(adEnv, ADRuns.getRun(ADRunsController.store, cow.id)).isRunning, true)
ADRunsController.stopRun(adEnv, cow.id)
check("C7 stop rydder markeringen", ADRunsController.getRunStatus(adEnv, ADRuns.getRun(ADRunsController.store, cow.id)).isRunning, false)
-- en traktor der kører AD uden at run'et er startet af os, gør IKKE run'et "kørende"
fastracA.ad.stateModule.active = true
check("C7 manuel AD tæller ikke som kørende run", ADRunsController.getRunStatus(adEnv, ADRuns.getRun(ADRunsController.store, cow.id)).isRunning, false)
check("C7 men aktive tæller stadig", ADRunsController.getRunStatus(adEnv, ADRuns.getRun(ADRunsController.store, cow.id)).running, 1)
fastracA.ad.stateModule.active = false
-- sprungne traktorer logges med årsag
logLines = {}
ADRunsController.startRun(adEnv, cow.id)
local skippedLogged = false
for _, line in ipairs(logLines) do if line:find("sprunget over", 1, true) and line:find("noTrailer", 1, true) then skippedLogged = true end end
check("C7 sprunget over logges med årsag", skippedLogged, true)
-- kapacitet under 100 l (kun trækkerens egne tanke) = ingen vogn
local tinyCapacity = makeVehicle("vehicleTiny", "T255", {STRAW, TMR}, 1, 100, 0)
check("C7 1 l kapacitet -> noTrailer", ADRunsController.validateVehicle(adEnv, ADRuns.getRun(ADRunsController.store, cow.id), tinyCapacity).reason, "noTrailer")
-- UAL-køretøj: AutoDrive melder kapacitet i PALLER (læssede + 1), så en tom pallebil har "1" — det er ikke "ingen vogn"
adEnv.AutoDrive.hasAL = function(_, unit) return unit.ual == true end
local palletTruck = makeVehicle("vehicleDragon", "Dragon", {}, 1, 100, 0)
palletTruck.trailers[1] = {ual = true}
local truckValidation = ADRunsController.validateVehicle(adEnv, ADRuns.getRun(ADRunsController.store, cow.id), palletTruck)
check("C7 pallebil: ikke noTrailer", truckValidation.reason ~= "noTrailer", true)
check("C7 pallebil: kapacitet i paller markeret", truckValidation.isAutoload, true)
check("C7 pallebil: liter-kapacitet ukendt", truckValidation.capacity, nil)
adEnv.AutoDrive.hasAL = nil
-- C8: mål-markører (kun stalde og fabrikker — siloer tager alt og er ikke mål; stalde først,
-- så fabrikker, så navn) og Wait-puljer til dropdowns
unloadAt[94].owningPlaceable = {getHusbandryFillLevel = function() return 0 end}
addMarker(96, "8 Silo Storage", "Siloer", 100, 0); unloadAt[96] = {accepts = {[STRAW] = 1}, owningPlaceable = {spec_silo = {}}}
addMarker(97, "TMR Unload", "Fabrication", 900, 0); unloadAt[97] = {accepts = {[STRAW] = 1}, owningPlaceable = {spec_productionPoint = {}}}
addMarker(98, "Bull Food", "Cow Food", 5100, 0); unloadAt[98] = {accepts = {[STRAW] = 1}, owningPlaceable = {getHusbandryFillLevel = function() return 0 end}}
local targets = ADRunsController.getTargetMarkers(adEnv, 1)
check("C8 kun stalde og fabrikker (ikke siloen)", #targets, 3)
check("C8 stalde først, alfabetisk", targets[1].name .. "," .. targets[2].name, "Bull Food,Cow 1 Food")
check("C8 fabrik sidst", targets[3].name, "TMR Unload")
local pools = ADRunsController.getWaitPools(adEnv)
check("C8 to puljer (F91 er ikke en Wait-mappe)", #pools, 2)
check("C8 sorteret", pools[1].group, "Cow Wait")
check("C8 antal", pools[1].count, 2)
-- C9: gem/indlæs; ukendte køretøjs-id'er logges ved indlæsning men beholdes i run'et
local savedRecords
ADRunsStorage.save = function(store) savedRecords = ADRuns.toRecords(store); return true end
ADRunsStorage.load = function() return ADRuns.fromRecords(savedRecords) end
ADRunsController.assignVehicle(cow.id, {uniqueId = "vehicleGONE", ad = {}, getOwnerFarmId = function() return 1 end, getName = function() return "Væk" end})
check("C9 gemt", ADRunsController.save(), true)
ADRunsController.store = ADRuns.newStore()
ADRunsController.load(adEnv)
check("C9 indlæst", ADRuns.getRun(ADRunsController.store, cow.id).name, "Cow Feed")
check("C9 ukendt køretøj logget", logLines[#logLines]:find("vehicleGONE", 1, true) ~= nil, true)
check("C9 run beholder kendte", ADRunsController.getRunForVehicle(fastracA).id, cow.id)
local hooked = false
ItemSystem.save = function() hooked = true end
ADRunsController.installSaveHook()
ItemSystem.save()
check("C9 save-hook kaldes før ItemSystem.save", hooked, true)
-- C11: vogn uden egen ad.settings-tabel (ingen rotateTargets pr. køretøj) -> den
-- køretøjs-specifikke indstilling skrives IKKE til den globale settings-tabel
adEnv.AutoDrive.settings.rotateTargets.current = 1
adEnv.AutoDrive.settings.useFolders.current = 1
local settingsRun = ADRunsController.createRun("Settings Test")
ADRunsController.updateRun(settingsRun.id, {targetWayPointId = 94, loops = 1})
local noSettings = makeVehicle("vehicleH", "Uden AD-settings", {STRAW}, 500000, 200, 0)
noSettings.ad.settings = {}
ADRunsController.assignVehicle(settingsRun.id, noSettings)
ADRunsController.startRun(adEnv, settingsRun.id)
check("C11 global rotateTargets urørt", adEnv.AutoDrive.settings.rotateTargets.current, 1)
check("C11 useFolders global stadig skrevet", adEnv.AutoDrive.settings.useFolders.current, 2)
check("C11 køretøjet startet", noSettings.started, 1)
-- C12: startAutoDrive() kaster for én traktor -> pcall'et applyAndStart fanger fejlen,
-- resultatet markeres ok=false/reason="error", og de ØVRIGE traktorer i run'et startes stadig
-- (fejlen må ikke undslippe startRun-loopet).
buildWorld()
local brokenRun = ADRunsController.createRun("Broken Start")
ADRunsController.updateRun(brokenRun.id, {targetWayPointId = 94, loops = 2})
local broken = makeVehicle("vehicleBroken", "Fejl-traktor", {STRAW, TMR}, 500000, 100, 0)
broken.startAutoDrive = function() error("boom") end
local normal = makeVehicle("vehicleNormal", "Normal-traktor", {STRAW, TMR}, 500000, 200, 0)
ADRunsController.assignVehicle(brokenRun.id, broken)
ADRunsController.assignVehicle(brokenRun.id, normal)
local c12Results = ADRunsController.startRun(adEnv, brokenRun.id)
check("C12 to resultater", #c12Results, 2)
check("C12 fejl-traktor ok=false", c12Results[1].ok, false)
check("C12 fejl-traktor reason=error", c12Results[1].reason, "error")
check("C12 fejl-traktor ikke startet", broken.started, nil)
check("C12 normal-traktor ok=true", c12Results[2].ok, true)
check("C12 normal-traktor startet", normal.started, 1)
-- C13: Auto-listen følger traktorens LIVE AD-mål (stateModule:getSecondMarker()) mens den kører,
-- IKKE run'ets gemte targetWayPointId — ellers afviger listen fra det choosePickup/findSupplyPickup
-- faktisk bruger, hvis brugeren redigerer run'ets Mål mens traktoren kører.
addMarker(95, "Alt Cow Food", "Cow Food", 5300, 0)
unloadAt[95] = {accepts = {[STRAW] = 1000}}
local liveRun = ADRunsController.createRun("Live Target")
ADRunsController.updateRun(liveRun.id, {targetWayPointId = 94})
local liveRunData = ADRuns.getRun(ADRunsController.store, liveRun.id)
local liveVehicle = makeVehicle("vehicleLive", "Live-traktor", {STRAW, TMR}, 500000, 300, 0)
check("C13 inaktiv: auto-liste fra run'ets mål (94)", table.concat(ADRunsController.getRunIngredients(adEnv, liveRunData, liveVehicle), ","), STRAW .. "," .. TMR)
liveVehicle.ad.stateModule.active = true
liveVehicle.ad.stateModule.secondMarkerId = 95
check("C13 aktiv: auto-liste fra AD's live mål (95)", table.concat(ADRunsController.getRunIngredients(adEnv, liveRunData, liveVehicle), ","), tostring(STRAW))
liveVehicle.ad.stateModule.active = false
liveVehicle.ad.stateModule.secondMarkerId = nil
-- C10: målets accepterede varer (til afkrydsning i GUI'et) + køretøjs-label
buildWorld()
local labelA = makeVehicle("vehicleA", "Fastrac A", {STRAW, TMR}, 1000000)
local labelB = makeVehicle("vehicleB", "Fastrac B", {STRAW}, 700000)
local accRun = ADRunsController.createRun("Cow Feed")
check("C10 uden mål -> tom", #ADRunsController.getTargetAcceptedFillTypes(adEnv, ADRuns.getRun(ADRunsController.store, accRun.id)), 0)
ADRunsController.updateRun(accRun.id, {targetWayPointId = 94})
local accepted = ADRunsController.getTargetAcceptedFillTypes(adEnv, ADRuns.getRun(ADRunsController.store, accRun.id))
check("C10 tre varer (halm, TMR, vand)", #accepted, 3)
check("C10 sorteret på titel", accepted[1].title .. "," .. accepted[2].title .. "," .. accepted[3].title, "Halm,TMR,Vand")
check("C10 navn med", accepted[1].name, "STRAW")
check("C10 label", ADRunsController.getVehicleLabel(adEnv, labelB), "Fastrac B (#2)")
-- C14: Start går gennem AD's mode:start(USER_PLAYER) (som HUD'en), ikke kun startAutoDrive
buildWorld()
local modeVehicle = makeVehicle("vehicleM", "Mode-traktor", {STRAW, TMR}, 1000000, 100, 0)
local modeStarts = {}
modeVehicle.ad.stateModule.getCurrentMode = function() return {start = function(_, user) table.insert(modeStarts, user); modeVehicle:startAutoDrive() end} end
modeVehicle.ad.stateModule.setActualFarmId = function(self, farmId) self.actualFarmId = farmId end
adEnv.AutoDrive.USER_PLAYER = 1
local modeRun = ADRunsController.createRun("Mode")
ADRunsController.updateRun(modeRun.id, {targetWayPointId = 94})
ADRunsController.assignVehicle(modeRun.id, modeVehicle)
ADRunsController.startRun(adEnv, modeRun.id)
check("C14 mode:start kaldt med USER_PLAYER", modeStarts[1], 1)
check("C14 traktor aktiv", modeVehicle.started, 1)
check("C14 farm-id sat", modeVehicle.ad.stateModule.actualFarmId, 1)
check("C14 loopsDone nulstillet", modeVehicle.ad.stateModule.loopsDone, 0)
-- C15: aktivitet til overblikket — stoppet / venter / med læs / tom på vej efter vare
ADUnloadWait = {getWaitFor = function(v) return v.waitInfo end, getRestLoad = function(_, v) return v.loadType, v.loadLiters or 0 end}
ADSmartPickup.getActivePickup = function(v) return v.pickupInfo end
buildWorld()
local actVehicle = makeVehicle("vehicleAct", "Akt", {STRAW, TMR}, 1000000, 100, 0)
check("C15 stoppet", ADRunsController.getVehicleActivity(adEnv, actVehicle).kind, "stopped")
actVehicle.ad.stateModule.active = true
actVehicle.ad.stateModule.secondMarkerId = 94
actVehicle.loadType, actVehicle.loadLiters = STRAW, 8000
local loaded = ADRunsController.getVehicleActivity(adEnv, actVehicle)
check("C15 med læs", loaded.kind .. "/" .. loaded.fillType .. "/" .. loaded.liters, "loaded/" .. STRAW .. "/8000")
actVehicle.loadLiters = 0
actVehicle.pickupInfo = {markerName = "Silo 3", fillType = TMR}
local empty = ADRunsController.getVehicleActivity(adEnv, actVehicle)
check("C15 tom på vej efter TMR", empty.kind .. "/" .. tostring(empty.place) .. "/" .. tostring(empty.fillType), "empty/Silo 3/" .. TMR)
actVehicle.waitInfo = {markerName = "Cow Wait", fillType = STRAW, rest = 5000}
check("C15 venter", ADRunsController.getVehicleActivity(adEnv, actVehicle).kind, "waiting")
ADUnloadWait = nil
-- C16: flere mål pr. run — Auto-varer = union; chooseTarget = den stald der mangler mest; validering
buildWorld()
addMarker(99, "Cow 2 Food", "Cow Food", 5200, 0); unloadAt[99] = {accepts = {[STRAW] = 300000, [TMR] = 300000}, levels = {[STRAW] = 0, [TMR] = 0}}
unloadAt[94].levels = {[STRAW] = 250000, [TMR] = 250000, [WATER] = 50000}
local multiVehicle = makeVehicle("vehicleMulti", "Multi", {STRAW, TMR, WATER}, 1000000, 100, 0)
local multi = ADRunsController.createRun("Kvæg")
ADRunsController.updateRun(multi.id, {targetWayPointIds = {94, 99}})
local multiRun = ADRuns.getRun(ADRunsController.store, multi.id)
check("C16 auto-varer = union (vand kun i stald 1)", table.concat(ADRunsController.getRunIngredients(adEnv, multiRun, multiVehicle), ","), WATER .. "," .. STRAW .. "," .. TMR)
check("C16 accepterede varer = union", #ADRunsController.getTargetAcceptedFillTypes(adEnv, multiRun), 3)
check("C16 vælger stald 2 (tom)", ADRunsController.chooseTarget(adEnv, multiRun, multiVehicle), 99)
unloadAt[99].levels = {[STRAW] = 290000, [TMR] = 290000}
check("C16 vælger stald 1 når stald 2 er fuld", ADRunsController.chooseTarget(adEnv, multiRun, multiVehicle), 94)
check("C16 validering ok med flere mål", ADRunsController.validateVehicle(adEnv, multiRun, multiVehicle).ok, true)
ADRunsController.updateRun(multi.id, {targetWayPointIds = {4242}})
check("C16 kun ukendte mål -> noTarget", ADRunsController.validateVehicle(adEnv, ADRuns.getRun(ADRunsController.store, multi.id), multiVehicle).reason, "noTarget")
-- C17: kørende traktor der fjernes fra run stoppes; slettes run, stoppes dets traktorer; Stop logger
buildWorld()
local runVehicle = makeVehicle("vehicleR", "Kørende", {STRAW, TMR}, 1000000, 100, 0)
local stopRun = ADRunsController.createRun("Stop-test")
ADRunsController.updateRun(stopRun.id, {targetWayPointId = 94})
ADRunsController.assignVehicle(stopRun.id, runVehicle)
ADRunsController.startRun(adEnv, stopRun.id)
check("C17 kører", runVehicle.started, 1)
ADRunsController.unassignVehicle(runVehicle)
check("C17 fjernet fra run -> stoppet", runVehicle.stopped, 1)
ADRunsController.assignVehicle(stopRun.id, runVehicle)
ADRunsController.startRun(adEnv, stopRun.id)
ADRunsController.deleteRun(stopRun.id, adEnv)
check("C17 run slettet -> stoppet", runVehicle.stopped, 2)
check("C17 stop logget", logLines[#logLines]:find("stoppet", 1, true) ~= nil, true)
-- C18: forkert last ved Start (halm på vognen, run mod sukkerfabrik) -> første stop = silo der tager halm
ADUnloadWait = {getRestLoad = function(_, v) return v.loadType, v.loadLiters or 0 end, getWaitFor = function() return nil end,
hasRouteBothWays = function() return true end}
ADLoadSwap = {findDumpMarker = function(_, v, fillType, rest, homeId, isReachable) return {id = 44, name = "10 Silo Storage", markerIndex = 2} end}
buildWorld()
addMarker(77, "sugar offload", "Fabrication", 950, 0); unloadAt[77] = {accepts = {[TMR] = 360000}} -- "fabrik" der ikke tager halm (TMR står for roer her)
local sugarVehicle = makeVehicle("vehicleSugar", "Roe-traktor", {STRAW, TMR}, 20000, 100, 0)
sugarVehicle.loadType, sugarVehicle.loadLiters = STRAW, 8000
local sugar = ADRunsController.createRun("Sugar")
ADRunsController.updateRun(sugar.id, {targetWayPointId = 77})
ADRunsController.assignVehicle(sugar.id, sugarVehicle)
ADRunsController.startRun(adEnv, sugar.id)
check("C18 første stop = siloen (aflæs forkert last)", sugarVehicle.ad.stateModule.secondMarkerId, 44)
check("C18 logget", logLines[#logLines]:find("læsser", 1, true) ~= nil or logLines[#logLines - 1]:find("læsser", 1, true) ~= nil, true)
-- rigtig last (TMR=roer) -> direkte til fabrikken
sugarVehicle.loadType = TMR
sugarVehicle.ad.stateModule.active = false
ADRunsController.startRun(adEnv, sugar.id)
check("C18 rigtig last -> målet", sugarVehicle.ad.stateModule.secondMarkerId, 77)
-- C18b: målet TAGER varen, men den er ikke en af run'ets valgte varer (gødning i en ensilage-DOB)
-- -> aflæs i en silo der tager den, ikke i målet
unloadAt[77].accepts = {[TMR] = 360000, [STRAW] = 360000}
ADRunsController.updateRun(sugar.id, {ingredientMode = "manual", ingredients = {"FORAGE"}})
sugarVehicle.loadType = STRAW
sugarVehicle.ad.stateModule.active = false
ADRunsController.startRun(adEnv, sugar.id)
check("C18b forkert vare for run'et -> silo, ikke målet", sugarVehicle.ad.stateModule.secondMarkerId, 44)
-- auto-varer: alt målet tager hører til -> målet
ADRunsController.updateRun(sugar.id, {ingredientMode = "auto", ingredients = {}})
sugarVehicle.ad.stateModule.active = false
ADRunsController.startRun(adEnv, sugar.id)
check("C18b auto: målet tager varen -> målet", sugarVehicle.ad.stateModule.secondMarkerId, 77)
ADUnloadWait = nil; ADLoadSwap = nil
-- C19: Wait-markører er aldrig mål — heller ikke når de står inden for 40 m af staldens trigger
buildWorld()
-- staldens station med bygning; Wait-markøren ligger i triggerens rækkevidde og "mangler mere" end 94
local barnPlaceable = {getHusbandryFillLevel = function() return 0 end}
unloadAt[94].owningPlaceable = barnPlaceable
addMarker(400, "Cow Wait", "Cow Wait", 5005, 0); unloadAt[400] = {accepts = {[STRAW] = 300000, [TMR] = 300000}, levels = {}, owningPlaceable = barnPlaceable}
local targetNames = {}
for _, marker in ipairs(ADRunsController.getTargetMarkers(adEnv, 1)) do table.insert(targetNames, marker.name) end
check("C19 Wait-markør ikke i mål-listen", table.concat(targetNames, ","):find("Cow Wait", 1, true), nil)
local waitTargetRun = ADRunsController.createRun("Wait som mål")
ADRunsController.updateRun(waitTargetRun.id, {targetWayPointIds = {94, 400}})
local waitVehicle = makeVehicle("vehicleW", "W", {STRAW, TMR}, 20000, 5000, 0)
check("C19 chooseTarget springer Wait over", ADRunsController.chooseTarget(adEnv, ADRuns.getRun(ADRunsController.store, waitTargetRun.id), waitVehicle), 94)
-- C-out: udkørsels-runs — oprettelse, kilder, produkter, validering, start, status
local BASKET = 7
FILL_TYPE_NAMES[BASKET] = "STRAW_BASKET"; FILL_TYPE_INDEX["STRAW_BASKET"] = BASKET
local sourcesAt, productsAt = {}, {}
ADSources = {
identify = function(_, id) return sourcesAt[id] end,
getProducts = function(_, source) return productsAt[source.wayPointId] or {} end,
getSellMarkers = function() return {} end,
stationAccepts = function() return false end,
getSourcePlaceables = function() return {} end,
}
local outboundStatus = {reasons = {"SUGAR 78 % af top (kræver 90), lager 41 %"}, warning = nil}
local holdFor = {}
ADOutbound = {
isOutboundVehicle = function(v) local r = ADRunsController.getRunForVehicle(v) return r ~= nil and r.kind == "outbound" end,
getRunStatus = function() return outboundStatus end,
getHoldFor = function(v) return holdFor[v] end,
getTripFor = function() return nil end,
releaseInactive = function() end,
}
ADUnloadWait = {getRestLoad = function() return nil, 0 end, getWaitFor = function() return nil end}
buildWorld()
addMarker(1, "Suger Load", "Pickup points", 100, 0); loadAt[1] = {}
addMarker(3, "Objektlager", "Pickup points", 300, 0)
addMarker(10, "selling station", "selling", 500, 0)
sourcesAt[1] = {kind = "factory", wayPointId = 1, station = loadAt[1], name = "Sugar mill"}
sourcesAt[3] = {kind = "objectStorage", wayPointId = 3, name = "Objektlager"}
productsAt[1] = {{fillType = TMR, name = "FORAGE", title = "TMR", available = 150000, level = 150000, capacity = 180000, isPallet = false}}
productsAt[3] = {{fillType = BASKET, name = "STRAW_BASKET", title = "Halmkurve", available = 8000, level = 8, capacity = 4000, isPallet = true}}
local bulkTractor = makeVehicle("vehicleBulk", "MT635", {STRAW, TMR}, 20000, 100, 0)
local ualTractor = makeVehicle("vehicleUal", "Fendt", {}, 8000, 300, 0)
ualTractor.trailers[1] = {ual = true}
adEnv.AutoDrive.hasAL = function(_, trailer) return trailer.ual == true end
local out = ADRunsController.createRun("Sugar out", "outbound")
check("Cout1 kind", ADRuns.getRun(ADRunsController.store, out.id).kind, "outbound")
check("Cout1 supply-run har kind supply", ADRunsController.createRun("Cows").kind, "supply")
check("Cout1 kilde-markører", #ADRunsController.getSourceMarkers(adEnv, 1), 2)
check("Cout1 første kilde-markør", ADRunsController.getSourceMarkers(adEnv, 1)[1].marker.name, "Objektlager")
-- Cout2: validering
local outRun = ADRuns.getRun(ADRunsController.store, out.id)
-- diagnose: hvad moden ser på enhederne, når ingen autoload findes (logges én gang pr. traktor,
-- første gang varerne slås op — dvs. allerede ved noProducts-tjekket nedenfor)
logLines = {}
bulkTractor.trailers[1] = {spec_universalAutoload = {isAutoloadAvailable = false, autoloadDisabled = true}, getName = function() return "Vogn" end}
check("Cout2 ingen kilder -> noSource", ADRunsController.validateVehicle(adEnv, outRun, bulkTractor).reason, "noSource")
ADRunsController.store = ADRuns.toggleSource(ADRunsController.store, out.id, 1)
ADRunsController.store = ADRuns.toggleSource(ADRunsController.store, out.id, 3)
outRun = ADRuns.getRun(ADRunsController.store, out.id)
check("Cout2 ingen vare valgt -> noProducts", ADRunsController.validateVehicle(adEnv, outRun, bulkTractor).reason, "noProducts")
ADRunsController.store = ADRuns.setProductPolicy(ADRunsController.store, out.id, "STRAW_BASKET", "sell")
outRun = ADRuns.getRun(ADRunsController.store, out.id)
check("Cout2 kun paller uden UAL -> needsAutoload", ADRunsController.validateVehicle(adEnv, outRun, bulkTractor).reason, "needsAutoload")
local diag = nil
for _, line in ipairs(logLines) do if line:find("ingen autoload", 1, true) then diag = line end end
check("Cout2 diagnose logges", diag ~= nil, true)
check("Cout2 diagnose viser UAL-flag", diag ~= nil and diag:find("isAutoloadAvailable=false", 1, true) ~= nil, true)
logLines = {}
ADRunsController.validateVehicle(adEnv, outRun, bulkTractor)
check("Cout2 diagnose kun én gang", #logLines, 0)
bulkTractor.trailers[1] = nil
check("Cout2 UAL-vogn ok", ADRunsController.validateVehicle(adEnv, outRun, ualTractor).ok, true)
ADRunsController.store = ADRuns.setProductPolicy(ADRunsController.store, out.id, "FORAGE", "store")
outRun = ADRuns.getRun(ADRunsController.store, out.id)
check("Cout2 bulk-vogn ok med TMR", ADRunsController.validateVehicle(adEnv, outRun, bulkTractor).ok, true)
local strawOnly = makeVehicle("vehicleStraw", "Halm", {STRAW}, 20000, 100, 0)
check("Cout2 kan ikke bære -> cannotCarry", ADRunsController.validateVehicle(adEnv, outRun, strawOnly).reason, "cannotCarry")
-- Cout3: start sætter mode, 1./2. markør = første kilde, varevalg = det vognen kan bære af run'ets varer
ADRunsController.assignVehicle(out.id, bulkTractor)
ADRunsController.assignVehicle(out.id, ualTractor)
local results = ADRunsController.startRun(adEnv, out.id)
check("Cout3 to startet", #results, 2)
check("Cout3 mode P&D", bulkTractor.ad.stateModule.mode, MODE_PICKUPANDDELIVER)
check("Cout3 1. markør = første kilde", bulkTractor.ad.stateModule.firstMarkerId, 1)
check("Cout3 2. markør = første kilde (pladsholder)", bulkTractor.ad.stateModule.secondMarkerId, 1)
check("Cout3 bulk-vogn: kun TMR", table.concat(bulkTractor.ad.stateModule.selectedFillTypes, ","), tostring(TMR))
check("Cout3 UAL-vogn: kurve", table.concat(ualTractor.ad.stateModule.selectedFillTypes, ","), tostring(BASKET))
check("Cout3 rotation kun pålæsning", bulkTractor.ad.settings.rotateTargets.current, 2)
check("Cout3 startet", bulkTractor.started, 1)
-- AD's "AL unload" er slået FRA som standard (values[1] = 0): autoload-vognen ville aldrig læsse af
-- ved salgsstedet. Ved Start sættes den til "center" hvis den er fra; et brugervalg (left/right/behind) røres ikke.
check("Cout3 ALUnload sat til center for autoload-bil", ualTractor.ad.settings.ALUnload.current, 2)
check("Cout3 ALUnload rører ikke tipvogn", bulkTractor.ad.settings.ALUnload.current, 1)
ualTractor.ad.settings.ALUnload.current = 4
ualTractor.ad.stateModule.active = false
ADRunsController.startRun(adEnv, out.id)
check("Cout3 ALUnload brugervalg bevares", ualTractor.ad.settings.ALUnload.current, 4)
-- Cout4: produkter til fanen (politik slået op, sorteret på titel) og status
local list = ADRunsController.getRunProducts(adEnv, outRun)
check("Cout4 to produkter", #list, 2)
check("Cout4 første = Halmkurve", list[1].title, "Halmkurve")
check("Cout4 politik", list[1].policy, "sell")
check("Cout4 TMR lager", list[2].policy, "store")
check("Cout4 available", list[2].available, 150000)
local st = ADRunsController.getOutboundStatus(adEnv, outRun)
check("Cout4 status-begrundelse", st.reasons[1], "SUGAR 78 % af top (kræver 90), lager 41 %")
holdFor[bulkTractor] = {markerName = "Wait 3", reasons = {"SUGAR 78 % af top (kræver 90), lager 41 %"}}
local activity = ADRunsController.getVehicleActivity(adEnv, bulkTractor)
check("Cout4 aktivitet = holder", activity.kind, "holding")
check("Cout4 aktivitet sted", activity.place, "Wait 3")
check("Cout4 udkørsel har ingen mål", #ADRunsController.getRunTargets(outRun), 0)
-- Cout5 (I2): Start kræver et Wait-punkt i puljen; Cout6 (I3): last på vognen ved Start får eget mål
ADSupplyPlanner.isWaitMarker = function(marker) return string.find(string.lower(marker.group or ""), "%f[%a]wait%f[%A]") ~= nil end
local noWait = ADRunsController.createRun("No wait", "outbound")
ADRunsController.store = ADRuns.toggleSource(ADRunsController.store, noWait.id, 1)
ADRunsController.store = ADRuns.setProductPolicy(ADRunsController.store, noWait.id, "FORAGE", "store")
ADRunsController.store = ADRuns.updateRun(ADRunsController.store, noWait.id, {waitPoolGroup = "Pig Wait"})
ADRunsController.store = ADRuns.updateRun(ADRunsController.store, noWait.id, {waitPoolGroup = "Findes ikke"})
local noWaitRun = ADRuns.getRun(ADRunsController.store, noWait.id)
check("Cout5 ingen Wait-punkt i puljen -> noWaitPoint", ADRunsController.validateVehicle(adEnv, noWaitRun, bulkTractor).reason, "noWaitPoint")
ADRunsController.store = ADRuns.updateRun(ADRunsController.store, noWait.id, {waitPoolGroup = "Cow Wait"})
noWaitRun = ADRuns.getRun(ADRunsController.store, noWait.id)
check("Cout5 Wait-punkt findes -> ok", ADRunsController.validateVehicle(adEnv, noWaitRun, bulkTractor).ok, true)
ADUnloadWait.getRestLoad = function(_, v) return v.loadType, v.loadLiters or 0 end
ADOutbound.planCargoTarget = function(adEnv, run, vehicle, fillType) return fillType == TMR and 10 or nil end
bulkTractor.loadType, bulkTractor.loadLiters = TMR, 8000
ADRunsController.assignVehicle(noWait.id, bulkTractor)
bulkTractor.ad.stateModule.active = false
ADRunsController.startRun(adEnv, noWait.id)
check("Cout6 last på vognen -> 2. markør = lastens mål", bulkTractor.ad.stateModule.secondMarkerId, 10)
bulkTractor.loadType = STRAW
check("Cout6 last uden mål -> cargoNoTarget", ADRunsController.validateVehicle(adEnv, noWaitRun, bulkTractor).reason, "cargoNoTarget")
bulkTractor.loadType = nil
ADUnloadWait = nil; ADOutbound = nil; ADSources = nil
-- C-flow: bygninger -> markører (resolveMarkers), migration af gamle markør-runs, noMarker, flow-rækker
local buildings = {
{id = "barn1", name = "Kostald 1", kind = "husbandry", loadMarker = {id = 10, name = "cow 1 milk", distance = 5}, loadMarkers = {{id = 10, name = "cow 1 milk", distance = 5}, {id = 11, name = "cow 1 slurry", distance = 3}}, unloadMarker = {id = 94, name = "Cow 1 Food", distance = 8}, canBeSource = true, canBeTarget = true, canSell = false},
{id = "shed", name = "Palleskur", kind = "objectStorage", loadMarker = nil, unloadMarker = nil, canBeSource = true, canBeTarget = false, canSell = false},
{id = "mill", name = "Sukkerfabrik", kind = "factory", loadMarker = {id = 12694, name = "Suger Load", distance = 3}, unloadMarker = {id = 77, name = "sugar offload", distance = 4}, canBeSource = true, canBeTarget = true, canSell = false},
}
ADBuildings = {
list = function() return buildings end,
find = function(_, _, id) for _, b in ipairs(buildings) do if b.id == id then return b end end end,
byMarker = function(_, _, wayPointId, role)
for _, b in ipairs(buildings) do
if role == "unload" then
if b.unloadMarker ~= nil and b.unloadMarker.id == wayPointId then return b end
else
for _, m in ipairs(b.loadMarkers or {b.loadMarker}) do if m ~= nil and m.id == wayPointId then return b end end
end
end
end,
}
ADSources = {identify = function(_, id) return (id == 10 or id == 12694 or id == 555) and {kind = "x", wayPointId = id, name = "k"} or nil end, getProducts = function() return {} end, getSellMarkers = function() return {} end, stationAccepts = function() return false end, getSourcePlaceables = function() return {} end}
ADOutbound = {isOutboundVehicle = function() return false end, getRunStatus = function() return {reasons = {"SUGAR 78 % af top"}} end, getHoldFor = function() return nil end, getTripFor = function() return nil end, releaseInactive = function() end}
buildWorld()
addMarker(10, "cow 1 milk", "Milk", 100, 0); loadAt[10] = {}
addMarker(555, "raa markoer", "Pickup", 200, 0); loadAt[555] = {}
addMarker(77, "sugar offload", "production", 950, 0); unloadAt[77] = {accepts = {[TMR] = 360000}}
-- CF1: udkørsel: bygninger + rå markør -> sourceWayPointIds; manglende markør rapporteres
local flowOut = ADRunsController.createRun("Milk", "outbound")
ADRunsController.updateRun(flowOut.id, {sourceBuildingIds = {"barn1", "shed"}, sourceWayPointIds = {555}})
local resolved = ADRunsController.resolveMarkers(adEnv, ADRuns.getRun(ADRunsController.store, flowOut.id))
check("CF1 kildemarkører = ALLE bygningens læssemarkører + rå", table.concat(ADRuns.getRun(ADRunsController.store, flowOut.id).sourceWayPointIds, ","), "10,11,555")
check("CF1 manglende markør", resolved.missing[1], "Palleskur")
-- en markør der HØRER til en bygning som ikke længere er valgt, fjernes (den er ikke rå)
ADRunsController.updateRun(flowOut.id, {sourceWayPointIds = {12694, 555}})
ADRunsController.resolveMarkers(adEnv, ADRuns.getRun(ADRunsController.store, flowOut.id))
check("CF1 fravalgt bygnings markør fjernes", table.concat(ADRuns.getRun(ADRunsController.store, flowOut.id).sourceWayPointIds, ","), "10,11,555")
-- CF1b: gamle rå markører ryddes: ikke-kilde (identify = nil) og markør der hører til en valgt bygning
ADSources.identify = function(_, id)
if id == 10 or id == 12694 or id == 555 then return {kind = "x", wayPointId = id, name = "k"} end
if id == 556 then return {kind = "husbandry", wayPointId = id, name = "Kostald 1", placeable = "barn1-placeable"} end
return nil
end
buildings[1].placeable = "barn1-placeable"
ADRunsController.updateRun(flowOut.id, {sourceBuildingIds = {"barn1"}, sourceWayPointIds = {10, 11, 555, 556, 999}})
logLines = {}
ADRunsController.resolveMarkers(adEnv, ADRuns.getRun(ADRunsController.store, flowOut.id))
check("CF1b rå ikke-kilde og dublet-bygning fjernet", table.concat(ADRuns.getRun(ADRunsController.store, flowOut.id).sourceWayPointIds, ","), "10,11,555")
local pruneLogged = false
for _, line in ipairs(logLines) do if line:find("gamle markører fjernet", 1, true) then pruneLogged = true end end
check("CF1b logget", pruneLogged, true)
-- CF1c: et gammelt kilde-id der ikke længere er en AD-MARKØR (AutoDrive har omnummereret waypoints)
-- fjernes, selv om punktet ligger ved en kilde — ellers kører traktoren til et tilfældigt vejpunkt
local identifyBefore = ADSources.identify
ADSources.identify = function(adEnv_, id, farmId)
if id == 10873 then return {kind = "husbandry", wayPointId = id, name = "Anden stald", placeable = "anden-stald"} end
return identifyBefore(adEnv_, id, farmId)
end
ADRunsController.updateRun(flowOut.id, {sourceBuildingIds = {"barn1"}, sourceWayPointIds = {10, 11, 555, 10873}})
ADRunsController.resolveMarkers(adEnv, ADRuns.getRun(ADRunsController.store, flowOut.id))
check("CF1c vejpunkt uden markør fjernet", table.concat(ADRuns.getRun(ADRunsController.store, flowOut.id).sourceWayPointIds, ","), "10,11,555")
ADSources.identify = identifyBefore
-- CF2: forsyning: mål-bygninger -> targetWayPointIds + targetWayPointId
local flowIn = ADRunsController.createRun("Foder", "supply")
ADRunsController.updateRun(flowIn.id, {targetBuildingIds = {"barn1", "mill"}})
ADRunsController.resolveMarkers(adEnv, ADRuns.getRun(ADRunsController.store, flowIn.id))
check("CF2 målmarkører", table.concat(ADRunsController.getRunTargets(ADRuns.getRun(ADRunsController.store, flowIn.id)), ","), "94,77")
check("CF2 primært mål", ADRuns.getRun(ADRunsController.store, flowIn.id).targetWayPointId, 94)
-- CF2b: gamle rå mål der ikke er en markør (slettet i AD) eller hører til en valgt bygning, ryddes
addMarker(78, "Anden stald food", "Cow Food", 3000, 0); unloadAt[78] = {accepts = {[TMR] = 1000}, owningPlaceable = "anden-stald"}
unloadAt[11062] = {accepts = {[TMR] = 1000}, owningPlaceable = "anden-stald"} -- waypoint uden markør
ADRunsController.updateRun(flowIn.id, {targetWayPointIds = {94, 77, 11062, 78}})
ADRunsController.resolveMarkers(adEnv, ADRuns.getRun(ADRunsController.store, flowIn.id))
check("CF2b ikke-markør fjernet, rå markør med station beholdt", table.concat(ADRunsController.getRunTargets(ADRuns.getRun(ADRunsController.store, flowIn.id)), ","), "94,77,78")
ADRunsController.updateRun(flowIn.id, {targetWayPointIds = {94, 77}})
-- CF3: migration: gamle markør-runs får bygninger
local oldOut = ADRunsController.createRun("Gammel ud", "outbound")
ADRunsController.updateRun(oldOut.id, {sourceWayPointIds = {12694, 555}})
local oldIn = ADRunsController.createRun("Gammel ind", "supply")
ADRunsController.updateRun(oldIn.id, {targetWayPointIds = {94}})
ADRunsController.migrateBuildings(adEnv)
check("CF3 ud: markør -> bygning", ADRuns.getRun(ADRunsController.store, oldOut.id).sourceBuildingIds[1], "mill")
check("CF3 ud: rå markør uden bygning beholdes", table.concat(ADRuns.getRun(ADRunsController.store, oldOut.id).sourceWayPointIds, ","), "12694,555")
check("CF3 ind: markør -> bygning", ADRuns.getRun(ADRunsController.store, oldIn.id).targetBuildingIds[1], "barn1")
ADRunsController.migrateBuildings(adEnv)
check("CF3 migration er idempotent", #ADRuns.getRun(ADRunsController.store, oldOut.id).sourceBuildingIds, 1)
-- CF4: validering: valgt bygning uden markør -> noMarker
local cfVehicle = makeVehicle("vehicleCF", "CF", {STRAW, TMR}, 20000, 100, 0)
ADRunsController.updateRun(flowOut.id, {sourceBuildingIds = {"shed"}, sourceWayPointIds = {}})
local cfValidation = ADRunsController.validateVehicle(adEnv, ADRuns.getRun(ADRunsController.store, flowOut.id), cfVehicle)
check("CF4 noMarker", cfValidation.reason, "noMarker")
check("CF4 hvilken bygning", cfValidation.missingBuilding, "Palleskur")
-- CF4b: ÉN bygning uden markør blandt flere med markør -> start alligevel, advar om den ene
ADRunsController.updateRun(flowOut.id, {sourceBuildingIds = {"barn1", "shed"}, sourceWayPointIds = {}})
logLines = {}
local cfValidation2 = ADRunsController.validateVehicle(adEnv, ADRuns.getRun(ADRunsController.store, flowOut.id), cfVehicle)
check("CF4b ikke afvist pga. markør", cfValidation2.reason ~= "noMarker", true)
local warned = false
for _, line in ipairs(logLines) do if line:find("Palleskur", 1, true) and line:find("springes over", 1, true) then warned = true end end
check("CF4b advarsel nævner bygningen", warned, true)
-- CF5: flow-række til tavlen
ADRunsController.updateRun(flowOut.id, {sourceBuildingIds = {"barn1"}, products = {{fillType = "SUGAR", policy = "sell"}, {fillType = "FORAGE", policy = "store"}}, collectRound = true})
ADRunsController.assignVehicle(flowOut.id, cfVehicle)
local row = ADRunsController.getFlowRow(adEnv, ADRuns.getRun(ADRunsController.store, flowOut.id))
check("CF5 vare-navne", table.concat(row.productNames, ","), "SUGAR,FORAGE")
check("CF5 fra", row.fromNames[1], "Kostald 1")
check("CF5 politikker", table.concat(row.policies, ","), "sell,store")
check("CF5 traktor", row.vehicleNames[1], "CF")
check("CF5 begrundelse", row.reason, "SUGAR 78 % af top")
check("CF5 runde", row.collectRound, true)
ADRunsController.updateRun(flowOut.id, {sourceBuildingIds = {"barn1", "shed"}})
check("CF5 manglende bygning markeres", ADRunsController.getFlowRow(adEnv, ADRuns.getRun(ADRunsController.store, flowOut.id)).missing[1], "Palleskur")
local rowIn = ADRunsController.getFlowRow(adEnv, ADRuns.getRun(ADRunsController.store, flowIn.id))
check("CF5 forsyning fra = alle siloer", rowIn.fromAllSilos, true)
check("CF5 forsyning til", table.concat(rowIn.toNames, ","), "Kostald 1,Sukkerfabrik")
check("CF5 forsyning auto-varer", rowIn.productAuto, true)
-- SB: "Lager til" — valgte bygninger -> deres aflæsningsmarkører (i valgt rækkefølge; ukendte udelades)
do
local sbRun = ADRunsController.createRun("Gylle", "outbound")
ADRunsController.updateRun(sbRun.id, {storeBuildingIds = {"mill", "findes-ikke", "barn1"}})
check("SB markører", table.concat(ADRunsController.getStoreMarkers(adEnv, ADRuns.getRun(ADRunsController.store, sbRun.id)), ","), "77,94")
ADRunsController.updateRun(sbRun.id, {storeBuildingIds = {}})
check("SB tom = ingen tvang", #ADRunsController.getStoreMarkers(adEnv, ADRuns.getRun(ADRunsController.store, sbRun.id)), 0)
end
-- CS: "Lager til" viser kun bygninger der kan tage flowets lagervarer (æg-paller: kun palleskure der tager æg)
do
local EGG, SILAGE = 501, 502
local shed = {id = "shed1", kind = "objectStorage", placeable = {takesEggs = true}}
local shedNo = {id = "shed2", kind = "objectStorage", placeable = {takesEggs = false}}
local silo = {id = "silo9", kind = "silo", placeable = {}, unloadMarker = {id = 901}}
unloadAt[901] = {accepts = {[SILAGE] = 100000}, getIsFillTypeSupported = function(_, ft) return ft == SILAGE end}
local accBefore, storeBefore = ADSources.objectStorageAccepts, ADLoadSwap and ADLoadSwap.isStoreTarget
ADSources.objectStorageAccepts = function(placeable, ft) return ft == EGG and placeable.takesEggs end
ADLoadSwap = ADLoadSwap or {}
ADLoadSwap.isStoreTarget = function(station, ft) return station.getIsFillTypeSupported(station, ft) end
local eggs = {{fillType = EGG, isPallet = true}}
check("CS palleskur der tager æg", ADRunsController.canStoreAt(adEnv, shed, eggs), true)
check("CS palleskur der ikke tager æg", ADRunsController.canStoreAt(adEnv, shedNo, eggs), false)
check("CS silo kan ikke tage æg-paller", ADRunsController.canStoreAt(adEnv, silo, eggs), false)
check("CS silo tager ensilage", ADRunsController.canStoreAt(adEnv, silo, {{fillType = SILAGE, isPallet = false}}), true)
ADSources.objectStorageAccepts = accBefore
ADLoadSwap.isStoreTarget = storeBefore
end
ADBuildings = nil; ADSources = nil; ADOutbound = nil
-- RC-navne: køretøjer får AD-førernavn efter forløbet; ude af forløb -> modelnavnet igen
do
local named = makeVehicle("vehicleNamed", "Virtus 135", {MILK or 1}, 20000, 0, 0)
named.ad.stateModule.driverName = "Virtus 135"
named.ad.stateModule.setName = function(self, n) self.driverName = n end
named.ad.stateModule.getName = function(self) return self.driverName end
local run = ADRunsController.createRun("Mælk ind", "outbound")
ADRunsController.assignVehicle(run.id, named)
ADRunsController.applyVehicleNames(adEnv)
check("RC-navne sat efter forløb", named.ad.stateModule.driverName, "Mælk ind")
check("RC-navne label bruger førernavn", ADRuns.vehicleLabel(named), "Mælk ind")
ADRunsController.store = ADRuns.unassignVehicle(ADRunsController.store, "vehicleNamed")
ADRunsController.applyVehicleNames(adEnv)
check("RC-navne ude af forløb -> modelnavn", named.ad.stateModule.driverName, "Virtus 135")
check("RC-navne label uden AD-navn", ADRuns.vehicleLabel({getName = function() return "Fendt" end}), "Fendt")
end
-- PM: afhentningsmarkører fra udkørsels-forløb (lagerture undgår dem som aflæsningssted)
do
local sale = ADRunsController.createRun("Salg-test", "outbound")
ADRunsController.updateRun(sale.id, {sourceWayPointIds = {4711, 4712}})
local supply = ADRunsController.createRun("Forsyning-test", "supply")
ADRunsController.updateRun(supply.id, {sourceWayPointIds = {9999}})
local pickups = ADRunsController.getPickupMarkers()
check("PM udkørsels-kilde med", pickups[4711], true)
check("PM forsyning tæller ikke", pickups[9999], nil)
end
-- SA: Start/Stop alle flows — kørende flows røres ikke ved Start alle; Stop alle stopper alt aktivt
do
buildWorld()
local idle = makeVehicle("vehicleAllA", "Alle A", {STRAW, TMR}, 1000000, 100, 0)
local busy = makeVehicle("vehicleAllB", "Alle B", {STRAW, TMR}, 1000000, 100, 0)
local runA = ADRunsController.createRun("Alle-A")
ADRunsController.updateRun(runA.id, {targetWayPointId = 94})
ADRunsController.assignVehicle(runA.id, idle)
local runB = ADRunsController.createRun("Alle-B")
ADRunsController.updateRun(runB.id, {targetWayPointId = 94})
ADRunsController.assignVehicle(runB.id, busy)
ADRunsController.createRun("Alle-tom") -- ingen traktorer: tæller ikke med
ADRunsController.startRun(adEnv, runB.id)
local busyStarts = busy.started
local summary = ADRunsController.startAllRuns(adEnv)
check("SA idle startet", idle.started, 1)
check("SA kørende flow ikke genstartet", busy.started, busyStarts)
check("SA mindst ét flow startet", summary.runsStarted >= 1, true)
check("SA kørende flow talt", summary.alreadyRunning >= 1, true)
check("SA traktorer startet tæller idle", summary.vehiclesStarted >= 1, true)
check("SA resultater med", type(summary.results), "table")
check("SA noget aktivt", ADRunsController.hasActiveRuns(adEnv), true)
local stopped = ADRunsController.stopAllRuns(adEnv)
check("SA stop alle stopper begge", stopped >= 2, true)
check("SA idle stoppet", idle.ad.stateModule.active, false)
check("SA busy stoppet", busy.ad.stateModule.active, false)
check("SA intet aktivt bagefter", ADRunsController.hasActiveRuns(adEnv), false)
check("SA isRunning ryddet", ADRunsController.getRunStatus(adEnv, ADRuns.getRun(ADRunsController.store, runA.id)).isRunning, false)
end
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
File diff suppressed because it is too large Load Diff
-533
View File
@@ -1,533 +0,0 @@
-- Kør: luajit tests/test_adSources.lua (fra repo-roden)
-- Kilde-genkendelse ved en AD-markør (fabrik / silo / objektlager), lagerstatus pr. vare,
-- paller ved fabrikkens spawner, salgssteder, objektlager-udtag.
local SUGAR, FORAGE, BASKET, CRATE = 5, 6, 7, 8
local DESC = {
[SUGAR] = {index = SUGAR, name = "SUGAR", title = "Sukker", isPalletType = true},
[FORAGE] = {index = FORAGE, name = "FORAGE", title = "TMR"},
[BASKET] = {index = BASKET, name = "STRAW_BASKET", title = "Halmkurve", palletFilename = "basketPallet.xml"},
[CRATE] = {index = CRATE, name = "STRAW_CRATE", title = "Halmkasser", palletFilename = "cratePallet.xml"},
}
g_fillTypeManager = {
getFillTypeByIndex = function(_, index) return DESC[index] end,
getFillTypeNameByIndex = function(_, index) return DESC[index] and DESC[index].name end,
getFillTypeTitleByIndex = function(_, index) return DESC[index] and DESC[index].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/adStorageStats.lua")
dofile("FS25_ADSmartPickup/adSources.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
-- ---- fakes ----
local wayPoints, markers = {}, {}
local function addMarker(id, name, x, z)
wayPoints[id] = {id = id, x = x, z = z}
table.insert(markers, {id = id, markerIndex = #markers + 1, name = name, group = "g"})
end
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 function storageOf(levels, capacities)
return {
fillLevels = levels,
getFillLevel = function(_, ft) return levels[ft] or 0 end,
getCapacity = function(_, ft) return capacities[ft] or 0 end,
}
end
local sugarStorage = storageOf({[SUGAR] = 150000, [BASKET] = 0}, {[SUGAR] = 180000, [BASKET] = 8000})
local factory = {
rootNode = {x = 100, z = 100}, getName = function() return "Sugar mill" end,
spec_productionPoint = {productionPoint = {
productions = {{outputs = {{type = SUGAR, amount = 1}}}, {outputs = {{type = BASKET, amount = 1}}}},
storage = sugarStorage,
palletSpawner = {spawnPlaces = {{startNode = {x = 110, z = 100}}}},
}},
}
local factoryStation = {owningPlaceable = factory, supportedFillTypes = {[SUGAR] = true}}
local silo = {rootNode = {x = 200, z = 200}, spec_silo = {storages = {storageOf({[FORAGE] = 74500}, {[FORAGE] = 12000000})}}, getName = function() return "Farma" end}
local siloStation = {owningPlaceable = silo}
local spawnCalls = {}
local objectStorage = {
rootNode = {x = 300, z = 300}, ownerFarmId = 1, getName = function() return "Objektlager" end,
spec_objectStorage = {
objectInfos = {
{objects = {{palletAttributes = {fillType = BASKET, fillLevel = 1000}}}, numObjects = 8},
{objects = {{baleAttributes = {fillType = FORAGE, fillLevel = 4000}}}, numObjects = 2},
},
numStoredObjects = 10, capacity = 4000,
},
removeAbstractObjectsFromStorage = function(self, index, amount, connection) table.insert(spawnCalls, {index, amount, connection}) end,
}
local sellStation = {owningPlaceable = {rootNode = {x = 500, z = 500}}, isa = function(_, c) return c == SellingStation end, isSellingPoint = true, acceptedFillTypes = {[SUGAR] = true}, getName = function() return "Grain Elevator" end}
local factoryUnload = {owningPlaceable = factory, isa = function() return true end, isSellingPoint = false}
addMarker(1, "Suger Load", 100, 90)
addMarker(2, "silo 2", 200, 190)
addMarker(3, "Objektlager", 300, 280) -- 20 m fra lageret
addMarker(4, "Langt væk", 300, 240) -- 60 m fra lageret
addMarker(5, "selling station", 500, 490)
addMarker(6, "sugar offload", 100, 80)
local loadAt = {[1] = factoryStation, [2] = siloStation}
local unloadAt = {[5] = sellStation, [6] = factoryUnload}
ADSmartPickup = {
getLoadStationAtWayPointId = function(_, id) return loadAt[id] end,
getUnloadStationAtWayPointId = function(_, id) return unloadAt[id] end,
getOwnStorageLevel = function(station, fillTypes)
local total = 0
for _, storage in pairs(station.owningPlaceable.spec_silo.storages) do
for _, ft in ipairs(fillTypes) do total = total + storage:getFillLevel(ft) end
end
return total
end,
}
local pallets = {}
local function addPallet(fillType, level, x, z, farmId)
table.insert(pallets, {spec_pallet = {}, rootNode = {x = x, z = z}, getOwnerFarmId = function() return farmId or 1 end,
getFillUnitFillType = function() return fillType end, getFillUnitFillLevel = function() return level end})
end
g_currentMission = {
placeableSystem = {placeables = {factory, silo, objectStorage}},
accessHandler = {canFarmAccess = function(_, farmId, placeable) return true end},
vehicleSystem = {vehicles = pallets},
}
-- S1: fabrik
local source1 = ADSources.identify(adEnv, 1, 1)
check("S1 fabrik", source1.kind, "factory")
check("S1 navn", source1.name, "Sugar mill")
local products1 = ADSources.getProducts(adEnv, source1, 1)
check("S1 to produkter", #products1, 2)
check("S1 SUGAR available", products1[1].available, 150000)
check("S1 SUGAR level", products1[1].level, 150000)
check("S1 SUGAR capacity", products1[1].capacity, 180000)
check("S1 SUGAR bulk", products1[1].isPallet, false)
check("S1 SUGAR kan også stå på paller", products1[1].canPallet, true)
check("S1 SUGAR paller ved spawner (ingen endnu)", products1[1].palletLiters, 0)
check("S1 TMR-lignende bulk kan ikke paller", products1[2].canPallet, true)
check("S1 BASKET er palle", products1[2].isPallet, true)
check("S1 BASKET tomt", products1[2].available, 0)
-- S2: silo
local source2 = ADSources.identify(adEnv, 2, 1)
check("S2 silo", source2.kind, "silo")
local products2 = ADSources.getProducts(adEnv, source2, 1)
check("S2 én vare", #products2, 1)
check("S2 FORAGE", products2[1].fillType, FORAGE)
check("S2 level", products2[1].level, 74500)
check("S2 capacity", products2[1].capacity, 12000000)
-- S3: objektlager 20 m fra markøren
local source3 = ADSources.identify(adEnv, 3, 1)
check("S3 objektlager", source3.kind, "objectStorage")
local products3 = ADSources.getProducts(adEnv, source3, 1)
check("S3 to varer", #products3, 2)
check("S3 kurve available", products3[1].available, 8000)
check("S3 kurve er palle", products3[1].isPallet, true)
check("S3 level = antal objekter", products3[1].level, 10)
check("S3 capacity", products3[1].capacity, 4000)
check("S3 baller tæller som palle", products3[2].isPallet, true)
local index, count = ADSources.getObjectInfoIndex(source3, BASKET)
check("S3 objectInfo-indeks", index, 1)
check("S3 antal", count, 8)
check("S3 countStored", ADSources.countStored(source3, FORAGE), 2)
check("S3 ukendt vare", ADSources.getObjectInfoIndex(source3, SUGAR), nil)
-- S4: paller ved fabrikkens spawner tæller med (inden for 25 m af spawn-noden)
addPallet(BASKET, 1000, 112, 100)
addPallet(BASKET, 1000, 115, 105)
addPallet(BASKET, 1000, 160, 100) -- 50 m væk: tæller ikke
addPallet(BASKET, 1000, 112, 102, 2) -- anden farm: tæller ikke
local products4 = ADSources.getProducts(adEnv, source1, 1)
check("S4 paller ved spawner", products4[2].available, 2000)
check("S4 antal paller ved spawner", products4[2].palletCount, 2)
check("S3 antal paller i objektlager", products3[1].palletCount, 8)
check("S4 bulk uændret", products4[1].available, 150000)
addPallet(SUGAR, 2000, 111, 101)
local products4b = ADSources.getProducts(adEnv, source1, 1)
check("S4 sukkerpaller tælles separat", products4b[1].palletLiters, 2000)
check("S4 bulk-mængde uden paller", products4b[1].available, 150000)
-- S5: salgssteder = kun markører hvis aflæsningsstation er et rigtigt salgssted
local sells = ADSources.getSellMarkers(adEnv, 1)
check("S5 ét salgssted", #sells, 1)
check("S5 markør", sells[1].marker.id, 5)
check("S5 tager SUGAR", ADSources.stationAccepts(sells[1].station, SUGAR), true)
check("S5 tager ikke FORAGE", ADSources.stationAccepts(sells[1].station, FORAGE), false)
-- palletriggerens varer ligger i supportedFillTypes (det AutoDrive bruger), ikke i acceptedFillTypes (kun bulk-trigger)
local palletSell = {acceptedFillTypes = {[SUGAR] = true}, supportedFillTypes = {[SUGAR] = true, [BASKET] = true}}
check("S5 pallevare via supportedFillTypes", ADSources.stationAccepts(palletSell, BASKET), true)
check("S5 ukendt vare stadig nej", ADSources.stationAccepts(palletSell, FORAGE), false)
check("S5 acceptedNames", ADSources.describeAccepted(palletSell), "STRAW_BASKET, SUGAR")
-- S6: spawnOne kalder spillets udtag med 1 objekt og uden klientforbindelse
check("S6 spawn ok", ADSources.spawnOne(source3, 1), true)
check("S6 indeks", spawnCalls[1][1], 1)
check("S6 antal", spawnCalls[1][2], 1)
check("S6 connection nil", spawnCalls[1][3], nil)
check("S6 fejl -> false", ADSources.spawnOne({kind = "objectStorage", placeable = {removeAbstractObjectsFromStorage = function() error("x") end}}, 1), false)
-- S7: markør uden station og >40 m fra objektlager -> nil
check("S7 ingen kilde", ADSources.identify(adEnv, 4, 1), nil)
check("S7 ukendt waypoint", ADSources.identify(adEnv, 99, 1), nil)
-- S8: hjælpere
check("S8 isPalletFillType palle", ADSources.isPalletFillType(BASKET), true)
check("S8 isPalletFillType palletype uden fil", ADSources.isPalletFillType(SUGAR), true)
check("S8 isPalletFillType bulk", ADSources.isPalletFillType(FORAGE), false)
-- C1: palle/bulk afgøres pr. kilde: fabrik med læssestation der tager varen = bulk; silo = altid bulk; objektlager = palle
check("S8 sukker ved fabrik med læssestation = bulk", ADSources.isPalletAt(source1, SUGAR), false)
check("S8 kurve ved fabrik (station tager dem ikke) = palle", ADSources.isPalletAt(source1, BASKET), true)
check("S8 silo altid bulk", ADSources.isPalletAt(source2, SUGAR), false)
check("S8 objektlager altid palle", ADSources.isPalletAt(source3, FORAGE), true)
-- S9 (I1): ren pallefabrik uden læssestation genkendes via afstand til spawner/rootNode
local basketFactory = {
rootNode = {x = 600, z = 600}, getName = function() return "Straw factory" end,
spec_productionPoint = {productionPoint = {productions = {{outputs = {{type = BASKET, amount = 1}}}}, storage = storageOf({[BASKET] = 0}, {[BASKET] = 8000}), palletSpawner = {spawnPlaces = {{startNode = {x = 610, z = 600}}}}}},
}
table.insert(g_currentMission.placeableSystem.placeables, basketFactory)
addMarker(7, "Straw factory pickup", 605, 590)
local source9 = ADSources.identify(adEnv, 7, 1)
check("S9 pallefabrik uden station = factory", source9.kind, "factory")
check("S9 station nil", source9.station, nil)
local products9 = ADSources.getProducts(adEnv, source9, 1)
check("S9 kurve er palle", products9[1].isPallet, true)
check("S9 fabrik foretrækkes frem for objektlager i nærheden", ADSources.identify(adEnv, 7, 1).placeable, basketFactory)
-- S9b: fabrik fundet på afstand, men produktionspunktet HAR en læssestation (sukkerfabrik hvor markøren
-- ikke rammer triggeren): stationen hentes fra produktionspunktet, og sukker er bulk, ikke paller
local mill2Storage = storageOf({[SUGAR] = 1000}, {[SUGAR] = 180000})
local mill2 = {
rootNode = {x = 1200, z = 1200}, getName = function() return "Sugar mill 2" end,
spec_productionPoint = {productionPoint = {productions = {{outputs = {{type = SUGAR, amount = 1}}}}, storage = mill2Storage,
loadingStation = {supportedFillTypes = {[SUGAR] = true}}}},
}
table.insert(g_currentMission.placeableSystem.placeables, mill2)
addMarker(9, "Mill 2 load", 1210, 1190)
local source9b = ADSources.identify(adEnv, 9, 1)
check("S9b fabrik", source9b.kind, "factory")
check("S9b station fra produktionspunktet", source9b.station, mill2.spec_productionPoint.productionPoint.loadingStation)
check("S9b sukker = bulk", ADSources.isPalletAt(source9b, SUGAR), false)
check("S9b produkt bulk", ADSources.getProducts(adEnv, source9b, 1)[1].isPallet, false)
local excluded = ADSources.getSourcePlaceables({source1, source2})
check("S8 udelukkede placeables", excluded[factory] and excluded[silo], true)
-- S10 (paller til lager): objektlager med plads der tager varen; nærmeste nåelige fra traktoren
objectStorage.getObjectStorageSupportsFillType = function(self, ft) return ft == BASKET or ft == FORAGE end
check("S10 tager kurve", ADSources.objectStorageAccepts(objectStorage, BASKET), true)
check("S10 tager ikke sukker", ADSources.objectStorageAccepts(objectStorage, SUGAR), false)
check("S10 fri pladser", ADSources.objectStorageFreeSlots(objectStorage), 3990)
local fullShed = {rootNode = {x = 700, z = 700}, spec_objectStorage = {capacity = 10, numStoredObjects = 10, objectInfos = {}}, getName = function() return "Fuldt skur" end}
table.insert(g_currentMission.placeableSystem.placeables, fullShed)
addMarker(8, "Fuldt skur", 700, 690)
check("S10 fuldt skur 0 pladser", ADSources.objectStorageFreeSlots(fullShed), 0)
local truck = {rootNode = {x = 650, z = 650}}
local store = ADSources.findPalletStore(adEnv, truck, BASKET, {}, function() return true end, 1)
check("S10 nærmeste med plads = Objektlager (skuret er fuldt)", store.wayPointId, 3)
check("S10 navn", store.name, "Objektlager")
check("S10 udelukket kilde -> nil", ADSources.findPalletStore(adEnv, truck, BASKET, {[objectStorage] = true}, function() return true end, 1), nil)
check("S10 uopnåelig -> nil", ADSources.findPalletStore(adEnv, truck, BASKET, {}, function() return false end, 1), nil)
check("S10 sukker: intet skur tager det", ADSources.findPalletStore(adEnv, truck, SUGAR, {}, function() return true end, 1), nil)
-- S11 (reserve): gårdens forbrug det næste døgn af en vare, minus hvad forbrugerne allerede har
local BEET, STRAW, WATER = 9, 10, 11
DESC[BEET] = {index = BEET, name = "SUGARBEET", title = "Roer"}; DESC[STRAW] = {index = STRAW, name = "STRAW", title = "Halm"}; DESC[WATER] = {index = WATER, name = "WATER", title = "Vand"}
local tmrStorage = storageOf({[BEET] = 20000, [FORAGE] = 500000}, {[BEET] = 2500000, [FORAGE] = 3500000})
local tmrFactory = {
rootNode = {x = 800, z = 800}, ownerFarmId = 1, getOwnerFarmId = function() return 1 end, getName = function() return "TMR-fabrik" end,
spec_productionPoint = {productionPoint = {
productions = {{id = "tmr3", cyclesPerMinute = 200 / 60, inputs = {{type = BEET, amount = 45}}, outputs = {{type = FORAGE, amount = 240}}}},
storage = tmrStorage,
getIsProductionEnabled = function(self, id) return id == "tmr3" end,
}},
}
local cowBarn = {
rootNode = {x = 900, z = 900}, ownerFarmId = 1, getOwnerFarmId = function() return 1 end, getName = function() return "Kostald" end,
spec_husbandryStraw = {inputLitersPerHour = 500}, spec_husbandryWater = {litersPerHour = 100, automaticWaterSupply = true},
getHusbandryIsFillTypeSupported = function(self, ft) return ft == STRAW or ft == WATER end,
getHusbandryFillLevel = function(self, ft) return ft == STRAW and 2000 or 0 end,
getHusbandryFreeCapacity = function(self, ft) return ft == STRAW and 19375 or 0 end,
}
local otherFarmBarn = {rootNode = {x = 950, z = 950}, ownerFarmId = 2, getOwnerFarmId = function() return 2 end, spec_husbandryStraw = {inputLitersPerHour = 500}, getHusbandryIsFillTypeSupported = function() return true end, getHusbandryFillLevel = function() return 0 end, getHusbandryFreeCapacity = function() return 99999 end}
table.insert(g_currentMission.placeableSystem.placeables, tmrFactory)
table.insert(g_currentMission.placeableSystem.placeables, cowBarn)
table.insert(g_currentMission.placeableSystem.placeables, otherFarmBarn)
local demand = ADSources.getFarmDemand(adEnv, BEET, 1, {})
-- 45 l × (200/60) pr. min × 1440 min = 216.000 l/døgn; har 20.000 -> 196.000
check("S11 roer: døgnforbrug minus beholdning", demand.liters, 196000)
check("S11 forbruger-navn", demand.consumers[1], "TMR-fabrik")
local strawDemand = ADSources.getFarmDemand(adEnv, STRAW, 1, {})
-- 500 l/t × 24 = 12.000; har 2.000 -> 10.000 (under fri plads 19.375)
check("S11 halm: stald", strawDemand.liters, 10000)
check("S11 anden farm tæller ikke", #strawDemand.consumers, 1)
check("S11 automatisk vand = intet behov", ADSources.getFarmDemand(adEnv, WATER, 1, {}).liters, 0)
check("S11 output er ikke behov", ADSources.getFarmDemand(adEnv, FORAGE, 1, {}).liters, 0)
check("S11 udelukket forbruger", ADSources.getFarmDemand(adEnv, BEET, 1, {[tmrFactory] = true}).liters, 0)
tmrStorage.fillLevels[BEET] = 300000
check("S11 nok på lager -> 0", ADSources.getFarmDemand(adEnv, BEET, 1, {}).liters, 0)
tmrStorage.fillLevels[BEET] = 20000
tmrFactory.spec_productionPoint.productionPoint.getIsProductionEnabled = function() return false end
check("S11 slukket produktion -> 0", ADSources.getFarmDemand(adEnv, BEET, 1, {}).liters, 0)
-- S11b: staldens OUTPUT (mælk, gylle) er aldrig et behov — kun varer i foderkrybben er foder
local MILK_OUT = 13
local feedBarn = {
rootNode = {x = 960, z = 960}, ownerFarmId = 1, getOwnerFarmId = function() return 1 end, getName = function() return "Malkestald" end,
spec_husbandryFood = {litersPerHour = 2000, fillLevels = {[FORAGE] = 1000}},
getHusbandryIsFillTypeSupported = function(self, ft) return ft == MILK_OUT or ft == FORAGE end,
getHusbandryFillLevel = function(self, ft) return ft == MILK_OUT and 200000 or ft == FORAGE and 1000 or 0 end,
getHusbandryFreeCapacity = function(self, ft) return 9999999 end,
}
table.insert(g_currentMission.placeableSystem.placeables, feedBarn)
check("S11b mælk er ikke behov", ADSources.getFarmDemand(adEnv, MILK_OUT, 1, {}).liters, 0)
check("S11b foder er behov (2000 l/t × 24 − 1000)", ADSources.getFarmDemand(adEnv, FORAGE, 1, {}).liters, 47000)
-- tom krybbe: foderet kendes fra dyretypens foderdefinition
feedBarn.spec_husbandryFood.fillLevels = {}
feedBarn.getHusbandryFillLevel = function(self, ft) return ft == MILK_OUT and 200000 or 0 end
g_currentMission.animalFoodSystem = {getAnimalFood = function() return {groups = {{fillTypes = {FORAGE}}}} end}
check("S11b tom krybbe: foder stadig behov", ADSources.getFarmDemand(adEnv, FORAGE, 1, {}).liters, 48000)
check("S11b tom krybbe: mælk stadig ikke behov", ADSources.getFarmDemand(adEnv, MILK_OUT, 1, {}).liters, 0)
g_currentMission.animalFoodSystem = nil
table.remove(g_currentMission.placeableSystem.placeables)
-- S12 (inventar): alt gården har liggende, pr. bygning, kun bygninger med indhold
factory.ownerFarmId = 1; silo.ownerFarmId = 1; objectStorage.ownerFarmId = 1
local inventory = ADSources.getFarmInventory(adEnv, 1)
local byName = {}
for _, entry in ipairs(inventory) do byName[entry.name] = entry end
check("S12 sukkerfabrik med", byName["Sugar mill"] ~= nil, true)
check("S12 fabrik-kind", byName["Sugar mill"].kind, "factory")
check("S12 fabrik sukker", byName["Sugar mill"].entries[1].level, 150000)
check("S12 fabrik sukker er output", byName["Sugar mill"].entries[1].isOutput, true)
local beetEntry
for _, e in ipairs(byName["TMR-fabrik"].entries) do if e.fillType == BEET then beetEntry = e end end
check("S12 TMR-fabrik roer er input", beetEntry.isOutput, false)
check("S12 outputs først", byName["TMR-fabrik"].entries[1].isOutput, true)
check("S12 silo", byName["Farma"].entries[1].fillType, FORAGE)
check("S12 objektlager kurve som antal", byName["Objektlager"].entries[1].count, 8)
-- 10 af 4000 pladser optaget -> kurvene har 8 + 3990 ledige pladser; liter-kapacitet = pladser x 1000 l
check("S12 objektlager pladser = egne + ledige", byName["Objektlager"].entries[1].slots, 3998)
check("S12 objektlager kapacitet i liter", byName["Objektlager"].entries[1].capacity, 3998000)
check("S12 stald halm", byName["Kostald"].entries[1].level, 2000)
check("S12 stald kapacitet = niveau + fri", byName["Kostald"].entries[1].capacity, 21375)
check("S12 tomt skur udeladt", byName["Fuldt skur"], nil)
check("S12 anden farm udeladt", #inventory, 5)
-- S13: Wait-markører er hverken kilder eller lagermål, selv om et objektlager står inden for 40 m
-- (in-game 23/9: 'cow wait 1' blev valgt som palle-lager -> Dragon holdt ved Wait med stropperne af)
wayPoints[20] = {id = 20, x = 300, z = 275}
table.insert(markers, {id = 20, markerIndex = #markers + 1, name = "cow wait 1", group = "cow wait"})
check("S13 Wait-markør er ikke en kilde", ADSources.identify(adEnv, 20, 1), nil)
local truck2 = {rootNode = {x = 300, z = 270}}
local store13 = ADSources.findPalletStore(adEnv, truck2, BASKET, {}, function() return true end, 1)
check("S13 palle-lager = objektlagerets egen markør, ikke Wait", store13.wayPointId, 3)
-- S14: stald som kilde — markør ved staldens læssestation (mælketank/gylle); varer = det stationen udleverer
local MILK = 12
DESC[MILK] = {index = MILK, name = "MILK", title = "Mælk"}
local barnLevels = {[MILK] = 4200, [STRAW] = 2000}
local milkBarn = {
rootNode = {x = 1500, z = 1500}, ownerFarmId = 1, getOwnerFarmId = function() return 1 end, getName = function() return "Kostald" end,
getHusbandryFillLevel = function(self, ft) return barnLevels[ft] or 0 end,
getHusbandryCapacity = function(self, ft) return ft == MILK and 25000 or ft == STRAW and 21375 or 0 end,
getHusbandryFreeCapacity = function(self, ft) return ft == MILK and 20800 or 0 end,
getHusbandryIsFillTypeSupported = function(self, ft) return ft == MILK or ft == STRAW end,
}
local milkStation = {owningPlaceable = milkBarn, supportedFillTypes = {[MILK] = true}}
addMarker(10, "cow 1 milk", 1500, 1490); loadAt[10] = milkStation
local source14 = ADSources.identify(adEnv, 10, 1)
check("S14 stald", source14.kind, "husbandry")
check("S14 navn", source14.name, "Kostald")
local products14 = ADSources.getProducts(adEnv, source14, 1)
check("S14 én vare (kun det tanken udleverer)", #products14, 1)
check("S14 mælk", products14[1].fillType, MILK)
check("S14 niveau", products14[1].level, 4200)
check("S14 kapacitet", products14[1].capacity, 25000)
check("S14 bulk", products14[1].isPallet, false)
check("S14 isPalletAt stald = bulk", ADSources.isPalletAt(source14, MILK), false)
check("S14 kilde-markør i listen", ADRunsController ~= nil and true or true, true)
-- S14b: mælketank-udvidelse (siloExtension) — læssestationen trækker fra stald + tank; niveauet skal
-- være stationens samlede mængde, ikke kun staldens egen beholder
milkStation.getAllFillLevels = function(self, farmId) return {[MILK] = 1275 + 136912}, 10025000 end
local products14b = ADSources.getProducts(adEnv, source14, 1)
check("S14b stald + udvidelsestank", products14b[1].level, 138187)
check("S14b tilgængeligt = samlet", products14b[1].available, 138187)
milkStation.getAllFillLevels = nil
-- S14c: mælketank (silo med udvidelses-lager) — tanken selv er tom, puljen via læssestationen har mælken
local tank = {spec_silo = {storages = {{fillLevels = {}, getCapacity = function() return 10000000 end}}}}
local tankSource = {kind = "silo", placeable = tank, wayPointId = 999, station = {getAllFillLevels = function() return {[MILK] = 500000}, 70000000 end}}
local tankProducts = ADSources.getProducts(adEnv, tankSource, 1)
check("S14c tom tank viser puljens mælk", tankProducts[1] and tankProducts[1].level, 500000)
check("S14c kapacitet = puljens", tankProducts[1] and tankProducts[1].capacity, 70000000)
-- S14d: generelt lager (separatorhal): kilde via læssestationen, varer = stationens niveauer
local DIGESTATE = 30
DESC[DIGESTATE] = {index = DIGESTATE, name = "DIGESTATE", title = "Digestat"}
local separator = {rootNode = {x = 7000, z = 0}, ownerFarmId = 1, getOwnerFarmId = function() return 1 end, getName = function() return "Separator Hall" end, spec_manureSeparator = {}}
local sepStation = {owningPlaceable = separator, supportedFillTypes = {[DIGESTATE] = true}, getAllFillLevels = function() return {[DIGESTATE] = 42000}, 200000 end}
addMarker(77, "Digestate pickup", 7000, 10); loadAt[77] = sepStation
local sepSource = ADSources.identify(adEnv, 77, 1)
check("S14d separator = lager-kilde", sepSource and sepSource.kind, "storage")
local sepProducts = sepSource and ADSources.getProducts(adEnv, sepSource, 1) or {}
check("S14d digestat fra stationen", sepProducts[1] and sepProducts[1].fillType, DIGESTATE)
check("S14d mængde", sepProducts[1] and sepProducts[1].level, 42000)
-- S14e: hønsehus — æg kommer som PALLER fra staldens egen pallespawner (spec_husbandryPallets), ingen
-- læssestation ved æggene: markøren ved spawneren er en staldkilde, æggene er pallevarer
local EGGS = 31
DESC[EGGS] = {index = EGGS, name = "EGG", title = "Æg", isPallet = true}
local coopSpawner = {spawnPlaces = {{startNode = {x = 8000, z = 0}}}}
local coop = {rootNode = {x = 8040, z = 0}, ownerFarmId = 1, getOwnerFarmId = function() return 1 end, getName = function() return "Chicken Coop" end,
getHusbandryFillLevel = function() return 0 end,
spec_husbandryPallets = {fillTypeIndexToPalletSpawner = {[EGGS] = coopSpawner}, activeFillTypes = {EGGS}, fillLevels = {[EGGS] = 0}, capacities = {[EGGS] = 5000}}}
table.insert(g_currentMission.placeableSystem.placeables, coop)
addPallet(EGGS, 1000, 8003, 2); addPallet(EGGS, 1000, 8006, 2); addPallet(EGGS, 800, 8009, 2)
addMarker(78, "Egg Pickup", 8000, 12)
local coopSource = ADSources.identify(adEnv, 78, 1)
check("S14e hønsehus-kilde via pallespawner", coopSource and coopSource.kind, "husbandry")
local coopProducts = coopSource and ADSources.getProducts(adEnv, coopSource, 1) or {}
local eggs = nil
for _, item in ipairs(coopProducts) do if item.fillType == EGGS then eggs = item end end
check("S14e æg som pallevare", eggs and eggs.isPallet, true)
check("S14e æg på paller ved spawneren", eggs and eggs.available, 2800)
check("S14e tre paller", eggs and eggs.palletCount, 3)
check("S14e isPalletAt æg", coopSource and ADSources.isPalletAt(coopSource, EGGS), true)
-- S15: forklaring af en markør til advarslen (hvad opslaget ser)
check("S15 forklaring stald", ADSources.explainMarker(adEnv, 10, 1), "læssestation ved markøren hører til 'Kostald' (stald)")
check("S15 forklaring ingen station", ADSources.explainMarker(adEnv, 4, 1), "ingen læssestation ved markøren, og ingen fabrik/objektlager inden for 40 m")
loadAt[11] = {owningPlaceable = nil}
wayPoints[11] = {id = 11, x = 2000, z = 2000}; table.insert(markers, {id = 11, markerIndex = #markers + 1, name = "løs", group = "g"})
check("S15 station uden placeable", ADSources.explainMarker(adEnv, 11, 1), "læssestation ved markøren uden bygning (owningPlaceable = nil)")
-- S16: strengt opslag fejler, løst opslag (40 m, uanset retning) finder mælketanken -> stald; en SILO
-- accepteres aldrig løst (tætte rækker -> nabosilo)
local looseAt = {}
ADSmartPickup.getLoadStationNearWayPointId = function(_, id, farmId, maxDistance) return looseAt[id] end
addMarker(12, "cow 2 milk", 1600, 1600)
looseAt[12] = milkStation
check("S16 stald via løst opslag", ADSources.identify(adEnv, 12, 1).kind, "husbandry")
addMarker(13, "silo løst", 1700, 1700)
looseAt[13] = siloStation
check("S16 silo aldrig via løst opslag", ADSources.identify(adEnv, 13, 1), nil)
check("S16 forklaring nævner den løse station", ADSources.explainMarker(adEnv, 13, 1):find("silo", 1, true) ~= nil, true)
-- S17: stald med ÉN station og TO læssepunkter (mælk i den ene ende, gylle i den anden): hver markør
-- giver kun de varer dens nærmeste læssepunkt udleverer (in-game 24/9, LargerAnimalBarns cowBarnBig)
local SLURRY = 13
DESC[SLURRY] = {index = SLURRY, name = "LIQUIDMANURE", title = "Gylle"}
barnLevels[SLURRY] = 9000
local twoEndBarn = {
rootNode = {x = 2500, z = 2500}, ownerFarmId = 1, getOwnerFarmId = function() return 1 end, getName = function() return "Stor kostald" end,
getHusbandryFillLevel = function(self, ft) return barnLevels[ft] or 0 end,
getHusbandryCapacity = function() return 25000 end,
}
local twoEndStation = {owningPlaceable = twoEndBarn, supportedFillTypes = {[MILK] = true, [SLURRY] = true},
loadTriggers = {{triggerNode = {x = 2500, z = 2470}, fillTypes = {[MILK] = true}}, {triggerNode = {x = 2500, z = 2530}, fillTypes = {[SLURRY] = true}}}}
addMarker(40, "Cow Milk", 2500, 2465); loadAt[40] = twoEndStation
addMarker(41, "Cow Slurry", 2500, 2535); loadAt[41] = twoEndStation
local milkEnd = ADSources.getProducts(adEnv, ADSources.identify(adEnv, 40, 1), 1)
check("S17 mælke-enden: én vare", #milkEnd, 1)
check("S17 mælke-enden: mælk", milkEnd[1].fillType, MILK)
local slurryEnd = ADSources.getProducts(adEnv, ADSources.identify(adEnv, 41, 1), 1)
check("S17 gylle-enden: én vare", #slurryEnd, 1)
check("S17 gylle-enden: gylle", slurryEnd[1].fillType, SLURRY)
-- læssepunkt uden fillTypes-tabel: stationens varer som før
twoEndStation.loadTriggers[1].fillTypes = nil
check("S17 uden punkt-varer: stationens varer", #ADSources.getProducts(adEnv, ADSources.identify(adEnv, 40, 1), 1), 2)
-- S18: gårdens forbrug og produktion pr. døgn (fabrikker + stalde) og bedste markedspris
tmrFactory.spec_productionPoint.productionPoint.getIsProductionEnabled = function(self, id) return id == "tmr3" end
tmrFactory.spec_productionPoint.productionPoint.productions[1].outputs = {{type = FORAGE, amount = 240}}
cowBarn.spec_husbandryWater.automaticWaterSupply = false
local rates = ADSources.getFarmRates(adEnv, 1)
-- TMR-fabrik: 45 l roer × 200/60 cyklus/min × 1440 = 216.000 l/døgn; TMR ud 240 × 200/60 × 1440 = 1.152.000
check("S18 roer forbrug", math.floor(rates[BEET].consumption + 0.5), 216000)
check("S18 TMR produktion", math.floor(rates[FORAGE].production + 0.5), 1152000)
check("S18 halm i stald 500 l/t", rates[STRAW].consumption, 12000)
check("S18 vand 100 l/t", rates[WATER].consumption, 2400)
check("S18 anden farm tæller ikke", rates[STRAW].consumption, 12000)
-- pris: bedste salgssted der tager varen; top = prisen ved 100 % af kurven
g_currentMission.storageSystem = {getUnloadingStations = function() return {sellStation, {isa = function() return true end, isSellingPoint = true, owningPlaceable = {}, acceptedFillTypes = {[SUGAR] = true}, getEffectiveFillTypePrice = function() return 2.0 end}} end}
sellStation.getEffectiveFillTypePrice = function() return 1.5 end
g_currentMission.environment = {currentPeriod = 1}
DESC[SUGAR].economy = {factors = {0.8, 1.0}}
local quote = ADSources.getMarketQuote(SUGAR)
check("S18 bedste pris", quote.price, 2.0)
check("S18 top ved 80 % af kurven", quote.top, 2.5)
check("S18 intet salgssted -> nil", ADSources.getMarketQuote(FORAGE), nil)
-- S19: staldenes produktion pr. døgn fra dyrenes output-kurver (liter pr. dyr pr. døgn efter alder)
local function curve(value) return {get = function(_, age) return age >= 12 and value or 0 end} end
g_currentMission.animalSystem = {getSubTypeByIndex = function(_, index)
if index == 1 then return {output = {milk = {fillType = MILK, curve = curve(360)}, liquidManure = curve(500)}} end
end}
g_fillTypeManager.getFillTypeIndexByName = function(_, name) for index, desc in pairs(DESC) do if desc.name == name then return index end end end
local producingBarn = {
rootNode = {x = 5000, z = 5000}, ownerFarmId = 1, getOwnerFarmId = function() return 1 end, getName = function() return "Malkestald" end,
getHusbandryFillLevel = function() return 0 end,
getClusters = function() return {
{getSubTypeIndex = function() return 1 end, getAge = function() return 24 end, getNumAnimals = function() return 10 end},
{getSubTypeIndex = function() return 1 end, getAge = function() return 6 end, getNumAnimals = function() return 5 end}, -- kalve: 0
} end,
}
table.insert(g_currentMission.placeableSystem.placeables, producingBarn)
local rates2 = ADSources.getFarmRates(adEnv, 1)
check("S19 mælk 10 køer × 360", rates2[MILK].production, 3600)
check("S19 gylle via navn", rates2[SLURRY].production, 5000)
-- S19b: andre dyretyper — høns: æg som "pallets" med fillType-indeks + gødning via navn
local EGG, MANURE_FT = 21, 22
DESC[EGG] = {index = EGG, name = "EGG", title = "Æg"}
DESC[MANURE_FT] = {index = MANURE_FT, name = "MANURE", title = "Gødning"}
local baseGetSubType = g_currentMission.animalSystem.getSubTypeByIndex
g_currentMission.animalSystem.getSubTypeByIndex = function(system, index)
if index == 2 then return {output = {pallets = {fillType = EGG, curve = curve(30)}, manure = curve(10)}} end
return baseGetSubType(system, index)
end
table.insert(g_currentMission.placeableSystem.placeables, {
rootNode = {x = 6000, z = 6000}, ownerFarmId = 1, getOwnerFarmId = function() return 1 end, getName = function() return "Hønsehus" end,
getHusbandryFillLevel = function() return 0 end,
getClusters = function() return {{subTypeIndex = 2, age = 12, numAnimals = 100}} end, -- felter i stedet for metoder
})
local rates3 = ADSources.getFarmRates(adEnv, 1)
check("S19b æg 100 høns × 30", rates3[EGG] and rates3[EGG].production, 3000)
check("S19b gødning via navn", rates3[MANURE_FT] and rates3[MANURE_FT].production, 1000)
check("S19b mælk uændret", rates3[MILK].production, 3600)
-- S20: statistik tæller kun produktioner der KØRER (ikke "mangler input"/slukket), og rater
-- ganges med spillets tidsjustering (1 / dage pr. periode)
ProductionPoint = {PROD_STATUS = {INACTIVE = 0, RUNNING = 1, MISSING_INPUTS = 2}}
local tmrProduction = tmrFactory.spec_productionPoint.productionPoint.productions[1]
tmrProduction.status = ProductionPoint.PROD_STATUS.MISSING_INPUTS
local idle = ADSources.getFarmRates(adEnv, 1)
check("S20 fabrik der mangler input tæller ikke", idle[FORAGE], nil)
tmrProduction.status = ProductionPoint.PROD_STATUS.RUNNING
g_currentMission.environment = {timeAdjustment = 0.5}
local running = ADSources.getFarmRates(adEnv, 1)
check("S20 kørende fabrik halveret ved 2 dage/periode", math.floor(running[FORAGE].production + 0.5), 576000)
check("S20 stald halveret", running[STRAW].consumption, 6000)
g_currentMission.environment, ProductionPoint = nil, nil
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
-80
View File
@@ -1,80 +0,0 @@
-- Kør: luajit tests/test_adStorageStats.lua (fra repo-roden)
-- Lagerstatistik pr. vare: fyldning, værdi nu / ved toppris, gårdens samlede beholdning, dage tilbage.
dofile("FS25_ADSmartPickup/adStorageStats.lua")
local S = ADStorageStats
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
-- ST1: fuld beregning
local st = S.itemStats({fillType = 5, level = 150000, capacity = 180000}, 412000, {consumption = 130000, production = 48000}, {price = 1.6, top = 2.0})
check("ST1 procent", st.percent, 83)
check("ST1 værdi nu", st.valueNow, 240000)
check("ST1 værdi top", st.valueTop, 300000)
check("ST1 gårdens beholdning", st.farmTotal, 412000)
check("ST1 dage på netto (412k / 82k)", math.floor(st.days * 10 + 0.5) / 10, 5.0)
check("ST1 netto pr. døgn", st.net, -82000)
-- ST2: ingen salgssted, intet forbrug, ingen kapacitet
local st2 = S.itemStats({fillType = 6, level = 5000, capacity = 0}, 5000, nil, nil)
check("ST2 ingen pris -> nil", st2.valueNow, nil)
check("ST2 intet forbrug -> dage nil", st2.days, nil)
check("ST2 kapacitet 0 -> procent nil", st2.percent, nil)
-- ST3: bygningens samlede værdi (kun varer med pris)
local total = S.buildingTotals({st, st2, S.itemStats({fillType = 7, level = 1000, capacity = 2000}, 1000, nil, {price = 2, top = 3})})
check("ST3 samlet nu", total.valueNow, 242000)
check("ST3 samlet top", total.valueTop, 303000)
-- ST4: gårdens totaler fra inventar
local totals = S.farmTotals({{entries = {{fillType = 5, level = 100}, {fillType = 6, level = 5}}}, {entries = {{fillType = 5, level = 50}}}})
check("ST4 sum pr. vare", totals[5], 150)
check("ST4 anden vare", totals[6], 5)
-- ST5: foder-forbrug i et fælles trug fordeles efter truget indhold; tomt trug -> bedste foder
local split = S.splitTroughConsumption(2400, {[116] = 3000, [7] = 1000}, {[116] = 1.0, [7] = 0.8})
check("ST5 TMR 75 %", split[116], 1800)
check("ST5 ensilage 25 %", split[7], 600)
local empty = S.splitTroughConsumption(2400, {}, {[116] = 1.0, [7] = 0.8})
check("ST5 tomt trug -> bedste foder", empty[116], 2400)
check("ST5 tomt trug -> intet på ensilage", empty[7], nil)
-- ST6: gitter — varer i rækker á n celler, tom liste giver én tom række
local rows = S.chunk({"a", "b", "c", "d"}, 3)
check("ST6 to rækker", #rows, 2)
check("ST6 første række 3", #rows[1], 3)
check("ST6 anden række 1", rows[2][1], "d")
check("ST6 tom liste -> én tom række", #S.chunk({}, 3), 1)
-- ST7: rate-kolonne: forbrug med minus, produktion med plus, intet -> streg
local fmt = function(v) return string.format("%.1fk", v / 1000) end
check("ST7 begge", S.rateText(14400, 3600, fmt), "-14.4k +3.6k")
check("ST7 kun prod", S.rateText(0, 3600, fmt), "+3.6k")
check("ST7 kun forbrug", S.rateText(2000, 0, fmt), "-2.0k")
check("ST7 intet", S.rateText(0, 0, fmt), "-")
-- ST8: fane pr. bygningstype — filtrér inventar på kind, rækkefølgen bevares
local inv = {{kind = "silo", name = "A"}, {kind = "factory", name = "B"}, {kind = "silo", name = "C"}}
local silos = S.filterKind(inv, "silo")
check("ST8 to siloer", #silos, 2)
check("ST8 rækkefølge", silos[2].name, "C")
check("ST8 ingen stalde", #S.filterKind(inv, "husbandry"), 0)
-- ST9: produktion ≥ forbrug -> beholdningen stiger, ingen dage
local rising = S.itemStats({fillType = 8, level = 1000, capacity = 2000}, 6700000, {consumption = 11500000, production = 19200000}, nil)
check("ST9 stiger -> dage nil", rising.days, nil)
check("ST9 stiger-flag", rising.rising, true)
check("ST1 falder -> ikke stiger", st.rising, false)
check("ST2 intet forbrug -> ikke stiger", st2.rising, false)
-- ST3: palleskur — procent af PLADSER (24 paller af 4000), ikke liter mod pladsantal (600 %)
local egg = S.itemStats({fillType = 8, level = 24000, capacity = 4000000, count = 24, slots = 4000}, 24000, nil, nil)
check("ST3 paller med", egg.count, 24)
check("ST3 pladser med", egg.slots, 4000)
check("ST3 procent af pladser", egg.percent, 1)
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
-248
View File
@@ -1,248 +0,0 @@
-- Kør: luajit tests/test_adSupplyPlanner.lua (fra repo-roden)
-- Tester de rene behovs-funktioner; ingen spil- eller AutoDrive-mocks nødvendige.
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)
-- Kildestrategi: lager-siloer tømmes mindst-først; tanke der løber fulde (mælk, gylle) fyldest-først
check("mælk hentes fyldest-først", ADSupplyPlanner.prefersFullestSource({"MILK"}), true)
check("gylle hentes fyldest-først", ADSupplyPlanner.prefersFullestSource({"LIQUIDMANURE"}), true)
check("roer hentes mindst-først", ADSupplyPlanner.prefersFullestSource({"SUGARBEET"}), false)
check("blandet valg -> mindst-først", ADSupplyPlanner.prefersFullestSource({"MILK", "WHEAT"}), false)
check("ukendt/tomt valg -> mindst-først", ADSupplyPlanner.prefersFullestSource({}), false)
check("mindst-først: lavere niveau er bedre", ADSupplyPlanner.isBetterSource(100, 500, false), true)
check("mindst-først: højere niveau er ikke bedre", ADSupplyPlanner.isBetterSource(900, 500, false), false)
check("fyldest-først: højere niveau er bedre", ADSupplyPlanner.isBetterSource(900, 500, true), true)
check("første kandidat er altid bedre end ingen", ADSupplyPlanner.isBetterSource(900, nil, false), true)
-- Fabriksstyret forsyning: hvilke produktioner "ønsker" input?
local MISSING_INPUTS, INACTIVE = 2, 0
check("tændt produktion ønskes", ADSupplyPlanner.isProductionWanted(true, INACTIVE, MISSING_INPUTS), true)
check("auto-slukket pga. mangel ønskes stadig", ADSupplyPlanner.isProductionWanted(false, MISSING_INPUTS, MISSING_INPUTS), true)
check("manuelt slukket ønskes ikke", ADSupplyPlanner.isProductionWanted(false, INACTIVE, MISSING_INPUTS), false)
check("ukendt status uden tænd -> ikke ønsket", ADSupplyPlanner.isProductionWanted(false, nil, MISSING_INPUTS), false)
-- collectProductionDemand: liter/min pr. vare fra ønskede produktioner, kun det vognen kan bære
local STRAW, MINERAL, WATER = 23, 24, 25
local productions = {
{id = "tmr", cyclesPerMinute = 2, inputs = {{type = SILAGE, amount = 100}, {type = STRAW, amount = 10}, {type = MINERAL, amount = 1}}},
{id = "mix", cyclesPerMinute = 1, inputs = {{type = SILAGE, amount = 50}, {type = WATER, amount = 500}}},
{id = "off", cyclesPerMinute = 5, inputs = {{type = HAY, amount = 100}}},
}
local wanted = {tmr = true, mix = true}
local canCarry = {[SILAGE] = true, [STRAW] = true, [MINERAL] = true, [HAY] = true}
local demand = ADSupplyPlanner.collectProductionDemand(productions, function(p) return wanted[p.id] == true end, canCarry)
check("forbrug summeres på tværs af produktioner", demand[SILAGE], 250)
check("forbrug ganges med cyklusser/min", demand[STRAW], 20)
check("uønsket produktion tæller ikke", demand[HAY], nil)
check("vare vognen ikke kan bære udelades", demand[WATER], nil)
check("tom produktionsliste -> tomt behov", next(ADSupplyPlanner.collectProductionDemand({}, function() return true end, canCarry)), nil)
check("produktion uden inputs tåles", next(ADSupplyPlanner.collectProductionDemand({{id = "x", cyclesPerMinute = 1}}, function() return true end, canCarry)), nil)
-- minutesToEmpty
check("minutter til tom", ADSupplyPlanner.minutesToEmpty(1000, 250), 4)
check("intet forbrug -> nil", ADSupplyPlanner.minutesToEmpty(1000, 0), nil)
check("ukendt forbrug -> nil", ADSupplyPlanner.minutesToEmpty(1000, nil), nil)
-- rankNeeds med forbrug: den der løber tør først vinder, selv om den er relativt fyldest
local timed = ADSupplyPlanner.rankNeeds({
{fillType = MINERAL, level = 500, capacity = 10000, litersPerMinute = 2}, -- 250 min, 95 % tom
{fillType = SILAGE, level = 50000, capacity = 100000, litersPerMinute = 250}, -- 200 min, 50 % tom
{fillType = STRAW, level = 90000, capacity = 100000, litersPerMinute = 20}, -- 4500 min
})
check("kortest tid til tom først", timed[1].fillType, SILAGE)
check("dernæst næstkortest", timed[2].fillType, MINERAL)
check("minutesToEmpty følger med", timed[1].minutesToEmpty, 200)
check("vare med forbrug slår vare uden", ADSupplyPlanner.rankNeeds({
{fillType = HAY, level = 0, capacity = 100000},
{fillType = STRAW, level = 90000, capacity = 100000, litersPerMinute = 20},
})[1].fillType, STRAW)
check("fuldt mål springes over trods forbrug", #ADSupplyPlanner.rankNeeds({
{fillType = STRAW, level = 99500, capacity = 100000, litersPerMinute = 20},
}), 0)
-- isProductionInput: en fabriks input må aldrig stjæles som kilde — medmindre
-- fabrikken også selv producerer varen (så er den et output, der må hentes)
local TMR = 26
local factory = {
{id = "tmr", inputs = {{type = SILAGE, amount = 100}, {type = STRAW, amount = 10}}, outputs = {{type = TMR, amount = 50}}},
{id = "pellets", inputs = {{type = TMR, amount = 10}}, outputs = {{type = HAY, amount = 5}}},
}
check("input til fabrikken er beskyttet", ADSupplyPlanner.isProductionInput(factory, STRAW), true)
check("output må hentes", ADSupplyPlanner.isProductionInput(factory, HAY), false)
check("både input og output (kæde) -> må hentes", ADSupplyPlanner.isProductionInput(factory, TMR), false)
check("vare fabrikken ikke kender -> ikke beskyttet", ADSupplyPlanner.isProductionInput(factory, WATER), false)
check("ingen produktioner -> ikke beskyttet", ADSupplyPlanner.isProductionInput({}, STRAW), false)
check("produktion uden inputs/outputs tåles", ADSupplyPlanner.isProductionInput({{id = "x"}}, STRAW), false)
-- rankNeeds med læs-størrelse: varer hvor et FULDT læs passer går forud, ellers mest trængende
local sized = ADSupplyPlanner.rankNeeds({
{fillType = STRAW, level = 40000, capacity = 50000, litersPerMinute = 500}, -- 80 min, kun 10000 fri
{fillType = TMR, level = 20000, capacity = 100000, litersPerMinute = 50}, -- 400 min, 80000 fri
}, 40000)
check("fuldt læs der passer vinder over mere trængende der ikke passer", sized[1].fillType, TMR)
check("den der ikke passer bevares som reserve", sized[2].fillType, STRAW)
check("fits markeres", sized[1].fits, true)
check("ikke-fits markeres", sized[2].fits, false)
local noneFit = ADSupplyPlanner.rankNeeds({
{fillType = STRAW, level = 40000, capacity = 50000, litersPerMinute = 500},
{fillType = TMR, level = 90000, capacity = 100000, litersPerMinute = 50},
}, 40000)
check("passer intet -> mest trængende først", noneFit[1].fillType, STRAW)
check("uden læs-størrelse -> som før", ADSupplyPlanner.rankNeeds({{fillType = STRAW, level = 40000, capacity = 50000}})[1].fits, nil)
-- Wait-punkter: MAPPEN har ordet "Wait" (uanset store/små); markørnavnet er ligegyldigt.
-- (Brugeren har mark-ventepladser som "F91 Wait" i mark-mapperne — de må ikke ryge i puljen.)
check("mappen 'Cow Wait' tæller", ADSupplyPlanner.isWaitMarker({name = "Cow Wait", group = "Cow Wait"}), true)
check("mappen 'Wait' tæller", ADSupplyPlanner.isWaitMarker({name = "P2", group = "Wait"}), true)
check("mappen 'wait stalde' tæller", ADSupplyPlanner.isWaitMarker({name = "3", group = "wait stalde"}), true)
check("mark-venteplads i mark-mappe tæller IKKE", ADSupplyPlanner.isWaitMarker({name = "F91 Wait", group = "Græs Marker"}), false)
check("mappen 'Waiter' tæller ikke", ADSupplyPlanner.isWaitMarker({name = "x", group = "Waiter"}), false)
check("uden mappe -> nej", ADSupplyPlanner.isWaitMarker({name = "Wait 1"}), false)
-- nærmeste ledige wait-punkt; candidates = {{marker, x, z}}, claims = {[markerId] = vehicle}
local V1, V2 = {}, {}
local waitPoints = {
{marker = {id = 1, name = "Wait A"}, x = 0, z = 0},
{marker = {id = 2, name = "Wait B"}, x = 100, z = 0},
{marker = {id = 3, name = "Wait C"}, x = 300, z = 0},
}
check("nærmeste ledige vælges", ADSupplyPlanner.pickNearestFreeWaitPoint(waitPoints, {}, 10, 0, V1).marker.id, 1)
check("reserveret springes over", ADSupplyPlanner.pickNearestFreeWaitPoint(waitPoints, {[1] = V2}, 10, 0, V1).marker.id, 2)
check("egen reservation tæller som ledig", ADSupplyPlanner.pickNearestFreeWaitPoint(waitPoints, {[1] = V1}, 10, 0, V1).marker.id, 1)
check("alle optaget -> nil", ADSupplyPlanner.pickNearestFreeWaitPoint(waitPoints, {[1] = V2, [2] = V2, [3] = V2}, 10, 0, V1), nil)
check("ingen punkter -> nil", ADSupplyPlanner.pickNearestFreeWaitPoint({}, {}, 0, 0, V1), nil)
-- fysisk optaget: et køretøj holder på punktet uden reservation (fx efter omplanlægning) -> spring over
local occ = ADSupplyPlanner.markOccupiedWaitPoints(waitPoints, {{x = waitPoints[1].x + 2, z = waitPoints[1].z}}, 6)
check("optaget punkt markeres", occ[waitPoints[1].marker.id], true)
check("optaget springes over", ADSupplyPlanner.pickNearestFreeWaitPoint(waitPoints, {}, 10, 0, V1, nil, occ).marker.id, 2)
check("egen reservation vinder over optaget (det er en selv)", ADSupplyPlanner.pickNearestFreeWaitPoint(waitPoints, {[1] = V1}, 10, 0, V1, nil, occ).marker.id, 1)
check("køretøj langt væk optager intet", next(ADSupplyPlanner.markOccupiedWaitPoints(waitPoints, {{x = 9999, z = 9999}}, 6)), nil)
-- ét køretøj mellem to punkter optager kun det NÆRMESTE
local between = ADSupplyPlanner.markOccupiedWaitPoints({{marker = {id = 7}, x = 0, z = 0}, {marker = {id = 8}, x = 6, z = 0}}, {{x = 2, z = 0}}, 6)
check("kun nærmeste punkt optages", (between[7] == true and between[8] == nil), true)
-- nåelighed: et punkt AD ikke kan finde vej til/fra springes over (ellers slukker AD sig selv)
local unreachableA = function(candidate) return candidate.marker.id ~= 1 end
check("unåeligt nærmeste springes over", ADSupplyPlanner.pickNearestFreeWaitPoint(waitPoints, {}, 10, 0, V1, unreachableA).marker.id, 2)
check("intet nåeligt -> nil", ADSupplyPlanner.pickNearestFreeWaitPoint(waitPoints, {}, 10, 0, V1, function() return false end), nil)
-- Hjem fra Wait: fri plads >= min(rest, halvdelen af målets kapacitet)
-- (en 700.000 l-vogn kan aldrig tømme HELE resten i en 300.000 l-stald)
check("hele resten passer -> hjem", ADSupplyPlanner.canUnloadRest(35000, 35000, 300000), true)
check("resten passer ikke endnu -> vent", ADSupplyPlanner.canUnloadRest(5000, 20000, 50000), false)
check("kæmpe rest: halv stald fri -> hjem", ADSupplyPlanner.canUnloadRest(150000, 700000, 300000), true)
check("kæmpe rest: under halv stald fri -> vent", ADSupplyPlanner.canUnloadRest(100000, 700000, 300000), false)
check("ukendt kapacitet -> kun hele resten tæller", ADSupplyPlanner.canUnloadRest(100000, 700000, nil), false)
-- canUnloadRest med run-tærskel: "under 30 %" = fri >= 70 % af kapaciteten
check("tærskel 30 %: 60 % fri er ikke nok", ADSupplyPlanner.canUnloadRest(60000, 90000, 100000, 0.7), false)
check("tærskel 30 %: 70 % fri er nok", ADSupplyPlanner.canUnloadRest(70000, 90000, 100000, 0.7), true)
check("hele resten passer vinder altid", ADSupplyPlanner.canUnloadRest(5000, 5000, 100000, 0.7), true)
check("uden tærskel = 50 %-reglen", ADSupplyPlanner.canUnloadRest(50000, 90000, 100000), true)
-- rankNeeds: fælles trug (scoreOverride) + fodervægt (priority): TMR før hø før græs,
-- men et separat halmlager der er tommere vinder over truget
local TMR, HAYX = 40, 41
local trough = ADSupplyPlanner.rankNeeds({
{fillType = GRASS, level = 0, capacity = 30000, scoreOverride = 0.3, priority = 0.4},
{fillType = TMR, level = 0, capacity = 30000, scoreOverride = 0.3, priority = 1.0},
{fillType = HAYX, level = 0, capacity = 30000, scoreOverride = 0.3, priority = 0.8},
{fillType = HAY, level = 20000, capacity = 100000}, -- halm-agtigt separat lager, 80 % tomt
})
check("separat lager 80 % tomt vinder over trug 30 % tomt", trough[1].fillType, HAY)
check("i truget: TMR (1.0) først", trough[2].fillType, TMR)
check("så hø (0.8)", trough[3].fillType, HAYX)
check("så græs (0.4)", trough[4].fillType, GRASS)
check("scoreOverride bruges som score", trough[2].score, 0.3)
-- Bedste foder først: lavere foder (ensilage 0,8) må kun hentes når truget er nødlidende
-- (fri ≥ EMERGENCY_TROUGH_FREE af kapaciteten), hvis der er bedre foder (TMR 1,0) i samme trug
local tmrNeed = {fillType = 116, priority = 1.0, score = 0.6, trough = true}
local silageNeed = {fillType = 7, priority = 0.8, score = 0.6, trough = true}
local strawNeed = {fillType = 31, priority = 0, score = 0.9}
check("NF1 bedste foder tillades altid", ADSupplyPlanner.isFoodAllowed(tmrNeed, 1.0), true)
check("NF1 ensilage afvist ved halvfuldt trug", ADSupplyPlanner.isFoodAllowed(silageNeed, 1.0), false)
silageNeed.score = 0.8
check("NF1 ensilage tilladt når truget er næsten tomt", ADSupplyPlanner.isFoodAllowed(silageNeed, 1.0), true)
check("NF1 ikke-foder (halm) påvirkes ikke", ADSupplyPlanner.isFoodAllowed(strawNeed, 1.0), true)
check("NF1 intet bedre foder på listen -> tilladt", ADSupplyPlanner.isFoodAllowed({fillType = 7, priority = 0.8, score = 0.1, trough = true}, 0.8), true)
check("NF1 bedste foderprioritet", ADSupplyPlanner.bestFoodPriority({tmrNeed, silageNeed, strawNeed}), 1.0)
-- Læs på vej (andre traktorer): lægges oven i målets niveau; foder deler trug -> hele trugets fri plads falder
local pendingNeeds = {
{fillType = 31, level = 5000, capacity = 20000}, -- halm
{fillType = 116, level = 0, capacity = 30000, scoreOverride = 1.0, troughCapacity = 30000}, -- TMR, tomt trug
{fillType = 7, level = 0, capacity = 30000, scoreOverride = 1.0, troughCapacity = 30000}, -- ensilage
}
local adjusted = ADSupplyPlanner.applyPending(pendingNeeds, {[116] = 20000, [31] = 20000})
check("AP halm: niveau + på vej, højst kapacitet", adjusted[1].level, 20000)
check("AP TMR: niveau + på vej", adjusted[2].level, 20000)
check("AP TMR: kapacitet = niveau + resterende fri", adjusted[2].capacity, 30000)
check("AP ensilage: truget har kun 10000 fri tilbage", adjusted[3].capacity - adjusted[3].level, 10000)
check("AP trug-score falder", math.floor(adjusted[3].scoreOverride * 100 + 0.5), 33)
check("AP input urørt", pendingNeeds[2].level, 0)
check("AP intet på vej -> samme værdier", ADSupplyPlanner.applyPending(pendingNeeds, {})[1].level, 5000)
local ranked = ADSupplyPlanner.rankNeeds(adjusted, 8000)
check("AP halm fuld (på vej) -> kun trug-varer tilbage", #ranked, 2)
-- PF: parallelt foder (høns: korn 80 % / roesnitter 20 % af det de spiser) — hver gruppe har sin andel af truget
do
local WHEAT, BARLEY, BEETCUT = 1, 2, 3
local groups = {{fillTypes = {WHEAT, BARLEY}, eatWeight = 0.8}, {fillTypes = {BEETCUT}, eatWeight = 0.2}}
local levels = {[WHEAT] = 60000, [BARLEY] = 20000, [BEETCUT] = 0}
local lvl, cap = ADSupplyPlanner.parallelFoodShare(groups, levels, 100000, BEETCUT)
check("PF roesnitter: andel 20k, har 0", lvl .. "/" .. cap, "0/20000")
lvl, cap = ADSupplyPlanner.parallelFoodShare(groups, levels, 100000, BARLEY)
check("PF korn-gruppen samlet: 80k af 80k", lvl .. "/" .. cap, "80000/80000")
check("PF ukendt vare -> nil", ADSupplyPlanner.parallelFoodShare(groups, levels, 100000, 99), nil)
end
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
-111
View File
@@ -1,111 +0,0 @@
-- Kør: luajit tests/test_adWaitPool.lua (fra repo-roden)
-- Wait-puljen: kandidater pr. mappe, reservationer, nærmeste ledige, rutetjek, redirect.
dofile("FS25_ADSmartPickup/adSupplyPlanner.lua")
dofile("FS25_ADSmartPickup/adWaitPool.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, incoming = {}}
local marker = {id = id, markerIndex = #markers + 1, name = name, group = group}
table.insert(markers, marker)
return marker
end
local unreachable = {}
local adEnv = {
ADGraphManager = {
getMapMarkers = function() return markers end,
getMapMarkerByWayPointId = function(_, id) for _, m in ipairs(markers) do if m.id == id then return m end end end,
getWayPointById = function(_, id) return wayPoints[id] end,
getPathTo = function(_, vehicle, id) if unreachable[id] then return {} end return {{id = id}} end,
pathFromTo = function(_, from, to) if unreachable[to] then return {} end return {{id = to}} end,
},
}
getWorldTranslation = function(node) return node.x, 0, node.z end
addMarker(10, "cow wait 1", "cow wait", 0, 0)
addMarker(11, "cow wait 2", "cow wait", 50, 0)
addMarker(20, "Wait 3", "General Wait", 10, 0)
addMarker(30, "silo 1", "silo", 5, 0)
-- W1: kandidater
check("W1 kun mappen", #ADWaitPool.getCandidates(adEnv, "cow wait"), 2)
check("W1 tom pulje = alle Wait-mapper", #ADWaitPool.getCandidates(adEnv, ""), 3)
check("W1 nil = alle", #ADWaitPool.getCandidates(adEnv, nil), 3)
-- W2: nærmeste ledige fra vognens position; reserverede springes over; egen reservation OK
local v1, v2 = {name = "v1", rootNode = {x = 45, z = 0}}, {name = "v2", rootNode = {x = 45, z = 0}}
check("W2 nærmeste", ADWaitPool.pickFree(adEnv, v1, "cow wait").marker.id, 11)
ADWaitPool.claim(11, v1)
check("W2 reserveret springes over", ADWaitPool.pickFree(adEnv, v2, "cow wait").marker.id, 10)
check("W2 egen reservation genbruges", ADWaitPool.pickFree(adEnv, v1, "cow wait").marker.id, 11)
-- W3: claim / owner / release
check("W3 ejer", ADWaitPool.getOwner(11), v1)
ADWaitPool.releaseVehicle(v1)
check("W3 frigivet", ADWaitPool.getOwner(11), nil)
check("W3 nu ledig for v2", ADWaitPool.pickFree(adEnv, v2, "cow wait").marker.id, 11)
-- W4: nåelighed
unreachable[11] = true
check("W4 uopnåelig nærmeste -> næste", ADWaitPool.pickFree(adEnv, v2, "cow wait", function(c) return ADWaitPool.hasRouteTo(adEnv, v2, c.marker.id) end).marker.id, 10)
check("W4 hasRouteTo false", ADWaitPool.hasRouteTo(adEnv, v2, 11), false)
check("W4 hasRouteBothWays true", ADWaitPool.hasRouteBothWays(adEnv, v2, 10, 30), true)
check("W4 hasRouteBothWays hjem uopnåelig", ADWaitPool.hasRouteBothWays(adEnv, v2, 10, 11), false)
unreachable[11] = nil
check("W4 intet punkt -> nil", ADWaitPool.pickFree(adEnv, v2, "cow wait", function() return false end), nil)
-- W5: getClaims sorteret på mappe, dernæst id
ADWaitPool.claim(20, v2)
ADWaitPool.claim(10, v1)
local claims = ADWaitPool.getClaims(adEnv)
check("W5 to reservationer", #claims, 2)
check("W5 første = General Wait", claims[1].marker.group, "General Wait")
check("W5 anden = cow wait 1", claims[2].marker.id, 10)
ADWaitPool.releaseVehicle(v1)
ADWaitPool.releaseVehicle(v2)
check("W5 alt frigivet", #ADWaitPool.getClaims(adEnv), 0)
-- W6: redirect stopper trigger-aktivitet og sætter sti
local stopped = {}
local task = {
destinationID = 99, state = 1,
vehicle = {ad = {
trailerModule = {isActiveAtTrigger = function() return true end, stopUnloading = function() stopped.unload = true end, stopLoading = function() stopped.load = true end},
drivePathModule = {setPathTo = function(_, id) stopped.pathTo = id end},
}},
}
ADWaitPool.STATE_DRIVING = 2
ADWaitPool.redirect(task, 10)
check("W6 destination", task.destinationID, 10)
check("W6 state driving", task.state, 2)
check("W6 setPathTo", stopped.pathTo, 10)
check("W6 stopUnloading", stopped.unload, true)
-- WR: AD's rutesøgning siger nej, men vejnettet har en vej fra traktorens nærmeste waypoint -> godtages
-- (in-game 24/9: 'Æg storage' ved Chicken Wait 1, "ingen vej til 'Chicken Storage Unload'" trods vej)
do
wayPoints[901] = {id = 901, x = 0, z = 0, out = {902}}
wayPoints[902] = {id = 902, x = 5, z = 0, out = {903}}
wayPoints[903] = {id = 903, x = 10, z = 0, out = {}}
wayPoints[904] = {id = 904, x = 20, z = 0, out = {}} -- ingen indgang
unreachable[903], unreachable[904] = true, true
local vehicle = {getClosestWayPoint = function() return 901, 1 end}
check("WR AD nej, vejnettet ja -> nåelig", ADWaitPool.hasRouteTo(adEnv, vehicle, 903), true)
check("WR hverken AD eller vejnet -> ikke nåelig", ADWaitPool.hasRouteTo(adEnv, vehicle, 904), false)
check("WR uden nærmeste waypoint -> AD's svar", ADWaitPool.hasRouteTo(adEnv, {}, 903), false)
unreachable[903], unreachable[904] = nil, nil
end
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)