From 16f4db3499a8e6c08a92e7b3c21cd7e065b0b38f Mon Sep 17 00:00:00 2001 From: Benjamin Leber Date: Sun, 16 Aug 2026 20:50:56 +0200 Subject: [PATCH 1/6] =?UTF-8?q?#96=20=E2=9C=A8=20[mod]=20the=20crop=20cale?= =?UTF-8?q?ndar=20and=20the=20weather=20forecast=20as=20their=20own=20chan?= =?UTF-8?q?nels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The game's Anbaukalender screen has two halves, and they move at very different speeds: the crop table is fixed the moment a map loads, while the forecast turns over on the hour. So they ship as two channels rather than one. cropCalendar.json carries, per crop the map shows, which of the twelve periods it may be sown in and which it may be harvested in — read through the same three calls the game's own frame uses. Event-driven on DAY_CHANGED and PERIOD_LENGTH_CHANGED, since the only part that moves is the today marker. Growth mode is the exception, and it has no message to subscribe to: GrowthSystem:setGrowthMode writes missionInfo, fires its multiplayer event and logs, and publishes nothing (MessageType.SETTING_CHANGED covers the client's GameSettings, not the savegame's). The channel polls it every 2 s instead, comparing against the mode the last collect() used so a skipped write retries. It has to: outside SEASONAL the game answers "plantable" for all twelve periods, so a stale file shows an entirely wrong calendar rather than a slightly old one. weather.json carries the forecast — now, twelve two-hourly steps, six days — subscribed to HOUR_CHANGED and DAY_CHANGED, the same two the in-game screen reloads on. Three things it does not normalise, each for a reason in the header: period labels come from g_i18n because the month shifts by hemisphere; Beaufort is taken from the raw m/s because the game ceils before converting; and the wind angle is left as the engine's own, since the current reading and the forecast entries come from different sources and one compass convention would make one of them wrong. Co-Authored-By: Claude Opus 5 (1M context) --- vdTelemetry/Readme.md | 4 + vdTelemetry/VDTelemetry.lua | 5 + .../spec/CropCalendarExporter_spec.lua | 390 ++++++++++++++++++ vdTelemetry/spec/WeatherExporter_spec.lua | 286 +++++++++++++ .../src/collect/CropCalendarExporter.lua | 303 ++++++++++++++ vdTelemetry/src/collect/WeatherExporter.lua | 303 ++++++++++++++ vdTelemetry/src/mapper/ValueMapper.lua | 16 + vdTelemetry/src/model/CropCalendarModel.lua | 39 ++ vdTelemetry/src/model/WeatherModel.lua | 54 +++ 9 files changed, 1400 insertions(+) create mode 100644 vdTelemetry/spec/CropCalendarExporter_spec.lua create mode 100644 vdTelemetry/spec/WeatherExporter_spec.lua create mode 100644 vdTelemetry/src/collect/CropCalendarExporter.lua create mode 100644 vdTelemetry/src/collect/WeatherExporter.lua create mode 100644 vdTelemetry/src/model/CropCalendarModel.lua create mode 100644 vdTelemetry/src/model/WeatherModel.lua diff --git a/vdTelemetry/Readme.md b/vdTelemetry/Readme.md index 41d538e..544d089 100644 --- a/vdTelemetry/Readme.md +++ b/vdTelemetry/Readme.md @@ -53,6 +53,8 @@ data actually changes — and none of them rides the 100 ms tick. | `missions.json` | the farm's contracts (core, `src/collect/MissionExporter.lua`) | on contract change + 10 s | | `finance.json` | the farm's books: balance, loan, the monthly table, the money log (core, `src/collect/FinanceExporter.lua`) | on period/loan change + 5 s | | `fieldInfo.json` | per-field agronomy, for the field-info popup (core, `src/collect/FieldInfoExporter.lua`) | own interval (30 s) | +| `cropCalendar.json` | which periods each crop may be sown and harvested in (core, `src/collect/CropCalendarExporter.lua`) | on day / season-length change, + a 2 s growth-mode watch | +| `weather.json` | the forecast: now, twelve two-hourly steps, six days (core, `src/collect/WeatherExporter.lua`) | on hour / day change | | `taskList.json` | [FS25_TaskList](https://www.farming-simulator.com/mod.php?mod_id=312938&title=fs2025) | on task/group change | | `cropRotation.json` | [FS25_CropRotation](https://www.farming-simulator.com/mod.php?mod_id=347316&title=fs2025) | on planner change | | `invoices.json` | [FS25_Invoices](https://github.com/Squallqt/FS25_Invoices) | on invoice or player-farm change | @@ -269,6 +271,8 @@ leftover `commands.xml` on load, so stale commands never fire at session start. + + diff --git a/vdTelemetry/VDTelemetry.lua b/vdTelemetry/VDTelemetry.lua index df9f958..1fee51d 100644 --- a/vdTelemetry/VDTelemetry.lua +++ b/vdTelemetry/VDTelemetry.lua @@ -80,6 +80,11 @@ local sourceFiles = { -- Finance channel: the farm's books -- balance, loan, the month-by-month finances table and the -- money notifications as a log (interval + event-driven). "src/collect/FinanceExporter.lua", + -- Calendar channels: the sowing/harvest periods per crop (event-driven, per in-game day) and the + -- weather forecast (event-driven, per in-game hour). Both are world state rather than farm state, + -- and both read only base-game managers. + "src/collect/CropCalendarExporter.lua", + "src/collect/WeatherExporter.lua", -- Integrations (optional third-party mods) — registry depends on the integration files "src/integrations/EnhancedVehicle.lua", "src/integrations/AdvancedDamageSystem.lua", diff --git a/vdTelemetry/spec/CropCalendarExporter_spec.lua b/vdTelemetry/spec/CropCalendarExporter_spec.lua new file mode 100644 index 0000000..7d79c80 --- /dev/null +++ b/vdTelemetry/spec/CropCalendarExporter_spec.lua @@ -0,0 +1,390 @@ +-- Unit tests for the crop calendar export channel (src/collect/CropCalendarExporter.lua): the pure +-- period/season helpers plus collect() against a stubbed fruit type manager. Whether the real +-- FruitTypeDesc still looks like these stubs is what the in-game smoke test covers. +-- +-- Run with `busted` from the vdTelemetry/ directory. The exporter self-registers a channel at load, +-- so ExportChannels loads first (only if not already loaded, so we don't reset a registry another +-- spec populated). + +if VDT == nil or VDT.ExportChannels == nil then + dofile("src/export/ExportChannels.lua") +end +if VDT.CropCalendarExporter == nil then + dofile("src/collect/CropCalendarExporter.lua") +end + +-- A stubbed FruitTypeDesc. `plant`/`harvest` are the sets of periods the predicates say yes to; both +-- take (growthMode, period) exactly as the engine's do. +local function makeFruit(opts) + local plant = {} + for _, p in ipairs(opts.plant or {}) do + plant[p] = true + end + local harvest = {} + for _, p in ipairs(opts.harvest or {}) do + harvest[p] = true + end + return { + index = opts.index, + name = opts.name, + shownOnMap = opts.shownOnMap ~= false, + getIsCatchCrop = function() + return opts.catchCrop == true + end, + getIsPlantableInPeriod = function(_, growthMode, period) + if growthMode ~= 1 then + return true + end + return plant[period] == true + end, + getIsHarvestableInPeriod = function(_, growthMode, period) + if growthMode ~= 1 then + return true + end + return harvest[period] == true + end, + } +end + +-- `titles` maps a fruit index -> its fill type title, mirroring the real +-- getFillTypeByFruitTypeIndex(idx).title chain. +local function installWorld(fruits, titles, opts) + opts = opts or {} + _G.g_fruitTypeManager = { + getFruitTypes = function() + return fruits + end, + getFillTypeByFruitTypeIndex = function(_, index) + local title = titles[index] + return title and { title = title } or nil + end, + } + _G.g_currentMission = { + missionInfo = { growthMode = opts.growthMode or 1 }, + environment = { + currentPeriod = opts.period or 6, + currentDayInPeriod = opts.dayInPeriod or 2, + daysPerPeriod = opts.daysPerPeriod or 3, + currentYear = opts.year or 1, + }, + } +end + +after_each(function() + _G.g_currentMission = nil + _G.g_fruitTypeManager = nil + _G.g_i18n = nil +end) + +describe("CropCalendarExporter.seasonForPeriod", function() + it("groups the twelve periods three to a season", function() + assert.are.equal("SPRING", VDT.CropCalendarExporter.seasonForPeriod(1)) + assert.are.equal("SPRING", VDT.CropCalendarExporter.seasonForPeriod(3)) + assert.are.equal("SUMMER", VDT.CropCalendarExporter.seasonForPeriod(4)) + assert.are.equal("AUTUMN", VDT.CropCalendarExporter.seasonForPeriod(7)) + assert.are.equal("WINTER", VDT.CropCalendarExporter.seasonForPeriod(10)) + assert.are.equal("WINTER", VDT.CropCalendarExporter.seasonForPeriod(12)) + end) +end) + +describe("CropCalendarExporter.growthMode", function() + it("maps the engine ids to their names", function() + installWorld({}, {}, { growthMode = 2 }) + local mode, name = VDT.CropCalendarExporter.growthMode() + assert.are.equal(2, mode) + assert.are.equal("DAILY", name) + end) + + it("falls back to SEASONAL when missionInfo is unreadable", function() + _G.g_currentMission = nil + local mode, name = VDT.CropCalendarExporter.growthMode() + assert.are.equal(1, mode) + assert.are.equal("SEASONAL", name) + end) + + it("falls back to SEASONAL for an unknown id", function() + installWorld({}, {}, { growthMode = 99 }) + local _, name = VDT.CropCalendarExporter.growthMode() + assert.are.equal("SEASONAL", name) + end) +end) + +describe("CropCalendarExporter.collectPeriods", function() + it("uses the game's own localized labels", function() + _G.g_i18n = { + formatPeriod = function(_, period, useShort) + assert.is_true(useShort) + return "P" .. period + end, + } + local periods = VDT.CropCalendarExporter.collectPeriods() + assert.are.equal(12, #periods) + assert.are.equal(1, periods[1].period) + assert.are.equal("P1", periods[1].label) + assert.are.equal("SPRING", periods[1].season) + assert.are.equal("P12", periods[12].label) + assert.are.equal("WINTER", periods[12].season) + end) + + it("falls back to the period number when i18n cannot answer", function() + local periods = VDT.CropCalendarExporter.collectPeriods() + assert.are.equal("1", periods[1].label) + assert.are.equal("12", periods[12].label) + end) +end) + +describe("CropCalendarExporter.tick", function() + local marked + local realMarkDirty + local debugger = { + info = function() end, + } + + before_each(function() + marked = 0 + realMarkDirty = VDT.ExportChannels.markDirty + VDT.ExportChannels.markDirty = function() + marked = marked + 1 + end + VDT.CropCalendarExporter.resetWatch() + _G.MessageType = { DAY_CHANGED = 1, PERIOD_LENGTH_CHANGED = 2 } + _G.g_messageCenter = { subscribe = function() end } + end) + + after_each(function() + VDT.ExportChannels.markDirty = realMarkDirty + VDT.CropCalendarExporter.resetWatch() + _G.MessageType = nil + _G.g_messageCenter = nil + end) + + it("subscribes once and queues the first write", function() + installWorld({ makeFruit({ index = 1, name = "WHEAT" }) }, { [1] = "Wheat" }) + + VDT.CropCalendarExporter.tick(debugger, 16) + assert.are.equal(1, marked) + assert.is_true(VDT.CropCalendarExporter.subscribed) + + -- a second tick inside the poll window neither re-subscribes nor re-queues + VDT.CropCalendarExporter.tick(debugger, 16) + assert.are.equal(1, marked) + end) + + it("waits for the fruit types before subscribing", function() + installWorld({}, {}) + + VDT.CropCalendarExporter.tick(debugger, 16) + + assert.is_false(VDT.CropCalendarExporter.subscribed) + assert.are.equal(0, marked) + end) + + it("queues a rewrite when the growth mode changes", function() + -- The mode decides what every period in the file means, and the game publishes no message for it, + -- so the channel polls. Without this a switch to Daily left the wrong calendar up until midnight. + installWorld({ makeFruit({ index = 1, name = "WHEAT" }) }, { [1] = "Wheat" }, { growthMode = 1 }) + VDT.CropCalendarExporter.tick(debugger, 16) + VDT.CropCalendarExporter.collect() -- the write the subscribe queued; records the mode it used + marked = 0 + + -- still seasonal after a full poll window -> nothing queued + VDT.CropCalendarExporter.tick(debugger, VDT.CropCalendarExporter.GROWTH_MODE_POLL_MS) + assert.are.equal(0, marked) + + _G.g_currentMission.missionInfo.growthMode = 2 + VDT.CropCalendarExporter.tick(debugger, VDT.CropCalendarExporter.GROWTH_MODE_POLL_MS) + assert.are.equal(1, marked) + end) + + it("keeps queueing until a document actually goes out with the new mode", function() + -- The watch compares against what was WRITTEN, not what was last seen, so a skipped write retries. + installWorld({ makeFruit({ index = 1, name = "WHEAT" }) }, { [1] = "Wheat" }, { growthMode = 1 }) + VDT.CropCalendarExporter.tick(debugger, 16) + VDT.CropCalendarExporter.collect() + _G.g_currentMission.missionInfo.growthMode = 3 + marked = 0 + + VDT.CropCalendarExporter.tick(debugger, VDT.CropCalendarExporter.GROWTH_MODE_POLL_MS) + VDT.CropCalendarExporter.tick(debugger, VDT.CropCalendarExporter.GROWTH_MODE_POLL_MS) + assert.are.equal(2, marked) + + VDT.CropCalendarExporter.collect() + VDT.CropCalendarExporter.tick(debugger, VDT.CropCalendarExporter.GROWTH_MODE_POLL_MS) + assert.are.equal(2, marked) + end) + + it("does not poll faster than GROWTH_MODE_POLL_MS", function() + installWorld({ makeFruit({ index = 1, name = "WHEAT" }) }, { [1] = "Wheat" }, { growthMode = 1 }) + VDT.CropCalendarExporter.tick(debugger, 16) + VDT.CropCalendarExporter.collect() + _G.g_currentMission.missionInfo.growthMode = 2 + marked = 0 + + -- a frame's worth of dt is nowhere near the window + VDT.CropCalendarExporter.tick(debugger, 16) + assert.are.equal(0, marked) + end) +end) + +describe("CropCalendarExporter.isAvailable", function() + it("waits for the fruit table to be populated, not just to exist", function() + -- The channel writes once per in-game day, so an empty first write would sit on disk for a whole + -- day; being unavailable until the map's fruits load is what prevents it. + installWorld({}, {}) + assert.is_false(VDT.CropCalendarExporter.isAvailable()) + + installWorld({ makeFruit({ index = 1, name = "WHEAT" }) }, { [1] = "Wheat" }) + assert.is_true(VDT.CropCalendarExporter.isAvailable()) + end) + + it("is false before the mission exists", function() + _G.g_currentMission = nil + _G.g_fruitTypeManager = nil + assert.is_false(VDT.CropCalendarExporter.isAvailable()) + end) +end) + +describe("CropCalendarExporter.collect", function() + it("collects the shownOnMap crops with their sow and harvest periods", function() + installWorld({ + makeFruit({ index = 1, name = "WHEAT", plant = { 9, 10 }, harvest = { 4, 5 } }), + makeFruit({ index = 2, name = "OAT", plant = { 1, 2 }, harvest = { 5, 6 } }), + }, { [1] = "Wheat", [2] = "Oat" }) + + local model = VDT.CropCalendarExporter.collect() + + assert.are.equal("1", model.version) + assert.are.equal("SEASONAL", model.growthMode) + assert.are.equal(2, #model.crops) + -- sorted by display name, the way the game's own frame sorts them: Oat before Wheat + assert.are.equal("Oat", model.crops[1].name) + assert.are.equal("OAT", model.crops[1].id) + assert.are.same({ 1, 2 }, model.crops[1].plant) + assert.are.same({ 5, 6 }, model.crops[1].harvest) + assert.are.equal("Wheat", model.crops[2].name) + assert.are.same({ 9, 10 }, model.crops[2].plant) + end) + + it("keeps a wrapped sow range as two runs of periods", function() + -- the screenshot's Ackergras: sows March..October and again in February + installWorld({ + makeFruit({ index = 1, name = "MEADOW", plant = { 1, 2, 3, 4, 5, 6, 7, 8, 12 } }), + }, { [1] = "Meadow" }) + + local model = VDT.CropCalendarExporter.collect() + + assert.are.same({ 1, 2, 3, 4, 5, 6, 7, 8, 12 }, model.crops[1].plant) + end) + + it("skips fruits the map does not show", function() + installWorld({ + makeFruit({ index = 1, name = "WHEAT", plant = { 9 } }), + makeFruit({ index = 2, name = "BUSH", shownOnMap = false, plant = { 1 } }), + }, { [1] = "Wheat", [2] = "Bush" }) + + local model = VDT.CropCalendarExporter.collect() + + assert.are.equal(1, #model.crops) + assert.are.equal("Wheat", model.crops[1].name) + end) + + it("skips a fruit whose fill type has no title", function() + installWorld({ + makeFruit({ index = 1, name = "WHEAT", plant = { 9 } }), + makeFruit({ index = 2, name = "BROKEN", plant = { 1 } }), + }, { [1] = "Wheat" }) + + local model = VDT.CropCalendarExporter.collect() + + assert.are.equal(1, #model.crops) + end) + + it("omits empty period lists rather than exporting {}", function() + installWorld({ + makeFruit({ index = 1, name = "POPLAR", plant = { 3 } }), + }, { [1] = "Poplar" }) + + local model = VDT.CropCalendarExporter.collect() + + assert.are.same({ 3 }, model.crops[1].plant) + assert.is_nil(model.crops[1].harvest) + -- a non-catch crop omits the flag entirely + assert.is_nil(model.crops[1].catchCrop) + end) + + it("flags a catch crop", function() + installWorld({ + makeFruit({ index = 1, name = "COVER", catchCrop = true, plant = { 8 } }), + }, { [1] = "Cover Crop" }) + + local model = VDT.CropCalendarExporter.collect() + + assert.is_true(model.crops[1].catchCrop) + end) + + it("reports every period plantable outside seasonal growth, and says which mode it is", function() + installWorld({ + makeFruit({ index = 1, name = "WHEAT", plant = { 9 }, harvest = { 4 } }), + }, { [1] = "Wheat" }, { growthMode = 2 }) + + local model = VDT.CropCalendarExporter.collect() + + assert.are.equal("DAILY", model.growthMode) + assert.are.equal(12, #model.crops[1].plant) + assert.are.equal(12, #model.crops[1].harvest) + end) + + it("carries the today marker", function() + installWorld( + { makeFruit({ index = 1, name = "WHEAT", plant = { 9 } }) }, + { [1] = "Wheat" }, + { period = 6, dayInPeriod = 2, daysPerPeriod = 3, year = 4 } + ) + + local model = VDT.CropCalendarExporter.collect() + + assert.are.equal(6, model.today.period) + assert.are.equal(2, model.today.dayInPeriod) + assert.are.equal(3, model.today.daysPerPeriod) + assert.are.equal(4, model.today.year) + end) + + it("floors daysPerPeriod at 1 so the marker never divides by zero", function() + installWorld({ makeFruit({ index = 1, name = "WHEAT" }) }, { [1] = "Wheat" }, { daysPerPeriod = 0 }) + + local model = VDT.CropCalendarExporter.collect() + + assert.are.equal(1, model.today.daysPerPeriod) + end) + + it("omits an entirely empty crop list rather than exporting {}", function() + -- Reachable only when every fruit is filtered out (none shownOnMap), since an empty fruit table + -- makes the channel unavailable in the first place. + installWorld({ makeFruit({ index = 1, name = "BUSH", shownOnMap = false }) }, { [1] = "Bush" }) + + assert.is_nil(VDT.CropCalendarExporter.collect().crops) + end) + + it("returns nil when the fruit types aren't up yet", function() + _G.g_currentMission = nil + _G.g_fruitTypeManager = nil + + assert.is_nil(VDT.CropCalendarExporter.collect()) + end) + + it("survives a fruit whose period predicates throw", function() + -- getIsPlantableInPeriod indexes growthDataSeasonal.periods[...] with no nil check of its own, so + -- on a map without seasonal growth data it throws rather than answering false + local fruit = makeFruit({ index = 1, name = "WHEAT", harvest = { 4 } }) + fruit.getIsPlantableInPeriod = function() + error("no seasonal growth data") + end + installWorld({ fruit }, { [1] = "Wheat" }) + + local model = VDT.CropCalendarExporter.collect() + + assert.are.equal(1, #model.crops) + assert.is_nil(model.crops[1].plant) + assert.are.same({ 4 }, model.crops[1].harvest) + end) +end) diff --git a/vdTelemetry/spec/WeatherExporter_spec.lua b/vdTelemetry/spec/WeatherExporter_spec.lua new file mode 100644 index 0000000..0a1ca01 --- /dev/null +++ b/vdTelemetry/spec/WeatherExporter_spec.lua @@ -0,0 +1,286 @@ +-- Unit tests for the weather export channel (src/collect/WeatherExporter.lua): the type mapping and +-- collect() against a stubbed WeatherForecast. Whether the real forecast still looks like this stub +-- -- on a multiplayer client above all -- is what the in-game smoke test covers. +-- +-- Run with `busted` from the vdTelemetry/ directory. The exporter self-registers a channel at load +-- and uses ValueMapper for the Beaufort conversion, so both dependencies load first. + +if VDT == nil or VDT.ExportChannels == nil then + dofile("src/export/ExportChannels.lua") +end +if ValueMapper == nil then + dofile("src/mapper/ValueMapper.lua") +end +if VDT.WeatherExporter == nil then + dofile("src/collect/WeatherExporter.lua") +end + +local MS_PER_HOUR = 60 * 60 * 1000 + +-- A stubbed forecast. `hourly` is keyed by hoursFromNow (so a missing key returns nil, the engine's +-- own answer when no forecast item covers that time) and `daily` by daysFromToday. +local function installWorld(opts) + opts = opts or {} + _G.g_currentMission = { + environment = { + currentPeriod = opts.period or 6, + currentDayInPeriod = opts.dayInPeriod or 1, + -- the forecast counts in monotonic days; these two turn one back into a calendar position + getPeriodFromDay = function(_, day) + return (opts.period or 6) + math.floor(day / 3) + end, + getDayInPeriodFromDay = function(_, day) + return day % 3 + 1 + end, + weather = { + forecast = { + getCurrentWeather = function() + return opts.current + end, + getHourlyForecast = function(_, hoursFromNow) + return (opts.hourly or {})[hoursFromNow] + end, + getDailyForecast = function(_, daysFromToday) + return (opts.daily or {})[daysFromToday] + end, + }, + }, + }, + } +end + +after_each(function() + _G.g_currentMission = nil + _G.g_i18n = nil +end) + +describe("WeatherExporter.mapWeatherType", function() + it("maps the engine's WeatherType ids to their names", function() + assert.are.equal("SUN", VDT.WeatherExporter.mapWeatherType(1)) + assert.are.equal("PARTIALLY_CLOUDY", VDT.WeatherExporter.mapWeatherType(2)) + assert.are.equal("RAIN", VDT.WeatherExporter.mapWeatherType(4)) + assert.are.equal("THUNDER", VDT.WeatherExporter.mapWeatherType(8)) + end) + + it("degrades an unknown or missing id to UNKNOWN", function() + assert.are.equal("UNKNOWN", VDT.WeatherExporter.mapWeatherType(99)) + assert.are.equal("UNKNOWN", VDT.WeatherExporter.mapWeatherType(nil)) + end) +end) + +describe("ValueMapper.windSpeedToBeaufort", function() + it("matches the game's own conversion", function() + -- floor((ceil(mps) / 0.836) ^ (2/3)) -- the menu's formula, rounding the speed up first + assert.are.equal(0, ValueMapper.windSpeedToBeaufort(0)) + assert.are.equal(1, ValueMapper.windSpeedToBeaufort(0.4)) + assert.are.equal(1, ValueMapper.windSpeedToBeaufort(1)) + assert.are.equal(2, ValueMapper.windSpeedToBeaufort(4)) + assert.are.equal(5, ValueMapper.windSpeedToBeaufort(10)) + end) + + it("passes nil through", function() + assert.is_nil(ValueMapper.windSpeedToBeaufort(nil)) + end) +end) + +describe("WeatherExporter.temperatureUnit", function() + it("asks g_i18n, so a Fahrenheit player is labelled Fahrenheit", function() + _G.g_i18n = { + getTemperatureUnit = function() + return "°F" + end, + } + assert.are.equal("°F", VDT.WeatherExporter.temperatureUnit()) + end) + + it("falls back to Celsius when i18n cannot answer", function() + assert.are.equal("°C", VDT.WeatherExporter.temperatureUnit()) + end) +end) + +describe("WeatherExporter.collect", function() + it("collects current conditions, the hourly strip and the outlook", function() + local hourly = {} + for step = 0, 11 do + hourly[step * 2] = { + time = (8 + step * 2) % 24 * MS_PER_HOUR, + temperature = 20 + step, + windSpeed = 1.44, + windDirection = 45, + forecastType = 1, + } + end + local daily = {} + for offset = 1, 6 do + daily[offset] = { + day = offset, + highTemperature = 30 + offset, + lowTemperature = 10 + offset, + forecastType = 4, + } + end + installWorld({ + current = { temperature = 27.4, windSpeed = 1.2, windDirection = 45, forecastType = 1 }, + hourly = hourly, + daily = daily, + }) + + local model = VDT.WeatherExporter.collect() + + assert.are.equal("1", model.version) + assert.are.equal("°C", model.temperatureUnit) + + assert.are.equal("SUN", model.current.type) + assert.are.equal(27, model.current.temperature) + assert.are.equal(1.2, model.current.windSpeed) + assert.are.equal(1, model.current.windBeaufort) + assert.are.equal(45, model.current.windDirection) + + assert.are.equal(12, #model.hourly) + assert.are.equal(8, model.hourly[1].hour) + assert.are.equal(10, model.hourly[2].hour) + assert.are.equal(20, model.hourly[1].temperature) + -- rounded to one decimal: Json.lua would otherwise print 1.4399999999999 + assert.are.equal(1.4, model.hourly[1].windSpeed) + -- the strip wraps past midnight and stays in order rather than sorting + assert.are.equal(6, model.hourly[12].hour) + + assert.are.equal(6, #model.daily) + assert.are.equal("RAIN", model.daily[1].type) + assert.are.equal(31, model.daily[1].high) + assert.are.equal(11, model.daily[1].low) + end) + + it("converts every temperature to the player's unit", function() + _G.g_i18n = { + getTemperature = function(_, celsius) + return celsius * 1.8 + 32 + end, + getTemperatureUnit = function() + return "°F" + end, + formatDayInPeriod = function() + return "August 1" + end, + } + installWorld({ + current = { temperature = 20, windSpeed = 1, windDirection = 0, forecastType = 1 }, + daily = { [1] = { day = 1, highTemperature = 30, lowTemperature = 10, forecastType = 1 } }, + }) + + local model = VDT.WeatherExporter.collect() + + assert.are.equal("°F", model.temperatureUnit) + assert.are.equal(68, model.current.temperature) + assert.are.equal(86, model.daily[1].high) + assert.are.equal(50, model.daily[1].low) + assert.are.equal("August 1", model.today.label) + end) + + it("skips an hourly step the forecast has no item for", function() + installWorld({ + current = { temperature = 20, windSpeed = 1, windDirection = 0, forecastType = 1 }, + -- only the first and third steps resolve; the gap must not end the list + hourly = { + [0] = { time = 8 * MS_PER_HOUR, temperature = 20, windSpeed = 1, windDirection = 0, forecastType = 1 }, + [4] = { time = 12 * MS_PER_HOUR, temperature = 24, windSpeed = 1, windDirection = 0, forecastType = 1 }, + }, + }) + + local model = VDT.WeatherExporter.collect() + + assert.are.equal(2, #model.hourly) + assert.are.equal(8, model.hourly[1].hour) + assert.are.equal(12, model.hourly[2].hour) + end) + + it("lifts a time that lands a hair below the hour onto it", function() + installWorld({ + current = { temperature = 20, windSpeed = 1, windDirection = 0, forecastType = 1 }, + hourly = { + [0] = { time = 8 * MS_PER_HOUR - 0.0001, temperature = 20, windSpeed = 1, windDirection = 0, forecastType = 1 }, + }, + }) + + local model = VDT.WeatherExporter.collect() + + assert.are.equal(8, model.hourly[1].hour) + end) + + it("omits empty lists rather than exporting {}", function() + installWorld({ current = { temperature = 20, windSpeed = 1, windDirection = 0, forecastType = 1 } }) + + local model = VDT.WeatherExporter.collect() + + assert.is_nil(model.hourly) + assert.is_nil(model.daily) + assert.is_not_nil(model.current) + end) + + it("keeps a partial read: current weather without any forecast items", function() + -- The shape a multiplayer client may well be in, if forecastItems turn out to be server-side only. + installWorld({ current = { temperature = 20, windSpeed = 1, windDirection = 0, forecastType = 4 } }) + + local model = VDT.WeatherExporter.collect() + + assert.are.equal("RAIN", model.current.type) + end) + + it("keeps the hourly strip when only the current reading throws", function() + installWorld({ + hourly = { + [0] = { time = 8 * MS_PER_HOUR, temperature = 20, windSpeed = 1, windDirection = 0, forecastType = 1 }, + }, + }) + _G.g_currentMission.environment.weather.forecast.getCurrentWeather = function() + error("no current weather on this client") + end + + local model = VDT.WeatherExporter.collect() + + assert.is_nil(model.current) + assert.are.equal(1, #model.hourly) + end) + + it("skips the write entirely when nothing at all is readable", function() + -- An absent file makes the app wait; a present empty one makes it claim there is no forecast. + installWorld({}) + + assert.is_nil(VDT.WeatherExporter.collect()) + end) + + it("keeps the Beaufort step of a speed just above a whole m/s", function() + -- windSpeed is rounded to one decimal for the file, but Beaufort is taken from the RAW value: + -- the game's conversion ceils first, so 2.04 must read as ceil(2.04) = 3 -> Bft 2, where the + -- rounded-down 2.0 would ceil to 2 -> Bft 1. + installWorld({ current = { temperature = 20, windSpeed = 2.04, windDirection = 0, forecastType = 1 } }) + + local model = VDT.WeatherExporter.collect() + + assert.are.equal(2.0, model.current.windSpeed) + assert.are.equal(2, model.current.windBeaufort) + assert.are.equal(1, ValueMapper.windSpeedToBeaufort(2.0)) + end) + + it("wraps a negative wind angle rather than exporting it negative", function() + installWorld({ current = { temperature = 20, windSpeed = 1, windDirection = -45, forecastType = 1 } }) + + assert.are.equal(315, VDT.WeatherExporter.collect().current.windDirection) + end) + + it("survives non-numeric wind fields", function() + installWorld({ current = { temperature = 20, windSpeed = "gusty", windDirection = nil, forecastType = 1 } }) + + local model = VDT.WeatherExporter.collect() + + assert.are.equal(0, model.current.windSpeed) + assert.are.equal(0, model.current.windBeaufort) + assert.are.equal(0, model.current.windDirection) + end) + + it("returns nil when the weather isn't up yet", function() + _G.g_currentMission = nil + + assert.is_nil(VDT.WeatherExporter.collect()) + end) +end) diff --git a/vdTelemetry/src/collect/CropCalendarExporter.lua b/vdTelemetry/src/collect/CropCalendarExporter.lua new file mode 100644 index 0000000..f5b7220 --- /dev/null +++ b/vdTelemetry/src/collect/CropCalendarExporter.lua @@ -0,0 +1,303 @@ +-- Crop calendar export channel: for every crop the game shows on its map, which of the twelve +-- periods it may be SOWN in and which it may be HARVESTED in, written to cropCalendar.json. This is +-- the game's own Anbaukalender (gui/InGameMenuCalendarFrame), and it reads the same three calls that +-- frame does: getFruitTypes() filtered to shownOnMap, getIsPlantableInPeriod and +-- getIsHarvestableInPeriod against missionInfo.growthMode. +-- +-- Base-game state only, so it lives in collect/, not integrations/. NOT farmScoped: growth is world +-- state, and every farm on the server reads the identical calendar. +-- +-- Event-driven rather than interval-driven, because almost nothing in here moves. The crop rows are +-- fixed the moment the map is loaded -- a fruit type's growth data comes out of its foliage XML and +-- no gameplay changes it. The only live part is `today`, so the channel subscribes to DAY_CHANGED +-- (the marker steps a day) and PERIOD_LENGTH_CHANGED (the season-length setting is changeable in +-- game, and it changes how far into its period a given day sits). That is a rewrite per in-game day +-- of a file of a couple of kilobytes. +-- +-- growthMode rides along because it decides what the periods MEAN: outside GrowthMode.SEASONAL both +-- predicates return true for every period unconditionally, so every crop would draw twelve full bars. +-- Exporting the mode lets the app say why instead of showing data that looks broken. +-- +-- Every engine read is pcall-guarded (fail-soft house rule). getIsPlantableInPeriod earns it more +-- than most: it indexes growthDataSeasonal.periods[period] with no nil check of its own, and +-- growthDataSeasonal is only built when the platform supports seasonal growth -- so on a map without +-- it, the call throws rather than returning false. +-- +-- Namespaced under VDT.* (see aspects/TurnOn.lua). + +VDT = VDT or {} +VDT.CropCalendarExporter = {} + +VDT.CropCalendarExporter.CHANNEL = "cropCalendar" +VDT.CropCalendarExporter.FILE_NAME = "cropCalendar.json" +-- Own version, evolving independently of VDTelemetry.VERSION and the shared Kotlin CropCalendarData. +VDT.CropCalendarExporter.VERSION = 1 + +-- The calendar is always twelve periods; the game hardcodes the same bound in its own frame +-- (`for i = 1, 12`) and in Environment.PERIODS_IN_YEAR. +VDT.CropCalendarExporter.PERIODS = 12 + +-- How often tick() re-reads the growth mode, in ms. There is NO message to subscribe to for it: +-- GrowthSystem:setGrowthMode writes missionInfo.growthMode, fires its multiplayer SavegameSettingsEvent +-- and logs, and publishes nothing to the message center (MessageType.SETTING_CHANGED covers the +-- client's GameSettings, not the savegame's). So this channel watches the value instead -- and it must +-- watch it, because the mode decides what every period in the file MEANS: outside SEASONAL the game +-- answers "plantable" for all twelve, so a stale file shows the wrong calendar entirely until the next +-- day rolls over. Two seconds is invisible for a menu action and keeps the per-frame path to a +-- counter compare. +VDT.CropCalendarExporter.GROWTH_MODE_POLL_MS = 2000 + +VDT.CropCalendarExporter.subscribed = false + +-- ms accumulated since the last growth-mode check, and the mode the last collect() actually used. +-- Comparing against what was WRITTEN rather than what was last seen makes the watch self-correcting: +-- if a write is skipped or fails, the next poll finds the mismatch still there and queues it again. +local growthModePoll = 0 +local lastWrittenGrowthMode = nil + +-- GrowthMode ids -> the names we export. The enum lives in the engine's growth/GrowthMode.lua; it is +-- mirrored here rather than read from the global so a missing GrowthMode table degrades to "unknown" +-- instead of throwing. +local GROWTH_MODES = { [1] = "SEASONAL", [2] = "DAILY", [3] = "DISABLED" } + +-- SeasonPeriod -> Season, the same three-periods-per-season grouping as the engine's +-- SeasonPeriod.getSeason. Used for the calendar's season band. +local SEASONS = { "SPRING", "SUMMER", "AUTUMN", "WINTER" } + +---The season a period belongs to, as an exported name. Periods run 1..12 with three per season. +---@param period number 1..12 +---@return string one of SPRING | SUMMER | AUTUMN | WINTER +function VDT.CropCalendarExporter.seasonForPeriod(period) + return SEASONS[math.floor((period - 1) / 3) + 1] or "SPRING" +end + +---The active growth mode's exported name. Reads missionInfo, which is synchronized to multiplayer +---clients; an unreadable or unknown value degrades to "SEASONAL" -- the game's own default, and the +---only mode in which this channel's contents are meaningful, so guessing it keeps the calendar shown +---rather than banner-ing a savegame that is in fact seasonal. +---@return number growthMode the raw engine id, for the period predicates +---@return string name the exported name +function VDT.CropCalendarExporter.growthMode() + local info = g_currentMission ~= nil and g_currentMission.missionInfo or nil + local mode = type(info) == "table" and info.growthMode or nil + if type(mode) ~= "number" or GROWTH_MODES[mode] == nil then + return 1, "SEASONAL" + end + return mode, GROWTH_MODES[mode] +end + +---The twelve column headers: number, the game's own localized short label, and the season it sits in. +--- +---The label comes from g_i18n:formatPeriod and is NOT derivable app-side from the period number: that +---function shifts the month by hemisphere (environment.daylight.latitude < 0), so on a southern map +---period 1 is September rather than March. Falls back to the period number as a string when i18n +---cannot answer, so the calendar still has twelve labelled columns. +---@return CropCalendarPeriodModel[] +function VDT.CropCalendarExporter.collectPeriods() + local periods = {} + for period = 1, VDT.CropCalendarExporter.PERIODS do + local label + if g_i18n ~= nil then + local ok, text = pcall(g_i18n.formatPeriod, g_i18n, period, true) + if ok and type(text) == "string" and text ~= "" then + label = text + end + end + periods[#periods + 1] = { + period = period, + label = label or tostring(period), + season = VDT.CropCalendarExporter.seasonForPeriod(period), + } + end + return periods +end + +-- The periods a predicate says yes to, ascending. `predicate` is the fruit desc's own +-- getIsPlantableInPeriod / getIsHarvestableInPeriod; a throwing period counts as "no" rather than +-- taking the whole crop down with it. +local function periodsWhere(fruitDesc, predicate, growthMode) + local periods = {} + for period = 1, VDT.CropCalendarExporter.PERIODS do + local ok, allowed = pcall(predicate, fruitDesc, growthMode, period) + if ok and allowed then + periods[#periods + 1] = period + end + end + return periods +end + +---One crop row, or nil when the fruit has no usable name (a broken mod fruit). +---@param fruitDesc table a FruitTypeDesc +---@param growthMode number the raw engine GrowthMode id +---@return CropCalendarCropModel|nil +function VDT.CropCalendarExporter.collectCrop(fruitDesc, growthMode) + -- The display name is the fruit's FILL type title, not the fruit type's own name: that is what the + -- game's frame shows, and it is the localized one ("Weizen", not "WHEAT"). + local name + local okFill, fillType = pcall(g_fruitTypeManager.getFillTypeByFruitTypeIndex, g_fruitTypeManager, fruitDesc.index) + if okFill and type(fillType) == "table" and type(fillType.title) == "string" and fillType.title ~= "" then + name = fillType.title + end + if name == nil then + return nil + end + + local okCatch, isCatchCrop = pcall(fruitDesc.getIsCatchCrop, fruitDesc) + + local plant = periodsWhere(fruitDesc, fruitDesc.getIsPlantableInPeriod, growthMode) + local harvest = periodsWhere(fruitDesc, fruitDesc.getIsHarvestableInPeriod, growthMode) + + return { + id = type(fruitDesc.name) == "string" and fruitDesc.name or name, + name = name, + catchCrop = (okCatch and isCatchCrop == true) or nil, + -- omit empty arrays (nil, not {}): an empty Lua table encodes as {} which the Kotlin lists reject + plant = #plant > 0 and plant or nil, + harvest = #harvest > 0 and harvest or nil, + } +end + +---The crop rows, sorted by display name the way the game's own frame sorts them. +---@param growthMode number the raw engine GrowthMode id +---@return CropCalendarCropModel[] +function VDT.CropCalendarExporter.collectCrops(growthMode) + local crops = {} + local okTypes, fruitTypes = pcall(g_fruitTypeManager.getFruitTypes, g_fruitTypeManager) + if not okTypes or type(fruitTypes) ~= "table" then + return crops + end + -- pairs, not ipairs: getFruitTypes returns the manager's own keyed table, which the game also walks + -- with pairs. Its iteration order is undefined, hence the sort below. + for _, fruitDesc in pairs(fruitTypes) do + -- shownOnMap is the game's own filter for this screen: it drops the fruits that are not really + -- crops you plan around (decorative foliage, and the fruits a map hides). + if type(fruitDesc) == "table" and fruitDesc.shownOnMap then + local crop = VDT.CropCalendarExporter.collectCrop(fruitDesc, growthMode) + if crop ~= nil then + crops[#crops + 1] = crop + end + end + end + table.sort(crops, function(a, b) + return a.name < b.name + end) + return crops +end + +---Where the year currently stands, for the app's "today" marker. +---@return CropCalendarTodayModel|nil +function VDT.CropCalendarExporter.collectToday() + local environment = g_currentMission ~= nil and g_currentMission.environment or nil + if type(environment) ~= "table" or type(environment.currentPeriod) ~= "number" then + return nil + end + return { + period = environment.currentPeriod, + dayInPeriod = type(environment.currentDayInPeriod) == "number" and environment.currentDayInPeriod or 1, + -- daysPerPeriod is what places the marker WITHIN its period; 1 is the game's own minimum, and + -- also the value that makes the marker sit at the period's start when we cannot read it. + daysPerPeriod = type(environment.daysPerPeriod) == "number" and math.max(environment.daysPerPeriod, 1) or 1, + year = type(environment.currentYear) == "number" and environment.currentYear or 1, + } +end + +---Available once the environment AND the fruit type table are actually populated. +--- +---The emptiness test is the point of this function. g_fruitTypeManager exists well before it holds +---any fruit, and this channel writes ONCE -- there is no interval behind it to correct a bad first +---write, only a day rollover. Registering as available too early would publish a calendar with no +---crops in it and leave that on disk for an in-game day. Being unavailable at startup instead just +---means the file is cleaned up and written as soon as the map's fruits are loaded, which is the same +---sequence the map channel goes through. +---@return boolean +function VDT.CropCalendarExporter.isAvailable() + if g_currentMission == nil or g_currentMission.environment == nil or g_fruitTypeManager == nil then + return false + end + local ok, fruitTypes = pcall(g_fruitTypeManager.getFruitTypes, g_fruitTypeManager) + -- next(), not #: the manager hands back its own keyed table, which the game itself walks with pairs. + return ok and type(fruitTypes) == "table" and next(fruitTypes) ~= nil +end + +---Build the crop calendar model, or nil when the fruit types aren't up yet (skips the write). +---@return CropCalendarModel|nil +function VDT.CropCalendarExporter.collect() + if not VDT.CropCalendarExporter.isAvailable() then + return nil + end + + local growthMode, growthModeName = VDT.CropCalendarExporter.growthMode() + local crops = VDT.CropCalendarExporter.collectCrops(growthMode) + -- Remember what this document was built with, so tick()'s watch has something to compare against. + lastWrittenGrowthMode = growthMode + + return { + version = tostring(VDT.CropCalendarExporter.VERSION), + growthMode = growthModeName, + today = VDT.CropCalendarExporter.collectToday(), + periods = VDT.CropCalendarExporter.collectPeriods(), + crops = #crops > 0 and crops or nil, + } +end + +-- Test seam: drop the tick's state -- the one-shot subscribe guard and the growth-mode watch -- so a +-- spec can drive tick() from a known point. Nothing in the mod calls it; the subscription is +-- deliberately never undone (see ExportChannels.subscribeFarmChanges for the same reasoning). +function VDT.CropCalendarExporter.resetWatch() + VDT.CropCalendarExporter.subscribed = false + growthModePoll = 0 + lastWrittenGrowthMode = nil +end + +-- MessageCenter invokes callback(target, ...); target is VDT.CropCalendarExporter, extras ignored. +function VDT.CropCalendarExporter.markDirty() + VDT.ExportChannels.markDirty(VDT.CropCalendarExporter.CHANNEL) +end + +-- Lazy subscribe: wait until the fruit types are loaded, then watch the two things that move the +-- today marker. The crop rows themselves never change while the growth mode holds, so nothing is +-- subscribed for their sake -- the initial markDirty() writes them once. +-- +-- The growth mode is the exception, and it has no message of its own (see GROWTH_MODE_POLL_MS), so +-- this tick also polls it. Unlike the subscribe, that part runs for the life of the session. +---@param debugger GrisuDebug +---@param dt number? frame delta in ms +function VDT.CropCalendarExporter.tick(debugger, dt) + if not VDT.CropCalendarExporter.isAvailable() then + return + end + if not VDT.CropCalendarExporter.subscribed then + if MessageType == nil or g_messageCenter == nil then + return + end + for _, message in ipairs({ "DAY_CHANGED", "PERIOD_LENGTH_CHANGED" }) do + if MessageType[message] ~= nil then + g_messageCenter:subscribe(MessageType[message], VDT.CropCalendarExporter.markDirty, VDT.CropCalendarExporter) + end + end + VDT.CropCalendarExporter.subscribed = true + VDT.CropCalendarExporter.markDirty() + debugger:info("Crop calendar channel active (subscribed to day/period-length changes)") + end + + growthModePoll = growthModePoll + (type(dt) == "number" and dt or 0) + if growthModePoll < VDT.CropCalendarExporter.GROWTH_MODE_POLL_MS then + return + end + growthModePoll = 0 + -- nil until the first document is built: there is nothing to compare against before then, and the + -- initial markDirty above has already queued that write. + if lastWrittenGrowthMode ~= nil and VDT.CropCalendarExporter.growthMode() ~= lastWrittenGrowthMode then + VDT.CropCalendarExporter.markDirty() + end +end + +-- Self-register the channel (see ExportChannels). Event-driven: no interval, the subscriptions above +-- do the marking. Deliberately NOT farmScoped -- the calendar is world state, the same for every farm. +VDT.ExportChannels.register({ + name = VDT.CropCalendarExporter.CHANNEL, + fileName = VDT.CropCalendarExporter.FILE_NAME, + isAvailable = VDT.CropCalendarExporter.isAvailable, + collect = VDT.CropCalendarExporter.collect, + tick = VDT.CropCalendarExporter.tick, +}) diff --git a/vdTelemetry/src/collect/WeatherExporter.lua b/vdTelemetry/src/collect/WeatherExporter.lua new file mode 100644 index 0000000..d31e244 --- /dev/null +++ b/vdTelemetry/src/collect/WeatherExporter.lua @@ -0,0 +1,303 @@ +-- Weather export channel: the forecast, written to weather.json -- current conditions, twelve +-- two-hourly steps ahead, and six days out. This is the bottom half of the game's own Anbaukalender +-- (gui/InGameMenuCalendarFrame), and it reads the same three calls that frame does: +-- forecast:getCurrentWeather(), :getHourlyForecast(hoursFromNow) and :getDailyForecast(daysFromToday). +-- +-- Distinct from the telemetry channel's `environment.weather`, which carries only the live +-- min/max/current temperature at the 100 ms tick. A forecast is eighteen entries of structure and +-- changes on the hour; putting it on the live tick is exactly what the channel registry exists to +-- avoid. +-- +-- Base-game state only, so it lives in collect/, not integrations/. NOT farmScoped: it rains on +-- every farm equally. +-- +-- Event-driven, subscribed to HOUR_CHANGED and DAY_CHANGED -- the same two the game's own frame +-- reloads on, so our readout moves exactly when the menu's does. At default timescale an in-game +-- hour is about a real minute, which makes this a ~1/min rewrite of a ~1 kB file. +-- +-- Temperatures go through g_i18n:getTemperature and the file names the resulting unit, so a player +-- on Fahrenheit gets Fahrenheit here and the app never converts. Wind ships twice: windSpeed in m/s +-- (the honest measurement) and windBeaufort (what the menu prints, via ValueMapper). +-- +-- windDirection is the game's RAW angle in degrees, deliberately not put through +-- ValueMapper.headingFromYRotation. The current wind's angle is derived from a y-rotation, but each +-- forecast entry's comes from `variation.wind.windAngle` in the weather XML -- two different sources +-- that only happen to share a unit. Forcing both through one compass convention would silently make +-- one of them wrong. The game draws its arrow at `windDirection + 180`; consumers wanting to match +-- the menu must do the same. +-- +-- Every engine read is pcall-guarded (fail-soft house rule); on a multiplayer client in particular, +-- whether the forecast items are replicated at all is unproven (see FUTURE.md). +-- +-- Namespaced under VDT.* (see aspects/TurnOn.lua). + +VDT = VDT or {} +VDT.WeatherExporter = {} + +VDT.WeatherExporter.CHANNEL = "weather" +VDT.WeatherExporter.FILE_NAME = "weather.json" +-- Own version, evolving independently of VDTelemetry.VERSION and the shared Kotlin WeatherForecastData. +VDT.WeatherExporter.VERSION = 1 + +-- The game's own two list lengths: twelve hourly cells at a two-hour step (so a full day ahead), and +-- six daily cells. Matching them keeps our strip and the menu's showing the same horizon. +VDT.WeatherExporter.HOURLY_STEPS = 12 +VDT.WeatherExporter.HOURLY_STEP_HOURS = 2 +VDT.WeatherExporter.DAILY_STEPS = 6 + +VDT.WeatherExporter.subscribed = false + +local MS_PER_HOUR = 60 * 60 * 1000 + +-- WeatherType ids -> the names we export. The enum lives in the engine's +-- environment/weather/WeatherType.lua; mirrored here rather than read from the global so a missing +-- WeatherType table degrades to "UNKNOWN" instead of throwing. +local WEATHER_TYPES = { + [1] = "SUN", + [2] = "PARTIALLY_CLOUDY", + [3] = "CLOUDY", + [4] = "RAIN", + [5] = "SNOW", + [6] = "HAIL", + [7] = "TWISTER", + [8] = "THUNDER", +} + +---A WeatherType id as its exported name. +---@param forecastType number|nil the engine's WeatherType id +---@return string one of the WeatherType names, or "UNKNOWN" +function VDT.WeatherExporter.mapWeatherType(forecastType) + return WEATHER_TYPES[forecastType] or "UNKNOWN" +end + +-- A temperature in the player's unit, rounded to whole degrees the way the menu shows it. Guarded +-- because g_i18n is absent in the specs and briefly during load. +local function temperature(celsius) + if type(celsius) ~= "number" then + return 0 + end + local value = celsius + if g_i18n ~= nil then + local ok, converted = pcall(g_i18n.getTemperature, g_i18n, celsius) + if ok and type(converted) == "number" then + value = converted + end + end + return math.floor(value + 0.5) +end + +local function num(value) + return type(value) == "number" and value or 0 +end + +local function windDirection(degrees) + -- Lua's % is non-negative for a positive divisor, so a negative angle wraps rather than staying + -- negative. The engine's own angles are already snapped to 45 degrees, but a mod's need not be. + return math.floor(num(degrees) + 0.5) % 360 +end + +-- The wind fields, shared by the current reading and each hourly step. Every read goes through num() +-- first: this runs inside collect(), not inside the pcall that fetched the forecast, so a non-numeric +-- field here would take the whole channel's write down rather than costing one entry. +local function windOf(info, target) + local mps = num(info.windSpeed) + -- Json.lua prints floats with %.14g, so an unrounded m/s reads as 1.3999999999999. One decimal is + -- well past what a wind readout means. + target.windSpeed = math.floor(mps * 10 + 0.5) / 10 + -- Beaufort from the RAW speed, not the rounded one: the game's conversion ceils to a whole m/s + -- first, so a value rounded down across an integer changes the answer -- 2.04 ceils to 3 (Bft 2) + -- where the rounded 2.0 ceils to 2 (Bft 1). + target.windBeaufort = ValueMapper.windSpeedToBeaufort(mps) or 0 + target.windDirection = windDirection(info.windDirection) + return target +end + +---The unit every temperature in this file is in. g_i18n owns the °C/°F choice, so the unit is asked +---of it rather than assumed -- the telemetry channel's environment block does the same. +---@return string +function VDT.WeatherExporter.temperatureUnit() + if g_i18n ~= nil then + local ok, unit = pcall(g_i18n.getTemperatureUnit, g_i18n, false) + if ok and type(unit) == "string" and unit ~= "" then + return unit + end + end + return "°C" +end + +---Which day the forecast starts from, with the game's own localized caption ("August 1"). +---@param environment table g_currentMission.environment +---@return WeatherDayModel|nil +function VDT.WeatherExporter.collectToday(environment) + local period = environment.currentPeriod + local dayInPeriod = environment.currentDayInPeriod + if type(period) ~= "number" or type(dayInPeriod) ~= "number" then + return nil + end + return { + label = VDT.WeatherExporter.dayLabel(dayInPeriod, period, false), + period = period, + dayInPeriod = dayInPeriod, + } +end + +---The game's own localized day caption. Not derivable app-side: formatDayInPeriod folds in the +---hemisphere's month shift AND drops the day number entirely when a period is one day long. Falls +---back to "/" when i18n cannot answer, which is at least unambiguous. +---@param dayInPeriod number +---@param period number +---@param useShort boolean short form ("Aug 2") vs long ("August 2") +---@return string +function VDT.WeatherExporter.dayLabel(dayInPeriod, period, useShort) + if g_i18n ~= nil then + local ok, text = pcall(g_i18n.formatDayInPeriod, g_i18n, dayInPeriod, period, useShort) + if ok and type(text) == "string" and text ~= "" then + return text + end + end + return string.format("%d/%d", period, dayInPeriod) +end + +---Current conditions. +---@param forecast table the weather forecast (environment.weather.forecast) +---@return WeatherNowModel|nil +function VDT.WeatherExporter.collectCurrent(forecast) + local ok, info = pcall(forecast.getCurrentWeather, forecast) + if not ok or type(info) ~= "table" then + return nil + end + return windOf(info, { + type = VDT.WeatherExporter.mapWeatherType(info.forecastType), + temperature = temperature(info.temperature), + }) +end + +---The hourly strip: HOURLY_STEPS entries HOURLY_STEP_HOURS apart, starting now. The list runs +---forward and wraps past midnight, so it is ordered rather than sorted -- an entry's `hour` alone +---does not say which day it belongs to, and consumers render it in the order given. +--- +---A nil step is skipped rather than ending the list: getHourlyForecast returns nil when it finds no +---forecast item covering that time, and a later step may still resolve. +---@param forecast table +---@return WeatherHourModel[] +function VDT.WeatherExporter.collectHourly(forecast) + local hours = {} + for step = 0, VDT.WeatherExporter.HOURLY_STEPS - 1 do + local ok, info = pcall(forecast.getHourlyForecast, forecast, step * VDT.WeatherExporter.HOURLY_STEP_HOURS) + if ok and type(info) == "table" and type(info.time) == "number" then + -- The engine's own rounding for this readout: the +0.0001 lifts a time that lands a hair below + -- a whole hour (floating-point ms) onto it, so 07:59.9997 prints as 08:00 rather than 07:00. + hours[#hours + 1] = windOf(info, { + hour = math.floor(info.time / MS_PER_HOUR + 0.0001) % 24, + type = VDT.WeatherExporter.mapWeatherType(info.forecastType), + temperature = temperature(info.temperature), + }) + end + end + return hours +end + +---The outlook: DAILY_STEPS days starting tomorrow (the game asks for offsets 1..6, so today is not +---repeated here -- `today` and `current` cover it). +---@param forecast table +---@param environment table g_currentMission.environment +---@return WeatherDailyModel[] +function VDT.WeatherExporter.collectDaily(forecast, environment) + local days = {} + for offset = 1, VDT.WeatherExporter.DAILY_STEPS do + local ok, info = pcall(forecast.getDailyForecast, forecast, offset) + if ok and type(info) == "table" and type(info.day) == "number" then + -- The forecast counts in monotonic days; the calendar position of one is the environment's to + -- work out (it folds in daysPerPeriod and the year wrap). + local okPeriod, period = pcall(environment.getPeriodFromDay, environment, info.day) + local okDay, dayInPeriod = pcall(environment.getDayInPeriodFromDay, environment, info.day) + if okPeriod and okDay and type(period) == "number" and type(dayInPeriod) == "number" then + days[#days + 1] = { + label = VDT.WeatherExporter.dayLabel(dayInPeriod, period, true), + period = period, + dayInPeriod = dayInPeriod, + type = VDT.WeatherExporter.mapWeatherType(info.forecastType), + high = temperature(info.highTemperature), + low = temperature(info.lowTemperature), + } + end + end + end + return days +end + +function VDT.WeatherExporter.isAvailable() + local environment = g_currentMission ~= nil and g_currentMission.environment or nil + return type(environment) == "table" + and type(environment.weather) == "table" + and type(environment.weather.forecast) == "table" +end + +---Build the forecast model, or nil when the weather isn't up yet (skips the write). +---@return WeatherForecastModel|nil +function VDT.WeatherExporter.collect() + if not VDT.WeatherExporter.isAvailable() then + return nil + end + local environment = g_currentMission.environment + local forecast = environment.weather.forecast + + local current = VDT.WeatherExporter.collectCurrent(forecast) + local hourly = VDT.WeatherExporter.collectHourly(forecast) + local daily = VDT.WeatherExporter.collectDaily(forecast, environment) + + -- Nothing readable at all: skip the write rather than publishing an empty forecast. The forecast + -- object exists from the moment the weather loads, but its items are generated a beat later, and a + -- file saying "no weather" would sit there until the next hour rolled over. An absent file makes + -- the app wait; a present empty one makes it claim there is no forecast. A PARTIAL read is kept, + -- though -- if a multiplayer client turns out to have the current weather but no forecast items, + -- the "now" block is still worth showing. + if current == nil and #hourly == 0 and #daily == 0 then + return nil + end + + return { + version = tostring(VDT.WeatherExporter.VERSION), + temperatureUnit = VDT.WeatherExporter.temperatureUnit(), + today = VDT.WeatherExporter.collectToday(environment), + current = current, + -- omit empty arrays (nil, not {}): an empty Lua table encodes as {} which the Kotlin lists reject + hourly = #hourly > 0 and hourly or nil, + daily = #daily > 0 and daily or nil, + } +end + +-- MessageCenter invokes callback(target, ...); target is VDT.WeatherExporter, extras ignored. +function VDT.WeatherExporter.markDirty() + VDT.ExportChannels.markDirty(VDT.WeatherExporter.CHANNEL) +end + +-- Lazy subscribe: wait until the weather is up, then watch the two messages the game's own frame +-- reloads on. The initial markDirty() writes the forecast that was already there on load. +---@param debugger GrisuDebug +function VDT.WeatherExporter.tick(debugger) + if VDT.WeatherExporter.subscribed or not VDT.WeatherExporter.isAvailable() then + return + end + if MessageType == nil or g_messageCenter == nil then + return + end + for _, message in ipairs({ "HOUR_CHANGED", "DAY_CHANGED" }) do + if MessageType[message] ~= nil then + g_messageCenter:subscribe(MessageType[message], VDT.WeatherExporter.markDirty, VDT.WeatherExporter) + end + end + VDT.WeatherExporter.subscribed = true + VDT.WeatherExporter.markDirty() + debugger:info("Weather channel active (subscribed to hour/day changes)") +end + +-- Self-register the channel (see ExportChannels). Event-driven: no interval, the subscriptions above +-- do the marking. Deliberately NOT farmScoped -- the weather is world state, the same for every farm. +VDT.ExportChannels.register({ + name = VDT.WeatherExporter.CHANNEL, + fileName = VDT.WeatherExporter.FILE_NAME, + isAvailable = VDT.WeatherExporter.isAvailable, + collect = VDT.WeatherExporter.collect, + tick = VDT.WeatherExporter.tick, +}) diff --git a/vdTelemetry/src/mapper/ValueMapper.lua b/vdTelemetry/src/mapper/ValueMapper.lua index 557d747..91cc5c4 100644 --- a/vdTelemetry/src/mapper/ValueMapper.lua +++ b/vdTelemetry/src/mapper/ValueMapper.lua @@ -56,6 +56,22 @@ function ValueMapper.convertFromMsToKMH(speedInMs) return speedInMs * 3.6 end +--- Wind speed as the Beaufort number the game prints in its own weather menu. +--- +--- Lifted verbatim from InGameMenuCalendarFrame:meterPerSecondToBeaufort rather than derived from the +--- real Beaufort scale: the game's version rounds the speed UP to a whole m/s before converting, so it +--- disagrees with the physical scale at most speeds. Matching the menu matters more than being right +--- about meteorology -- a forecast that reads "3" beside a game that says "4" is just wrong to a player. +--- (`^` rather than the engine's math.pow: identical in Lua 5.1, and it survives a newer interpreter.) +---@param speedInMs number The wind speed in m/s +---@return number the Beaufort number +function ValueMapper.windSpeedToBeaufort(speedInMs) + if speedInMs == nil then + return nil + end + return math.floor((math.ceil(speedInMs) / 0.836) ^ (2 / 3)) +end + ---@param value number 0..1 ---@param decimals number How much decimals it should return, defaults to 2 ---@return string The formated value as percentage diff --git a/vdTelemetry/src/model/CropCalendarModel.lua b/vdTelemetry/src/model/CropCalendarModel.lua new file mode 100644 index 0000000..24fb8cc --- /dev/null +++ b/vdTelemetry/src/model/CropCalendarModel.lua @@ -0,0 +1,39 @@ +-- Model definitions for the crop calendar export channel (cropCalendar.json, +-- src/collect/CropCalendarExporter.lua). +-- +-- Annotation-only (LuaLS @class): these files carry NO runtime logic and are not source()'d. +-- The shape maps 1:1 to the Kotlin model in VDTerminal/shared (model/CropCalendar.kt) and the +-- fixtures in examples/json/cropCalendar/*. +-- +-- This is the game's own Anbaukalender (InGameMenuCalendarFrame): for every crop the game shows on +-- the map, which of the twelve periods it may be SOWN in and which it may be HARVESTED in. World +-- state, identical for every farm, and near-static -- the only thing that moves is `today`. + +---@class CropCalendarTodayModel where the year currently stands, for the "today" marker +---@field period number the current period, 1..12 (1 = the first period of spring) +---@field dayInPeriod number the day within that period, 1..daysPerPeriod +---@field daysPerPeriod number the season-length setting; user-changeable in game +---@field year number the current game year, 1-based + +---@class CropCalendarPeriodModel one column of the calendar +---@field period number 1..12 +---@field label string the game's own localized short label for it ("Mar", "Sep", ...). NOT derivable +--- app-side: g_i18n:formatPeriod shifts the month by hemisphere, so a southern map labels period 1 +--- as September +---@field season string SPRING | SUMMER | AUTUMN | WINTER + +---@class CropCalendarCropModel one crop row +---@field id string the fruit type's internal name ("WHEAT"), stable across locales -- the row key +---@field name string the localized display name, from the fruit's fill type title +---@field catchCrop boolean? true for a cover/catch crop (fruitDesc:getIsCatchCrop()); omitted when false +---@field plant number[]? the periods it may be sown in, ascending; omitted when there are none +---@field harvest number[]? the periods it may be harvested in, ascending; omitted when there are none + +---@class CropCalendarModel +---@field version string channel version, independent of VDTelemetry.VERSION +---@field growthMode string SEASONAL | DAILY | DISABLED. Outside SEASONAL the game answers "yes" to +--- every period for every crop, so `plant`/`harvest` are all twelve and mean nothing -- the app says +--- so rather than drawing twelve full bars +---@field today CropCalendarTodayModel? +---@field periods CropCalendarPeriodModel[]? the twelve columns, in order +---@field crops CropCalendarCropModel[]? sorted by name, the way the game's own frame sorts them diff --git a/vdTelemetry/src/model/WeatherModel.lua b/vdTelemetry/src/model/WeatherModel.lua new file mode 100644 index 0000000..2628931 --- /dev/null +++ b/vdTelemetry/src/model/WeatherModel.lua @@ -0,0 +1,54 @@ +-- Model definitions for the weather export channel (weather.json, +-- src/collect/WeatherExporter.lua). +-- +-- Annotation-only (LuaLS @class): these files carry NO runtime logic and are not source()'d. +-- The shape maps 1:1 to the Kotlin model in VDTerminal/shared (model/WeatherForecast.kt) and the +-- fixtures in examples/json/weather/*. +-- +-- The forecast half of the game's Anbaukalender (InGameMenuCalendarFrame): what it is doing now, +-- twelve two-hourly steps ahead, and six days out. Distinct from the telemetry channel's +-- `environment.weather`, which carries only the live min/max/current temperature at the 100 ms tick. +-- +-- Temperatures are already in the player's chosen unit (g_i18n:getTemperature) and `temperatureUnit` +-- names it, so a Fahrenheit player gets Fahrenheit here and the app never converts. + +---@class WeatherDayModel which day the forecast starts from +---@field label string the game's own localized day caption ("August 1"), from formatDayInPeriod +---@field period number 1..12 +---@field dayInPeriod number 1..daysPerPeriod + +---@class WeatherNowModel current conditions (forecast:getCurrentWeather()) +---@field type string the WeatherType name: SUN | PARTIALLY_CLOUDY | CLOUDY | RAIN | SNOW | HAIL | +--- TWISTER | THUNDER +---@field temperature number in `temperatureUnit` +---@field windSpeed number m/s +---@field windBeaufort number the game's own Beaufort number (ValueMapper.windSpeedToBeaufort), so +--- our readout matches the one in the menu +---@field windDirection number degrees, the game's raw angle. The game draws its arrow at +--- `windDirection + 180`; consumers wanting to match it must do the same. Deliberately NOT put +--- through ValueMapper.headingFromYRotation -- see the collector's header for why + +---@class WeatherHourModel one step of the hourly forecast, two hours apart +---@field hour number the hour of the in-game day, 0..23. The list runs forward from now and wraps +--- past midnight, so it is ordered rather than sorted +---@field type string WeatherType name, as WeatherNowModel.type +---@field temperature number in `temperatureUnit` +---@field windSpeed number m/s +---@field windBeaufort number +---@field windDirection number degrees; same convention as WeatherNowModel.windDirection + +---@class WeatherDailyModel one day of the outlook +---@field label string the game's own localized short day caption ("Aug 2") +---@field period number 1..12 +---@field dayInPeriod number 1..daysPerPeriod +---@field type string WeatherType name; the day's dominant type, as the game aggregates it +---@field high number day's high, in `temperatureUnit` +---@field low number day's low, in `temperatureUnit` + +---@class WeatherForecastModel +---@field version string channel version, independent of VDTelemetry.VERSION +---@field temperatureUnit string the unit every temperature in this file is in ("°C" / "°F") +---@field today WeatherDayModel? +---@field current WeatherNowModel? +---@field hourly WeatherHourModel[]? twelve steps, two hours apart, starting now +---@field daily WeatherDailyModel[]? six days, starting tomorrow From 8a1d094d239aea7227d9f7325702c99c3fc738d2 Mon Sep 17 00:00:00 2001 From: Benjamin Leber Date: Sun, 16 Aug 2026 20:51:04 +0200 Subject: [PATCH 2/6] =?UTF-8?q?#96=20=F0=9F=9A=91=20[mod]=20the=20exported?= =?UTF-8?q?=20temperature=20unit=20follows=20the=20player's=20setting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit environment.weather.temperature ran its three values through g_i18n:getTemperature, which converts to Fahrenheit for anyone who picked it, and then labelled them "°C" from a hardcoded string. A Fahrenheit player got Fahrenheit numbers under a Celsius label. The unit now comes from g_i18n:getTemperatureUnit, the same place the value's conversion does. Found while writing the weather channel, which has to get this pairing right and would have sat next to the wrong one. No shape change, so VDTelemetry.VERSION stands. Co-Authored-By: Claude Opus 5 (1M context) --- vdTelemetry/src/collect/EnvironmentExporter.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/vdTelemetry/src/collect/EnvironmentExporter.lua b/vdTelemetry/src/collect/EnvironmentExporter.lua index 0b4ccb8..f94039d 100644 --- a/vdTelemetry/src/collect/EnvironmentExporter.lua +++ b/vdTelemetry/src/collect/EnvironmentExporter.lua @@ -21,12 +21,15 @@ function VDT.EnvironmentExporter.collect(pda) local weather = environment.weather local minTemperatureInC, maxTemperatureInC = weather:getCurrentMinMaxTemperatures() local currentTemperatureInC = weather.forecast:getCurrentWeather() + -- getTemperature converts to the player's chosen unit, so the unit label has to come from g_i18n + -- too: hardcoding "°C" here reported Fahrenheit values under a Celsius label for anyone who had + -- switched. Same pairing as the weather channel (src/collect/WeatherExporter.lua). model.weather = { temperature = { min = MathUtil.round(g_i18n:getTemperature(minTemperatureInC), 0), max = MathUtil.round(g_i18n:getTemperature(maxTemperatureInC), 0), current = MathUtil.round(g_i18n:getTemperature(currentTemperatureInC.temperature), 0), - unit = "°C", + unit = g_i18n:getTemperatureUnit(false), }, } From 6a71df86c7eaa8e5afb2dcfa6a201a8067b548c0 Mon Sep 17 00:00:00 2001 From: Benjamin Leber Date: Sun, 16 Aug 2026 20:51:20 +0200 Subject: [PATCH 3/6] =?UTF-8?q?#96=20=E2=9C=A8=20[term]=20the=20calendar?= =?UTF-8?q?=20channels=20reach=20the=20app=20as=20typed=20messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CropCalendarData and WeatherForecastData, their parsers, their ServerMessage variants and the two watcher registrations — the same path every other channel takes, including the rule that an absent file broadcasts null so the app clears rather than freezing. On a different map the crop list is a different set of crops entirely, and a stale forecast is read to decide whether to cut hay. The forecast type is named WeatherForecastData because model.Weather is already taken by the telemetry channel's live min/max/current block. WeatherKind is parsed by hand rather than serialized as an enum, so a weather type a future game version adds costs one icon instead of the whole channel. Two derivations live in shared, where the panel and the widget both reach them and a test can call them off the composition: periodRuns merges a crop's periods into contiguous bars (grass sows March–October *and* February, which has to draw as two), and todayFraction places the marker, half-day offset included, the way the game's own updateTodayBar does. The four captures are Grisu's, taken 2026-08-16. They passed every assertion first time, and they pinned down three things that had only been reasoned about: the hourly strip really wraps past midnight, formatDayInPeriod drops the day number entirely at daysPerPeriod = 1, and only the current wind angle is snapped to 45° while the forecast angles are raw. noSeasons.json is vanilla.json's savegame flipped to GrowthMode.DAILY — the case where every crop reports all twelve periods and the calendar stops meaning anything. Co-Authored-By: Claude Opus 5 (1M context) --- .../net/vertexdezign/vdt/server/Server.kt | 24 + .../kotlin/net/vertexdezign/vdt/Protocol.kt | 32 + .../kotlin/net/vertexdezign/vdt/VdtParser.kt | 8 + .../vertexdezign/vdt/model/CropCalendar.kt | 131 +++ .../vertexdezign/vdt/model/WeatherForecast.kt | 116 +++ .../vertexdezign/vdt/CropCalendarModelTest.kt | 199 ++++ .../net/vertexdezign/vdt/WeatherModelTest.kt | 145 +++ examples/json/cropCalendar/modded.json | 673 +++++++++++++ examples/json/cropCalendar/noSeasons.json | 907 ++++++++++++++++++ examples/json/cropCalendar/vanilla.json | 453 +++++++++ examples/json/weather/vanilla.json | 164 ++++ 11 files changed, 2852 insertions(+) create mode 100644 VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/model/CropCalendar.kt create mode 100644 VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/model/WeatherForecast.kt create mode 100644 VDTerminal/shared/src/jvmTest/kotlin/net/vertexdezign/vdt/CropCalendarModelTest.kt create mode 100644 VDTerminal/shared/src/jvmTest/kotlin/net/vertexdezign/vdt/WeatherModelTest.kt create mode 100644 examples/json/cropCalendar/modded.json create mode 100644 examples/json/cropCalendar/noSeasons.json create mode 100644 examples/json/cropCalendar/vanilla.json create mode 100644 examples/json/weather/vanilla.json diff --git a/VDTerminal/server/src/main/kotlin/net/vertexdezign/vdt/server/Server.kt b/VDTerminal/server/src/main/kotlin/net/vertexdezign/vdt/server/Server.kt index 5f4cebe..9a34f2f 100644 --- a/VDTerminal/server/src/main/kotlin/net/vertexdezign/vdt/server/Server.kt +++ b/VDTerminal/server/src/main/kotlin/net/vertexdezign/vdt/server/Server.kt @@ -159,6 +159,14 @@ fun main() { // the absence rule carries an extra meaning: the file only ever exists when FS25_Invoices is // installed, so null is what tells the app the whole feature is unavailable. val invoicesState = watcher.register("invoices.json", nullOnAbsent = true) { VdtParser.parseInvoices(it) } + // cropCalendar.json is event-driven and nearly static -- rewritten once per in-game day for the + // today marker. Same absence rule: on a different map the crop list is a different set entirely, so + // the app must clear rather than keep the last one. + val cropCalendarState = + watcher.register("cropCalendar.json", nullOnAbsent = true) { VdtParser.parseCropCalendar(it) } + // weather.json is event-driven on the in-game hour; same absence rule -- a stale forecast is worse + // than none, since it is read to decide whether to cut hay. + val weatherState = watcher.register("weather.json", nullOnAbsent = true) { VdtParser.parseWeather(it) } watcher.launchIn(appScope) // The ground-layer rasters live in their own folder, one file per plane plus index.json naming the @@ -363,6 +371,20 @@ fun main() { send(Frame.Text(json.encodeToString(ServerMessage.serializer(), message))) } } + val cropCalendarJob = + launch { + cropCalendarState.collect { data -> + val message: ServerMessage = ServerMessage.CropCalendar(data) + send(Frame.Text(json.encodeToString(ServerMessage.serializer(), message))) + } + } + val weatherJob = + launch { + weatherState.collect { data -> + val message: ServerMessage = ServerMessage.Weather(data) + send(Frame.Text(json.encodeToString(ServerMessage.serializer(), message))) + } + } val channelStatsJob = launch { channelStatsState.collect { data -> @@ -425,6 +447,8 @@ fun main() { missionsJob.cancel() financeJob.cancel() invoicesJob.cancel() + cropCalendarJob.cancel() + weatherJob.cancel() channelStatsJob.cancel() mapLayersJob.cancel() } diff --git a/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/Protocol.kt b/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/Protocol.kt index 1d1c377..99f24d7 100644 --- a/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/Protocol.kt +++ b/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/Protocol.kt @@ -2,6 +2,7 @@ package net.vertexdezign.vdt import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import net.vertexdezign.vdt.model.CropCalendarData import net.vertexdezign.vdt.model.CropRotationData import net.vertexdezign.vdt.model.FieldInfoData import net.vertexdezign.vdt.model.FinanceData @@ -16,6 +17,7 @@ import net.vertexdezign.vdt.model.ProductionData import net.vertexdezign.vdt.model.StorageData import net.vertexdezign.vdt.model.TaskListData import net.vertexdezign.vdt.model.VdtData +import net.vertexdezign.vdt.model.WeatherForecastData /** * Messages pushed server -> client over the WebSocket, JSON-encoded. @@ -189,6 +191,36 @@ sealed interface ServerMessage { val data: InvoicesData? = null, ) : ServerMessage + /** + * The crop calendar channel (`cropCalendar.json`): which periods each crop may be sown and + * harvested in. Event-driven and very nearly static — the mod rewrites it once per in-game day, for + * the "today" marker alone — which is a cadence of its own again, hence its own message. + * + * [data] is **null when `cropCalendar.json` is absent** (export disabled / no data yet): the app + * clears the grid then rather than leaving last session's crop list up, which on a different map is + * an entirely different set of crops. + */ + @Serializable + @SerialName("cropCalendar") + data class CropCalendar( + val data: CropCalendarData? = null, + ) : ServerMessage + + /** + * The weather channel (`weather.json`): the forecast — now, twelve two-hourly steps, six days. + * Event-driven on the in-game hour, the same beat the game's own weather menu refreshes on, so it + * is its own message rather than a field on [Telemetry] (whose `environment.weather` carries only + * the live temperature, at the ~100 ms tick). + * + * [data] is **null when `weather.json` is absent** (export disabled / no data yet): the app clears + * the strip then. A stale forecast is worse than none — it is read to decide whether to cut hay. + */ + @Serializable + @SerialName("weather") + data class Weather( + val data: WeatherForecastData? = null, + ) : ServerMessage + /** * Diagnostics: the **observed** write cadence of each channel file, as measured server-side (how * often the file actually changes on disk — what the consumer receives, independent of what the mod diff --git a/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/VdtParser.kt b/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/VdtParser.kt index 6a56e34..456dd14 100644 --- a/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/VdtParser.kt +++ b/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/VdtParser.kt @@ -1,6 +1,7 @@ package net.vertexdezign.vdt import kotlinx.serialization.json.Json +import net.vertexdezign.vdt.model.CropCalendarData import net.vertexdezign.vdt.model.CropRotationData import net.vertexdezign.vdt.model.FieldInfoData import net.vertexdezign.vdt.model.FinanceData @@ -16,6 +17,7 @@ import net.vertexdezign.vdt.model.ProductionData import net.vertexdezign.vdt.model.StorageData import net.vertexdezign.vdt.model.TaskListData import net.vertexdezign.vdt.model.VdtData +import net.vertexdezign.vdt.model.WeatherForecastData /** * Parses the mod's `vdTelemetry.json` into the typed [VdtData] model. @@ -73,6 +75,12 @@ object VdtParser { /** Parse the optional `invoices.json` channel (FS25_Invoices) into [InvoicesData]. */ fun parseInvoices(text: String): InvoicesData = json.decodeFromString(InvoicesData.serializer(), text) + /** Parse the `cropCalendar.json` channel (sow/harvest periods per crop) into [CropCalendarData]. */ + fun parseCropCalendar(text: String): CropCalendarData = json.decodeFromString(CropCalendarData.serializer(), text) + + /** Parse the `weather.json` channel (the forecast) into [WeatherForecastData]. */ + fun parseWeather(text: String): WeatherForecastData = json.decodeFromString(WeatherForecastData.serializer(), text) + /** Parse one `mapLayers/.json` raster plane into [MapLayerData]. */ fun parseMapLayer(text: String): MapLayerData = json.decodeFromString(MapLayerData.serializer(), text) diff --git a/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/model/CropCalendar.kt b/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/model/CropCalendar.kt new file mode 100644 index 0000000..484ea6e --- /dev/null +++ b/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/model/CropCalendar.kt @@ -0,0 +1,131 @@ +package net.vertexdezign.vdt.model + +import kotlinx.serialization.Serializable + +/** + * Typed model of the **crop calendar** channel the mod writes to `cropCalendar.json` (separate file, + * event-driven — see the mod's `src/collect/CropCalendarExporter.lua`): for every crop the game + * shows on its map, which of the twelve periods it may be sown in and which it may be harvested in. + * + * This is the game's own *Anbaukalender*. World state rather than farm state — every farm on a + * server reads the identical calendar — and near-static: the crop rows are fixed at map load and only + * [today] moves, which is why the channel is rewritten per in-game day rather than on a clock. + * + * Its own [version], independent of [VdtData.version]. Omitted keys fall back to these defaults. + */ +@Serializable +data class CropCalendarData( + val version: String = "", + /** + * `SEASONAL`, `DAILY` or `DISABLED`. **This decides what the rest of the file means.** Outside + * `SEASONAL` the game answers "yes" to every period for every crop, so [CalendarCrop.plant] and + * [CalendarCrop.harvest] are all twelve and say nothing — the app tells the user that instead of + * drawing twelve full bars and letting them conclude the data is broken. See [isSeasonal]. + */ + val growthMode: String = "", + val today: CalendarToday? = null, + /** The twelve columns, in order. */ + val periods: List = emptyList(), + /** Sorted by [CalendarCrop.name], the way the game's own frame sorts them. */ + val crops: List = emptyList(), +) { + /** Whether the sow/harvest periods carry information at all — see [growthMode]. */ + val isSeasonal: Boolean get() = growthMode == SEASONAL + + /** + * Where the "today" line sits across the whole twelve-period grid, as a fraction in `[0,1)`; null + * while [today] is absent. + * + * The half-day offset centres the line on the current day rather than putting it at the day's + * leading edge, matching what the game's own `updateTodayBar` draws. Expressed per period rather + * than per season (the game's form is `season * 0.25 + intoSeason * 0.25`) — identical arithmetic, + * since a season is exactly three periods, and it avoids carrying a second unit around. + */ + val todayFraction: Float? + get() { + val now = today ?: return null + val days = now.daysPerPeriod.coerceAtLeast(1) + val intoPeriod = (now.dayInPeriod - 1 + 0.5f) / days + return ((now.period - 1) + intoPeriod) / PERIODS + } + + companion object { + /** The calendar is always twelve periods; the game hardcodes the same bound. */ + const val PERIODS: Int = 12 + const val SEASONAL: String = "SEASONAL" + } +} + +/** Where the year currently stands, for the "today" marker. */ +@Serializable +data class CalendarToday( + /** The current period, 1..12 (1 is the first period of spring). */ + val period: Int = 1, + /** The day within that period, 1..[daysPerPeriod]. */ + val dayInPeriod: Int = 1, + /** The season-length setting; the player can change it mid-game, which moves the marker. */ + val daysPerPeriod: Int = 1, + /** The current game year, 1-based. */ + val year: Int = 1, +) + +/** One column of the calendar. */ +@Serializable +data class CalendarPeriod( + /** 1..12. */ + val period: Int = 0, + /** + * The game's own localized short label ("Mar", "Sep", …). + * + * **Not derivable from [period].** The game shifts the month by hemisphere, so on a southern map + * period 1 is September rather than March — which is why the label crosses the wire instead of + * being a lookup table on this side. + */ + val label: String = "", + /** `SPRING`, `SUMMER`, `AUTUMN` or `WINTER` — three periods each. */ + val season: String = "", +) + +/** One crop row. */ +@Serializable +data class CalendarCrop( + /** The fruit type's internal name ("WHEAT"). Stable across locales, so it is the row key. */ + val id: String = "", + /** The localized display name, from the fruit's fill type title ("Weizen"). */ + val name: String = "", + /** True for a cover/catch crop. */ + val catchCrop: Boolean = false, + /** The periods it may be sown in, ascending; empty when there are none. */ + val plant: List = emptyList(), + /** The periods it may be harvested in, ascending; empty when there are none. */ + val harvest: List = emptyList(), +) + +/** + * Merge ascending period numbers into contiguous runs, so a crop draws as bars rather than as twelve + * separate cells. + * + * A crop's periods are not necessarily one run: the game's meadow grass sows March through October + * *and* again in February, which arrives as `[1..8, 12]` and must draw as two bars. The list is + * sorted defensively — the mod emits it ascending, but a single out-of-order entry would otherwise + * silently split every following run. + */ +fun List.periodRuns(): List { + if (isEmpty()) return emptyList() + val runs = mutableListOf() + val ordered = sorted() + var start = ordered.first() + var previous = start + for (period in ordered.drop(1)) { + // Equal rather than only greater: a duplicate would otherwise open a new run of length zero. + if (period == previous || period == previous + 1) { + previous = period + continue + } + runs += start..previous + start = period + previous = period + } + runs += start..previous + return runs +} diff --git a/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/model/WeatherForecast.kt b/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/model/WeatherForecast.kt new file mode 100644 index 0000000..22ac57b --- /dev/null +++ b/VDTerminal/shared/src/commonMain/kotlin/net/vertexdezign/vdt/model/WeatherForecast.kt @@ -0,0 +1,116 @@ +package net.vertexdezign.vdt.model + +import kotlinx.serialization.Serializable + +/** + * Typed model of the **weather** channel the mod writes to `weather.json` (separate file, + * event-driven — see the mod's `src/collect/WeatherExporter.lua`): the forecast, as the game's own + * *Anbaukalender* shows it — current conditions, twelve two-hourly steps ahead, six days out. + * + * Named `WeatherForecastData` rather than `Weather` because [Weather] is already taken: that one is + * the live min/max/current temperature block on the telemetry channel's [Environment], published at + * the ~100 ms tick. This is the eighteen-entry forecast, published on the in-game hour. + * + * Every temperature here is **already in the player's chosen unit** and [temperatureUnit] names it, + * so the app prints rather than converts. + * + * Its own [version], independent of [VdtData.version]. Omitted keys fall back to these defaults. + */ +@Serializable +data class WeatherForecastData( + val version: String = "", + /** The unit every temperature in this payload is in — "°C" or "°F". */ + val temperatureUnit: String = "", + val today: WeatherDay? = null, + val current: ForecastNow? = null, + /** Twelve steps two hours apart, starting now. Ordered, not sorted — see [ForecastHour.hour]. */ + val hourly: List = emptyList(), + /** Six days, starting tomorrow; today is [current] / [today]. */ + val daily: List = emptyList(), +) + +/** Which day the forecast starts from. */ +@Serializable +data class WeatherDay( + /** + * The game's own localized caption ("August 1"). Not derivable on this side: it folds in the + * hemisphere's month shift, and the game drops the day number entirely when a period is one day + * long. + */ + val label: String = "", + val period: Int = 0, + val dayInPeriod: Int = 0, +) + +/** Current conditions. */ +@Serializable +data class ForecastNow( + /** A [WeatherKind] name; see [kind] for the parsed form. */ + val type: String = "", + val temperature: Int = 0, + /** m/s — the honest measurement. [windBeaufort] is what the game prints. */ + val windSpeed: Float = 0f, + /** The game's own Beaufort number, so our readout matches the one in the menu. */ + val windBeaufort: Int = 0, + /** Degrees; the arrow points at `windDirection + 180`, as the game draws it. */ + val windDirection: Int = 0, +) { + val kind: WeatherKind get() = WeatherKind.of(type) +} + +/** One step of the hourly strip. */ +@Serializable +data class ForecastHour( + /** + * The hour of the in-game day, 0..23. The strip runs forward from now and **wraps past midnight**, + * so it is ordered rather than sorted: this number alone does not say which day the step is on, + * and the list must be rendered in the order it arrived. + */ + val hour: Int = 0, + val type: String = "", + val temperature: Int = 0, + val windSpeed: Float = 0f, + val windBeaufort: Int = 0, + val windDirection: Int = 0, +) { + val kind: WeatherKind get() = WeatherKind.of(type) +} + +/** One day of the outlook. */ +@Serializable +data class ForecastDay( + /** The game's own localized short caption ("Aug 2"). */ + val label: String = "", + val period: Int = 0, + val dayInPeriod: Int = 0, + /** The day's dominant type, as the game aggregates it across that day's forecast items. */ + val type: String = "", + val high: Int = 0, + val low: Int = 0, +) { + val kind: WeatherKind get() = WeatherKind.of(type) +} + +/** + * The engine's `WeatherType`, parsed from the wire's name. + * + * Deliberately parsed by hand rather than serialized as an enum: a future game version adding a + * weather type would make a strict enum decode throw and take the whole channel down, where + * [UNKNOWN] costs one unrecognised icon. + */ +enum class WeatherKind { + SUN, + PARTIALLY_CLOUDY, + CLOUDY, + RAIN, + SNOW, + HAIL, + TWISTER, + THUNDER, + UNKNOWN, + ; + + companion object { + fun of(name: String): WeatherKind = entries.firstOrNull { it.name == name } ?: UNKNOWN + } +} diff --git a/VDTerminal/shared/src/jvmTest/kotlin/net/vertexdezign/vdt/CropCalendarModelTest.kt b/VDTerminal/shared/src/jvmTest/kotlin/net/vertexdezign/vdt/CropCalendarModelTest.kt new file mode 100644 index 0000000..d2691cd --- /dev/null +++ b/VDTerminal/shared/src/jvmTest/kotlin/net/vertexdezign/vdt/CropCalendarModelTest.kt @@ -0,0 +1,199 @@ +package net.vertexdezign.vdt + +import kotlinx.serialization.json.Json +import net.vertexdezign.vdt.model.CropCalendarData +import net.vertexdezign.vdt.model.periodRuns +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Decodes the committed `examples/json/cropCalendar` fixtures through the real server path + * ([VdtParser.parseCropCalendar]) and asserts the field mapping, the omission defaults, a lossless + * round-trip, and the two derivations the grid is drawn from ([periodRuns], + * [CropCalendarData.todayFraction]) — the crop calendar channel's half of the mod↔Kotlin contract. + * + * Three real captures, all German-locale: + * * `vanilla.json` — a base-game seasonal save, one day per period. + * * `modded.json` — a modded map: 38 crops, four cover crops, four days per period, year 2. + * * `noSeasons.json` — the same map on `GrowthMode.DAILY`, which is what makes the app's banner + * necessary and is the only fixture that shows what the mode actually does to the data. + */ +class CropCalendarModelTest { + private val json = Json { encodeDefaults = true } + + private fun example(name: String): String { + var dir: File? = File(".").absoluteFile + while (dir != null) { + val candidate = File(dir, "examples/json/cropCalendar/$name") + if (candidate.exists()) return candidate.readText() + dir = dir.parentFile + } + error("Could not locate examples/json/cropCalendar/$name from ${File(".").absolutePath}") + } + + private fun assertRoundTrips(data: CropCalendarData) { + val encoded = json.encodeToString(CropCalendarData.serializer(), data) + val decoded = json.decodeFromString(CropCalendarData.serializer(), encoded) + assertEquals(data, decoded, "JSON round-trip should be lossless") + } + + @Test + fun parsesTheVanillaCapture() { + val data = VdtParser.parseCropCalendar(example("vanilla.json")) + + assertEquals("1", data.version) + assertEquals("SEASONAL", data.growthMode) + assertTrue(data.isSeasonal) + + val today = assertNotNull(data.today) + assertEquals(6, today.period) + assertEquals(1, today.dayInPeriod) + assertEquals(1, today.daysPerPeriod) + assertEquals(1, today.year) + + // Twelve columns, spring-first, labelled by the game's own localisation — the reason the labels + // cross the wire at all instead of being a table on this side. + assertEquals(12, data.periods.size) + assertEquals(listOf("März", "Apr", "Mai"), data.periods.take(3).map { it.label }) + assertEquals("Feb", data.periods.last().label) + assertEquals(listOf("SPRING", "SPRING", "SPRING", "SUMMER"), data.periods.take(4).map { it.season }) + assertEquals("WINTER", data.periods.last().season) + + assertEquals(26, data.crops.size) + val cotton = assertNotNull(data.crops.firstOrNull { it.id == "COTTON" }) + assertEquals("Baumwolle", cotton.name) + assertEquals(listOf(1, 12), cotton.plant) + assertEquals(listOf(8, 9), cotton.harvest) + // The wrapped case the grid has to draw as two bars, in real data: cotton sows in March and again + // in February. + assertEquals(listOf(1..1, 12..12), cotton.plant.periodRuns()) + + // catchCrop is omitted rather than written false — exactly one crop in this capture carries it. + assertEquals(1, data.crops.count { it.catchCrop }) + assertFalse(cotton.catchCrop) + assertEquals("OILSEEDRADISH", data.crops.first { it.catchCrop }.id) + + // period 6 of 12, the only day of that period -> centred half a day in + assertEquals(5.5f / 12f, assertNotNull(data.todayFraction), 1e-6f) + + assertRoundTrips(data) + } + + @Test + fun cropsArriveInTheOrderTheGameSortsThem() { + val data = VdtParser.parseCropCalendar(example("vanilla.json")) + val names = data.crops.map { it.name } + + assertEquals(listOf("Baumwolle", "Buschbohnen", "Erbsen", "Gerste"), names.take(4)) + // Byte-wise, not locale-aware: "Ölrettich" lands last rather than beside "Oat". That is not a bug + // to fix here — the mod sorts with the same `<` the game's own calendar frame uses, so the two + // screens list the crops in the same order, which matters more than dictionary order. + assertEquals("Ölrettich", names.last()) + assertEquals(names.sorted(), names) + } + + @Test + fun parsesTheModdedCapture() { + val data = VdtParser.parseCropCalendar(example("modded.json")) + + assertTrue(data.isSeasonal) + assertEquals(38, data.crops.size) + assertEquals(4, data.crops.count { it.catchCrop }) + + val today = assertNotNull(data.today) + assertEquals(5, today.period) + assertEquals(4, today.daysPerPeriod) + assertEquals(2, today.year) + // Four days to a period, sitting on the first -> an eighth of the way into period 5. + assertEquals((4f + 0.125f) / 12f, assertNotNull(data.todayFraction), 1e-6f) + + // A map with mod crops is where the wrapped ranges actually show up in bulk. + val fieldGrass = assertNotNull(data.crops.firstOrNull { it.id == "FIELDGRASS" }) + assertEquals(listOf(1..8, 12..12), fieldGrass.plant.periodRuns()) + assertEquals(listOf(1..9), fieldGrass.harvest.periodRuns()) + val wheat = assertNotNull(data.crops.firstOrNull { it.id == "WHEAT" }) + assertEquals(listOf(1..1, 12..12), wheat.plant.periodRuns()) + + assertRoundTrips(data) + } + + @Test + fun parsesTheNonSeasonalCapture() { + val data = VdtParser.parseCropCalendar(example("noSeasons.json")) + + assertEquals("DAILY", data.growthMode) + assertFalse(data.isSeasonal) + + // The point of the fixture: outside seasonal growth the game answers "yes" for every period, so + // every crop's bars run the whole year and say nothing. This is what the app's banner is for. + assertEquals(26, data.crops.size) + assertTrue(data.crops.all { it.plant == (1..12).toList() && it.harvest == (1..12).toList() }) + assertTrue(data.crops.all { it.plant.periodRuns() == listOf(1..12) }) + + assertRoundTrips(data) + } + + @Test + fun parsesAnEmptyCalendarWithOmittedArrays() { + // Inline: every captured crop carries both period lists, so nothing on disk exercises their + // absence — but the mod omits an empty one rather than writing [], and a crop with no harvest + // period (a tree) would arrive exactly like this. + val data = VdtParser.parseCropCalendar("""{ "version": "1", "growthMode": "SEASONAL" }""") + + assertEquals("1", data.version) + assertNull(data.today) + assertTrue(data.periods.isEmpty()) + assertTrue(data.crops.isEmpty()) + assertNull(data.todayFraction) + assertRoundTrips(data) + } + + @Test + fun todayFractionSurvivesAZeroDaysPerPeriod() { + // The mod floors daysPerPeriod at 1, but a corrupt file must not divide by zero here either. + val data = + VdtParser.parseCropCalendar( + """{ "version": "1", "today": { "period": 1, "dayInPeriod": 1, "daysPerPeriod": 0 } }""", + ) + assertEquals(0.5f / 12f, assertNotNull(data.todayFraction), 1e-6f) + } + + @Test + fun periodRunsMergeContiguousPeriodsAndKeepGapsApart() { + assertEquals(listOf(1..8, 12..12), listOf(1, 2, 3, 4, 5, 6, 7, 8, 12).periodRuns()) + assertEquals(listOf(9..10), listOf(9, 10).periodRuns()) + assertEquals(listOf(3..3), listOf(3).periodRuns()) + assertEquals(emptyList(), emptyList().periodRuns()) + assertEquals(listOf(1..1, 3..3, 5..5), listOf(1, 3, 5).periodRuns()) + } + + @Test + fun periodRunsTolerateUnorderedAndDuplicatePeriods() { + assertEquals(listOf(1..3, 12..12), listOf(12, 2, 1, 3).periodRuns()) + assertEquals(listOf(4..5), listOf(4, 4, 5).periodRuns()) + } + + @Test + fun cropCalendarRidesTheServerMessageDiscriminator() { + val data = VdtParser.parseCropCalendar(example("vanilla.json")) + val message: ServerMessage = ServerMessage.CropCalendar(data) + val encoded = json.encodeToString(ServerMessage.serializer(), message) + + assertTrue(encoded.contains("\"type\":\"cropCalendar\""), "expected the cropCalendar discriminator") + val decoded = json.decodeFromString(ServerMessage.serializer(), encoded) + assertEquals(message, assertNotNull(decoded as? ServerMessage.CropCalendar)) + } + + @Test + fun cropCalendarCarriesTheAbsentFileNull() { + val message: ServerMessage = ServerMessage.CropCalendar(null) + val encoded = json.encodeToString(ServerMessage.serializer(), message) + val decoded = json.decodeFromString(ServerMessage.serializer(), encoded) + assertNull(assertNotNull(decoded as? ServerMessage.CropCalendar).data) + } +} diff --git a/VDTerminal/shared/src/jvmTest/kotlin/net/vertexdezign/vdt/WeatherModelTest.kt b/VDTerminal/shared/src/jvmTest/kotlin/net/vertexdezign/vdt/WeatherModelTest.kt new file mode 100644 index 0000000..545d181 --- /dev/null +++ b/VDTerminal/shared/src/jvmTest/kotlin/net/vertexdezign/vdt/WeatherModelTest.kt @@ -0,0 +1,145 @@ +package net.vertexdezign.vdt + +import kotlinx.serialization.json.Json +import net.vertexdezign.vdt.model.WeatherForecastData +import net.vertexdezign.vdt.model.WeatherKind +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Decodes the committed `examples/json/weather` fixture through the real server path + * ([VdtParser.parseWeather]) and asserts the field mapping, the omission defaults, the lenient + * [WeatherKind] parse and a lossless round-trip — the weather channel's half of the mod↔Kotlin + * contract. + * + * `vanilla.json` is a German-locale base-game capture taken mid-afternoon on a rainy August day, with + * one day per period. It happens to pin down three things worth having in a fixture: the hourly strip + * wrapping past midnight, the day captions losing their day number at `daysPerPeriod = 1`, and the + * current wind angle being snapped to 45° while the forecast angles are not. + */ +class WeatherModelTest { + private val json = Json { encodeDefaults = true } + + private fun example(name: String): String { + var dir: File? = File(".").absoluteFile + while (dir != null) { + val candidate = File(dir, "examples/json/weather/$name") + if (candidate.exists()) return candidate.readText() + dir = dir.parentFile + } + error("Could not locate examples/json/weather/$name from ${File(".").absolutePath}") + } + + private fun assertRoundTrips(data: WeatherForecastData) { + val encoded = json.encodeToString(WeatherForecastData.serializer(), data) + val decoded = json.decodeFromString(WeatherForecastData.serializer(), encoded) + assertEquals(data, decoded, "JSON round-trip should be lossless") + } + + @Test + fun parsesTheVanillaCapture() { + val data = VdtParser.parseWeather(example("vanilla.json")) + + assertEquals("1", data.version) + assertEquals("°C", data.temperatureUnit) + + val today = assertNotNull(data.today) + // One day per period, so the game's own caption drops the day number entirely — which is exactly + // why this label crosses the wire rather than being assembled from period + dayInPeriod here. + assertEquals("August", today.label) + assertEquals(6, today.period) + assertEquals(1, today.dayInPeriod) + + val now = assertNotNull(data.current) + assertEquals(WeatherKind.RAIN, now.kind) + assertEquals(24, now.temperature) + assertEquals(10.6f, now.windSpeed) + assertEquals(5, now.windBeaufort) + // The current angle is snapped to 45° by the engine; the forecast ones below are not. + assertEquals(315, now.windDirection) + + assertRoundTrips(data) + } + + @Test + fun theHourlyStripRunsTwoHourlyAndWrapsPastMidnight() { + val data = VdtParser.parseWeather(example("vanilla.json")) + + assertEquals(12, data.hourly.size) + // Ordered, not sorted: it starts at the capture's own hour and runs a full day forward, so the + // numbers descend across midnight. Anything that sorts this list breaks the strip. + assertEquals(listOf(17, 19, 21, 23, 1, 3, 5, 7, 9, 11, 13, 15), data.hourly.map { it.hour }) + assertEquals(WeatherKind.RAIN, data.hourly.first().kind) + assertEquals(23, data.hourly.first().temperature) + assertEquals(WeatherKind.CLOUDY, data.hourly.last().kind) + + // Forecast angles come from the weather XML rather than from a y-rotation, so unlike the current + // reading they are arbitrary degrees. + assertEquals(listOf(322, 332, 332, 332, 24), data.hourly.take(5).map { it.windDirection }) + assertTrue(data.hourly.none { it.windDirection % 45 == 0 }) + } + + @Test + fun theOutlookRunsSixDaysFromTomorrow() { + val data = VdtParser.parseWeather(example("vanilla.json")) + + assertEquals(6, data.daily.size) + // Today is period 6; the outlook starts at the next one and never repeats today. + assertEquals(listOf(7, 8, 9, 10, 11, 12), data.daily.map { it.period }) + assertEquals(listOf("Sept", "Okt", "Nov", "Dez", "Jan", "Feb"), data.daily.map { it.label }) + + val first = data.daily.first() + assertEquals(WeatherKind.SUN, first.kind) + assertEquals(13, first.high) + assertEquals(8, first.low) + // Winter arrives on schedule — a fixture that actually exercises a second weather glyph. + assertEquals(WeatherKind.SNOW, data.daily.last().kind) + assertEquals(WeatherKind.PARTIALLY_CLOUDY, data.daily[1].kind) + } + + @Test + fun parsesAnEmptyForecastWithOmittedArrays() { + // Inline: the capture is a healthy forecast. The mod omits an empty list rather than writing [], + // and a client that can read the current weather but has no forecast items would land here. + val data = VdtParser.parseWeather("""{ "version": "1", "temperatureUnit": "°C" }""") + + assertNull(data.today) + assertNull(data.current) + assertTrue(data.hourly.isEmpty()) + assertTrue(data.daily.isEmpty()) + assertRoundTrips(data) + } + + @Test + fun anUnrecognisedWeatherTypeDegradesRatherThanFailingTheParse() { + // A weather type a future game version adds must cost one icon, not the whole channel. + val data = + VdtParser.parseWeather( + """{ "version": "1", "current": { "type": "ACID_RAIN", "temperature": 12 } }""", + ) + assertEquals(WeatherKind.UNKNOWN, assertNotNull(data.current).kind) + } + + @Test + fun weatherRidesTheServerMessageDiscriminator() { + val data = VdtParser.parseWeather(example("vanilla.json")) + val message: ServerMessage = ServerMessage.Weather(data) + val encoded = json.encodeToString(ServerMessage.serializer(), message) + + assertTrue(encoded.contains("\"type\":\"weather\""), "expected the weather discriminator") + val decoded = json.decodeFromString(ServerMessage.serializer(), encoded) + assertEquals(message, assertNotNull(decoded as? ServerMessage.Weather)) + } + + @Test + fun weatherCarriesTheAbsentFileNull() { + val message: ServerMessage = ServerMessage.Weather(null) + val encoded = json.encodeToString(ServerMessage.serializer(), message) + val decoded = json.decodeFromString(ServerMessage.serializer(), encoded) + assertNull(assertNotNull(decoded as? ServerMessage.Weather).data) + } +} diff --git a/examples/json/cropCalendar/modded.json b/examples/json/cropCalendar/modded.json new file mode 100644 index 0000000..e0ed5bb --- /dev/null +++ b/examples/json/cropCalendar/modded.json @@ -0,0 +1,673 @@ +{ + "periods": [ + { + "season": "SPRING", + "period": 1, + "label": "März" + }, + { + "season": "SPRING", + "period": 2, + "label": "Apr" + }, + { + "season": "SPRING", + "period": 3, + "label": "Mai" + }, + { + "season": "SUMMER", + "period": 4, + "label": "Jun" + }, + { + "season": "SUMMER", + "period": 5, + "label": "Jul" + }, + { + "season": "SUMMER", + "period": 6, + "label": "Aug" + }, + { + "season": "AUTUMN", + "period": 7, + "label": "Sept" + }, + { + "season": "AUTUMN", + "period": 8, + "label": "Okt" + }, + { + "season": "AUTUMN", + "period": 9, + "label": "Nov" + }, + { + "season": "WINTER", + "period": 10, + "label": "Dez" + }, + { + "season": "WINTER", + "period": 11, + "label": "Jan" + }, + { + "season": "WINTER", + "period": 12, + "label": "Feb" + } + ], + "version": "1", + "today": { + "dayInPeriod": 1, + "daysPerPeriod": 4, + "period": 5, + "year": 2 + }, + "growthMode": "SEASONAL", + "crops": [ + { + "name": "Ackergras", + "id": "FIELDGRASS", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ] + }, + { + "name": "Blühende Zwischenfrucht", + "id": "FLOWERINGCATCHCROP", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7 + ], + "catchCrop": true, + "harvest": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Buschbohnen", + "id": "GREENBEAN", + "plant": [ + 2, + 3, + 4 + ], + "harvest": [ + 6, + 7, + 8, + 9 + ] + }, + { + "name": "Dinkel", + "id": "SPELT", + "plant": [ + 8, + 9 + ], + "harvest": [ + 5, + 6 + ] + }, + { + "name": "Erbsen", + "id": "PEA", + "plant": [ + 1, + 2 + ], + "harvest": [ + 5, + 6, + 7 + ] + }, + { + "name": "Gras", + "id": "GRASS", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Grünroggen", + "id": "GREENRYE", + "plant": [ + 7, + 8 + ], + "harvest": [ + 2 + ] + }, + { + "name": "Hafer", + "id": "OAT", + "plant": [ + 1, + 2 + ], + "harvest": [ + 5, + 6 + ] + }, + { + "name": "Hirse", + "id": "SORGHUM", + "plant": [ + 2, + 3 + ], + "harvest": [ + 6, + 7 + ] + }, + { + "name": "Humusaktiv", + "id": "HUMUSACTIVE", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8 + ], + "catchCrop": true, + "harvest": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Karotten", + "id": "CARROT", + "plant": [ + 2, + 3, + 4, + 5 + ], + "harvest": [ + 6, + 7, + 8, + 9 + ] + }, + { + "name": "Kartoffeln", + "id": "POTATO", + "plant": [ + 1, + 2 + ], + "harvest": [ + 6, + 7 + ] + }, + { + "name": "Klee", + "id": "CLOVER", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 12 + ], + "harvest": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ] + }, + { + "name": "Langkornreis", + "id": "RICELONGGRAIN", + "plant": [ + 2 + ], + "harvest": [ + 7 + ] + }, + { + "name": "Luzerne", + "id": "ALFALFA", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 12 + ], + "harvest": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ] + }, + { + "name": "Mais", + "id": "MAIZE", + "plant": [ + 2, + 3 + ], + "harvest": [ + 8, + 9 + ] + }, + { + "name": "Oliven", + "id": "OLIVE", + "plant": [ + 1, + 2, + 3, + 4 + ], + "harvest": [ + 8 + ] + }, + { + "name": "Pappel", + "id": "POPLAR", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Pastinaken", + "id": "PARSNIP", + "plant": [ + 2, + 3, + 4 + ], + "harvest": [ + 6, + 7, + 8, + 9 + ] + }, + { + "name": "Raps", + "id": "CANOLA", + "plant": [ + 6, + 7 + ], + "harvest": [ + 5, + 6 + ] + }, + { + "name": "Reis", + "id": "RICE", + "plant": [ + 2, + 3 + ], + "harvest": [ + 6, + 7 + ] + }, + { + "name": "Roggen", + "id": "RYE", + "plant": [ + 7, + 8 + ], + "harvest": [ + 5, + 6 + ] + }, + { + "name": "Rote Beete", + "id": "BEETROOT", + "plant": [ + 2, + 3, + 4 + ], + "harvest": [ + 6, + 7, + 8, + 9 + ] + }, + { + "name": "Senf", + "id": "MUSTARD", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8 + ], + "catchCrop": true, + "harvest": [ + 5, + 6, + 7, + 8, + 9 + ] + }, + { + "name": "Silage Mais", + "id": "SILAGEMAIZE", + "plant": [ + 2, + 3, + 4, + 5 + ], + "harvest": [ + 8, + 9, + 10 + ] + }, + { + "name": "Sojabohnen", + "id": "SOYBEAN", + "plant": [ + 2, + 3 + ], + "harvest": [ + 8, + 9 + ] + }, + { + "name": "Sommergerste", + "id": "BARLEY", + "plant": [ + 1, + 12 + ], + "harvest": [ + 5, + 6 + ] + }, + { + "name": "Sommerweizen", + "id": "WHEAT", + "plant": [ + 1, + 12 + ], + "harvest": [ + 6, + 7 + ] + }, + { + "name": "Sonnenblumen", + "id": "SUNFLOWER", + "plant": [ + 1, + 2 + ], + "harvest": [ + 8, + 9 + ] + }, + { + "name": "Spinat", + "id": "SPINACH", + "plant": [ + 1, + 2, + 3 + ], + "harvest": [ + 4, + 5, + 6, + 7, + 8, + 9 + ] + }, + { + "name": "Trauben", + "id": "GRAPE", + "plant": [ + 1, + 2, + 3 + ], + "harvest": [ + 7, + 8 + ] + }, + { + "name": "Triticale", + "id": "TRITICALE", + "plant": [ + 7, + 8 + ], + "harvest": [ + 5, + 6 + ] + }, + { + "name": "Wickroggen", + "id": "VETCHRYE", + "plant": [ + 7, + 8 + ], + "harvest": [ + 2 + ] + }, + { + "name": "Wintergerste", + "id": "WINTERBARLEY", + "plant": [ + 7, + 8 + ], + "harvest": [ + 4, + 5 + ] + }, + { + "name": "Winterweizen", + "id": "WINTERWHEAT", + "plant": [ + 7, + 8 + ], + "harvest": [ + 5, + 6 + ] + }, + { + "name": "Zuckerrüben", + "id": "SUGARBEET", + "plant": [ + 1, + 2 + ], + "harvest": [ + 8, + 9 + ] + }, + { + "name": "Zwiebeln", + "id": "ONION", + "plant": [ + 1, + 2 + ], + "harvest": [ + 6, + 7 + ] + }, + { + "name": "Ölrettich", + "id": "OILSEEDRADISH", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8 + ], + "catchCrop": true, + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + } + ] +} diff --git a/examples/json/cropCalendar/noSeasons.json b/examples/json/cropCalendar/noSeasons.json new file mode 100644 index 0000000..ebee1c7 --- /dev/null +++ b/examples/json/cropCalendar/noSeasons.json @@ -0,0 +1,907 @@ +{ + "periods": [ + { + "season": "SPRING", + "period": 1, + "label": "März" + }, + { + "season": "SPRING", + "period": 2, + "label": "Apr" + }, + { + "season": "SPRING", + "period": 3, + "label": "Mai" + }, + { + "season": "SUMMER", + "period": 4, + "label": "Jun" + }, + { + "season": "SUMMER", + "period": 5, + "label": "Jul" + }, + { + "season": "SUMMER", + "period": 6, + "label": "Aug" + }, + { + "season": "AUTUMN", + "period": 7, + "label": "Sept" + }, + { + "season": "AUTUMN", + "period": 8, + "label": "Okt" + }, + { + "season": "AUTUMN", + "period": 9, + "label": "Nov" + }, + { + "season": "WINTER", + "period": 10, + "label": "Dez" + }, + { + "season": "WINTER", + "period": 11, + "label": "Jan" + }, + { + "season": "WINTER", + "period": 12, + "label": "Feb" + } + ], + "version": "1", + "today": { + "dayInPeriod": 1, + "daysPerPeriod": 1, + "period": 6, + "year": 1 + }, + "growthMode": "DAILY", + "crops": [ + { + "name": "Baumwolle", + "id": "COTTON", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Buschbohnen", + "id": "GREENBEAN", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Erbsen", + "id": "PEA", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Gerste", + "id": "BARLEY", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Gras", + "id": "GRASS", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Hafer", + "id": "OAT", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Hirse", + "id": "SORGHUM", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Karotten", + "id": "CARROT", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Kartoffeln", + "id": "POTATO", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Langkornreis", + "id": "RICELONGGRAIN", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Mais", + "id": "MAIZE", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Oliven", + "id": "OLIVE", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Pappel", + "id": "POPLAR", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Pastinaken", + "id": "PARSNIP", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Raps", + "id": "CANOLA", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Reis", + "id": "RICE", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Rote Beete", + "id": "BEETROOT", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Sojabohnen", + "id": "SOYBEAN", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Sonnenblumen", + "id": "SUNFLOWER", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Spinat", + "id": "SPINACH", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Trauben", + "id": "GRAPE", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Weizen", + "id": "WHEAT", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Zuckerrohr", + "id": "SUGARCANE", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Zuckerrüben", + "id": "SUGARBEET", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Zwiebeln", + "id": "ONION", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Ölrettich", + "id": "OILSEEDRADISH", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "catchCrop": true, + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + } + ] +} diff --git a/examples/json/cropCalendar/vanilla.json b/examples/json/cropCalendar/vanilla.json new file mode 100644 index 0000000..5bbe677 --- /dev/null +++ b/examples/json/cropCalendar/vanilla.json @@ -0,0 +1,453 @@ +{ + "periods": [ + { + "season": "SPRING", + "period": 1, + "label": "März" + }, + { + "season": "SPRING", + "period": 2, + "label": "Apr" + }, + { + "season": "SPRING", + "period": 3, + "label": "Mai" + }, + { + "season": "SUMMER", + "period": 4, + "label": "Jun" + }, + { + "season": "SUMMER", + "period": 5, + "label": "Jul" + }, + { + "season": "SUMMER", + "period": 6, + "label": "Aug" + }, + { + "season": "AUTUMN", + "period": 7, + "label": "Sept" + }, + { + "season": "AUTUMN", + "period": 8, + "label": "Okt" + }, + { + "season": "AUTUMN", + "period": 9, + "label": "Nov" + }, + { + "season": "WINTER", + "period": 10, + "label": "Dez" + }, + { + "season": "WINTER", + "period": 11, + "label": "Jan" + }, + { + "season": "WINTER", + "period": 12, + "label": "Feb" + } + ], + "version": "1", + "today": { + "dayInPeriod": 1, + "daysPerPeriod": 1, + "period": 6, + "year": 1 + }, + "growthMode": "SEASONAL", + "crops": [ + { + "name": "Baumwolle", + "id": "COTTON", + "plant": [ + 1, + 12 + ], + "harvest": [ + 8, + 9 + ] + }, + { + "name": "Buschbohnen", + "id": "GREENBEAN", + "plant": [ + 2, + 3, + 4 + ], + "harvest": [ + 6, + 7, + 8, + 9 + ] + }, + { + "name": "Erbsen", + "id": "PEA", + "plant": [ + 1, + 2 + ], + "harvest": [ + 5, + 6, + 7 + ] + }, + { + "name": "Gerste", + "id": "BARLEY", + "plant": [ + 7, + 8 + ], + "harvest": [ + 4, + 5 + ] + }, + { + "name": "Gras", + "id": "GRASS", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Hafer", + "id": "OAT", + "plant": [ + 1, + 2 + ], + "harvest": [ + 5, + 6 + ] + }, + { + "name": "Hirse", + "id": "SORGHUM", + "plant": [ + 2, + 3 + ], + "harvest": [ + 6, + 7 + ] + }, + { + "name": "Karotten", + "id": "CARROT", + "plant": [ + 2, + 3, + 4, + 5 + ], + "harvest": [ + 6, + 7, + 8, + 9 + ] + }, + { + "name": "Kartoffeln", + "id": "POTATO", + "plant": [ + 1, + 2 + ], + "harvest": [ + 6, + 7 + ] + }, + { + "name": "Langkornreis", + "id": "RICELONGGRAIN", + "plant": [ + 2 + ], + "harvest": [ + 7 + ] + }, + { + "name": "Mais", + "id": "MAIZE", + "plant": [ + 2, + 3 + ], + "harvest": [ + 8, + 9 + ] + }, + { + "name": "Oliven", + "id": "OLIVE", + "plant": [ + 1, + 2, + 3, + 4 + ], + "harvest": [ + 8 + ] + }, + { + "name": "Pappel", + "id": "POPLAR", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6 + ], + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + }, + { + "name": "Pastinaken", + "id": "PARSNIP", + "plant": [ + 2, + 3, + 4 + ], + "harvest": [ + 6, + 7, + 8, + 9 + ] + }, + { + "name": "Raps", + "id": "CANOLA", + "plant": [ + 6, + 7 + ], + "harvest": [ + 5, + 6 + ] + }, + { + "name": "Reis", + "id": "RICE", + "plant": [ + 2, + 3 + ], + "harvest": [ + 6, + 7 + ] + }, + { + "name": "Rote Beete", + "id": "BEETROOT", + "plant": [ + 2, + 3, + 4 + ], + "harvest": [ + 6, + 7, + 8, + 9 + ] + }, + { + "name": "Sojabohnen", + "id": "SOYBEAN", + "plant": [ + 2, + 3 + ], + "harvest": [ + 8, + 9 + ] + }, + { + "name": "Sonnenblumen", + "id": "SUNFLOWER", + "plant": [ + 1, + 2 + ], + "harvest": [ + 8, + 9 + ] + }, + { + "name": "Spinat", + "id": "SPINACH", + "plant": [ + 1, + 2, + 3 + ], + "harvest": [ + 4, + 5, + 6, + 7, + 8, + 9 + ] + }, + { + "name": "Trauben", + "id": "GRAPE", + "plant": [ + 1, + 2, + 3 + ], + "harvest": [ + 7, + 8 + ] + }, + { + "name": "Weizen", + "id": "WHEAT", + "plant": [ + 7, + 8 + ], + "harvest": [ + 5, + 6 + ] + }, + { + "name": "Zuckerrohr", + "id": "SUGARCANE", + "plant": [ + 1, + 2 + ], + "harvest": [ + 8, + 9 + ] + }, + { + "name": "Zuckerrüben", + "id": "SUGARBEET", + "plant": [ + 1, + 2 + ], + "harvest": [ + 8, + 9 + ] + }, + { + "name": "Zwiebeln", + "id": "ONION", + "plant": [ + 1, + 2 + ], + "harvest": [ + 6, + 7 + ] + }, + { + "name": "Ölrettich", + "id": "OILSEEDRADISH", + "plant": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8 + ], + "catchCrop": true, + "harvest": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ] + } + ] +} diff --git a/examples/json/weather/vanilla.json b/examples/json/weather/vanilla.json new file mode 100644 index 0000000..8def41d --- /dev/null +++ b/examples/json/weather/vanilla.json @@ -0,0 +1,164 @@ +{ + "current": { + "temperature": 24, + "windSpeed": 10.6, + "windDirection": 315, + "type": "RAIN", + "windBeaufort": 5 + }, + "version": "1", + "hourly": [ + { + "type": "RAIN", + "windSpeed": 10.6, + "temperature": 23, + "windDirection": 322, + "hour": 17, + "windBeaufort": 5 + }, + { + "type": "SUN", + "windSpeed": 2.8, + "temperature": 21, + "windDirection": 332, + "hour": 19, + "windBeaufort": 2 + }, + { + "type": "SUN", + "windSpeed": 2.8, + "temperature": 19, + "windDirection": 332, + "hour": 21, + "windBeaufort": 2 + }, + { + "type": "SUN", + "windSpeed": 2.8, + "temperature": 17, + "windDirection": 332, + "hour": 23, + "windBeaufort": 2 + }, + { + "type": "CLOUDY", + "windSpeed": 7.8, + "temperature": 8, + "windDirection": 24, + "hour": 1, + "windBeaufort": 4 + }, + { + "type": "CLOUDY", + "windSpeed": 7.8, + "temperature": 8, + "windDirection": 24, + "hour": 3, + "windBeaufort": 4 + }, + { + "type": "CLOUDY", + "windSpeed": 7.8, + "temperature": 9, + "windDirection": 24, + "hour": 5, + "windBeaufort": 4 + }, + { + "type": "CLOUDY", + "windSpeed": 7.8, + "temperature": 10, + "windDirection": 24, + "hour": 7, + "windBeaufort": 4 + }, + { + "type": "CLOUDY", + "windSpeed": 5.8, + "temperature": 11, + "windDirection": 10, + "hour": 9, + "windBeaufort": 3 + }, + { + "type": "CLOUDY", + "windSpeed": 5.8, + "temperature": 12, + "windDirection": 10, + "hour": 11, + "windBeaufort": 3 + }, + { + "type": "CLOUDY", + "windSpeed": 5.8, + "temperature": 13, + "windDirection": 10, + "hour": 13, + "windBeaufort": 3 + }, + { + "type": "CLOUDY", + "windSpeed": 5.8, + "temperature": 13, + "windDirection": 10, + "hour": 15, + "windBeaufort": 3 + } + ], + "today": { + "dayInPeriod": 1, + "period": 6, + "label": "August" + }, + "temperatureUnit": "°C", + "daily": [ + { + "low": 8, + "type": "SUN", + "period": 7, + "label": "Sept", + "dayInPeriod": 1, + "high": 13 + }, + { + "low": 8, + "type": "PARTIALLY_CLOUDY", + "period": 8, + "label": "Okt", + "dayInPeriod": 1, + "high": 13 + }, + { + "low": 8, + "type": "PARTIALLY_CLOUDY", + "period": 9, + "label": "Nov", + "dayInPeriod": 1, + "high": 14 + }, + { + "low": 4, + "type": "SNOW", + "period": 10, + "label": "Dez", + "dayInPeriod": 1, + "high": 8 + }, + { + "low": 4, + "type": "SNOW", + "period": 11, + "label": "Jan", + "dayInPeriod": 1, + "high": 10 + }, + { + "low": 4, + "type": "SNOW", + "period": 12, + "label": "Feb", + "dayInPeriod": 1, + "high": 10 + } + ] +} From 5082d437846b432bd198f36129107e5022374d16 Mon Sep 17 00:00:00 2001 From: Benjamin Leber Date: Sun, 16 Aug 2026 20:51:59 +0200 Subject: [PATCH 4/6] =?UTF-8?q?#96=20=F0=9F=94=A8=20[app]=20lift=20the=20f?= =?UTF-8?q?ilter=20chip=20and=20the=20search=20field=20into=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were private copies about to gain a third: InvoicesSection's FilterChip and MapPanel's field/POI search box, which the calendar needs as-is. Moved into components/ and both call sites repointed — no visual change, the definitions came across verbatim. WidgetDashboard's own Chip is deliberately left alone. It looks different on purpose (bordered, on white, no ripple) for the page editor, and folding it in would have restyled that screen as a side effect of unrelated work. Co-Authored-By: Claude Opus 5 (1M context) --- .../vdt/app/components/FilterChip.kt | 50 +++++++++++++++++ .../vdt/app/components/SearchField.kt | 55 +++++++++++++++++++ .../vdt/app/panels/InvoicesSection.kt | 16 +----- .../vertexdezign/vdt/app/panels/MapPanel.kt | 23 ++------ 4 files changed, 110 insertions(+), 34 deletions(-) create mode 100644 VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/components/FilterChip.kt create mode 100644 VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/components/SearchField.kt diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/components/FilterChip.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/components/FilterChip.kt new file mode 100644 index 0000000..a6271b7 --- /dev/null +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/components/FilterChip.kt @@ -0,0 +1,50 @@ +package net.vertexdezign.vdt.app.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.vertexdezign.vdt.app.theme.VdtColors + +/** + * A list filter's on/off chip — "incoming", "sow now", "harvest now". + * + * The on state does **not** shift hue: it fills the chip and knocks the label out in [VdtColors.White], + * so off-vs-on differs in ink brightness (5.0:1 grey on grey vs white on green) as well as in the fill. + * That is the sanctioned mechanism for a two-state mark on a light panel (see `VDTerminal/README.md` → + * "Design rules"), and the padding is spent in both states so nothing shifts when it toggles. + * + * Lifted out of `InvoicesSection` when the calendar became its second caller. `WidgetDashboard`'s own + * `Chip` is deliberately left alone: it is a visually different control (bordered, on white, no + * ripple) for the page editor, and folding it in here would restyle that screen for no reason. + */ +@Composable +fun FilterChip( + label: String, + active: Boolean, + // Ahead of `modifier`, per the same ktlint rule ActionIcon documents: a required event lambda may + // not be trailing, and a filter chip without a click does nothing. + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Text( + label.uppercase(), + color = if (active) VdtColors.White else VdtColors.DarkGray, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + modifier = + modifier + .clip(RoundedCornerShape(4.dp)) + .background(if (active) VdtColors.Green else VdtColors.TrackGray) + .clickable(role = Role.Button, onClick = onClick) + .padding(horizontal = 10.dp, vertical = 5.dp), + ) +} diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/components/SearchField.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/components/SearchField.kt new file mode 100644 index 0000000..0489222 --- /dev/null +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/components/SearchField.kt @@ -0,0 +1,55 @@ +package net.vertexdezign.vdt.app.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.vertexdezign.vdt.app.theme.VdtColors + +/** + * A panel's search box: one line, a placeholder while empty. + * + * A [BasicTextField] with a `decorationBox` rather than Material's `OutlinedTextField` — the terminal's + * fields are 13sp on a 4dp radius, well under the ~56dp minimum height Material's own decoration + * imposes, and every text input in the app is built this way. + * + * Lifted out of `MapPanel`'s field/POI search when the calendar became its second caller. + */ +@Composable +fun SearchField( + value: String, + placeholder: String, + // Ahead of `modifier`, per the same ktlint rule ActionIcon documents. + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + textStyle = TextStyle(fontSize = 13.sp, color = VdtColors.TextDark), + modifier = + modifier + .clip(RoundedCornerShape(4.dp)) + .background(VdtColors.White) + .border(1.dp, VdtColors.PanelBorder, RoundedCornerShape(4.dp)) + .padding(horizontal = 8.dp, vertical = 6.dp), + decorationBox = { inner -> + Box { + if (value.isEmpty()) { + Text(placeholder, fontSize = 13.sp, color = VdtColors.DarkGray) + } + inner() + } + }, + ) +} diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/InvoicesSection.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/InvoicesSection.kt index e0cd5ca..347a33d 100644 --- a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/InvoicesSection.kt +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/InvoicesSection.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.unit.sp import net.vertexdezign.vdt.ClientMessage import net.vertexdezign.vdt.app.components.Centered import net.vertexdezign.vdt.app.components.ConfirmDialog +import net.vertexdezign.vdt.app.components.FilterChip import net.vertexdezign.vdt.app.components.Panel import net.vertexdezign.vdt.app.theme.VdtColors import net.vertexdezign.vdt.model.Invoice @@ -216,21 +217,6 @@ private fun InvoicesHeadline(data: InvoicesData) { } } -@Composable -private fun FilterChip(label: String, active: Boolean, onClick: () -> Unit) { - Text( - label.uppercase(), - color = if (active) VdtColors.White else VdtColors.DarkGray, - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier - .clip(RoundedCornerShape(4.dp)) - .background(if (active) VdtColors.Green else VdtColors.TrackGray) - .clickable(role = Role.Button, onClick = onClick) - .padding(horizontal = 10.dp, vertical = 5.dp), - ) -} - /** The status word the mod's own list prints, and the ink for it. */ private fun statusOf(invoice: Invoice): Pair = when { invoice.isPaid -> "Paid" to VdtColors.DarkGray diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/MapPanel.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/MapPanel.kt index 06ad836..464ae84 100644 --- a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/MapPanel.kt +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/MapPanel.kt @@ -31,7 +31,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -107,6 +106,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import net.vertexdezign.vdt.ClientMessage import net.vertexdezign.vdt.app.components.Panel +import net.vertexdezign.vdt.app.components.SearchField import net.vertexdezign.vdt.app.components.SectionStrip import net.vertexdezign.vdt.app.components.boomOf import net.vertexdezign.vdt.app.theme.VdtColors @@ -1704,26 +1704,11 @@ private fun BoxScope.MapFilterPanel( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - BasicTextField( + SearchField( value = query, + placeholder = "Search field / POI…", onValueChange = onQuery, - singleLine = true, - textStyle = TextStyle(fontSize = 13.sp, color = VdtColors.TextDark), - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(4.dp)) - .background(VdtColors.White) - .border(1.dp, VdtColors.PanelBorder, RoundedCornerShape(4.dp)) - .padding(horizontal = 8.dp, vertical = 6.dp), - decorationBox = { inner -> - Box { - if (query.isEmpty()) { - Text("Search field / POI…", fontSize = 13.sp, color = VdtColors.DarkGray) - } - inner() - } - }, + modifier = Modifier.fillMaxWidth(), ) if (query.isNotBlank()) { From 604dfaf263b8d5b2e9584a0f94ce0752c6a25ee8 Mon Sep 17 00:00:00 2001 From: Benjamin Leber Date: Sun, 16 Aug 2026 20:52:14 +0200 Subject: [PATCH 5/6] =?UTF-8?q?#96=20=E2=9C=A8=20[app]=20the=20Calendar=20?= =?UTF-8?q?app:=20crop=20grid,=20forecast=20strip=20and=20a=20weather=20wi?= =?UTF-8?q?dget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The game's Anbaukalender, plus the two questions it makes you scan the whole grid to answer — what can I sow now, what can I harvest now — as a search box and two filter chips carrying their own counts. The chips are not exclusive: both on means both, which is how the question actually gets asked. Layout is four scroll containers over two shared ScrollStates rather than one per row: the header and the bars move sideways together so a column stays under its label, the names and the bars move vertically together so a row stays beside its name. Periods fill the width when there is room and scroll when there is not. Sowing and harvest are told apart four ways over, because the game's own legend does it by hue alone and that is the one thing this dashboard may not do: fixed lane order (sow above, harvest below), brightness, square versus capsule bar ends, and S/H letters in the name column. Rows alternate a guidance shade and highlight when pressed, held by crop id so re-filtering cannot move the highlight; the selected row also carries a solid leading edge bar and darker ink, since DarkGray drops to 3.9:1 on the selection wash. The grid rules are drawn as an alpha so they survive all three row backgrounds. WeatherIcons draws its eight glyphs rather than borrowing them. Weather type is carried by shape alone — rain slants, snow is angular, hail is round — and a set assembled half from Material would not read as one strip. The arrow is turned to windDirection + 180 like the game's, then negated: the engine measures counter-clockwise and Compose's rotate() turns clockwise, which had the vane mirrored about the vertical axis. Outside seasonal growth the game answers "plantable" for all twelve periods, so every bar fills and the filters mean nothing. The panel says so rather than letting the grid look broken. Co-Authored-By: Claude Opus 5 (1M context) --- VDTerminal/README.md | 1 + .../vertexdezign/vdt/app/apps/AppRegistry.kt | 1 + .../vertexdezign/vdt/app/apps/CalendarApp.kt | 36 + .../vdt/app/net/TelemetryRepository.kt | 20 + .../vdt/app/panels/CalendarPanel.kt | 771 ++++++++++++++++++ .../vdt/app/panels/DiagnosticsPanel.kt | 5 + .../vdt/app/panels/WeatherIcons.kt | 191 +++++ .../vertexdezign/vdt/app/state/VdtStore.kt | 9 + .../vdt/app/widgets/BuiltinWidgets.kt | 22 + .../vdt/app/panels/CalendarPanelTest.kt | 150 ++++ .../kotlin/net/vertexdezign/vdt/app/Main.kt | 2 + 11 files changed, 1208 insertions(+) create mode 100644 VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/apps/CalendarApp.kt create mode 100644 VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/CalendarPanel.kt create mode 100644 VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/WeatherIcons.kt create mode 100644 VDTerminal/app/src/commonTest/kotlin/net/vertexdezign/vdt/app/panels/CalendarPanelTest.kt diff --git a/VDTerminal/README.md b/VDTerminal/README.md index 3cbe39e..cfc998d 100644 --- a/VDTerminal/README.md +++ b/VDTerminal/README.md @@ -25,6 +25,7 @@ whose mod isn't installed is not listed at all, rather than showing an empty scr | **Map** | the PDA map: the DDS map image, POIs, fields, vehicle markers, the steering course, and the ground-layer overlays below | | **Production** / **Storage** / **Animals** | the farm's production points and factories, its silos and object storages, and its animal pens | | **Contracts** | the farm's missions — on offer, running, waiting to be collected — with accept / cancel / collect | +| **Calendar** | the game's crop calendar — the sowing and harvest periods of every crop, with a today line — searchable by name and filterable to what can be sown or harvested *now*, over the weather forecast (now, twelve two-hourly steps, six days) | | **Finance** | the balance, the month-by-month table and the money log, borrow and repay; Enhanced Loan System's annuity loans stand in for the base loan where it is installed, and FS25_Invoices adds an Invoices tab | | **Tasks** / **Crop Rotation** | FS25_TaskList and FS25_CropRotation, both read *and* write | | **Diagnostics** | what the mod is actually writing: each channel's observed cadence and staleness, measured server-side | diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/apps/AppRegistry.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/apps/AppRegistry.kt index 6db585e..7968f64 100644 --- a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/apps/AppRegistry.kt +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/apps/AppRegistry.kt @@ -15,6 +15,7 @@ object AppRegistry { StorageApp, AnimalsApp, MissionsApp, + CalendarApp, FinanceApp, TasksApp, CropRotationApp, diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/apps/CalendarApp.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/apps/CalendarApp.kt new file mode 100644 index 0000000..859a7d8 --- /dev/null +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/apps/CalendarApp.kt @@ -0,0 +1,36 @@ +package net.vertexdezign.vdt.app.apps + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import net.vertexdezign.vdt.app.panels.CalendarPanel +import net.vertexdezign.vdt.app.state.LocalVdtStore +import net.vertexdezign.vdt.app.widgets.WeatherWidget +import net.vertexdezign.vdt.app.widgets.Widget + +/** + * The Calendar app: the game's own crop calendar — which periods each crop may be sown and harvested + * in — over the weather forecast, plus the search and the two "now" filters the in-game screen makes + * you scan for. Base-game data, so it is always available (the panel renders its own waiting states). + * + * The forecast also goes out as a placeable [WeatherWidget]: it is the half you want glanceable while + * driving. The grid is not — it is a look-it-up screen, and it needs the width. + */ +object CalendarApp : VdtApp { + override val id = "calendar" + override val title = "Calendar" + override val icon: ImageVector = Icons.Filled.CalendarMonth + override val widgets: List = listOf(WeatherWidget) + + @Composable + override fun FullPage(modifier: Modifier) { + val store = LocalVdtStore.current + val calendar by store.cropCalendar.collectAsState() + val weather by store.weather.collectAsState() + CalendarPanel(calendar, weather, modifier) + } +} diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/net/TelemetryRepository.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/net/TelemetryRepository.kt index c2c4ec3..63e21d9 100644 --- a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/net/TelemetryRepository.kt +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/net/TelemetryRepository.kt @@ -16,6 +16,7 @@ import kotlinx.serialization.json.Json import net.vertexdezign.vdt.ChannelStatsData import net.vertexdezign.vdt.ClientMessage import net.vertexdezign.vdt.ServerMessage +import net.vertexdezign.vdt.model.CropCalendarData import net.vertexdezign.vdt.model.CropRotationData import net.vertexdezign.vdt.model.FieldInfoData import net.vertexdezign.vdt.model.FinanceData @@ -30,6 +31,7 @@ import net.vertexdezign.vdt.model.ProductionData import net.vertexdezign.vdt.model.StorageData import net.vertexdezign.vdt.model.TaskListData import net.vertexdezign.vdt.model.VdtData +import net.vertexdezign.vdt.model.WeatherForecastData import kotlin.math.roundToInt import kotlin.time.DurationUnit import kotlin.time.TimeSource @@ -130,6 +132,16 @@ class TelemetryRepository(private val scope: CoroutineScope, private val wsUrl: private val _invoices = MutableStateFlow(null) val invoices: StateFlow = _invoices.asStateFlow() + // Which periods each crop may be sown and harvested in, rewritten once per in-game day; same + // null-when-absent contract as production. + private val _cropCalendar = MutableStateFlow(null) + val cropCalendar: StateFlow = _cropCalendar.asStateFlow() + + // The forecast (now, twelve two-hourly steps, six days), on the in-game hour; same + // null-when-absent contract as production. + private val _weather = MutableStateFlow(null) + val weather: StateFlow = _weather.asStateFlow() + // Server-measured observed cadence of every channel file (diagnostics), refreshed on the server's // own slow timer. Null until the first stats frame arrives. private val _channelStats = MutableStateFlow(null) @@ -240,6 +252,14 @@ class TelemetryRepository(private val scope: CoroutineScope, private val wsUrl: _invoices.value = msg.data } + is ServerMessage.CropCalendar -> { + _cropCalendar.value = msg.data + } + + is ServerMessage.Weather -> { + _weather.value = msg.data + } + is ServerMessage.ChannelStats -> { _channelStats.value = msg.data } diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/CalendarPanel.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/CalendarPanel.kt new file mode 100644 index 0000000..6f0a378 --- /dev/null +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/CalendarPanel.kt @@ -0,0 +1,771 @@ +package net.vertexdezign.vdt.app.panels + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.vertexdezign.vdt.app.components.Centered +import net.vertexdezign.vdt.app.components.FilterChip +import net.vertexdezign.vdt.app.components.Panel +import net.vertexdezign.vdt.app.components.SearchField +import net.vertexdezign.vdt.app.theme.VdtColors +import net.vertexdezign.vdt.model.CalendarCrop +import net.vertexdezign.vdt.model.CropCalendarData +import net.vertexdezign.vdt.model.ForecastDay +import net.vertexdezign.vdt.model.ForecastHour +import net.vertexdezign.vdt.model.ForecastNow +import net.vertexdezign.vdt.model.WeatherForecastData +import net.vertexdezign.vdt.model.periodRuns + +/** + * The Calendar screen: the game's own *Anbaukalender*, plus the two questions it makes you scan the + * whole grid to answer — **what can I sow now**, and **what can I harvest now** — as a search box and + * two filters. + * + * Two channels feed it (`cropCalendar.json` and `weather.json`) on two very different cadences, and + * each half renders its own absent state: turning one channel off in the mod's settings leaves the + * other working. + */ +@Composable +fun CalendarPanel(calendar: CropCalendarData?, weather: WeatherForecastData?, modifier: Modifier = Modifier) { + Panel(title = "Calendar", icon = Icons.Filled.CalendarMonth, modifier = modifier) { + if (calendar == null && weather == null) { + Centered("Waiting for calendar data…") + return@Panel + } + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + CropCalendarSection(calendar, Modifier.weight(1f)) + WeatherSection(weather) + } + } +} + +// ---- Crop calendar ---- + +@Composable +private fun CropCalendarSection(data: CropCalendarData?, modifier: Modifier = Modifier) { + if (data == null || data.crops.isEmpty()) { + Box(modifier.fillMaxWidth()) { + Centered(if (data == null) "Waiting for the crop calendar…" else "No crops on this map") + } + return + } + + var query by remember { mutableStateOf("") } + var sowNow by remember { mutableStateOf(false) } + var harvestNow by remember { mutableStateOf(false) } + // Held by crop id, not by row index: the list is re-filtered and re-sorted under it, and an index + // would silently move the highlight to whatever crop landed in that slot. + var selectedId by remember { mutableStateOf(null) } + + val period = data.today?.period ?: 0 + val sowable = remember(data) { data.crops.count { period in it.plant } } + val harvestable = remember(data) { data.crops.count { period in it.harvest } } + val rows = remember(data, query, sowNow, harvestNow) { filterCrops(data, query, sowNow, harvestNow) } + + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SearchField( + value = query, + placeholder = "Search crop…", + onValueChange = { query = it }, + modifier = Modifier.width(180.dp), + ) + // The counts sit in the labels so the answer is there without clicking: the whole reason to + // open this screen is usually just "how many can I sow today". + FilterChip("Sow now ($sowable)", sowNow, { sowNow = !sowNow }) + FilterChip("Harvest now ($harvestable)", harvestNow, { harvestNow = !harvestNow }) + Spacer(Modifier.weight(1f)) + Legend() + } + + if (!data.isSeasonal) { + // Outside seasonal growth the game answers "yes" to every period for every crop, so every bar + // below is full. Saying so beats letting the grid look broken. + GrowthModeBanner(data.growthMode) + } + + if (rows.isEmpty()) { + Box(Modifier.fillMaxWidth().weight(1f)) { Centered("No crops match") } + return@Column + } + + BoxWithConstraints(Modifier.fillMaxWidth().weight(1f)) { + // Fill the width when there is room, scroll when there is not: the grid never squeezes a period + // narrower than PERIOD_MIN_WIDTH, below which a one-period bar stops being a bar. + val viewport = maxWidth - NAME_COLUMN_WIDTH + val periodWidth = maxOf(PERIOD_MIN_WIDTH, viewport / CropCalendarData.PERIODS) + val gridWidth = periodWidth * CropCalendarData.PERIODS + val todayFraction = data.todayFraction + + // Four scroll containers over two shared ScrollStates, rather than one per row. + // + // Horizontally, the header and the bars move together so a column stays under its label; + // vertically, the names and the bars move together so a row stays beside its name. Sharing a + // ScrollState is what couples each pair: both containers of a pair hold the same content and + // viewport size, so they agree on the scroll range and simply read the same offset. + // + // The header sits OUTSIDE the vertical pair on purpose — it scrolls sideways with the grid and + // stays put as the crops scroll under it. The name column is outside the horizontal one for the + // mirrored reason: it is the pinned column. + val hScroll = rememberScrollState() + val vScroll = rememberScrollState() + + Column(Modifier.fillMaxSize()) { + Row(Modifier.fillMaxWidth()) { + Spacer(Modifier.width(NAME_COLUMN_WIDTH)) + Box(Modifier.weight(1f).horizontalScroll(hScroll)) { + CalendarHeader(data, periodWidth, gridWidth, todayFraction) + } + } + Row(Modifier.fillMaxWidth().weight(1f)) { + // Alternate rows are shaded, the way the in-game calendar shades its own: twelve columns + // wide, the eye needs something to run along or it loses which row a far-right bar belongs + // to. The stripe is keyed on the row's position in the list, so the name column and the + // bars — which scroll together but are laid out separately — shade the same rows. + Column(Modifier.width(NAME_COLUMN_WIDTH).verticalScroll(vScroll)) { + rows.forEachIndexed { index, crop -> + CropNameCell( + crop = crop, + currentPeriod = period, + striped = isStriped(index), + selected = selectedId == crop.id, + onSelect = { selectedId = toggleSelection(selectedId, crop.id) }, + ) + } + } + Box(Modifier.weight(1f).horizontalScroll(hScroll)) { + Column(Modifier.verticalScroll(vScroll)) { + rows.forEachIndexed { index, crop -> + CropLanes( + crop = crop, + periodWidth = periodWidth, + gridWidth = gridWidth, + todayFraction = todayFraction, + striped = isStriped(index), + selected = selectedId == crop.id, + onSelect = { selectedId = toggleSelection(selectedId, crop.id) }, + ) + } + } + } + } + } + } + } +} + +/** + * The season band over the twelve period labels. + * + * Laid out over the fixed twelve rather than over `data.periods`, and each column looked up by its + * number: the bars and the grid lines are always twelve wide, so a file that arrived short a period + * would otherwise slide every label out from under its column instead of leaving one blank. + * + * Neither band is given a fixed height. `Modifier.height()` is an *exact* constraint, so a text + * measured inside one is clipped rather than overflowing when the font's line box is taller than the + * number guessed here — which is what cut the descenders off "Spring" and "Autumn". Padding sizes + * these rows instead, and they end up as tall as the type actually needs. + */ +@Composable +private fun CalendarHeader(data: CropCalendarData, periodWidth: Dp, gridWidth: Dp, todayFraction: Float?) { + val byPeriod = remember(data) { data.periods.associateBy { it.period } } + Column(Modifier.width(gridWidth)) { + Row(Modifier.fillMaxWidth()) { + // One cell per season rather than per period: a season is exactly three periods, so the label + // centres over its own span the way the game's season mark does. + for (season in 0 until CropCalendarData.PERIODS / SEASON_PERIODS) { + Box( + Modifier.width(periodWidth * SEASON_PERIODS).padding(vertical = 3.dp), + contentAlignment = Alignment.Center, + ) { + Text( + seasonLabel(byPeriod[season * SEASON_PERIODS + 1]?.season ?: ""), + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + color = VdtColors.DarkGray, + maxLines = 1, + overflow = TextOverflow.Clip, + ) + } + } + } + Box(Modifier.fillMaxWidth()) { + GridLines(todayFraction, Modifier.matchParentSize()) + Row(Modifier.fillMaxWidth()) { + for (period in 1..CropCalendarData.PERIODS) { + Box( + Modifier.width(periodWidth).padding(vertical = 3.dp), + contentAlignment = Alignment.Center, + ) { + Text( + (byPeriod[period]?.label ?: "").uppercase(), + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + color = VdtColors.TextDark, + maxLines = 1, + overflow = TextOverflow.Clip, + ) + } + } + } + } + // The rule the grid hangs from, as in the game's own header. + Box(Modifier.fillMaxWidth().height(1.dp).background(VdtColors.PanelBorder)) + } +} + +/** + * The pinned left cell: the crop's name, and the two lane letters. + * + * The letters are the lane key. Sow is always the upper lane and harvest always the lower, so + * position alone already decides which bar is which — `S` and `H` make that readable without + * consulting the legend, and neither depends on telling green from blue. + * + * Selection lives on the leading edge as a solid bar as well as in the row's wash: the wash alone is + * a small step in lightness, and a bar at the row's start is what makes the highlight unmistakable + * without leaning on a colour. + */ +@Composable +private fun CropNameCell( + crop: CalendarCrop, + currentPeriod: Int, + striped: Boolean, + selected: Boolean, + onSelect: () -> Unit, +) { + Row( + Modifier + .width(NAME_COLUMN_WIDTH) + .height(ROW_HEIGHT) + .background(rowShade(striped, selected)) + .selectable(selected = selected, onClick = onSelect) + .padding(end = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier + .width(SELECTION_EDGE_WIDTH) + .fillMaxHeight() + .background(if (selected) VdtColors.TextDark else Color.Transparent), + ) + Spacer(Modifier.width(4.dp)) + Column(Modifier.weight(1f)) { + Text( + crop.name, + fontSize = 11.sp, + color = VdtColors.TextDark, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (crop.catchCrop) { + // Every ink on a selected row is TextDark: DarkGray reads at only 3.9:1 on the selection + // wash, under AA. Quiet-vs-loud inside the row is carried by weight and size instead, which + // is what the palette asks for anyway (see VdtColors) — never by a paler grey. + Text("Cover crop", fontSize = 8.sp, color = rowInk(selected, quiet = true), maxLines = 1) + } + } + Column(verticalArrangement = Arrangement.spacedBy(LANE_GAP)) { + LaneKey("S", currentPeriod in crop.plant, selected) + LaneKey("H", currentPeriod in crop.harvest, selected) + } + } +} + +/** + * One lane's letter. [activeNow] bolds and darkens it — two channels, because [VdtColors.DarkGray] + * and any of the fills sit at nearly the same contrast and would otherwise differ in hue alone. + * + * On a [selected] row the ink is forced dark for contrast (see [rowInk]), so there the two states are + * told apart by weight alone. That is the channel the palette prefers regardless; the colour was only + * ever reinforcing it. + */ +@Composable +private fun LaneKey(letter: String, activeNow: Boolean, selected: Boolean) { + Box(Modifier.height(LANE_HEIGHT), contentAlignment = Alignment.Center) { + Text( + letter, + fontSize = 8.sp, + fontWeight = if (activeNow) FontWeight.Bold else FontWeight.Normal, + color = rowInk(selected, quiet = !activeNow), + ) + } +} + +/** A crop's two bar lanes over the twelve periods. Selectable too — a row is tapped from either half. */ +@Composable +private fun CropLanes( + crop: CalendarCrop, + periodWidth: Dp, + gridWidth: Dp, + todayFraction: Float?, + striped: Boolean, + selected: Boolean, + onSelect: () -> Unit, +) { + Box( + Modifier + .width(gridWidth) + .height(ROW_HEIGHT) + .background(rowShade(striped, selected)) + .selectable(selected = selected, onClick = onSelect), + ) { + GridLines(todayFraction, Modifier.fillMaxSize()) + Column( + Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + ) { + Lane(crop.plant, periodWidth, VdtColors.Green, RectangleShape) + Spacer(Modifier.height(LANE_GAP)) + Lane(crop.harvest, periodWidth, VdtColors.ProgressBlue, RoundedCornerShape(percent = 50)) + } + } +} + +/** + * One lane's bars. + * + * [shape] is not decoration: square-ended for sowing and capsule-ended for harvest is the third thing + * separating the two lanes (after their fixed order and their letters), so nothing about reading this + * grid rests on telling the two fills apart by hue. + * + * Periods are merged into runs first — a crop that sows March through October *and again* in February + * is one list of periods that has to draw as two bars. + */ +@Composable +private fun Lane(periods: List, periodWidth: Dp, color: Color, shape: Shape) { + Box(Modifier.fillMaxWidth().height(LANE_HEIGHT)) { + periods.periodRuns().forEach { run -> + Box( + Modifier + .offset(x = periodWidth * (run.first - 1) + LANE_INSET) + .width(periodWidth * (run.last - run.first + 1) - LANE_INSET * 2) + .height(LANE_HEIGHT) + .clip(shape) + .background(color), + ) + } + } +} + +/** + * The period separators and the today line, drawn behind the bars. + * + * Works from its own measured width rather than from the period width the callers lay out with: the + * canvas is always exactly the twelve periods wide, so dividing by [CropCalendarData.PERIODS] is the + * same number and cannot drift from it. + * + * The today line is dashed for the same reason the game's is: it crosses every bar in the grid, and a + * solid rule at that length reads as part of the chart rather than as a marker on it. + */ +@Composable +private fun GridLines(todayFraction: Float?, modifier: Modifier = Modifier) { + Canvas(modifier) { + val step = size.width / CropCalendarData.PERIODS + for (index in 1 until CropCalendarData.PERIODS) { + val x = step * index + // Season boundaries every third period get the heavier rule, as the game's grid does. + val seasonBoundary = index % SEASON_PERIODS == 0 + drawLine( + color = if (seasonBoundary) GRID_RULE_SEASON else GRID_RULE, + start = Offset(x, 0f), + end = Offset(x, size.height), + strokeWidth = if (seasonBoundary) 1.5f else 1f, + ) + } + if (todayFraction != null) drawTodayLine(todayFraction) + } +} + +private fun DrawScope.drawTodayLine(fraction: Float) { + val x = size.width * fraction + drawLine( + color = VdtColors.TextDark, + start = Offset(x, 0f), + end = Offset(x, size.height), + strokeWidth = 1.5f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(3f, 3f)), + ) +} + +@Composable +private fun Legend() { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + LegendEntry("Sow", VdtColors.Green, RectangleShape) + LegendEntry("Harvest", VdtColors.ProgressBlue, RoundedCornerShape(percent = 50)) + } +} + +@Composable +private fun LegendEntry(label: String, color: Color, shape: Shape) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.width(16.dp).height(LANE_HEIGHT).clip(shape).background(color)) + Text(label, fontSize = 9.sp, fontWeight = FontWeight.Bold, color = VdtColors.DarkGray) + } +} + +@Composable +private fun GrowthModeBanner(growthMode: String) { + Text( + growthModeNotice(growthMode), + fontSize = 10.sp, + color = VdtColors.TextDark, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(3.dp)) + .background(VdtColors.TrackGray) + .padding(horizontal = 8.dp, vertical = 5.dp), + ) +} + +// ---- Weather ---- + +@Composable +private fun WeatherSection(data: WeatherForecastData?, modifier: Modifier = Modifier) { + Box( + modifier + .fillMaxWidth() + .height(WEATHER_HEIGHT) + .clip(RoundedCornerShape(3.dp)) + .background(VdtColors.White.copy(alpha = 0.6f)), + ) { + if (data == null) { + Centered("Waiting for the forecast…") + return@Box + } + // The three blocks scroll as one strip rather than shrinking: a forecast that has squeezed its + // temperatures out of legibility is not a forecast. + Row( + Modifier.fillMaxSize().horizontalScroll(rememberScrollState()).padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + NowBlock(data) + VerticalRule() + data.hourly.forEach { HourBlock(it) } + if (data.daily.isNotEmpty()) { + VerticalRule() + data.daily.forEach { DayBlock(it, data.temperatureUnit) } + } + } + } +} + +/** + * The forecast as a placeable tile: current conditions, then as many two-hourly steps as the tile is + * wide enough for. Deliberately the same blocks the full page draws — a widget that rendered the + * weather its own way would be a second thing to keep in step for no gain. + * + * The hours scroll rather than shrink, for the same reason the section's do. + */ +@Composable +fun WeatherSummary(data: WeatherForecastData?, modifier: Modifier = Modifier) { + Panel(title = "Weather", icon = WeatherIcons.PartiallyCloudy, modifier = modifier) { + if (data == null) { + Centered("Waiting for the forecast…") + return@Panel + } + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Box(Modifier.height(WIDGET_NOW_HEIGHT)) { NowBlock(data) } + if (data.hourly.isNotEmpty()) { + Box(Modifier.fillMaxWidth().height(1.dp).background(VdtColors.PanelBorder)) + Row( + Modifier.fillMaxWidth().weight(1f).horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + data.hourly.forEach { HourBlock(it) } + } + } + } + } +} + +@Composable +private fun NowBlock(data: WeatherForecastData) { + val now: ForecastNow? = data.current + Row( + Modifier.fillMaxHeight(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (now != null) { + Icon( + WeatherIcons.of(now.kind), + contentDescription = WeatherIcons.labelOf(now.kind), + tint = VdtColors.TextDark, + modifier = Modifier.size(40.dp), + ) + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + (data.today?.label ?: "Today").uppercase(), + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = VdtColors.DarkGray, + maxLines = 1, + ) + if (now != null) { + Text( + "${now.temperature}${data.temperatureUnit}", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = VdtColors.TextDark, + ) + Text(WeatherIcons.labelOf(now.kind), fontSize = 10.sp, color = VdtColors.DarkGray, maxLines = 1) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + WindVane(now.windDirection, 12.dp) + Text("Bft ${now.windBeaufort}", fontSize = 10.sp, color = VdtColors.DarkGray, maxLines = 1) + } + } + } + } +} + +@Composable +private fun HourBlock(hour: ForecastHour) { + Column( + Modifier.width(FORECAST_COLUMN_WIDTH).fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(formatHour(hour.hour), fontSize = 9.sp, color = VdtColors.DarkGray, maxLines = 1) + Icon( + WeatherIcons.of(hour.kind), + contentDescription = WeatherIcons.labelOf(hour.kind), + tint = VdtColors.TextDark, + modifier = Modifier.size(20.dp).padding(vertical = 2.dp), + ) + Text( + "${hour.temperature}°", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = VdtColors.TextDark, + maxLines = 1, + ) + Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) { + WindVane(hour.windDirection, 9.dp) + Text("${hour.windBeaufort}", fontSize = 9.sp, color = VdtColors.DarkGray, maxLines = 1) + } + } +} + +@Composable +private fun DayBlock(day: ForecastDay, unit: String) { + Column( + Modifier.width(FORECAST_COLUMN_WIDTH).fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(day.label.uppercase(), fontSize = 9.sp, color = VdtColors.DarkGray, maxLines = 1) + Icon( + WeatherIcons.of(day.kind), + contentDescription = WeatherIcons.labelOf(day.kind), + tint = VdtColors.TextDark, + modifier = Modifier.size(20.dp).padding(vertical = 2.dp), + ) + Text( + "${day.high}$unit", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = VdtColors.TextDark, + maxLines = 1, + ) + Text("${day.low}$unit", fontSize = 10.sp, color = VdtColors.DarkGray, maxLines = 1) + } +} + +/** + * The wind arrow, rotated the way the game rotates its own: the exported angle says where the wind + * comes **from**, so the arrow is turned half a turn past it to point where the wind is going. + */ +@Composable +internal fun WindVane(windDirection: Int, size: Dp) { + Icon( + WeatherIcons.WindArrow, + contentDescription = "Wind from $windDirection°", + tint = VdtColors.DarkGray, + modifier = Modifier.size(size).rotate(windArrowRotation(windDirection)), + ) +} + +@Composable +private fun VerticalRule() { + Box(Modifier.width(1.dp).fillMaxHeight().background(VdtColors.PanelBorder)) +} + +// ---- Pure helpers (kept top-level and non-private so CalendarPanelTest can reach them) ---- + +/** + * The crop rows a query and the two "now" filters leave. + * + * The filters are **not** mutually exclusive: both on means both conditions, which is how the + * question is actually asked ("what is ready to come off and go straight back in"). + */ +internal fun filterCrops( + data: CropCalendarData, + query: String, + sowNow: Boolean, + harvestNow: Boolean, +): List { + val needle = query.trim().lowercase() + val period = data.today?.period ?: 0 + return data.crops.filter { crop -> + (needle.isEmpty() || crop.name.lowercase().contains(needle) || crop.id.lowercase().contains(needle)) && + (!sowNow || period in crop.plant) && + (!harvestNow || period in crop.harvest) + } +} + +/** The in-game clock is fixed 24h, as everywhere else in the terminal. */ +internal fun formatHour(hour: Int): String = "${hour.toString().padStart(2, '0')}:00" + +/** + * Degrees to turn [WeatherIcons.WindArrow] by; see [WindVane]. + * + * Two corrections, not one. The `+ 180` is the game's: the exported angle says where the wind comes + * *from*, so the arrow is turned half a turn past it to point where the wind is going. The **negation** + * is ours: the engine measures its angles the way maths does, counter-clockwise, and drives its own + * overlay with `setImageRotation`, which turns the same way — but Compose's `Modifier.rotate` turns + * **clockwise** for a positive number. Feeding the engine's angle to it straight produced an arrow + * mirrored about the vertical axis, which is exactly what negating undoes. + * + * This is the same handedness the mod's `ValueMapper.headingFromYRotation` exists to absorb; the wind + * angle deliberately does not go through it (see the weather collector's header for why), so the + * correction lands here instead. + */ +internal fun windArrowRotation(windDirection: Int): Float = (180 - windDirection).mod(360).toFloat() + +internal fun seasonLabel(season: String): String = when (season) { + "SPRING" -> "Spring" + "SUMMER" -> "Summer" + "AUTUMN" -> "Autumn" + "WINTER" -> "Winter" + else -> season +} + +/** What the banner says when the savegame is not on seasonal growth. */ +internal fun growthModeNotice(growthMode: String): String = when (growthMode) { + "DAILY" -> "Growth is set to Daily — every crop can be sown and harvested in any period." + "DISABLED" -> "Growth is disabled — every crop can be sown and harvested in any period." + else -> "This savegame is not on seasonal growth — every crop can be sown and harvested in any period." +} + +/** Whether the crop row at [index] carries the guidance shade. */ +internal fun isStriped(index: Int): Boolean = index % 2 == 1 + +/** + * Pressing a crop row highlights it; pressing it again clears it, and pressing another moves the + * highlight. A toggle rather than a plain set, because the highlight is a reading aid across twelve + * columns and not a mode — there has to be a way back out of it that is not "pick a different crop". + */ +internal fun toggleSelection(current: String?, id: String): String? = if (current == id) null else id + +// ---- Metrics ---- + +/** A season is exactly three periods, in the game and here. */ +private const val SEASON_PERIODS = 3 + +/** + * A crop row's background: transparent, the guidance stripe, or the selection wash. + * + * The three are separated by **lightness alone** and no hue is involved anywhere — the stripe lifts + * the panel the way the in-game calendar's banding does, the selection drops well below it. Against + * the panel's own `#F0F0F2` the steps are `+15` and `-31` per channel, roughly twice the separation + * the first cut used, which was too timid to follow across twelve columns. + * + * The stripe is plain [VdtColors.White] rather than an alpha over the panel, and the selection is + * [VdtColors.PanelBorder]: both are palette tokens. The palette has nothing between `PanelBorder` and + * `TrackGray`, so the selection lands slightly past a literal doubling rather than being given an + * invented tone — [VdtColors.Gray] beyond it is the "unlit mark" tone and too dark to read a row on. + * + * Selection also carries the leading edge bar (see [CropNameCell]) and the darker ink below, so three + * things say which row is picked, not one. + */ +private fun rowShade(striped: Boolean, selected: Boolean): Color = when { + selected -> VdtColors.PanelBorder + striped -> VdtColors.White + else -> Color.Transparent +} + +/** + * Ink for text sitting on a crop row. + * + * [VdtColors.DarkGray] reads at 5.0:1 on the panel and 5.7:1 on the stripe, but only **3.9:1** on the + * selection wash — under AA. So a selected row's text is all [VdtColors.TextDark] (8.6:1 there), and + * the [quiet] distinction falls back to weight and size. That is the palette's own instruction anyway: + * quieter text is made with size and weight, never with a paler grey. + */ +private fun rowInk(selected: Boolean, quiet: Boolean): Color = + if (quiet && !selected) VdtColors.DarkGray else VdtColors.TextDark + +/** + * The grid rules, as an alpha over whatever the row is painted with rather than as a fixed grey. + * + * A row has three possible backgrounds (see [rowShade]) and a fixed tone can only suit one of them: + * the season rule in [VdtColors.PanelBorder] vanished entirely on a selected row, which is painted in + * that exact colour. A translucent dark line stays a line on all three. + */ +private val GRID_RULE = VdtColors.TextDark.copy(alpha = 0.10f) +private val GRID_RULE_SEASON = VdtColors.TextDark.copy(alpha = 0.22f) + +/** The selected row's leading edge bar. */ +private val SELECTION_EDGE_WIDTH = 3.dp + +private val NAME_COLUMN_WIDTH = 132.dp +private val PERIOD_MIN_WIDTH = 46.dp +private val ROW_HEIGHT = 28.dp +private val LANE_HEIGHT = 8.dp +private val LANE_GAP = 3.dp + +/** Breathing room at each end of a bar, so two adjacent runs never read as one. */ +private val LANE_INSET = 1.dp + +private val WEATHER_HEIGHT = 118.dp +private val FORECAST_COLUMN_WIDTH = 40.dp + +/** The widget's "now" block: fixed, so the hours below get whatever height the tile has left. */ +private val WIDGET_NOW_HEIGHT = 76.dp diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/DiagnosticsPanel.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/DiagnosticsPanel.kt index b0cf135..307d320 100644 --- a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/DiagnosticsPanel.kt +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/DiagnosticsPanel.kt @@ -169,6 +169,11 @@ private val FRIENDLY_NAMES = "husbandry.json" to "Animals", "missions.json" to "Missions", "fieldInfo.json" to "Field info", + "cropCalendar.json" to "Crop calendar", + "weather.json" to "Weather", + // Missing until now, so these two listed as their raw filenames. + "finance.json" to "Finance", + "invoices.json" to "Invoices", "taskList.json" to "Task list", "cropRotation.json" to "Crop rotation", ) diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/WeatherIcons.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/WeatherIcons.kt new file mode 100644 index 0000000..52b309e --- /dev/null +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/panels/WeatherIcons.kt @@ -0,0 +1,191 @@ +package net.vertexdezign.vdt.app.panels + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.unit.dp +import net.vertexdezign.vdt.model.WeatherKind + +/** + * The forecast glyphs, drawn rather than borrowed from Material — the same call [ClusterIcons] makes, + * for two of the same reasons and one of its own. + * + * *Its own reason:* this is a **set**. Eight conditions have to be told apart at a glance in a strip + * of 20dp icons, which only works if they share one visual language — one cloud shape, one weight of + * line, one scale. Material has a sun and a cloud, nothing faithful for partly-cloudy or hail, and + * assembling the set from four Material glyphs and four drawn ones would leave the strip looking like + * two strips. + * + * *The shared reasons:* a character like `☀` is tofu in the wasm build (no font fallback), and the + * shapes are what carry the meaning here. **Weather type is distinguished by shape alone** — every + * glyph is drawn in one ink and tinted by its caller, so nothing about the reading depends on hue + * (see `VDTerminal/README.md` → "Design rules"). Rain slants, snow is angular, hail is round: the + * three that hang below the same cloud are told apart by form, not by colour or position. + * + * Every glyph is a **fill** on a 24×24 viewport, no strokes, [Color.Black] throughout because `Icon` + * tints the whole thing — exactly the conventions [ClusterIcons] documents. + */ +object WeatherIcons { + /** Full sun: a disc and eight rays. */ + val Sun = weather("Sun") { + fill(SUN_DISC + SUN_RAYS) + } + + /** A small sun clear of the cloud's upper left, so both shapes stay legible at 16dp. */ + val PartiallyCloudy = weather("PartiallyCloudy") { + fill(SMALL_SUN + CLOUD_LOW) + } + + val Cloudy = weather("Cloudy") { fill(CLOUD) } + + /** Cloud plus three slanted drops — slanted is what separates it from [Snow] and [Hail]. */ + val Rain = weather("Rain") { fill(CLOUD + RAIN_DROPS) } + + /** Cloud plus three diamonds: angular and symmetric where rain slants. */ + val Snow = weather("Snow") { fill(CLOUD + SNOW_FLAKES) } + + /** Cloud plus three round pellets: round where snow is angular. */ + val Hail = weather("Hail") { fill(CLOUD + HAIL_STONES) } + + val Thunder = weather("Thunder") { fill(CLOUD + BOLT) } + + /** A banded funnel. Even-odd so the two bands read as slots cut out of the cone. */ + val Twister = weather("Twister") { fill(FUNNEL, PathFillType.EvenOdd) } + + /** + * A bare ring for a condition we have no glyph for — a weather type a future game version adds. + * Deliberately unlike every other glyph in the set, so it reads as "no reading" rather than as + * some eighth kind of weather. + */ + val Unknown = weather("Unknown") { fill(RING, PathFillType.EvenOdd) } + + /** + * The wind vane, pointing **up** at zero rotation. The caller rotates it by `windDirection + 180`, + * which is what the game does to its own arrow — the exported angle is where the wind comes from, + * so the arrow has to point the other way to show where it is going. + */ + val WindArrow = weather("WindArrow") { + fill("M12 3 L18 20 L12 16.4 L6 20 Z") + } + + /** The glyph for a forecast entry's condition. */ + fun of(kind: WeatherKind): ImageVector = when (kind) { + WeatherKind.SUN -> Sun + WeatherKind.PARTIALLY_CLOUDY -> PartiallyCloudy + WeatherKind.CLOUDY -> Cloudy + WeatherKind.RAIN -> Rain + WeatherKind.SNOW -> Snow + WeatherKind.HAIL -> Hail + WeatherKind.TWISTER -> Twister + WeatherKind.THUNDER -> Thunder + WeatherKind.UNKNOWN -> Unknown + } + + /** What a screen reader says, and the caption under the "now" block. */ + fun labelOf(kind: WeatherKind): String = when (kind) { + WeatherKind.SUN -> "Sunny" + WeatherKind.PARTIALLY_CLOUDY -> "Partly cloudy" + WeatherKind.CLOUDY -> "Cloudy" + WeatherKind.RAIN -> "Rain" + WeatherKind.SNOW -> "Snow" + WeatherKind.HAIL -> "Hail" + WeatherKind.TWISTER -> "Twister" + WeatherKind.THUNDER -> "Thunderstorm" + WeatherKind.UNKNOWN -> "Unknown" + } +} + +// ---- Path data ---- +// +// Circles are written as two half-arcs (`A r r 0 1 0 …` twice) rather than as polygons: the parser +// takes SVG arcs, and a real arc stays round at every size these are drawn at (14dp in the strip, +// 40dp in the "now" block). Overlapping subpaths are unioned by the default NonZero fill, which is +// what lets the cloud be three discs and a bar rather than one hand-fitted outline. + +/** Disc of radius 5 at the viewport centre. */ +private const val SUN_DISC = "M12 7 A5 5 0 1 0 12 17 A5 5 0 1 0 12 7 Z" + +/** + * Eight rays from radius 6.5 to 9.5. The four cardinals are axis-aligned bars; the four diagonals are + * the same bar rotated 45°, written out as parallelograms because the path has no rotate. + */ +private const val SUN_RAYS = + "M11 1.5 H13 V4.5 H11 Z" + + "M11 19.5 H13 V22.5 H11 Z" + + "M1.5 11 H4.5 V13 H1.5 Z" + + "M19.5 11 H22.5 V13 H19.5 Z" + + "M15.89 6.70 L17.30 8.11 L19.42 5.99 L18.01 4.58 Z" + + "M8.11 6.70 L6.70 8.11 L4.58 5.99 L5.99 4.58 Z" + + "M15.89 17.30 L17.30 15.89 L19.42 18.01 L18.01 19.42 Z" + + "M8.11 17.30 L6.70 15.89 L4.58 18.01 L5.99 19.42 Z" + +/** + * The cloud: three discs and a bar joining their bottoms. Spans x 4.5..20.5, y 7.5..17, so the drops, + * flakes, pellets and bolt all hang from y 17 downward without touching it. + */ +private const val CLOUD = + "M8 10 A3.5 3.5 0 1 0 8 17 A3.5 3.5 0 1 0 8 10 Z" + + "M13 7.5 A4.5 4.5 0 1 0 13 16.5 A4.5 4.5 0 1 0 13 7.5 Z" + + "M17.5 11 A3 3 0 1 0 17.5 17 A3 3 0 1 0 17.5 11 Z" + + "M8 13.5 H17.5 V17 H8 Z" + +/** The same cloud dropped 3.5 down and shrunk, to leave the corner free for [SMALL_SUN]. */ +private const val CLOUD_LOW = + "M9.5 13.5 A3 3 0 1 0 9.5 19.5 A3 3 0 1 0 9.5 13.5 Z" + + "M14 11.5 A4 4 0 1 0 14 19.5 A4 4 0 1 0 14 11.5 Z" + + "M18 14.5 A2.5 2.5 0 1 0 18 19.5 A2.5 2.5 0 1 0 18 14.5 Z" + + "M9.5 16.5 H18 V19.5 H9.5 Z" + +/** Sun for the partly-cloudy glyph: disc at (7, 6.5) with only the rays that clear the cloud. */ +private const val SMALL_SUN = + "M7 3.3 A3.2 3.2 0 1 0 7 9.7 A3.2 3.2 0 1 0 7 3.3 Z" + + "M6.2 0.4 H7.8 V2.4 H6.2 Z" + + "M0.4 5.7 H2.4 V7.3 H0.4 Z" + + "M2.35 1.55 L3.48 2.68 L2.35 3.81 L1.22 2.68 Z" + + "M11.65 1.55 L12.78 2.68 L11.65 3.81 L10.52 2.68 Z" + +/** Three slanted drops. The slant is the mark: it is what tells rain from snow and hail. */ +private const val RAIN_DROPS = + "M8.6 18.3 L10 18.9 L8.4 22.4 L7 21.8 Z" + + "M12.6 18.3 L14 18.9 L12.4 22.4 L11 21.8 Z" + + "M16.6 18.3 L18 18.9 L16.4 22.4 L15 21.8 Z" + +/** Three diamonds — symmetric and pointed where the drops slant. */ +private const val SNOW_FLAKES = + "M8.4 18.6 L9.9 20.4 L8.4 22.2 L6.9 20.4 Z" + + "M12.4 18.6 L13.9 20.4 L12.4 22.2 L10.9 20.4 Z" + + "M16.4 18.6 L17.9 20.4 L16.4 22.2 L14.9 20.4 Z" + +/** Three pellets — round where the flakes are pointed. */ +private const val HAIL_STONES = + "M8.4 19.1 A1.3 1.3 0 1 0 8.4 21.7 A1.3 1.3 0 1 0 8.4 19.1 Z" + + "M12.4 19.1 A1.3 1.3 0 1 0 12.4 21.7 A1.3 1.3 0 1 0 12.4 19.1 Z" + + "M16.4 19.1 A1.3 1.3 0 1 0 16.4 21.7 A1.3 1.3 0 1 0 16.4 19.1 Z" + +private const val BOLT = "M14.6 17.4 L8.8 22.6 L12.2 22.6 L11 24 L16.4 19.2 L13 19.2 Z" + +/** Funnel plus two slots; even-odd turns the slots into holes rather than more cone. */ +private const val FUNNEL = + "M3 4.5 H21 L13.8 14 L12.8 21 L11.8 23.5 L10.2 21 L9.2 14 Z" + + "M6.6 7.6 H17.4 V8.9 H6.6 Z" + + "M8.4 11.1 H15.6 V12.4 H8.4 Z" + +/** Outer disc minus an inner one; even-odd leaves the ring. */ +private const val RING = + "M12 3.5 A8.5 8.5 0 1 0 12 20.5 A8.5 8.5 0 1 0 12 3.5 Z" + + "M12 6 A6 6 0 1 1 12 18 A6 6 0 1 1 12 6 Z" + +private fun weather(name: String, block: ImageVector.Builder.() -> Unit): ImageVector = ImageVector.Builder( + name = "weather.$name", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, +).apply(block).build() + +/** One filled subpath set. [Color.Black] is a placeholder — `Icon` tints over it. */ +private fun ImageVector.Builder.fill(pathData: String, fillType: PathFillType = PathFillType.NonZero) { + addPath(addPathNodes(pathData), pathFillType = fillType, fill = SolidColor(Color.Black)) +} diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/state/VdtStore.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/state/VdtStore.kt index d0e49b1..4b890d5 100644 --- a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/state/VdtStore.kt +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/state/VdtStore.kt @@ -9,6 +9,7 @@ import net.vertexdezign.vdt.app.WakeLockStatus import net.vertexdezign.vdt.app.alerts.AlertEngine import net.vertexdezign.vdt.app.net.ConnectionState import net.vertexdezign.vdt.app.pages.PageStore +import net.vertexdezign.vdt.model.CropCalendarData import net.vertexdezign.vdt.model.CropRotationData import net.vertexdezign.vdt.model.FieldInfoData import net.vertexdezign.vdt.model.FinanceData @@ -23,6 +24,7 @@ import net.vertexdezign.vdt.model.ProductionData import net.vertexdezign.vdt.model.StorageData import net.vertexdezign.vdt.model.TaskListData import net.vertexdezign.vdt.model.VdtData +import net.vertexdezign.vdt.model.WeatherForecastData /** * Ambient container for everything a screen or widget might need: the live telemetry channels (as @@ -58,6 +60,13 @@ class VdtStore( * installed mod with nothing to show, which sends an empty list. */ val invoices: StateFlow, + /** + * Which of the twelve periods each crop may be sown and harvested in — the game's own + * Anbaukalender. Null when the channel is absent. + */ + val cropCalendar: StateFlow, + /** The forecast: now, twelve two-hourly steps, six days out; null when the channel is absent. */ + val weather: StateFlow, /** Server-measured observed cadence of each channel file (diagnostics app); null until first frame. */ val channelStats: StateFlow, val wakeLock: StateFlow, diff --git a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/widgets/BuiltinWidgets.kt b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/widgets/BuiltinWidgets.kt index 27be87d..d4ca2a4 100644 --- a/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/widgets/BuiltinWidgets.kt +++ b/VDTerminal/app/src/commonMain/kotlin/net/vertexdezign/vdt/app/widgets/BuiltinWidgets.kt @@ -34,6 +34,8 @@ import net.vertexdezign.vdt.app.panels.Navigation import net.vertexdezign.vdt.app.panels.RigSlot import net.vertexdezign.vdt.app.panels.RigSlotPanel import net.vertexdezign.vdt.app.panels.TaskListPanel +import net.vertexdezign.vdt.app.panels.WeatherIcons +import net.vertexdezign.vdt.app.panels.WeatherSummary import net.vertexdezign.vdt.app.state.LocalVdtStore import net.vertexdezign.vdt.app.theme.VdtColors @@ -338,6 +340,26 @@ object CropRotationWidget : Widget { } } +/** + * The forecast at a glance: now, then the next few two-hourly steps. The glanceable half of the + * Calendar app — the crop grid needs a full page, and a tile that tried to hold twelve periods of it + * would be unreadable at any placeable size. + */ +object WeatherWidget : Widget { + override val id = "weather" + override val title = "Weather" + override val icon: ImageVector = WeatherIcons.PartiallyCloudy + override val defaultColSpan = 4 + override val defaultRowSpan = 2 + override val minColSpan = 2 + + @Composable + override fun Content(modifier: Modifier, config: WidgetConfig) { + val weather by LocalVdtStore.current.weather.collectAsState() + WeatherSummary(weather, modifier) + } +} + /** Panel chrome with a centered "not available" message, for widgets whose data is currently absent. */ @Composable private fun MissingPanel(title: String, icon: ImageVector, modifier: Modifier = Modifier) { diff --git a/VDTerminal/app/src/commonTest/kotlin/net/vertexdezign/vdt/app/panels/CalendarPanelTest.kt b/VDTerminal/app/src/commonTest/kotlin/net/vertexdezign/vdt/app/panels/CalendarPanelTest.kt new file mode 100644 index 0000000..31c8ddb --- /dev/null +++ b/VDTerminal/app/src/commonTest/kotlin/net/vertexdezign/vdt/app/panels/CalendarPanelTest.kt @@ -0,0 +1,150 @@ +package net.vertexdezign.vdt.app.panels + +import net.vertexdezign.vdt.model.CalendarCrop +import net.vertexdezign.vdt.model.CalendarToday +import net.vertexdezign.vdt.model.CropCalendarData +import net.vertexdezign.vdt.model.WeatherKind +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * What the Calendar screen's controls actually do: which crops survive the search box and the two + * "now" chips, and the small formatting the strip depends on. + */ +class CalendarPanelTest { + private val wheat = CalendarCrop(id = "WHEAT", name = "Wheat", plant = listOf(9, 10), harvest = listOf(4, 5)) + private val oat = CalendarCrop(id = "OAT", name = "Oat", plant = listOf(1, 2), harvest = listOf(5, 6)) + private val grass = + CalendarCrop( + id = "MEADOW", + name = "Meadow", + catchCrop = true, + plant = listOf(1, 2, 3, 4, 5, 6), + harvest = listOf(5, 6, 7), + ) + + /** Today is period 5 — where wheat and oat can both be harvested but only grass can be sown. */ + private val data = + CropCalendarData( + version = "1", + growthMode = "SEASONAL", + today = CalendarToday(period = 5, dayInPeriod = 1, daysPerPeriod = 2), + crops = listOf(wheat, oat, grass), + ) + + @Test + fun anEmptyQueryAndNoFiltersKeepEveryCrop() { + assertEquals(data.crops, filterCrops(data, "", sowNow = false, harvestNow = false)) + } + + @Test + fun theQueryMatchesTheDisplayNameCaseInsensitivelyAndAnywhere() { + assertEquals(listOf(wheat), filterCrops(data, "wheat", sowNow = false, harvestNow = false)) + assertEquals(listOf(wheat), filterCrops(data, "HEA", sowNow = false, harvestNow = false)) + assertTrue(filterCrops(data, " oat ", sowNow = false, harvestNow = false).contains(oat)) + } + + @Test + fun theQueryAlsoMatchesTheInternalId() { + // The row key is the fruit type's internal name; on a non-English client it is often the only + // spelling the player knows from a mod description. + assertEquals(listOf(grass), filterCrops(data, "meadow", sowNow = false, harvestNow = false)) + } + + @Test + fun sowNowKeepsOnlyCropsPlantableInTheCurrentPeriod() { + assertEquals(listOf(grass), filterCrops(data, "", sowNow = true, harvestNow = false)) + } + + @Test + fun harvestNowKeepsOnlyCropsHarvestableInTheCurrentPeriod() { + assertEquals(listOf(wheat, oat, grass), filterCrops(data, "", sowNow = false, harvestNow = true)) + } + + @Test + fun bothFiltersMeanBothConditions() { + // Not mutually exclusive: "what comes off and goes straight back in" is one question. + assertEquals(listOf(grass), filterCrops(data, "", sowNow = true, harvestNow = true)) + } + + @Test + fun theQueryAndTheFiltersCompose() { + assertTrue(filterCrops(data, "wheat", sowNow = true, harvestNow = false).isEmpty()) + assertEquals(listOf(wheat), filterCrops(data, "wheat", sowNow = false, harvestNow = true)) + } + + @Test + fun withNoTodayNothingIsSowableOrHarvestableNow() { + // No calendar position means no current period; the filters must empty the list rather than + // matching period 0 against something. + val undated = data.copy(today = null) + assertTrue(filterCrops(undated, "", sowNow = true, harvestNow = false).isEmpty()) + assertEquals(undated.crops, filterCrops(undated, "", sowNow = false, harvestNow = false)) + } + + @Test + fun hoursArePrintedAsAFixed24HourClock() { + assertEquals("00:00", formatHour(0)) + assertEquals("08:00", formatHour(8)) + assertEquals("23:00", formatHour(23)) + } + + @Test + fun theWindArrowTurnsHalfATurnPastTheReportedAngleAndTheOtherWayRound() { + // Half a turn because the exported angle is where the wind comes FROM; negated because the engine + // measures counter-clockwise and Compose's rotate() turns clockwise. Without the negation the + // arrow came out mirrored about the vertical axis. + assertEquals(180f, windArrowRotation(0)) + assertEquals(135f, windArrowRotation(45)) + assertEquals(0f, windArrowRotation(180)) + assertEquals(270f, windArrowRotation(270)) + // Straight down the middle of both corrections: 90 and 270 must land on opposite sides. + assertEquals(90f, windArrowRotation(90)) + } + + @Test + fun theWindArrowRotationStaysInsideOneTurn() { + // .mod, not %: a plain remainder goes negative past 180 and the arrow jumps a turn. + for (degrees in 0..359) { + val rotation = windArrowRotation(degrees) + assertTrue(rotation >= 0f && rotation < 360f, "$degrees -> $rotation") + } + } + + @Test + fun theRowShadeAlternates() { + // The name column and the bars are laid out separately but must shade the same rows. + assertEquals(listOf(false, true, false, true), (0..3).map { isStriped(it) }) + } + + @Test + fun pressingARowSelectsItAndPressingItAgainClearsIt() { + assertEquals("WHEAT", toggleSelection(null, "WHEAT")) + assertEquals(null, toggleSelection("WHEAT", "WHEAT")) + assertEquals("OAT", toggleSelection("WHEAT", "OAT")) + } + + @Test + fun theNonSeasonalNoticeNamesTheModeItFound() { + assertTrue(growthModeNotice("DAILY").contains("Daily")) + assertTrue(growthModeNotice("DISABLED").contains("disabled")) + // An unrecognised mode still gets a sentence rather than an empty banner. + assertTrue(growthModeNotice("SOMETHING_NEW").isNotEmpty()) + } + + @Test + fun everyWeatherKindHasItsOwnGlyphAndLabel() { + // The set is told apart by shape alone, so two conditions sharing a glyph would be unreadable. + val glyphs = WeatherKind.entries.map { WeatherIcons.of(it) } + for (i in glyphs.indices) { + for (j in i + 1 until glyphs.size) { + assertNotEquals(glyphs[i].name, glyphs[j].name, "${WeatherKind.entries[i]} and ${WeatherKind.entries[j]}") + } + } + assertEquals(WeatherKind.entries.size, WeatherKind.entries.map { WeatherIcons.labelOf(it) }.toSet().size) + assertSame(WeatherIcons.Unknown, WeatherIcons.of(WeatherKind.UNKNOWN)) + } +} diff --git a/VDTerminal/app/src/wasmJsMain/kotlin/net/vertexdezign/vdt/app/Main.kt b/VDTerminal/app/src/wasmJsMain/kotlin/net/vertexdezign/vdt/app/Main.kt index 9c52f75..feae3a1 100644 --- a/VDTerminal/app/src/wasmJsMain/kotlin/net/vertexdezign/vdt/app/Main.kt +++ b/VDTerminal/app/src/wasmJsMain/kotlin/net/vertexdezign/vdt/app/Main.kt @@ -103,6 +103,8 @@ fun main() { missions = repository.missions, finance = repository.finance, invoices = repository.invoices, + cropCalendar = repository.cropCalendar, + weather = repository.weather, channelStats = repository.channelStats, wakeLock = wakeLock.asStateFlow(), mapUrl = mapUrl, From def68ed2c90ca8bee05a994f7ca7febcb3cc15aa Mon Sep 17 00:00:00 2001 From: Benjamin Leber Date: Sun, 16 Aug 2026 20:52:26 +0200 Subject: [PATCH 6/6] =?UTF-8?q?#96=20=F0=9F=93=96=20[doc]=20what=20the=20c?= =?UTF-8?q?alendar=20work=20left=20open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four deferrals, each with the reason rather than a plan. The one worth knowing before anyone tries it: crop icons are unreachable — the game's row icon is fillType.hudOverlayFilename, which points inside dataS2/, and AssetResolver opens gameDir-relative paths and mod zips, neither of which reaches into a packed archive. The ground-layer legend is the only cheap substitute and only covers crops already growing on the map, which is precisely the set you do not need to look up. Three in-game checks left, all needing a session this branch could not reach: a multiplayer client (are forecastItems replicated at all?), a southern-hemisphere map, and a season length changed mid-session. The growth-mode watch and the non-seasonal calendar are already answered — noSeasons.json came out of flipping the setting live in a running savegame, so both the poll and the data are proven. The captures-wanted entry is retired for these two channels and replaced with what is still missing from them: HAIL, THUNDER and TWISTER have never appeared on the wire, so three of the eight drawn glyphs are unexercised by any fixture, as is a day caption from a map with more than one day per period. Co-Authored-By: Claude Opus 5 (1M context) --- FUTURE.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/FUTURE.md b/FUTURE.md index 96a884d..eec4904 100644 --- a/FUTURE.md +++ b/FUTURE.md @@ -347,10 +347,41 @@ supports it: a channel whose `markDirty()` is driven by a position bucket rather --- +## Weather and crop calendar (#96) + +Both channels and the Calendar app are built. What they leave behind: + +- **Crop icons are not reachable, so the rows are name-only.** The game's own calendar puts the fill type's icon beside + each crop, from `fillType.hudOverlayFilename` — which points inside `dataS2/`, the game's packed archive. + `AssetResolver` opens gameDir-relative paths and mod zips, and neither reaches into that. The one cheap substitute is + a colour swatch from the `crops` ground-layer legend, and it only covers fruits actually growing on the map, so a + crop you have never planted would have no swatch — exactly the crops you open a calendar to look up. Left out rather + than done badly. +- **Weather alerts.** "Rain in N hours" and "frost tonight" are natural `AlertRule`s off this channel and the first ones + that would change what a player *does* — cut hay, get a crop off. Deliberately not in this round: the alert engine + reads `AlertInputs`, which today combines only telemetry and taskList, so this is a wiring change as well as a rule. +- **Daily wind is exported by the game and dropped by us.** `getDailyForecast` returns `windSpeed`/`windDirection` + alongside the temperatures; the outlook strip renders neither, so the collector does not carry them. Two fields to add + if the outlook ever grows a wind row. +- **`WidgetDashboard`'s own `Chip` was left duplicated.** `FilterChip` moved to `components/` when the calendar became + its second caller, but the page editor's chip is a visually different control (bordered, on white, no ripple) and + folding it in would have restyled that screen as a side effect of this work. + ## In-game checks nobody has run Each one is cheap to do while playing and settles something above. +- **Is the forecast populated on a multiplayer client?** `WeatherForecast` reads `owner.forecastItems`, and whether + those are replicated to clients or only exist server-side is unverified. Same question for `missionInfo.growthMode`, + which the crop calendar's period predicates need. Both reads are `pcall`-guarded, so the failure mode is a channel + that never appears rather than a Lua error — but which of the two happens is unknown. Join a dedicated server and look + for `weather.json` / `cropCalendar.json`. +- **A southern-hemisphere map**, to confirm the calendar's column labels really shift: `g_i18n:formatPeriod` keys off + `environment.daylight.latitude < 0` and should label period 1 September rather than March. This is the whole reason + the labels cross the wire instead of being a lookup table in the app. +- **Season length changed mid-session**, to confirm `PERIOD_LENGTH_CHANGED` fires and the today marker moves within its + period. It is the one subscription in the crop calendar channel that is not exercised by simply letting a day pass. + - Does a base-game baler set `uiDisplayType="STEP"` on its consumable fill unit? It is visible in the exported JSON as `display`, so this is just a matter of looking. Decides whether the stepped bar is worth building. - Does a multi-state pipe report sensibly — an auger wagon should give `pipe.numStates > 2`? Read the JSON; nothing @@ -475,6 +506,11 @@ machine that has them. contain, because that session never got there: an **incoming** invoice, a **paid** one, and one that has **accrued a penalty**. `InvoicesModelTest` covers those three with inline JSON meanwhile, and says so at the top. +- **A weather capture that is not mid-afternoon rain.** `examples/json/weather/vanilla.json` covers the shapes that + matter — the strip wrapping past midnight, captions losing their day number at `daysPerPeriod = 1`, SUN / CLOUDY / + PARTIALLY_CLOUDY / RAIN / SNOW — but four `WeatherType`s have still never been seen on the wire: `HAIL`, `THUNDER`, + `TWISTER`, and whatever a `daysPerPeriod > 1` day caption looks like ("Aug 2" rather than "August"). The glyphs for + the first three exist and are unexercised by any fixture. - The rule these follow: fixtures are **real game captures, never hand-authored**. A hand-written file claiming to be a capture was rejected before, and fill-type names live in `fillTypes.xml`, which is not readable from here — inventing them would put made-up game data in `examples/json`.