Calendar: convert ticks to an in-game date, and fix the units of time #104

Merged
liamjd merged 5 commits from 87-game-calendar-conversations into main 2026-09-17 07:50:34 +01:00
Owner

Adds the time substrate the threat timeline and forecast UI will be built on: a calendar that projects a tick onto a moon-date, the pacing constants as documented decisions, and a HUD panel showing both the date and contract progress.

GameClock.advance() is unchanged. Nothing here adds state to the simulation — the calendar is a pure function of the tick counter from #65.

Design

The calendar is a projection, never an accumulator. All arithmetic happens in ticks; GameDate is the picture taken of the result, is read-only, and has no "add a day" method. Two code paths for "what time is it" is how a calendar stops agreeing with itself, so there is only one — which is what makes the round-trip tests meaningful rather than decorative.

Absolute and run-relative ticks are different numbers. Absolute counts from the calendar epoch (Y1 M1 D1 00:00) and is what the threat timeline will be placed on. Run-relative is GameClock.now, zeroed at landing, and is what contract progress counts in. GDScript types both as int and will happily let them be mixed, so the defence is structural: run_epoch_tick + GameClock.now appears exactly once, in GameState.absolute_tick().

To keep that visible at call sites, GameClock.tick's payload is renamed now → run_tick and documented as run-relative. The calendar panel takes it as _run_tick — the underscore marks that ignoring it is a choice, since it converts via GameState instead. debug_layer genuinely wants the raw run tick and still uses the payload.

