Skip to content

feat: run seasons from configuration (Stage 4) - #225

Open
TheMeinerLP wants to merge 4 commits into
feat/build-serversfrom
feat/season-packages
Open

feat: run seasons from configuration (Stage 4)#225
TheMeinerLP wants to merge 4 commits into
feat/build-serversfrom
feat/season-packages

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

Stacked on #219. Implements spec Stage 4 — US-4.01 … US-4.08. 180 tests green. This completes all seven stages.

A season is a file, not a jar — and that is a deliberate departure

The plan sketched an extension jar per season. This ships the data half instead, because the research the spec is built on says the cost has to be near zero or the whole thing is a net loss: cosmetic seasonal events move concurrent players by roughly zero (Deep Rock Galactic's cosmetic Octobers: −0.37%, −12.16%, −4.92%), while content moves them 20–150%.

A jar per season means a build, a review and a deployment per season — precisely the cost the telemetry says is not repaid. InnoGames, after six iterations of the same event: "The event currency and 21-day runtime have been constant throughout, leaving everything else open to change."

So: window, gate and world selection are stable code; everything seasonal is data.

Adding a season, as an operator experiences it

Write seasons/lantern-nights.json (copy the shipped example; every field and effect type is tabulated in seasons/README.md). If it wants its own lobby, drop the world under worlds/ and name it. Restart. /season list shows priority, stage, window, world, kill switch and whether it is live.

Set "stage": "internal", give yourself titan.season.preview, and you see the icons before the window opens. Move to ga when ready. To end it early, "enabled": false — the lobby unwinds it within five seconds, no restart.

No Java at any point. And a typo does not start the server: block and material keys are resolved at load, so a misspelt jack_o_lanturn is a startup failure naming the file and the value, not a December surprise.

deactivate() is the requirement, so it shaped the design

SeasonCanvas exists for exactly one reason: every operation has an inverse. Each effect pushes its own undo as it is applied; deactivate() pops LIFO. Both methods idempotent.

Blocks are restored to what was actually read at that position, not to a hard-coded assumption — there is a test for precisely that. Two overlapping seasons unwind to the block underneath both, and synchronize tears the stack down before rebuilding rather than pulling one season out of the middle.

What it cannot do, stated plainly: preserve a build-team edit made at a decorated position during a running season (it is overwritten at season end), and survive SIGKILL — though the next boot's synchronise still removes the decoration, because the undo is recomputed rather than persisted.

Two judgement calls I want reviewed

US-4.07 is marked teilweise, not done. Preview works for per-viewer content (navigator icons). It cannot work for a block in a shared world — no permission hides a block from one player. That is a property of blocks, not of the gate, and saying so is more useful than a tick. It is why every effect carries a Scope.

US-4.06 has no ArchUnit rule. With seasons as data there are no per-season classes to constrain, so such a rule would be green forever without ever being able to fail. SeasonIsolationTest checks the property instead: reflection proves no field on SeasonDefinition or any SeasonEffect could name another season (adding one fails the test), and deleting any file leaves the rest loading.

The Stage 2 seam is real, not speculative

NamedWindowResolver(name, year, zone) → Optional<SeasonWindow> — lets a file write "window": {"named": "WINTER", "year": 2026}. Today unavailable() is installed and such a file fails to load with a message naming the seam, rather than silently running all year. Stage 2 implements it over SeasonBoundaryStrategy and passes it in; nothing in the season package needs to learn those types. A test drives the seam with a stand-in resolver, so it is not dead code waiting for a believer.

Also worth a look

FeatureGate gained decide(FeatureState, UUID) — a season's window lives in its own file, not in Togglz, and without this the kill-switch/stage/window comparison would exist twice. FeatureDecision.ALLOWED_PREVIEW changes isAllowed() semantics on a shared enum; all existing tests pass, but it is worth a second pair of eyes.

<prefix> in TitanMiniMessageImpl now resolves lazily, so message_prefix is a real effect across every message the lobby writes.

Not tested

No live CloudNet or LuckPerms run — the preview permission goes through FeatureAudience fixtures. ambient_sound's task lifecycle is asserted against real Minestom Task.isAlive(), but nobody asserts a sound packet reaches a client. And MinestomSeasonCanvas.ensureLoaded calls loadChunk(...).join(): fine at boot, but a season decorating a far-off chunk during a live synchronize would block the calling thread.

@TheMeinerLP
TheMeinerLP requested a review from a team as a code owner August 28, 2026 10:10
…onour a preview permission

Two additions to FeatureGate, both needed by the season packages.

decide(FeatureState, UUID) makes the state an argument rather than something
the gate looks up. A season's window lives in its own JSON file, not in the
Togglz repository, so without this a season would need its own copy of the
kill switch, the release stage and the window comparison - three things that
must not exist twice.

The preview step implements US-4.07. A holder of titan.season.preview passes
the window check and is told so, by ALLOWED_PREVIEW rather than ALLOWED, so a
status readout cannot be mistaken for "the season is live". It is asked after
the release stage, not before, which keeps it to widening the window: "let me
look at it early" and "let me see what I am not cleared for" are different
requests and only the first one is being granted here.
…selves back out

A season is a JSON file next to the process plus, if it wants one, a world
directory. There is no per-season Java and no per-season deployment, which is
the whole point: measured telemetry says cosmetic seasonal events move
concurrent players by roughly nothing, so a decorative season is only worth
having if it is nearly free to add.

The stable half is SeasonDirector, SeasonWindow and FeatureGate - who decides,
in which order, and when. The seasonal half is values in a file.

US-4.04 is caught twice, deliberately. A type nobody implements is refused by
SeasonLoader with the type named and the known types listed, at startup rather
than at the moment a player would have seen it. A type that exists but that
some switch forgot does not compile: the hierarchy is sealed and every switch
over it is exhaustive with no default.

US-4.02 is the reason SeasonCanvas is shaped the way it is. Every operation on
it has an inverse, each effect pushes its own undo as it is applied, and
deactivation pops the stack in reverse - so what comes back is the block that
was actually read, not the one the season author assumed, and two overlapping
seasons unwind to what was underneath both. The tests assert the world before
and after rather than that a method ran; deleting the body of the undo loop
fails eight of them.

Priority resolution is explicit and total: ascending priority, ties broken by
id, so every permutation of the same files produces the same order.

Seasons cannot reference each other because there is no field in which one
could name another - checked by reflection, so adding such a field breaks the
test.
… icon

The lobby loads seasons/ at boot, puts the live ones into the world, and looks
again every five seconds so a window that opens or a kill switch that is thrown
takes effect without a restart. Shutdown runs the same undo the end of a season
runs, so a lobby that stops mid-season does not leave decoration in the world
files.

The navigator asks the director per player rather than once per lobby. That is
what makes preview mean anything for an icon: a holder of titan.season.preview
opens the menu and sees next month's icon while everybody else still sees this
month's, and no permission is checked a second time - the gate already decided.

/season list is the counterpart to /season status. A season's window lives in
its own file rather than in the Togglz repository, so without it the seasons
would be invisible to the one command whose job is to spare an operator a trip
to the log.

The world a season names is read and logged but not yet acted on: choosing the
lobby world is spec stage 1 and is not on this branch.
Two departures are written down rather than glossed over.

The plan called for one extension jar per season; what is built is the data
half of that, a JSON file plus a world directory. The reason is the research
itself: cosmetic seasons move the player count by roughly nothing, so a jar
per season buys a build, a review and a deployment for a return that is not
there.

US-4.06 names an ArchUnit rule. With seasons as data there are no per-season
classes for such a rule to constrain, so it would pass forever without ever
being able to fail. What is checked instead is the property itself - no field
exists in which one season could name another, and removing any one file
leaves the rest loading.

US-4.07 is marked partial on purpose. Preview works for what is decided per
viewer and cannot work for a block in a shared world; the limit is a property
of blocks rather than of the gate, and saying so is more useful than a tick.
@TheMeinerLP
TheMeinerLP force-pushed the feat/season-packages branch from b275cc9 to 4eb69b6 Compare August 28, 2026 10:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant