diff --git a/FS25_ADSmartPickup/adMonthView.lua b/FS25_ADSmartPickup/adMonthView.lua
new file mode 100644
index 0000000..21ac50c
--- /dev/null
+++ b/FS25_ADSmartPickup/adMonthView.lua
@@ -0,0 +1,141 @@
+-- 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: Courseplays egen (gennemsnit af sættene) vinder; høst/slåning ellers ud fra hvor meget
+-- af afgrøden der er væk; ellers ukendt (nil).
+function ADMonthView.progress(field, info, cpProgress)
+ if type(cpProgress) == "number" then return math.max(0, math.min(1, cpProgress)) end
+ if (field.stage == "harvesting" or field.stage == "mowing") 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
diff --git a/FS25_ADSmartPickup/adSmartPickup.lua b/FS25_ADSmartPickup/adSmartPickup.lua
index bec0b33..0ad5b78 100644
--- a/FS25_ADSmartPickup/adSmartPickup.lua
+++ b/FS25_ADSmartPickup/adSmartPickup.lua
@@ -25,6 +25,7 @@ if ADSmartPickup.dir ~= nil and source ~= nil then
source(ADSmartPickup.dir .. "gui/SmartPickupStorageTab.lua")
source(ADSmartPickup.dir .. "gui/SmartPickupFlowTab.lua")
source(ADSmartPickup.dir .. "gui/SmartPickupFieldTab.lua")
+ source(ADSmartPickup.dir .. "gui/SmartPickupMonthTab.lua")
source(ADSmartPickup.dir .. "gui/SmartPickupGuide.lua")
source(ADSmartPickup.dir .. "gui/SmartPickupFieldGuide.lua")
source(ADSmartPickup.dir .. "gui/SmartPickupFlowGuide.lua")
diff --git a/FS25_ADSmartPickup/gui/SmartPickupFrame.lua b/FS25_ADSmartPickup/gui/SmartPickupFrame.lua
index bae73f4..cc62ba5 100644
--- a/FS25_ADSmartPickup/gui/SmartPickupFrame.lua
+++ b/FS25_ADSmartPickup/gui/SmartPickupFrame.lua
@@ -11,10 +11,12 @@ 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 = 1, 2, 3, 4
+-- Måned-fanen genopbygges hvert N. sekund (ingen klikbare rækker, så fokus går ikke tabt)
+SmartPickupFrame.MONTH_REBUILD_TICKS = 5
-- 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_factories", "spu_tab_silos", "spu_tab_barns", "spu_tab_sheds"}
+SmartPickupFrame.TAB_KINDS = {[5] = "factory", [6] = "silo", [7] = "husbandry", [8] = "objectStorage"}
function SmartPickupFrame.getTabKind(tab)
return SmartPickupFrame.TAB_KINDS[tab]
@@ -49,6 +51,7 @@ 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}
self.flowButtons = {self.backButtonInfo, self.startButtonInfo, self.newButtonInfo, self.renameButtonInfo, self.deleteButtonInfo}
self.overviewButtons = {self.backButtonInfo, self.startAllButtonInfo, self.stopAllButtonInfo, self.newButtonInfo}
-- Marker: Start nu / Stop for den valgte mark
@@ -409,6 +412,17 @@ function SmartPickupFrame:rebuild()
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)
@@ -540,6 +554,8 @@ function SmartPickupFrame:refreshButtons()
wanted = self.overviewButtons
elseif self.currentTab == SmartPickupFrame.TAB_FIELDS then
wanted = self:getFieldButtons()
+ elseif self.currentTab == SmartPickupFrame.TAB_MONTH then
+ wanted = self.monthButtons
end
if self.menuButtonInfo ~= wanted then
self.menuButtonInfo = wanted
@@ -560,6 +576,12 @@ function SmartPickupFrame:refreshLive()
self:setStatusText(self.i18n:getText("spu_status_noAd"))
return
end
+ if self.currentTab == SmartPickupFrame.TAB_MONTH 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)
diff --git a/FS25_ADSmartPickup/gui/SmartPickupMonthTab.lua b/FS25_ADSmartPickup/gui/SmartPickupMonthTab.lua
new file mode 100644
index 0000000..b5c8384
--- /dev/null
+++ b/FS25_ADSmartPickup/gui/SmartPickupMonthTab.lua
@@ -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.fieldPrefab, {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.fieldPrefab, 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
diff --git a/FS25_ADSmartPickup/l10n/l10n_da.xml b/FS25_ADSmartPickup/l10n/l10n_da.xml
index 57a282b..0c98df5 100644
--- a/FS25_ADSmartPickup/l10n/l10n_da.xml
+++ b/FS25_ADSmartPickup/l10n/l10n_da.xml
@@ -40,6 +40,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FS25_ADSmartPickup/l10n/l10n_en.xml b/FS25_ADSmartPickup/l10n/l10n_en.xml
index f232a4f..c482904 100644
--- a/FS25_ADSmartPickup/l10n/l10n_en.xml
+++ b/FS25_ADSmartPickup/l10n/l10n_en.xml
@@ -40,6 +40,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FS25_ADSmartPickup/modDesc.xml b/FS25_ADSmartPickup/modDesc.xml
index 8ec4501..37ceb6a 100644
--- a/FS25_ADSmartPickup/modDesc.xml
+++ b/FS25_ADSmartPickup/modDesc.xml
@@ -1,7 +1,7 @@
masterdraco
- 1.34.2.0
+ 1.35.0.0
AD Smart Pickup
@@ -39,6 +39,7 @@
+
diff --git a/tests/test_adMonthView.lua b/tests/test_adMonthView.lua
new file mode 100644
index 0000000..e28249a
--- /dev/null
+++ b/tests/test_adMonthView.lua
@@ -0,0 +1,82 @@
+-- Kør: luajit tests/test_adMonthView.lua (fra repo-roden)
+dofile("FS25_ADSmartPickup/adFieldModel.lua")
+dofile("FS25_ADSmartPickup/adMonthView.lua")
+local V, M = ADMonthView, 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
+
+-- MV1: perioder
+check("MV1 næste periode", V.nextPeriod(5), 6)
+check("MV1 vinter -> forår", V.nextPeriod(12), 1)
+
+-- MV2: såvindue (hvede sås i periode 6-7)
+local function plantableIn(name, period) return name == "WHEAT" and (period == 6 or period == 7) end
+check("MV2 lukker", V.sowWindow(plantableIn, "WHEAT", 7), "closes")
+check("MV2 åbent", V.sowWindow(plantableIn, "WHEAT", 6), "open")
+check("MV2 lukket", V.sowWindow(plantableIn, "WHEAT", 8), "closed")
+
+-- MV3: visning (Dakota-kartofler: harvestReady 6 -> dead 7 i EARLY_AUTUMN = periode 7)
+local potato = {witheredState = 7, growthDataSeasonal = {periods = {[7] = {updates = {{startState = 6, endState = 7}}}}}}
+check("MV3 visner næste måned", V.withersIn(potato, 7, 6), true)
+check("MV3 ikke måneden før", V.withersIn(potato, 6, 6), false)
+check("MV3 ukendte data", V.withersIn({witheredState = 7}, 7, 6), false)
+
+-- MV4: fremdrift
+local harvest = M.with(M.newField(15), {stage = "harvesting", kind = "harvest"})
+check("MV4 CP vinder", V.progress(harvest, {readyRatio = 0.9}, 0.5), 0.5)
+check("MV4 høst ud fra afgrøde", V.progress(harvest, {readyRatio = 0.25}, nil), 0.75)
+check("MV4 kultivering ukendt", V.progress(M.with(harvest, {stage = "cultivating"}), {readyRatio = 0}, nil), nil)
+check("MV4 tekst", V.percentText(0.504), "50 %")
+check("MV4 tekst ukendt", V.percentText(nil), "–")
+
+-- MV5: hel fane
+local fields = {
+ [15] = M.with(harvest, {plan = {sow = true, sowFruit = "WHEAT", soil = "cultivate", straw = "chop"}}),
+ [16] = M.with(M.newField(16), {stage = "harvesting", kind = "harvest"}),
+ [18] = M.with(M.newField(18), {stage = "sowing", kind = "harvest", plan = {sow = true, sowFruit = "WHEAT"}}),
+ [20] = M.with(M.newField(20), {stage = "fertilizing"}),
+ [21] = M.newField(21),
+ [22] = M.with(M.newField(22), {plan = {targetFruit = "WHEAT"}}),
+ [23] = M.with(M.newField(23), {stage = "ready"}),
+}
+local infos = {
+ {id = 15, kind = "harvest", readyRatio = 0.5, cropTitle = "Hvede", needs = {}},
+ {id = 16, kind = "harvest", readyRatio = 1, cropTitle = "Kartofler", growthState = 6},
+ {id = 18, kind = "bare", readyRatio = 0},
+ {id = 20, kind = "harvest", readyRatio = 0},
+ {id = 21, kind = "bare"},
+ {id = 22, kind = "bare"},
+ {id = 23, kind = "harvest", readyRatio = 1},
+}
+local ctx = {period = 7, plantableIn = plantableIn,
+ withers = function(info, nextPeriod) return info.id == 16 and nextPeriod == 8 end,
+ cpProgress = function(id) return id == 18 and 0.1 or nil end}
+local view = V.build(infos, fields, ctx)
+check("MV5 aktive marker (21 venter, 22 kommer, 23 passiv klar)", #view.rows, 4)
+check("MV5 kritiske", view.critical, 3)
+check("MV5 høst før sidste såmåned først (id)", view.rows[1].id, 15)
+check("MV5 høst -> såning senere lukker", view.rows[1].window, "lastSowMonth")
+check("MV5 høst næste trin", view.rows[1].nextStage, "cultivating")
+check("MV5 høst der visner", view.rows[2].id, 16)
+check("MV5 vindue høst", view.rows[2].window, "harvestCloses")
+check("MV5 sidste såmåned", view.rows[3].id, 18)
+check("MV5 vindue såning", view.rows[3].window, "lastSowMonth")
+check("MV5 såafgrøde", view.rows[3].sowFruit, "WHEAT")
+check("MV5 CP-fremdrift", view.rows[3].progress, 0.1)
+check("MV5 pleje sidst", view.rows[4].id, 20)
+check("MV5 pleje intet næste", view.rows[4].nextStage, nil)
+check("MV5 kommer: F22 hvede", view.upcoming[1] and view.upcoming[1].id, 22)
+check("MV5 kommer: lukker", view.upcoming[1] and view.upcoming[1].closes, true)
+
+-- MV6: klargøring før såning med lukkende vindue er kritisk
+local cultivating = M.with(M.newField(30), {stage = "cultivating", plan = {sow = true, sowFruit = "WHEAT"}})
+check("MV6 kultivering før sidste såmåned", V.window(cultivating, {kind = "bare"}, ctx), "lastSowMonth")
+check("MV6 aktiv klar (auto)", V.isActive(M.with(M.newField(31), {stage = "ready", plan = {mode = "auto"}})), true)
+
+print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
+os.exit(failures == 0 and 0 or 1)