Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions FUTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down
1 change: 1 addition & 0 deletions VDTerminal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ object AppRegistry {
StorageApp,
AnimalsApp,
MissionsApp,
CalendarApp,
FinanceApp,
TasksApp,
CropRotationApp,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Widget> = 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)
}
}
Original file line number Diff line number Diff line change
@@ -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),
)
}
Original file line number Diff line number Diff line change
@@ -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()
}
},
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -130,6 +132,16 @@ class TelemetryRepository(private val scope: CoroutineScope, private val wsUrl:
private val _invoices = MutableStateFlow<InvoicesData?>(null)
val invoices: StateFlow<InvoicesData?> = _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<CropCalendarData?>(null)
val cropCalendar: StateFlow<CropCalendarData?> = _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<WeatherForecastData?>(null)
val weather: StateFlow<WeatherForecastData?> = _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<ChannelStatsData?>(null)
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading