feat: run seasons from configuration (Stage 4) - #225
Open
TheMeinerLP wants to merge 4 commits into
Open
Conversation
…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
force-pushed
the
feat/season-packages
branch
from
August 28, 2026 10:17
b275cc9 to
4eb69b6
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 inseasons/README.md). If it wants its own lobby, drop the world underworlds/and name it. Restart./season listshows priority, stage, window, world, kill switch and whether it is live.Set
"stage": "internal", give yourselftitan.season.preview, and you see the icons before the window opens. Move togawhen 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_lanturnis a startup failure naming the file and the value, not a December surprise.deactivate()is the requirement, so it shaped the designSeasonCanvasexists 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
synchronizetears 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.
SeasonIsolationTestchecks the property instead: reflection proves no field onSeasonDefinitionor anySeasonEffectcould 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}. Todayunavailable()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 overSeasonBoundaryStrategyand 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
FeatureGategaineddecide(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_PREVIEWchangesisAllowed()semantics on a shared enum; all existing tests pass, but it is worth a second pair of eyes.<prefix>inTitanMiniMessageImplnow resolves lazily, somessage_prefixis a real effect across every message the lobby writes.Not tested
No live CloudNet or LuckPerms run — the preview permission goes through
FeatureAudiencefixtures.ambient_sound's task lifecycle is asserted against real MinestomTask.isAlive(), but nobody asserts a sound packet reaches a client. AndMinestomSeasonCanvas.ensureLoadedcallsloadChunk(...).join(): fine at boot, but a season decorating a far-off chunk during a livesynchronizewould block the calling thread.