TICKS_PER_DAY is not derived from ticks_per_second. Design pacing and simulation fidelity are different questions; deriving one from the other means tuning the tick rate for pawn smoothness silently rewrites every authored duration in the game. test_the_day_length_does_not_depend_on_the_simulation_rate asserts the separation directly, and TICKS_PER_DAY has moved off GameClock onto GameCalendar so there is one copy rather than two (knock-on for #88: read GameCalendar.TICKS_PER_DAY).

Bug found and fixed

Contract progress was computed as date.month - contract_start_date.month. That reads "Month 0 of 6" on landing day, and breaks outright across a year boundary — a contract landing in M10 computes 1 - 10 = -9 in its second year. Now counted from elapsed ticks (GameClock.now / TICKS_PER_MONTH), which is both correct across the wrap and the right tick space for the question.

GameState was also hand-building a GameDate via GameDate.new(0, 1, 1, 1, 1, 0, 1, 1), which the calendar explicitly warns against — a constructed date can hold a field combination the calendar cannot produce. Now projected through date_at().

AC coverage (#87)

  • GameDate converting now → day / hour / phase, and ticks_until_day() back
  • A basic UI, top-right
  • Run start seeds an absolute moon-date; run-relative and absolute both available and distinguished
  • ticks_per_second settable only before the first tick
  • GameClock.reset() zeroes _now and _accumulator
  • Round-trip tests at boundaries, and ticks_until agreeing with the forward conversion
  • Constants documented in docs/colony-game-time-and-calendar.md

Verification

451 tests pass (--headless -s addons/gut/gut_cmdln.gd), 1 pending — the pre-existing #85 placeholder in test_task_tree.gd, unrelated.

Six new clock tests cover the two AC bullets that had none. The one worth calling out is test_reset_discards_the_banked_fraction: everywhere else in that file the accumulator is preserved on purpose, so reset() has to opt out explicitly, and a leftover part-tick makes the next colony's first tick land early. now == 0 does not catch it.

The two rejection tests use GUT's assert_push_error(), so they pin the error text rather than suppressing it.

Visual check, headed. gl_compatibility renders nothing headless, so the panel was verified by running the game and reading the values back. At tick 525, with the run seeded to Y1 M3 D1 12:00 (run_epoch_tick = 24,600):

shown check
absolute tick — 24,600 + 525 = 25,125
hour 22:00 (25125 / 50) % 24 = 22 ✓
date 01/03/1 day index 20 → Y1 M3 D1 ✓
phase crescent 22 ≥ DUSK_HOUR → night ✓
progress Month 1 of 6 525 / 12,000 → month 1, run-relative ✓

The last row is the load-bearing one: the date reports month 3 (seeded epoch) while progress reports month 1 (run-relative), from the same tick. If either read the wrong tick space they would agree, and they do not. Real-time pacing also checks out — 525 ticks is ~52 s at 1x, and 10.5 in-game hours have passed since a 12:00 landing, matching the documented 5 s per hour.

Caveats and follow-ups

  • The panel is functional, not designed. Styling is deliberately untouched; the goal for now is relevant information on screen.
  • The progress bar is nearly invisible early in a run. It and its label share a PanelContainer, which sizes both children to the same rect, so the label draws over the bar — intended. But at month 1 of 6 the fill is 0.7% of the width, so there is little to see for the first twenty real minutes.
  • GameState.contract_start_date is now unread, since the progress fix removed its only consumer. Left in place as it is likely wanted by the forecast UI; cut it if not.
  • "Month 7 of 6" is undecided. The design allows overrunning a contract, so the label is deliberately not clamped and shows the honest value. Recorded as an open question in the doc.
  • Speed controls are filed separately as #103. Watching anything that takes game-months currently means editing ticks_per_second in source, which is the wrong knob — it is an engineering constant, rejected mid-run, and pinned by a test. That test caught exactly this during development.
  • Not in scope, per the issue: rendering a day/night cycle, the threat timeline itself, and sensor-tier lead time.

Closes #87

🤖 Generated with Claude Code

Adds the time substrate the threat timeline and forecast UI will be built on: a calendar that projects a tick onto a moon-date, the pacing constants as documented decisions, and a HUD panel showing both the date and contract progress. `GameClock.advance()` is unchanged. Nothing here adds state to the simulation — the calendar is a pure function of the tick counter from #65. ## Design **The calendar is a projection, never an accumulator.** All arithmetic happens in ticks; `GameDate` is the picture taken of the result, is read-only, and has no "add a day" method. Two code paths for "what time is it" is how a calendar stops agreeing with itself, so there is only one — which is what makes the round-trip tests meaningful rather than decorative. **Absolute and run-relative ticks are different numbers.** Absolute counts from the calendar epoch (Y1 M1 D1 00:00) and is what the threat timeline will be placed on. Run-relative is `GameClock.now`, zeroed at landing, and is what contract progress counts in. GDScript types both as `int` and will happily let them be mixed, so the defence is structural: `run_epoch_tick + GameClock.now` appears exactly once, in `GameState.absolute_tick()`. To keep that visible at call sites, `GameClock.tick`'s payload is renamed `now` → `run_tick` and documented as run-relative. The calendar panel takes it as `_run_tick` — the underscore marks that ignoring it is a choice, since it converts via `GameState` instead. `debug_layer` genuinely wants the raw run tick and still uses the payload. **`TICKS_PER_DAY` is not derived from `ticks_per_second`.** Design pacing and simulation fidelity are different questions; deriving one from the other means tuning the tick rate for pawn smoothness silently rewrites every authored duration in the game. `test_the_day_length_does_not_depend_on_the_simulation_rate` asserts the separation directly, and `TICKS_PER_DAY` has moved off `GameClock` onto `GameCalendar` so there is one copy rather than two (knock-on for #88: read `GameCalendar.TICKS_PER_DAY`). ## Bug found and fixed Contract progress was computed as `date.month - contract_start_date.month`. That reads "Month 0 of 6" on landing day, and breaks outright across a year boundary — a contract landing in M10 computes `1 - 10 = -9` in its second year. Now counted from elapsed ticks (`GameClock.now / TICKS_PER_MONTH`), which is both correct across the wrap and the right tick space for the question. `GameState` was also hand-building a `GameDate` via `GameDate.new(0, 1, 1, 1, 1, 0, 1, 1)`, which the calendar explicitly warns against — a constructed date can hold a field combination the calendar cannot produce. Now projected through `date_at()`. ## AC coverage (#87) - [x] `GameDate` converting `now` → day / hour / phase, and `ticks_until_day()` back - [x] A basic UI, top-right - [x] Run start seeds an absolute moon-date; run-relative and absolute both available and distinguished - [x] `ticks_per_second` settable only before the first tick - [x] `GameClock.reset()` zeroes `_now` and `_accumulator` - [x] Round-trip tests at boundaries, and `ticks_until` agreeing with the forward conversion - [x] Constants documented in `docs/colony-game-time-and-calendar.md` ## Verification **451 tests pass** (`--headless -s addons/gut/gut_cmdln.gd`), 1 pending — the pre-existing `#85` placeholder in `test_task_tree.gd`, unrelated. Six new clock tests cover the two AC bullets that had none. The one worth calling out is `test_reset_discards_the_banked_fraction`: everywhere else in that file the accumulator is preserved on purpose, so `reset()` has to opt out explicitly, and a leftover part-tick makes the next colony's first tick land early. `now == 0` does not catch it. The two rejection tests use GUT's `assert_push_error()`, so they pin the error text rather than suppressing it. **Visual check, headed.** `gl_compatibility` renders nothing headless, so the panel was verified by running the game and reading the values back. At tick 525, with the run seeded to Y1 M3 D1 12:00 (`run_epoch_tick` = 24,600): | | shown | check | |---|---|---| | absolute tick | — | 24,600 + 525 = 25,125 | | hour | `22:00` | (25125 / 50) % 24 = 22 ✓ | | date | `01/03/1` | day index 20 → Y1 M3 D1 ✓ | | phase | crescent | 22 ≥ `DUSK_HOUR` → night ✓ | | progress | `Month 1 of 6` | 525 / 12,000 → month 1, **run-relative** ✓ | The last row is the load-bearing one: the date reports month 3 (seeded epoch) while progress reports month 1 (run-relative), from the same tick. If either read the wrong tick space they would agree, and they do not. Real-time pacing also checks out — 525 ticks is ~52 s at 1x, and 10.5 in-game hours have passed since a 12:00 landing, matching the documented 5 s per hour. ## Caveats and follow-ups - **The panel is functional, not designed.** Styling is deliberately untouched; the goal for now is relevant information on screen. - **The progress bar is nearly invisible early in a run.** It and its label share a `PanelContainer`, which sizes both children to the same rect, so the label draws over the bar — intended. But at month 1 of 6 the fill is 0.7% of the width, so there is little to see for the first twenty real minutes. - **`GameState.contract_start_date` is now unread**, since the progress fix removed its only consumer. Left in place as it is likely wanted by the forecast UI; cut it if not. - **"Month 7 of 6" is undecided.** The design allows overrunning a contract, so the label is deliberately not clamped and shows the honest value. Recorded as an open question in the doc. - **Speed controls are filed separately as #103.** Watching anything that takes game-months currently means editing `ticks_per_second` in source, which is the wrong knob — it is an engineering constant, rejected mid-run, and pinned by a test. That test caught exactly this during development. - **Not in scope**, per the issue: rendering a day/night cycle, the threat timeline itself, and sensor-tier lead time. Closes #87 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Completes the remaining #87 acceptance criteria beyond the date logic.

Calendar panel (top-right): absolute date, a sun/moon phase icon, and a
contract progress bar. Redraws on the hour rather than on every tick,
since nothing on it changes faster than that and an unguarded handler
allocates a GameDate per tick to redraw an identical label.

Contract progress now counts elapsed ticks instead of subtracting
calendar months. The subtraction read "Month 0 of 6" on landing day and
broke outright across a year boundary - a contract landing in M10
computed 1 - 10 = -9 in its second year. It is also the right tick
space: progress through a contract is run-relative, while the date is
absolute, and those are different numbers on purpose.

Make the tick spaces visible where the compiler cannot. GameClock.tick's
payload is renamed now -> run_tick and documented as run-relative, so a
listener wanting a date converts via GameState.absolute_tick() instead;
the calendar panel takes it as _run_tick to mark that it is ignored
deliberately. debug_layer genuinely wants the raw run tick and keeps
using the payload.

Document, rather than change, the ticks_per_second guard: a mid-run
change invalidates every computed forecast and desyncs a replay, so it
is rejected and the old value kept. reset() makes it settable again on
purpose - a new run has no forecasts to invalidate, and the rate is run
configuration in the same sense as run_seed. That was accidental before
and is now stated and test-locked.

Six tests covering the two AC bullets that had none: reset discards the
banked fraction (the half that `now == 0` does not cover - a leftover
part-tick makes the next run's first tick land early), reset starts a
second run on day one, the rate cannot change mid-run, a rejected change
does not disturb the clock, the rate is settable after reset, and a new
rate takes effect next run.

GameState projects contract_start_date through date_at() rather than
hand-building a GameDate, which could otherwise hold a field combination
the calendar cannot produce.

docs/colony-game-time-and-calendar.md records the pacing constants as
decisions: the three layers, why TICKS_PER_DAY must not derive from
ticks_per_second, real-time durations at 1x and 3x, the deliberate
season compression, and the absolute-vs-run-relative split.

451 tests pass. Left open in the doc: what the widget shows at
"Month 7 of 6", since the design allows overrunning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
liamjd merged commit bd32f14490 into main 2026-09-17 07:50:34 +01:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
liamjd/UntitledColonyBuilder!104
No description provided.